From 7911f5456d6cffcf206786334f25e78157c6da47 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 26 Oct 2026 12:17:58 +1100 Subject: [PATCH 1/9] refactor: adopt shared v6 JWT and bus clients --- README.md | 23 +++++ package-lock.json | 217 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- src/bus.ts | 95 +++----------------- test/bus.test.ts | 11 +-- 5 files changed, 255 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index dff6472..302463d 100644 --- a/README.md +++ b/README.md @@ -187,3 +187,26 @@ See [deploy/README.md](deploy/README.md). CircleCI checks every branch and deplo pinned Topcoder AWS credential helper as forms-api-v6. Each environment needs its existing ECS service/ECR repository, required SSM references and API migrations before deployment. The pipeline registers a new task revision and updates the existing service; it does not apply CloudFormation. + +## Shared authentication and bus client (v6 refresh) + +This branch uses `tc-core-library-js` **4.0.0-rc.1**. Use its named +`createJwtVerifier` / `createJwtAuthenticator`, `createM2MClient`, and +`createBusApiClient` exports for shared authentication and bus API publishing. +Service-specific roles, scopes, sessions and ownership checks remain in this service. +The former bus wrapper dependency and local JWT cryptography must not be reintroduced. + +RSA verification requires an exact configured issuer and audience (`JWT_AUDIENCE` +for client tokens, `AUTH0_AUDIENCE` for machine/API tokens, or this service's explicit +issuer-policy settings). HMAC secrets must be at least 32 bytes; no default secret +is supplied. Expiry is enforced. Machine identity comes from the shared verifier, +not the presence of scopes. Supply real development credentials through the service's +existing environment configuration before deployment. + +Bus publishers use `createBusApiClient(config).postEvent(event)` with the existing +event envelope and optional key. Configure `BUSAPI_URL` for v6 and the existing +OAuth credentials/token provider. The client uses bounded HTTPS requests and does +not retry POSTs automatically. Application retry/outbox behavior remains local. + +See the [core migration guide](https://github.com/topcoder-platform/tc-core-library-js/blob/v4.0.0-rc.1/MIGRATION.md) +for the API mapping, compatibility requirements, and rollout scope. diff --git a/package-lock.json b/package-lock.json index 72d0804..f383e79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@platformatic/kafka": "2.8.0", "pg": "8.23.0", + "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#v4.0.0-rc.1", "zod": "4.3.6" }, "devDependencies": { @@ -1011,11 +1012,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -1392,6 +1408,12 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1485,6 +1507,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1951,6 +1982,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -1994,6 +2034,65 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2018,6 +2117,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2034,6 +2138,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2041,6 +2187,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2048,6 +2200,28 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2391,6 +2565,26 @@ "node": ">=0.10.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -2401,7 +2595,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2468,6 +2661,19 @@ "node": ">=8" } }, + "node_modules/tc-core-library-js": { + "version": "4.0.0-rc.1", + "resolved": "git+ssh://git@github.com/topcoder-platform/tc-core-library-js.git#6c326fea0abfccb5ae29dff2ac5bce01aabc1fa4", + "license": "ISC", + "dependencies": { + "@types/jsonwebtoken": "^9.0.10", + "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^3.2.0" + }, + "engines": { + "node": ">=22.13" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2572,7 +2778,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, "node_modules/uri-js": { @@ -2620,6 +2825,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index e8eff27..5d8a67f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "dependencies": { "@platformatic/kafka": "2.8.0", "pg": "8.23.0", - "zod": "4.3.6" + "zod": "4.3.6", + "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#v4.0.0-rc.1" }, "devDependencies": { "@eslint/js": "9.39.2", diff --git a/src/bus.ts b/src/bus.ts index 24ae29b..c9c0173 100644 --- a/src/bus.ts +++ b/src/bus.ts @@ -1,3 +1,4 @@ +import { createBusApiClient, HttpError, type BusApiClient as SharedBusClient } from "tc-core-library-js"; /** Settings for the standard Topcoder OAuth client-credentials/Bus API endpoints. */ export interface BusConfig { BUSAPI_URL: string; @@ -23,93 +24,21 @@ export interface EventPublisher { /** Contains only a fixed operational code, never credentials or response bodies. */ export class BusError extends Error {} -/** Publishes authenticated Bus API events with bounded, abortable requests and cached M2M tokens. - * Uses the same client-credentials and auth0_url proxy contract as tc-core-library-js. - */ +/** Adapts the shared bus publisher to the processor's durable-delivery error contract. */ export class BusApiClient implements EventPublisher { - private token?: { value: string; expiresAt: number }; - /** Accepts validated settings and optional HTTP/clock dependencies; performs no network requests. */ - constructor( - private readonly config: BusConfig, - private readonly request: typeof fetch = fetch, - private readonly now: () => number = Date.now, - ) {} + private readonly client: SharedBusClient; - /** Publish an envelope to /bus/events; resolves on HTTP acceptance or throws a sanitized BusError. - * Token acquisition and publication share a 15-second deadline, below the database lock timeout. - */ - async postEvent(event: BusEvent): Promise { - const signal = AbortSignal.timeout(15_000); - try { - const token = await this.getToken(signal); - const response = await this.request( - `${this.config.BUSAPI_URL}/bus/events`, - { - method: "POST", - redirect: "error", - signal, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(event), - }, - ); - await response.body?.cancel().catch(() => {}); - if (response.status === 401) this.token = undefined; - if (!response.ok) throw new BusError(`bus_http_${response.status}`); - } catch (error) { - if (error instanceof BusError) throw error; - throw new BusError("bus_network_or_timeout"); - } + /** Creates the shared publisher from config and an optional test transport/clock; throws for invalid config. */ + constructor(config: BusConfig, request: typeof fetch = fetch, now: () => number = Date.now) { + void now; + this.client = createBusApiClient({ ...config, fetch: request, timeoutMs: 7000 }); } - /** Get a cached token or request one with the caller's deadline; throws sanitized auth errors. - * Cache only responses with a valid expires_in, refreshing 60 seconds before expiry. - */ - private async getToken(signal: AbortSignal): Promise { - if (this.token && this.token.expiresAt > this.now()) - return this.token.value; - const config = this.config; - const response = await this.request( - config.AUTH0_PROXY_SERVER_URL || config.AUTH0_URL, - { - method: "POST", - redirect: "error", - signal, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "client_credentials", - client_id: config.AUTH0_CLIENT_ID, - client_secret: config.AUTH0_CLIENT_SECRET, - audience: config.AUTH0_AUDIENCE, - auth0_url: config.AUTH0_URL, - }), - }, - ); - if (!response.ok) { - await response.body?.cancel().catch(() => {}); - throw new BusError(`bus_auth_http_${response.status}`); + /** Publishes an envelope and resolves on acceptance; throws sanitized BusError on token or bus failure. */ + async postEvent(event: BusEvent): Promise { + try { await this.client.postEvent(event); } + catch (error) { + throw new BusError(error instanceof HttpError && error.status ? `bus_http_${error.status}` : 'bus_network_or_timeout'); } - const body: unknown = await response.json(); - if ( - !body || - typeof body !== "object" || - !("access_token" in body) || - typeof body.access_token !== "string" || - !body.access_token - ) - throw new BusError("bus_auth_invalid_response"); - const expires = - "expires_in" in body && - typeof body.expires_in === "number" && - Number.isFinite(body.expires_in) - ? body.expires_in - : 0; - this.token = { - value: body.access_token, - expiresAt: this.now() + Math.max(0, Math.min(expires, 86400) - 60) * 1000, - }; - return body.access_token; } } diff --git a/test/bus.test.ts b/test/bus.test.ts index e2a7c17..d358547 100644 --- a/test/bus.test.ts +++ b/test/bus.test.ts @@ -21,7 +21,6 @@ const event: BusEvent = { test("requests/caches M2M tokens and posts the envelope to the v6 Bus endpoint", async () => { let tokenRequests = 0; let posts = 0; - let now = 100000; const request: typeof fetch = async (url, init) => { assert.ok(init?.signal); assert.equal(init.redirect, "error"); @@ -32,7 +31,6 @@ test("requests/caches M2M tokens and posts the envelope to the v6 Bus endpoint", client_id: "client", client_secret: "secret", audience: options.AUTH0_AUDIENCE, - auth0_url: options.AUTH0_URL, }); return Response.json({ access_token: "token", expires_in: 120 }); } @@ -45,14 +43,13 @@ test("requests/caches M2M tokens and posts the envelope to the v6 Bus endpoint", assert.deepEqual(JSON.parse(String(init.body)), event); return new Response(null, { status: 202 }); }; - const client = new BusApiClient(options, request, () => now); + const client = new BusApiClient(options, request); await client.postEvent(event); await client.postEvent(event); assert.equal(tokenRequests, 1); assert.equal(posts, 2); - now += 61000; await client.postEvent(event); - assert.equal(tokenRequests, 2); + assert.equal(tokenRequests, 1); }); test("supports the standard Auth0 proxy endpoint and auth0_url field", async () => { const proxy = "https://proxy.example.com/token"; @@ -76,7 +73,7 @@ test("authentication failure prevents Bus publication and never exposes response calls++; return new Response("secret", { status: 403 }); }).postEvent(event), - /^Error: bus_auth_http_403$/, + /^Error: bus_http_403$/, ); assert.equal(calls, 1); }); @@ -85,7 +82,7 @@ test("invalid token responses do not publish", async () => { new BusApiClient(options, async () => Response.json({ error: "secret" }), ).postEvent(event), - /bus_auth_invalid_response/, + /bus_network_or_timeout/, ); }); test("401 clears cached token; publication failure remains retryable", async () => { From eb6a566ee1e3cfae955807deb9c09eb2eefd4224 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 30 Oct 2026 16:46:07 +0200 Subject: [PATCH 2/9] Sales lead: send all recipients as "replyTo" --- .env.example | 2 +- README.md | 2 +- ...alk-ai-classification-and-label-actions.md | 8 +++-- docs/emails/README.md | 2 +- src/actions.ts | 20 +++++++---- src/config.ts | 2 +- src/lets-talk-flows.ts | 10 +++--- test/actions.test.ts | 35 ++++++++++++++++++- test/lets-talk.test.ts | 12 ++++--- 9 files changed, 69 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index dc66093..c24eb0b 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,6 @@ LETS_TALK_WIPRO_TEMPLATE_ID= LETS_TALK_SALES_REPLY_TEMPLATE_ID= LETS_TALK_SUPPORT_TEMPLATE_ID= # LETS_TALK_SUPPORT_RECIPIENTS=support@topcoder.com -# First address is the sales reply's reply-to; the rest are copied. +# The sales reply's reply-to addresses: a prospect's reply goes to all of them. # LETS_TALK_SALES_REPLY_TO=support@topcoder.com,test1@topcoder.com # LETS_TALK_REPLY_CAP_PER_DAY=1 diff --git a/README.md b/README.md index 2a41ddf..466fea3 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ SecureStrings (`src/lets-talk-flows.ts`), not from the database. | `lets-talk-community-skills-reply` | `COMMUNITY_SKILLS` | Reply to the submitter (`LETS_TALK_SKILLS_TEMPLATE_ID`) | | `lets-talk-community-general-reply` | `COMMUNITY_GENERAL` | Reply to the submitter (`LETS_TALK_GENERAL_TEMPLATE_ID`) | | `lets-talk-wipro-queries-reply` | `WIPRO_QUERIES` | Reply to the submitter (`LETS_TALK_WIPRO_TEMPLATE_ID`) | -| `lets-talk-sales-reply` | `SALES_LEAD` | Reply to the submitter (`LETS_TALK_SALES_REPLY_TEMPLATE_ID`); the first `LETS_TALK_SALES_REPLY_TO` address is reply-to, the others are copied | +| `lets-talk-sales-reply` | `SALES_LEAD` | Reply to the submitter (`LETS_TALK_SALES_REPLY_TEMPLATE_ID`); every `LETS_TALK_SALES_REPLY_TO` address is a reply-to (SendGrid `reply_to_list`); none is copied | | `lets-talk-support-forward` | `COMMUNITY_SUPPORT` | Full submission to `LETS_TALK_SUPPORT_RECIPIENTS`, reply-to the submitter (`LETS_TALK_SUPPORT_TEMPLATE_ID`) | | `lets-talk-sales-email` (existing) | after cut-over: `UNKNOWN` only | The original internal email, now the fallback when classification fails | diff --git a/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md b/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md index b4efc9a..248fb0c 100644 --- a/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md +++ b/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md @@ -36,7 +36,7 @@ Constraints from the existing system: - forms-processor-v6 routes a submission to **all** matching flows at once, using rules on answers only. It holds a row lock for the length of each action, and the pool's idle-in-transaction limit is 45 s. - The processor's email action deliberately takes recipients **only from trusted configuration**. -- email-service-v6 accepts **one** `replyTo`. +- email-service-v6 accepted **one** `replyTo` (it now also takes a list, sent as SendGrid `reply_to_list`; Decision 7). - The AI workflow's access policy is scopes-only, so administrator member tokens can't read its runs. - The Let's talk form allows 500-character names and company. The workflow's input schema caps them at 100/100/200 and expects the selector's display labels, while the form sends stable keys. @@ -86,7 +86,7 @@ Flows (seeded disabled by a forms-api-v6 migration): | `lets-talk-community-general-reply` | `sendgrid-email`, to the submitter | `COMMUNITY_GENERAL` | | `lets-talk-wipro-queries-reply` | `sendgrid-email`, to the submitter | `WIPRO_QUERIES` | | `lets-talk-support-forward` | `sendgrid-email`, to `support@topcoder.com`, reply-to the submitter | `COMMUNITY_SUPPORT` | -| `lets-talk-sales-reply` | `sendgrid-email`, to the submitter, reply-to and copies from `LETS_TALK_SALES_REPLY_TO` | `SALES_LEAD` | +| `lets-talk-sales-reply` | `sendgrid-email`, to the submitter, reply-to every `LETS_TALK_SALES_REPLY_TO` address | `SALES_LEAD` | | `lets-talk-sales-email` (existing) | `sendgrid-email`, to `LETS_TALK_RECIPIENTS` | `UNKNOWN` (fallback, Decision 6) | As for the existing flow, SSM values are authoritative for every Let's talk flow: template ids, the support recipients, the reply-to pool, and the AI API URL and audience. Changing them needs a redeploy. @@ -129,7 +129,9 @@ A submission whose classification ends `FAILED` keeps the label `UNKNOWN`. It is `needsReview` doesn't change the action; it is shown in the UI. Holding auto-replies for review would affect at least the 45% of submissions with an empty message (Q1). -### 7. Sales reply: the first listed address gets the replies, the rest are copied +### 7. Sales reply: every listed address is a reply-to + +**Revised 2026-10-29.** Copying the other addresses (`cc`) sent each of them a copy of the reply to the prospect, which was not wanted. email-service-v6 now accepts `replyTo` as a list and sends two or more addresses as SendGrid `reply_to_list`, so **every** `LETS_TALK_SALES_REPLY_TO` address is a reply-to and none is copied. A prospect who presses Reply writes to all of them. This needs email-service-v6 deployed first: an older email-service-v6 skips a message whose `replyTo` is a list as malformed. The original decision follows. Decided 2026-10-22, revising the earlier "one address per submission from a pool". The sales reply should reach a configurable list of people (`LETS_TALK_SALES_REPLY_TO`). email-service-v6 accepts a single `replyTo`, so: diff --git a/docs/emails/README.md b/docs/emails/README.md index 500c808..c4bb5c2 100644 --- a/docs/emails/README.md +++ b/docs/emails/README.md @@ -91,4 +91,4 @@ The Figma designs don't include these links, so check them first: - "sign up here" (general reply, developer getting started): `https://accounts-auth0.topcoder.com/?mode=signUp`. The practice email's "create your free account" links to `https://www.topcoder.com/`, as in its design. - The social links: Facebook, YouTube, LinkedIn, Twitter and Instagram (`https://discord.com/invite/topcoder` comes from the designs). -- The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to the first `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`; the rest, default `test1@topcoder.com`, are copied). Set that list for production so the first address is the signer's. +- The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to every `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`, `test1@topcoder.com`). Set that list for production so it includes the signer. diff --git a/src/actions.ts b/src/actions.ts index 7adc4bc..28f75bb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -76,8 +76,11 @@ export const emailSettingsSchema = z templateId: z.string().regex(/^d-[a-f0-9]{32}$/), fromEmail: z.email().nullish(), fromName: z.string().max(200).optional(), - /** A trusted reply-to address, or the address in an answer (e.g. support forwards). */ - replyTo: z.email().optional(), + /** Trusted reply-to address(es), or the address in an answer (e.g. support forwards). + * Several addresses become SendGrid's reply_to_list in email-service-v6. */ + replyTo: z + .union([z.email(), z.array(z.email()).min(1).max(100)]) + .optional(), replyToFromAnswer: z.string().min(1).optional(), /** Trusted copies, e.g. the sales team on a sales reply. */ cc: z.array(z.email()).max(100).optional(), @@ -139,11 +142,16 @@ export class BusEmail implements Action { ...new Set(s.recipients!.map((email) => email.toLowerCase())), ]; } + const replyTos = s.replyTo + ? [...new Set([s.replyTo].flat().map((email) => email.toLowerCase()))] + : []; const replyTo = - s.replyTo?.toLowerCase() ?? - (s.replyToFromAnswer - ? answerAddress(event, s.replyToFromAnswer) - : undefined); + replyTos.length > 1 + ? replyTos + : (replyTos[0] ?? + (s.replyToFromAnswer + ? answerAddress(event, s.replyToFromAnswer) + : undefined)); const cc = [ ...new Set((s.cc ?? []).map((email) => email.toLowerCase())), ].filter((email) => !recipients.includes(email)); diff --git a/src/config.ts b/src/config.ts index 7eec909..456ca1a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,7 +63,7 @@ const schema = z.object({ LETS_TALK_SALES_REPLY_TEMPLATE_ID: templateId, LETS_TALK_SUPPORT_TEMPLATE_ID: templateId, LETS_TALK_SUPPORT_RECIPIENTS: emailList("support@topcoder.com"), - /** First address: the sales reply's reply-to; the rest are copied (email-service-v6 takes one reply-to). */ + /** The sales reply's reply-to addresses; all of them receive a prospect's reply. */ LETS_TALK_SALES_REPLY_TO: emailList( "support@topcoder.com,test1@topcoder.com", ), diff --git a/src/lets-talk-flows.ts b/src/lets-talk-flows.ts index 4faab6d..3f49c28 100644 --- a/src/lets-talk-flows.ts +++ b/src/lets-talk-flows.ts @@ -37,7 +37,6 @@ export function letsTalkFlowSettings(config: Config): Map { templateId, replyCapPerDay: config.LETS_TALK_REPLY_CAP_PER_DAY, }); - const [salesReplyTo, ...salesCopies] = config.LETS_TALK_SALES_REPLY_TO; return new Map([ [ LETS_TALK_FLOWS.legacySalesEmail, @@ -65,10 +64,11 @@ export function letsTalkFlowSettings(config: Config): Map { LETS_TALK_FLOWS.salesReply, { ...reply(config.LETS_TALK_SALES_REPLY_TEMPLATE_ID), - // email-service-v6 accepts a single reply-to: the first address gets the - // replies, the rest of the list is copied on every sales reply. - ...(salesReplyTo ? { replyTo: salesReplyTo } : {}), - ...(salesCopies.length ? { cc: salesCopies } : {}), + // Every address is a reply-to: a prospect's reply goes to all of them, + // and none of them gets a copy of the reply itself. + ...(config.LETS_TALK_SALES_REPLY_TO.length + ? { replyTo: config.LETS_TALK_SALES_REPLY_TO } + : {}), }, ], [ diff --git a/test/actions.test.ts b/test/actions.test.ts index b0fac50..cb4a4eb 100644 --- a/test/actions.test.ts +++ b/test/actions.test.ts @@ -145,7 +145,38 @@ test("a submitter reply goes to the submitted address with only the first name a }); }); -test("the sales reply sets the trusted reply-to and copies the rest, never the recipient twice", async () => { +test("several trusted reply-to addresses are published as a deduplicated list", async () => { + const { published, action } = capture(); + await action.execute( + event, + { + ...replySettings, + replyTo: [ + "Support@topcoder.com", + "test1@topcoder.com", + "support@topcoder.com", + ], + }, + context(), + ); + assert.deepEqual(published[0].payload.replyTo, [ + "support@topcoder.com", + "test1@topcoder.com", + ]); + assert.equal(published[0].payload.cc, undefined); +}); + +test("a one-address reply-to list is published as a single reply-to", async () => { + const { published, action } = capture(); + await action.execute( + event, + { ...replySettings, replyTo: ["Support@topcoder.com"] }, + context(), + ); + assert.equal(published[0].payload.replyTo, "support@topcoder.com"); +}); + +test("a trusted reply-to with copies never copies the recipient twice", async () => { const { published, action } = capture(); await action.execute( event, @@ -247,6 +278,8 @@ test("recipient settings must name exactly one source, and one reply-to", async { ...replySettings, replyTo: "a@example.com", replyToFromAnswer: "email" }, { ...replySettings, templateId: "" }, { ...replySettings, cc: ["not-an-email"] }, + { ...replySettings, replyTo: [] }, + { ...replySettings, replyTo: ["a@example.com", "not-an-email"] }, ]) await assert.rejects( action.execute(event, bad, context()), diff --git a/test/lets-talk.test.ts b/test/lets-talk.test.ts index cf27901..cc9f4a3 100644 --- a/test/lets-talk.test.ts +++ b/test/lets-talk.test.ts @@ -101,7 +101,7 @@ const base = { KAFKA_URL: "localhost:9092", }; -test("flow settings: the sales reply's first address is reply-to and the rest are copied", () => { +test("flow settings: every sales reply address is a reply-to and none is copied", () => { const settings = letsTalkFlowSettings( config({ ...base, @@ -114,8 +114,11 @@ test("flow settings: the sales reply's first address is reply-to and the rest ar recipientsFromAnswer: "email", templateId: "d-" + "b".repeat(32), replyCapPerDay: 1, - replyTo: "support@topcoder.com", - cc: ["test1@topcoder.com", "sales@topcoder.com"], + replyTo: [ + "support@topcoder.com", + "test1@topcoder.com", + "sales@topcoder.com", + ], }); }); @@ -125,8 +128,7 @@ test("flow settings: defaults, support forward and classification", () => { recipientsFromAnswer: "email", templateId: "", replyCapPerDay: 1, - replyTo: "support@topcoder.com", - cc: ["test1@topcoder.com"], + replyTo: ["support@topcoder.com", "test1@topcoder.com"], }); assert.deepEqual(settings.get(LETS_TALK_FLOWS.supportForward), { recipients: ["support@topcoder.com"], From bacea93f346f8bfbb951bde4c9a9b56659e446bd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 2 Nov 2026 14:09:11 +1100 Subject: [PATCH 3/9] Patch forms processor dependencies and minimize the runtime image --- .npmrc | 1 + .nvmrc | 2 +- Dockerfile | 26 +- README.md | 6 + eslint.config.mjs | 1 + package-lock.json | 347 +++++++++++++------------- package.json | 20 +- tools/minimatch-compat/index.cjs | 38 +++ tools/minimatch-compat/index.test.cjs | 30 +++ tools/minimatch-compat/package.json | 10 + 10 files changed, 292 insertions(+), 189 deletions(-) create mode 100644 .npmrc create mode 100644 tools/minimatch-compat/index.cjs create mode 100644 tools/minimatch-compat/index.test.cjs create mode 100644 tools/minimatch-compat/package.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c7d3517 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +install-links=true diff --git a/.nvmrc b/.nvmrc index 60bb1e6..63f87c2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -26.8.1 +26.12.1 diff --git a/Dockerfile b/Dockerfile index 533b11e..339e971 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,29 @@ -FROM node:26.8.1-bookworm-slim AS build +ARG NODE_VERSION=26.12.1 +FROM node:${NODE_VERSION}-alpine AS build WORKDIR /app -COPY package*.json ./ +COPY package.json package-lock.json .npmrc ./ +COPY tools ./tools RUN npm ci COPY . . -RUN npm run lint && npm run typecheck && npm test && npm run build +RUN npm run lint && npm run typecheck && npm test && npm run test:dependency-compat && npm run build -FROM node:26.8.1-bookworm-slim AS runtime +FROM build AS production-dependencies +RUN npm prune --omit=dev + +FROM alpine:3.24 AS runtime +ARG NODE_VERSION WORKDIR /app ENV NODE_ENV=production -COPY package*.json ./ -RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force -COPY --from=build --chown=node:node /app/dist ./dist +RUN apk upgrade --no-cache \ + && apk add --no-cache ca-certificates "nodejs-current~${NODE_VERSION%%.*}" \ + && addgroup -S -g 10001 app \ + && adduser -S -D -u 10001 -G app app +COPY --from=production-dependencies --chown=app:app /app/package.json /app/package-lock.json ./ +COPY --from=production-dependencies --chown=app:app /app/node_modules ./node_modules +COPY --from=build --chown=app:app /app/dist ./dist COPY deploy/certs/us-east-1-bundle.pem ./deploy/certs/us-east-1-bundle.pem ENV NODE_EXTRA_CA_CERTS=/app/deploy/certs/us-east-1-bundle.pem -USER node +USER app EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] CMD ["node", "dist/main.js"] diff --git a/README.md b/README.md index 466fea3..ff5341e 100644 --- a/README.md +++ b/README.md @@ -278,3 +278,9 @@ not retry POSTs automatically. Application retry/outbox behavior remains local. See the [core migration guide](https://github.com/topcoder-platform/tc-core-library-js/blob/v4.0.0-rc.1/MIGRATION.md) for the API mapping, compatibility requirements, and rollout scope. + +## Security maintenance + +The production image uses Node.js 26.12.1 on Alpine 3.24 and runs as UID 10001. It contains only compiled application code, production dependencies, and the RDS trust bundle. npm, development dependencies, and environment files are excluded. Dependencies install from the lockfile, with the shared core pinned to an immutable patched revision. + +Run `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:dependency-compat`, and `npm audit`. The dependency compatibility tests preserve the legacy matcher API while using its patched engine. Database integration tests continue to require a disposable database named `forms_processor_test`. diff --git a/eslint.config.mjs b/eslint.config.mjs index c05d584..1c86c43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,4 +4,5 @@ export default tseslint.config( { ignores: ["dist/**", "node_modules/**"] }, js.configs.recommended, ...tseslint.configs.recommended, + { files: ["tools/**/*.cjs"], languageOptions: { sourceType: "commonjs", globals: { require: "readonly", module: "readonly", process: "readonly" } }, rules: { "@typescript-eslint/no-require-imports": "off" } }, ); diff --git a/package-lock.json b/package-lock.json index f383e79..767d024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "@platformatic/kafka": "2.8.0", "pg": "8.23.0", - "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#v4.0.0-rc.1", - "zod": "4.3.6" + "tc-core-library-js": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/a25576b8b038211d1ba910eca2d900271ba6659d", + "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "9.39.2", @@ -22,7 +22,7 @@ "prettier": "3.8.1", "tsx": "4.22.4", "typescript": "5.9.3", - "typescript-eslint": "8.54.0" + "typescript-eslint": "8.72.1" }, "engines": { "node": ">=26.8.1 <27" @@ -704,6 +704,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@node-rs/crc32": { "version": "1.10.8", "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.8.tgz", @@ -836,9 +843,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -855,9 +859,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -874,9 +875,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -893,9 +891,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -999,9 +994,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", "dev": true, "license": "MIT" }, @@ -1050,20 +1045,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1073,15 +1068,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.72.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", "dev": true, "license": "MIT", "engines": { @@ -1089,16 +1084,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "engines": { @@ -1109,19 +1104,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", "debug": "^4.4.3" }, "engines": { @@ -1132,18 +1127,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1154,9 +1149,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", "dev": true, "license": "MIT", "engines": { @@ -1167,21 +1162,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1191,14 +1186,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", "dev": true, "license": "MIT", "engines": { @@ -1210,21 +1205,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1234,46 +1229,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1283,19 +1252,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.72.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1305,10 +1274,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -1391,21 +1373,26 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/buffer-equal-constant-time": { @@ -1461,13 +1448,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1771,9 +1751,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -1858,9 +1838,9 @@ } }, "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2223,22 +2203,36 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "name": "@topcoder/minimatch-compat", + "version": "1.0.0", + "resolved": "file:tools/minimatch-compat", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "minimatch-engine": "npm:minimatch@11.0.1" + } + }, + "node_modules/minimatch-engine": { + "name": "minimatch", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.1.tgz", + "integrity": "sha512-t+Vn6DX4wPCxS23y03C0uhF7S1jpi1GfqPrnlEipCJaA2ykiNbMkVlDutZtmvLfRcUN6R8AYVlzTsQrT4RZoLQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/nonrecursive-glob-match": "^1.0.1", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mnemonist": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.5.tgz", - "integrity": "sha512-egXDkYJsKZCizjk8FydZ3g8TOigBwt+dIaehgKcQSAE3DCkCTWG4N94trvwR1ZYeAHo1exxE+ZhZOUuj01HdNw==", + "version": "0.40.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.6.tgz", + "integrity": "sha512-c4mbin5HCz77Srw1L3nwThCH/0hLVYuY3oMF2plP2tKfp/gqzSgHP63qRUOpL8jutYWNNCr49LUlKSt7exkaww==", "license": "MIT", "dependencies": { "obliterator": "^2.0.4" @@ -2374,16 +2368,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "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.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "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": { @@ -2405,9 +2399,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "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": { @@ -2514,9 +2508,9 @@ } }, "node_modules/protobufjs": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", - "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.1.tgz", + "integrity": "sha512-d9e9179bjXSJdpJ3+R9fP8hzXsnKLyxTjHLG1dUzje8a27pPyXCe6PrcRtgx2rjoM3IZzHd2vAs+6J/BsO1k7Q==", "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -2663,7 +2657,8 @@ }, "node_modules/tc-core-library-js": { "version": "4.0.0-rc.1", - "resolved": "git+ssh://git@github.com/topcoder-platform/tc-core-library-js.git#6c326fea0abfccb5ae29dff2ac5bce01aabc1fa4", + "resolved": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/a25576b8b038211d1ba910eca2d900271ba6659d", + "integrity": "sha512-kruFbsCv/E2Fw6CSZbc4Tk1Z/vcceQKRnbYkDhzhTmUui7Aq3m+AsjadLt+XV/5Ts2reRojjscxahYnqAGzGAQ==", "license": "ISC", "dependencies": { "@types/jsonwebtoken": "^9.0.10", @@ -2751,16 +2746,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz", + "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.72.1", + "@typescript-eslint/parser": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2770,8 +2765,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici-types": { @@ -2845,9 +2840,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 7b3c1d8..79838e1 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "start": "node dist/main.js", "dev": "tsx watch --env-file=.env src/main.ts", "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"", - "format": "prettier --write src test *.json *.mjs .circleci/config.yml deploy/*.yaml" + "format": "prettier --write src test *.json *.mjs .circleci/config.yml deploy/*.yaml", + "test:dependency-compat": "node --test tools/minimatch-compat/index.test.cjs" }, "dependencies": { "@platformatic/kafka": "2.8.0", "pg": "8.23.0", - "zod": "4.3.6", - "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#v4.0.0-rc.1" + "zod": "4.6.7", + "tc-core-library-js": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/a25576b8b038211d1ba910eca2d900271ba6659d" }, "devDependencies": { "@eslint/js": "9.39.2", @@ -33,6 +34,17 @@ "prettier": "3.8.1", "tsx": "4.22.4", "typescript": "5.9.3", - "typescript-eslint": "8.54.0" + "typescript-eslint": "8.72.1" + }, + "overrides": { + "minimatch": "file:tools/minimatch-compat", + "brace-expansion@^1": "1.1.21", + "brace-expansion@^2": "2.1.7", + "brace-expansion@>=3": "5.0.12", + "js-yaml@^4": "4.3.2", + "qs": "6.16.1", + "ajv@^6": "6.15.0", + "flatted": "3.4.2", + "fast-uri": "3.1.9" } } diff --git a/tools/minimatch-compat/index.cjs b/tools/minimatch-compat/index.cjs new file mode 100644 index 0000000..c48079e --- /dev/null +++ b/tools/minimatch-compat/index.cjs @@ -0,0 +1,38 @@ +const modern = require('minimatch-engine') + +// Older build tools expect a callable CommonJS export. glob 7 additionally +// reads _glob and uses String.match on compiled segments. Delegate matching +// to minimatch 11's bounded matcher, without restoring the old regex engine. +function compatible (defaultOptions = {}) { + const options = (value) => ({ ...defaultOptions, ...value }) + const match = (path, pattern, value) => + modern.minimatch(path, pattern, options(value)) + Object.assign(match, modern) + match.Minimatch = class extends modern.Minimatch { + constructor (pattern, value) { + super(pattern, options(value)) + for (const row of this.set) { + for (const segment of row) { + if (typeof segment === 'object' && segment !== null) { + segment._glob = segment.pattern + segment[Symbol.match] = (input) => + segment.test(input) ? [input] : null + } + } + } + } + + static defaults (value) { + return compatible(options(value)).Minimatch + } + } + match.defaults = (value) => compatible(options(value)) + match.filter = (pattern, value) => modern.filter(pattern, options(value)) + match.match = (paths, pattern, value) => + modern.match(paths, pattern, options(value)) + match.braceExpand = (pattern, value) => + modern.braceExpand(pattern, options(value)) + return match +} + +module.exports = compatible() diff --git a/tools/minimatch-compat/index.test.cjs b/tools/minimatch-compat/index.test.cjs new file mode 100644 index 0000000..c1d71e8 --- /dev/null +++ b/tools/minimatch-compat/index.test.cjs @@ -0,0 +1,30 @@ +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const legacyRequire = createRequire(require.resolve('eslint')); +const match = legacyRequire('minimatch'); + +test('legacy consumers retain the callable minimatch API', () => { + assert.equal(typeof match, 'function'); + assert.equal(match('src/main.ts', '**/*.ts'), true); + assert.equal(match('src/main.js', '**/*.ts'), false); + assert.equal(match('.env', '*'), false); + assert.equal(match.defaults({ dot: true })('.env', '*'), true); + assert.deepEqual(match.match(['a.ts', 'b.js'], '*.ts'), ['a.ts']); + assert.deepEqual(['a.ts', 'b.js'].filter(match.filter('*.ts')), ['a.ts']); + assert.deepEqual(match.braceExpand('*.{js,ts}'), ['*.js', '*.ts']); + assert.equal(new match.Minimatch('**/*.ts').match('src/main.ts'), true); + assert.equal(match.Minimatch.defaults({ dot: true }) !== undefined, true); + assert.equal(typeof match.GLOBSTAR, 'symbol'); +}); + +test('amplifying glob patterns terminate within a bounded time', () => { + const result = spawnSync(process.execPath, ['-e', ` + const match = require(${JSON.stringify(legacyRequire.resolve('minimatch'))}); + new match.Minimatch('**/../'.repeat(30) + '*.ts', { optimizationLevel: 2 }); + `], { timeout: 5000, encoding: 'utf8' }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/tools/minimatch-compat/package.json b/tools/minimatch-compat/package.json new file mode 100644 index 0000000..3f163b2 --- /dev/null +++ b/tools/minimatch-compat/package.json @@ -0,0 +1,10 @@ +{ + "name": "@topcoder/minimatch-compat", + "version": "1.0.0", + "private": true, + "description": "Legacy CommonJS entry point backed by patched minimatch", + "main": "index.cjs", + "dependencies": { + "minimatch-engine": "npm:minimatch@11.0.1" + } +} From cdc6248a13f116fef371b8b0d2e459aba92ebcd7 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 2 Nov 2026 07:30:09 +0200 Subject: [PATCH 4/9] PM-6686 - space tech emails --- .env.example | 4 +++ README.md | 9 +++++++ deploy/README.md | 1 + deploy/release.py | 6 +++-- docs/emails/README.md | 19 +++++++++++++ src/config.ts | 3 +++ src/main.ts | 6 ++++- src/space-tech-flows.ts | 24 +++++++++++++++++ test/space-tech-flows.test.ts | 50 +++++++++++++++++++++++++++++++++++ 9 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/space-tech-flows.ts create mode 100644 test/space-tech-flows.test.ts diff --git a/.env.example b/.env.example index c24eb0b..e2c7c1e 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,7 @@ LETS_TALK_SUPPORT_TEMPLATE_ID= # The sales reply's reply-to addresses: a prospect's reply goes to all of them. # LETS_TALK_SALES_REPLY_TO=support@topcoder.com,test1@topcoder.com # LETS_TALK_REPLY_CAP_PER_DAY=1 + +# SpaceTech challenge notification (docs/emails/README.md). Empty values defer the flow. +SPACE_TECH_CHALLENGE_RECIPIENTS= +SPACE_TECH_CHALLENGE_TEMPLATE_ID= diff --git a/README.md b/README.md index 466fea3..2c1031f 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,15 @@ and register it in `src/main.ts` for a new action type. Rule validation lives in only validates and persists events. No browser redirects occur in this async worker; a future member signup redirect belongs in the website's form response flow. +## SpaceTech challenge notification + +Flow `space-tech-challenge-email` (forms-api-v6 migration `20261102010000_space_tech_challenge_email`, +seeded disabled) emails every `space_tech_challenge` submission to `SPACE_TECH_CHALLENGE_RECIPIENTS` +(comma-separated, same rules as `LETS_TALK_RECIPIENTS`) using `SPACE_TECH_CHALLENGE_TEMPLATE_ID`, +with the submitter's `email` answer as reply-to. Both come from processor SSM SecureStrings +(`src/space-tech-flows.ts`) and replace the flow's database settings; unset values defer delivery. +Setup: [docs/emails/README.md](docs/emails/README.md#spacetech-challenge-notification). + ## Reliability and operations * Kafka commits only after PostgreSQL persists the event. Duplicate submission IDs diff --git a/deploy/README.md b/deploy/README.md index 5b0720e..9dfea9f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -33,6 +33,7 @@ parameters and `kms:Decrypt` when a customer-managed key is used. | `AI_API_URL`, `AI_AUTH0_AUDIENCE` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; tc-ai-api base ending in `/v6/ai`, e.g. `https://api.topcoder.com/v6/ai`; the audience defaults to `AUTH0_AUDIENCE`) | | `LETS_TALK_OPPORTUNITY_TEMPLATE_ID`, `LETS_TALK_SKILLS_TEMPLATE_ID`, `LETS_TALK_GENERAL_TEMPLATE_ID`, `LETS_TALK_WIPRO_TEMPLATE_ID`, `LETS_TALK_SALES_REPLY_TEMPLATE_ID`, `LETS_TALK_SUPPORT_TEMPLATE_ID` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; one SendGrid template per label flow, see `docs/emails/README.md`) | | `LETS_TALK_SUPPORT_RECIPIENTS`, `LETS_TALK_SALES_REPLY_TO`, `LETS_TALK_REPLY_CAP_PER_DAY`, `LETS_TALK_CLASSIFY_MAX_ATTEMPTS`, `LETS_TALK_CLASSIFY_MAX_AGE_MINUTES` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; defaults `support@topcoder.com`, `support@topcoder.com,test1@topcoder.com`, `1`, `5`, `30`) | +| `SPACE_TECH_CHALLENGE_RECIPIENTS`, `SPACE_TECH_CHALLENGE_TEMPLATE_ID` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; comma-separated addresses and the active `d-` template ID for the SpaceTech challenge notification; unset defers it) | | `DATABASE_URL`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` | `/config/forms-api-v6/appvar/` | | `AUTH0_URL`, `AUTH0_AUDIENCE`, optional `AUTH0_PROXY_SERVER_URL` | Forms API appvars when present, otherwise `/config/common/global-appvar/` | | `KAFKA_URL`, optional `KAFKA_SSL_ENABLED` | `/config/common/global-appvar/` | diff --git a/deploy/release.py b/deploy/release.py index 5788e29..ade1a8d 100644 --- a/deploy/release.py +++ b/deploy/release.py @@ -34,8 +34,9 @@ def read_service(ecs): return service -# Optional processor-owned settings for AI classification and label actions (docs/adr/0001). -# Absent values defer the matching flows; the code defaults apply where documented. +# Optional processor-owned settings for AI classification and label actions (docs/adr/0001), +# and for the SpaceTech challenge notification. Absent values defer the matching flows; +# the code defaults apply where documented. LABEL_ACTION_SETTINGS = [ 'AI_API_URL', 'AI_AUTH0_AUDIENCE', 'LETS_TALK_CLASSIFY_MAX_ATTEMPTS', 'LETS_TALK_CLASSIFY_MAX_AGE_MINUTES', @@ -43,6 +44,7 @@ def read_service(ecs): 'LETS_TALK_GENERAL_TEMPLATE_ID', 'LETS_TALK_WIPRO_TEMPLATE_ID', 'LETS_TALK_SALES_REPLY_TEMPLATE_ID', 'LETS_TALK_SUPPORT_TEMPLATE_ID', 'LETS_TALK_SUPPORT_RECIPIENTS', 'LETS_TALK_SALES_REPLY_TO', 'LETS_TALK_REPLY_CAP_PER_DAY', + 'SPACE_TECH_CHALLENGE_RECIPIENTS', 'SPACE_TECH_CHALLENGE_TEMPLATE_ID', ] diff --git a/docs/emails/README.md b/docs/emails/README.md index c4bb5c2..1548bf1 100644 --- a/docs/emails/README.md +++ b/docs/emails/README.md @@ -92,3 +92,22 @@ The Figma designs don't include these links, so check them first: - "sign up here" (general reply, developer getting started): `https://accounts-auth0.topcoder.com/?mode=signUp`. The practice email's "create your free account" links to `https://www.topcoder.com/`, as in its design. - The social links: Facebook, YouTube, LinkedIn, Twitter and Instagram (`https://discord.com/invite/topcoder` comes from the designs). - The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to every `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`, `test1@topcoder.com`). Set that list for production so it includes the signer. + +## SpaceTech challenge notification + +[`space-tech-challenge-email.html`](space-tech-challenge-email.html) is the internal email for the SpaceTech landing page form (`space_tech_challenge`). Flow `space-tech-challenge-email` (forms-api-v6 migration `20261102010000_space_tech_challenge_email`) sends every submission to the addresses in `SPACE_TECH_CHALLENGE_RECIPIENTS`, with the submitter's `email` answer as reply-to. Suggested subject: **New space-data challenge** (keep it fixed). + +It uses `answers.full_name`, `answers.email`, `answers.company`, `answers.challenge`, `submittedAt`, `sourcePage` and `submissionId`. All the answers are submitter-controlled: double braces only. For test data, use `{"answers":{"full_name":"Alex Doe","email":"alex@example.com","company":"Orbital Labs","challenge":"Detect debris in\nlow-orbit imagery."},"submittedAt":"2026-11-02T10:00:00.000Z","sourcePage":"/space-tech/","submissionId":"00000000-0000-0000-0000-000000000000"}`. + +To go live in an environment: + +1. Create the template (steps 1–6 above). +2. Create the SecureStrings `/config/forms-processor-v6/appvar/SPACE_TECH_CHALLENGE_RECIPIENTS` (comma-separated addresses) and `/config/forms-processor-v6/appvar/SPACE_TECH_CHALLENGE_TEMPLATE_ID`, then **redeploy the processor**. +3. Enable the flow: + + ```sql + UPDATE forms."ProcessorFlow" SET enabled = true, "updatedAt" = now() + WHERE id = 'space-tech-challenge-email' AND "formKey" = 'space_tech_challenge'; + ``` + +Submissions made while the flow is disabled are queued and are sent once it is enabled. Changing the list means updating the parameter and redeploying. diff --git a/src/config.ts b/src/config.ts index 456ca1a..02b60a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,6 +73,9 @@ const schema = z.object({ .min(0) .max(100) .default(1), + // SpaceTech landing page challenges: internal notification to a fixed list. + SPACE_TECH_CHALLENGE_RECIPIENTS: emailList(""), + SPACE_TECH_CHALLENGE_TEMPLATE_ID: templateId, PORT: z.coerce.number().int().min(1).max(65535).default(3000), }); export type Config = z.infer & { diff --git a/src/main.ts b/src/main.ts index 5881ae7..0fa0fc5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { letsTalkFlowSettings, } from "./lets-talk-flows.js"; import { LETS_TALK_ANSWERS } from "./lets-talk.js"; +import { spaceTechFlowSettings } from "./space-tech-flows.js"; import { Store, log } from "./store.js"; /** Start the database inbox worker, Kafka consumer, and private health endpoint with environment-owned Let's talk settings. @@ -43,7 +44,10 @@ async function main(): Promise { ), ], ]), - letsTalkFlowSettings(settings), + new Map([ + ...letsTalkFlowSettings(settings), + ...spaceTechFlowSettings(settings), + ]), { submitterFlowIds: LETS_TALK_SUBMITTER_FLOWS, submitterAddressAnswer: LETS_TALK_ANSWERS.email, diff --git a/src/space-tech-flows.ts b/src/space-tech-flows.ts new file mode 100644 index 0000000..15cbfbd --- /dev/null +++ b/src/space-tech-flows.ts @@ -0,0 +1,24 @@ +import type { Config } from "./config.js"; + +/** SpaceTech landing page flow id, as seeded by forms-api-v6 migration 20261102010000_space_tech_challenge_email. */ +export const SPACE_TECH_CHALLENGE_FLOW = "space-tech-challenge-email"; + +/** + * Settings for the SpaceTech challenge notification, from SSM-backed configuration. They replace + * the flow's database settings: every submission goes to SPACE_TECH_CHALLENGE_RECIPIENTS with the + * submitter's email as reply-to. An empty recipient list or template id leaves its deliveries + * deferred with invalid_email_settings until set. Never throws. + */ +export function spaceTechFlowSettings(config: Config): Map { + return new Map([ + [ + SPACE_TECH_CHALLENGE_FLOW, + { + recipients: config.SPACE_TECH_CHALLENGE_RECIPIENTS, + templateId: config.SPACE_TECH_CHALLENGE_TEMPLATE_ID, + // Replies go straight to the submitter. + replyToFromAnswer: "email", + }, + ], + ]); +} diff --git a/test/space-tech-flows.test.ts b/test/space-tech-flows.test.ts new file mode 100644 index 0000000..efdde90 --- /dev/null +++ b/test/space-tech-flows.test.ts @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { emailSettingsSchema } from "../src/actions.js"; +import { config } from "../src/config.js"; +import { + SPACE_TECH_CHALLENGE_FLOW, + spaceTechFlowSettings, +} from "../src/space-tech-flows.js"; + +const base = { + DATABASE_URL: "postgresql://localhost/test", + KAFKA_URL: "localhost:9092", +}; + +test("space tech settings: recipients come from the environment, replies go to the submitter", () => { + const settings = spaceTechFlowSettings( + config({ + ...base, + SPACE_TECH_CHALLENGE_RECIPIENTS: + " Test-Support@topcoder.com, test2-support@topcoder.com,test-support@topcoder.com", + SPACE_TECH_CHALLENGE_TEMPLATE_ID: "d-" + "c".repeat(32), + }), + ).get(SPACE_TECH_CHALLENGE_FLOW); + assert.deepEqual(settings, { + recipients: ["test-support@topcoder.com", "test2-support@topcoder.com"], + templateId: "d-" + "c".repeat(32), + replyToFromAnswer: "email", + }); + assert.equal(emailSettingsSchema.safeParse(settings).success, true); +}); + +test("space tech settings: unset variables defer delivery and malformed ones fail startup", () => { + const settings = spaceTechFlowSettings(config(base)).get( + SPACE_TECH_CHALLENGE_FLOW, + ); + assert.deepEqual(settings, { + recipients: [], + templateId: "", + replyToFromAnswer: "email", + }); + assert.equal(emailSettingsSchema.safeParse(settings).success, false); + for (const [key, value] of [ + ["SPACE_TECH_CHALLENGE_RECIPIENTS", "test-support@topcoder.com,"], + ["SPACE_TECH_CHALLENGE_TEMPLATE_ID", "d-123"], + ]) + assert.throws( + () => config({ ...base, [key!]: value }), + new RegExp(`^Error: Invalid configuration: ${key}(?:\\.\\d+)?$`), + ); +}); From c7bfdc97653f5b1397a08f2e7a40d487d6eb3c7d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 10:08:15 +1100 Subject: [PATCH 5/9] chore: adopt core v4.0.0-rc.2 identity denylists --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 767d024..e47b492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@platformatic/kafka": "2.8.0", "pg": "8.23.0", - "tc-core-library-js": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/a25576b8b038211d1ba910eca2d900271ba6659d", + "tc-core-library-js": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/58de37670180e183e8378c3d203df0112a0414f6", "zod": "4.6.7" }, "devDependencies": { @@ -2656,9 +2656,9 @@ } }, "node_modules/tc-core-library-js": { - "version": "4.0.0-rc.1", - "resolved": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/a25576b8b038211d1ba910eca2d900271ba6659d", - "integrity": "sha512-kruFbsCv/E2Fw6CSZbc4Tk1Z/vcceQKRnbYkDhzhTmUui7Aq3m+AsjadLt+XV/5Ts2reRojjscxahYnqAGzGAQ==", + "version": "4.0.0-rc.2", + "resolved": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/58de37670180e183e8378c3d203df0112a0414f6", + "integrity": "sha512-sCM3ieXGZtHGJw5OKeqOC9q1iMlj6faEmwnJU7rQ56aiiIFsjN9IfG3GVRBLEeUsAMJH52DIK1/l1NUzV6n+gQ==", "license": "ISC", "dependencies": { "@types/jsonwebtoken": "^9.0.10", diff --git a/package.json b/package.json index 79838e1..37d85b9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@platformatic/kafka": "2.8.0", "pg": "8.23.0", "zod": "4.6.7", - "tc-core-library-js": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/a25576b8b038211d1ba910eca2d900271ba6659d" + "tc-core-library-js": "https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/58de37670180e183e8378c3d203df0112a0414f6" }, "devDependencies": { "@eslint/js": "9.39.2", From 09d779acd18302ccfb086924a533e1dda06e6803 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Nov 2026 20:42:30 +1100 Subject: [PATCH 6/9] Apply scoped OIDC deployment migration to current develop --- .circleci/config.yml | 213 ++++++++++++++++++--------------- .gitignore | 4 + deploy/MIGRATION.md | 31 +++++ deploy/prod-healthcheck.json | 14 +++ deploy/prod.json | 19 +++ deploy/release.py | 211 ++------------------------------- deploy/tests/test_release.py | 220 ----------------------------------- 7 files changed, 193 insertions(+), 519 deletions(-) create mode 100644 deploy/MIGRATION.md create mode 100644 deploy/prod-healthcheck.json create mode 100644 deploy/prod.json delete mode 100644 deploy/tests/test_release.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 712804a..21bfab8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,107 +1,130 @@ version: 2.1 - -jobs: - quality: +executors: + reviewed: docker: - - image: node:26.8.1-bookworm - - image: postgres:16-alpine - environment: - POSTGRES_PASSWORD: forms_local - POSTGRES_DB: forms_processor_test - environment: - TEST_DATABASE_URL: postgresql://postgres:forms_local@localhost:5432/forms_processor_test + - image: cimg/aws@sha256:4cf3768db00ddf9bc439b8c84e8a60c11a14edf81c8baf76f74132144f8340d5 +commands: + checkout-suite: steps: - - checkout - - run: npm ci - - run: - name: Lint, type-check, test routing and retries, and compile - command: | - npm run lint - npm run typecheck - npm test - npm run test:integration - npm run build - deployment-checks: - docker: - - image: cimg/python:3.13.2 + - run: + name: Verify the reviewed deployment suite + command: | + set -euo pipefail + git init --quiet /tmp/tc-deploy-scripts + git -C /tmp/tc-deploy-scripts remote add origin https://github.com/topcoder-platform/tc-deploy-scripts.git + git -C /tmp/tc-deploy-scripts fetch --quiet --depth 1 origin bbc9b3d0c702947d26b6d9686d7b29104ebe461a + git -C /tmp/tc-deploy-scripts checkout --quiet --detach FETCH_HEAD + test "$(git -C /tmp/tc-deploy-scripts rev-parse HEAD)" = bbc9b3d0c702947d26b6d9686d7b29104ebe461a +jobs: + build: + executor: reviewed steps: - - checkout - - run: pip install boto3 - - run: python3 -m unittest discover -s deploy/tests + - checkout + - setup_remote_docker + - checkout-suite + - run: + name: Build committed source without AWS or broker credentials + command: | + set -euo pipefail + python3 -I /tmp/tc-deploy-scripts/build_image.py --dockerfile Dockerfile --image forms-processor-v6:candidate + mkdir -p /tmp/release + docker save --output /tmp/release/image.tar forms-processor-v6:candidate + no_output_timeout: 30m + - persist_to_workspace: + root: /tmp/release + paths: + - image.tar deploy: - docker: - - image: cimg/python:3.13.2-browsers + executor: reviewed parameters: - deploy_env: - type: enum - enum: [DEV, PROD] environment: type: enum - enum: [dev, production] + enum: + - dev + - prod environment: - AWS_REGION: us-east-1 - AWS_DEFAULT_REGION: us-east-1 - AWS_STS_REGIONAL_ENDPOINTS: regional - AWS_RETRY_MODE: standard - AWS_MAX_ATTEMPTS: "10" - APPNAME: forms-processor-v6 - DEPLOY_ENV: << parameters.deploy_env >> - DEPLOYMENT_ENVIRONMENT: << parameters.environment >> + DEPLOY_ENV: << parameters.environment >> + steps: + - checkout + - setup_remote_docker + - checkout-suite + - attach_workspace: + at: /tmp/release + - run: + name: Promote a scanned digest with repository-bound OIDC + command: | + set +x + set -euo pipefail + test -f "deploy/${DEPLOY_ENV}.json" + docker load --input /tmp/release/image.tar + python3 -I deploy/release.py "deploy/${DEPLOY_ENV}.json" + no_output_timeout: 45m + quality: + docker: + - image: node@sha256:9f94d34c787165dca03b74e5bf9c3bf90e8de79b19aa3d87fe1fa1694bf75c89 + - image: postgres@sha256:0cefaabd882e42bcb2a2f3dc56d3ce4770560ba62fd7f197f1a2e008fd5731ab + environment: + POSTGRES_PASSWORD: forms_local + POSTGRES_DB: forms_processor_test + environment: + TEST_DATABASE_URL: postgresql://postgres:forms_local@localhost:5432/forms_processor_test + steps: + - checkout + - run: npm ci + - run: + name: Lint, type-check, test routing and retries, and compile + command: | + npm run lint + npm run typecheck + npm test + npm run test:integration + npm run build + deployment-checks: + executor: reviewed steps: - - checkout - - setup_remote_docker - - run: - name: Install deployment dependencies - command: | - sudo apt-get update -qq - sudo apt-get install -y -qq jq python3-pip - pip install boto3 awscli - - run: - name: Install the pinned Topcoder AWS credential helper - command: | - git -c advice.detachedHead=false clone --quiet --depth 1 --branch v1.4.20 https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript - test "$(git -C ../buildscript rev-parse HEAD)" = 5f3745c35463ce0e2475c12269aa7a2b446456e5 - cp ../buildscript/awsconfiguration.sh . - - run: - name: Build tested runtime image - command: docker buildx build --load --tag "${APPNAME}:candidate" . - - run: - name: Deploy a new task revision to the existing ECS service - no_output_timeout: 35m - command: | - set -euo pipefail - set +x - trap 'rm -f awsenvconf awsconfiguration.sh' EXIT - ./awsconfiguration.sh "$DEPLOY_ENV" "$AWS_REGION" - source awsenvconf - python3 -u deploy/release.py "$DEPLOYMENT_ENVIRONMENT" "${DEPLOYMENT_ENVIRONMENT}-${CIRCLE_SHA1}-${CIRCLE_BUILD_NUM}" - - store_artifacts: - path: deploy/release-<< parameters.environment >>.json - destination: release + - checkout-suite + - run: + name: Test the pinned deployment security and rollout checks + command: python3 -m unittest discover -s /tmp/tc-deploy-scripts/tests -q workflows: - build-and-deploy: + release: jobs: - - quality - - deployment-checks - - deploy: - name: deploy-dev - context: org-global - requires: [quality, deployment-checks] - deploy_env: DEV - environment: dev - serial-group: << pipeline.project.slug >>/deploy-dev - filters: - branches: - only: develop - tags: - only: /^dev-.*/ - - deploy: - name: deploy-production - context: org-global - requires: [quality, deployment-checks] - deploy_env: PROD - environment: production - serial-group: << pipeline.project.slug >>/deploy-production - filters: - branches: - only: master + - quality + - deployment-checks + - build: + requires: + - quality + - deployment-checks + - deploy: + name: deploy-dev + environment: dev + requires: + - build + context: forms-processor-v6-dev-deploy + serial-group: << pipeline.project.slug >>/deploy-dev + filters: + branches: + only: develop + tags: + ignore: /.*/ + - approve-prod: + type: approval + requires: + - build + filters: + branches: + only: master + tags: + ignore: /.*/ + - deploy: + name: deploy-prod + environment: prod + requires: + - approve-prod + context: forms-processor-v6-prod-deploy + serial-group: << pipeline.project.slug >>/deploy-prod + filters: + branches: + only: master + tags: + ignore: /.*/ diff --git a/.gitignore b/.gitignore index ded37c1..3532471 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ awsenvconf awsconfiguration.sh __pycache__/ deploy/release-*.json + +# Infrastructure definitions and detailed AWS inventories are maintained privately. +/deploy/iam/ +/deploy/*-baseline.json diff --git a/deploy/MIGRATION.md b/deploy/MIGRATION.md new file mode 100644 index 0000000..8ca62d2 --- /dev/null +++ b/deploy/MIGRATION.md @@ -0,0 +1,31 @@ +# OIDC deployment migration + +This change replaces the Auth0 credential broker and SSM-to-shell deployment path with the reviewed [shared suite PR #40](https://github.com/topcoder-platform/tc-deploy-scripts/pull/40), pinned to `bbc9b3d0c702947d26b6d9686d7b29104ebe461a`. Build and deployment run in separate jobs. The deployer cannot read Parameter Store, Secrets Manager or decrypt KMS data, including through another assumed role. ECS continues resolving the existing runtime secret references using the execution role. + +## Production target + +The read-only inventory on 2026-11-03 verified account `409275337247`, cluster `topcoder-infrastructure`, service `forms-processor-v6`, family `forms-processor-v6`, and container `forms-processor-v6`. `prod.json` preserves the observed task/execution roles and names the proposed service-specific deployment role. [prod-baseline.json](https://github.com/topcoder-platform/v6-services-iac/blob/main/services/forms-processor-v6/deploy/prod-baseline.json) records references only; it contains no parameter values. Do not recreate the service or replace its complete task definition from this baseline. + +## Infrastructure prerequisites, applied separately by an administrator + +1. In each account, register the CircleCI OIDC issuer `https://oidc.circleci.com/org/947bef5e-1669-415d-b83c-4fc85155b7df` with client ID `947bef5e-1669-415d-b83c-4fc85155b7df`. The current production account has only the legacy Auth0 provider. Do not remove that provider until all its consumers have migrated. +2. Review and install [iam/prod-role.cloudformation.json](https://github.com/topcoder-platform/v6-services-iac/blob/main/services/forms-processor-v6/deploy/iam/prod-role.cloudformation.json) with an administrator/IaC identity. It creates the proposed deployment role and applies the same least-privilege policy as a permissions boundary. The trust binds this CircleCI project, repository, checkout branch and configuration branch. It does not grant infrastructure creation to CircleCI. +3. Configure ECR repository `forms-processor-v6` with unconditional `IMMUTABLE` tags and supported scan-on-push. Existing tags are not retagged. The release fails for MEDIUM/HIGH/CRITICAL/UNDEFINED findings, failed scans, or a mismatched digest. +4. In the infrastructure that owns the task definition, add the reviewed container check from `prod-healthcheck.json`, keeping all roles, runtime secret references, resources, mounts and sidecars. Verify the health endpoint against the running container in dev first. Preserve circuit-breaker rollback and positive desired capacity. Remove any `RESET_DB` secret binding and plaintext secret environment entries before adoption. Keep the execution role's runtime read permissions scoped to the references that service needs. +5. Create restricted `forms-processor-v6-dev-deploy` and `forms-processor-v6-prod-deploy` CircleCI contexts without Auth0/static AWS credentials. Restrict production to the reviewed `master` checkout and configuration ref, authorized operators, and approval job. Remove legacy credentials from project settings too: removing `org-global` alone does not remove project-level variables. Confirm `CIRCLE_OIDC_TOKEN_V3` is issued. Disable secret access for forks and SSH debugging in deployment jobs. + +## Development acceptance before merge/release + +The saved dev AWS session had expired during preparation. No dev cluster, role or family has been guessed. **This PR is a draft until an administrator verifies and commits `deploy/dev.json` for account `811668436784` and branch `develop`, with corresponding narrow IAM policy/trust.** Use the same schema as `prod.json`, but inspect the actual dev resources and roles; do not replace just the account number. The dev job fails closed while its manifest is absent. Branches other than `develop` and `master` build only and receive no deployment context. + +Run the shared offline suite, validate the CircleCI config and IAM policies, and test a dev release plus an unhealthy-image rollback. Verify CloudTrail contains no SSM/Secrets Manager calls from the deployment principal. Remove its old broker grants only after migration. Check the runtime secret references and roles stayed unchanged. Production requires the manual approval job and a passing digest/scan/health verification. + +The pinned executor is `cimg/aws@sha256:4cf3768db00ddf9bc439b8c84e8a60c11a14edf81c8baf76f74132144f8340d5`. Python/AWS CLI/Buildx/Git compatibility was tested with the shared offline suite. No package installation or service build runs in the deployment job. The Dockerfile retains the existing in-image quality gates. This PR does not deploy, change IAM, rotate credentials, or claim a successful live rollout. + +## Processor release behavior + +Application lint/type checks, routing/retry unit tests, PostgreSQL integration tests and compilation remain prerequisites. Deployment tests now run the pinned shared security/rollout suite. The old SSM-discovery release implementation and its discovery-specific tests are replaced. Existing ECS runtime references are preserved byte-for-byte instead of discovering optional settings during a release; additions/removals belong in the infrastructure that owns the task. Forms API schema migration must still precede a processor release. Zero-capacity activation/bootstrap is an administrator operation: the deployer refuses it. + +## Infrastructure source + +IAM templates and detailed AWS baselines are maintained in the private [v6-services-iac service directory](https://github.com/topcoder-platform/v6-services-iac/tree/main/services/forms-processor-v6). Provision infrastructure separately with an administrator identity; application CI does not fetch that repository or install IAM. Required application manifests and health-check proposals remain here. diff --git a/deploy/prod-healthcheck.json b/deploy/prod-healthcheck.json new file mode 100644 index 0000000..d23559b --- /dev/null +++ b/deploy/prod-healthcheck.json @@ -0,0 +1,14 @@ +{ + "forms-processor-v6": { + "command": [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + } +} diff --git a/deploy/prod.json b/deploy/prod.json new file mode 100644 index 0000000..6fcfac1 --- /dev/null +++ b/deploy/prod.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "environment": "prod", + "accountId": "409275337247", + "region": "us-east-1", + "deployRoleArn": "arn:aws:iam::409275337247:role/forms-processor-v6-prod-deploy", + "taskRoleArn": "arn:aws:iam::409275337247:role/forms-processor-v6-production-TaskRole-F7Uvj6UKRNRm", + "executionRoleArn": "arn:aws:iam::409275337247:role/forms-processor-v6-production-ExecutionRole-M3KAOLfyCIxx", + "cluster": "topcoder-infrastructure", + "service": "forms-processor-v6", + "taskFamily": "forms-processor-v6", + "circleciOrgId": "947bef5e-1669-415d-b83c-4fc85155b7df", + "circleciProjectId": "9abc16b0-0d52-490f-a4c0-6e3db3ede656", + "repository": "github.com/topcoder-platform/forms-processor-v6", + "branch": "master", + "images": { + "forms-processor-v6": "forms-processor-v6" + } +} diff --git a/deploy/release.py b/deploy/release.py index ade1a8d..de70d94 100644 --- a/deploy/release.py +++ b/deploy/release.py @@ -1,210 +1,13 @@ #!/usr/bin/env python3 -"""Release a prebuilt image to an existing ECS service, without provisioning infrastructure. - -Usage: release.py dev|production IMAGE_TAG [LOCAL_IMAGE]. Uses inherited temporary -AWS credentials, boto3 and Docker. API migrations must precede this release. -""" -import base64 -from copy import deepcopy +"""Pass the reviewed manifest and this job's prebuilt images to the pinned suite.""" import json -import os from pathlib import Path -import re import subprocess import sys -import time - -import boto3 -from botocore.config import Config - -APP = 'forms-processor-v6' -CLUSTER = 'topcoder-infrastructure' -ACCOUNTS = {'dev': '811668436784', 'production': '409275337247'} -SSM_CLIENT_CONFIG = Config(retries={'mode': 'standard', 'total_max_attempts': 10}) - - -def read_service(ecs): - """Read the fixed ECS service using ecs; return active service or raise on missing/invalid state.""" - result = ecs.describe_services(cluster=CLUSTER, services=[APP]) - if result.get('failures') or len(result.get('services', [])) != 1: - raise RuntimeError('Existing forms-processor-v6 ECS service is required') - service = result['services'][0] - if service['status'] != 'ACTIVE': - raise RuntimeError('Existing ECS service must be active') - return service - - -# Optional processor-owned settings for AI classification and label actions (docs/adr/0001), -# and for the SpaceTech challenge notification. Absent values defer the matching flows; -# the code defaults apply where documented. -LABEL_ACTION_SETTINGS = [ - 'AI_API_URL', 'AI_AUTH0_AUDIENCE', - 'LETS_TALK_CLASSIFY_MAX_ATTEMPTS', 'LETS_TALK_CLASSIFY_MAX_AGE_MINUTES', - 'LETS_TALK_OPPORTUNITY_TEMPLATE_ID', 'LETS_TALK_SKILLS_TEMPLATE_ID', - 'LETS_TALK_GENERAL_TEMPLATE_ID', 'LETS_TALK_WIPRO_TEMPLATE_ID', - 'LETS_TALK_SALES_REPLY_TEMPLATE_ID', 'LETS_TALK_SUPPORT_TEMPLATE_ID', - 'LETS_TALK_SUPPORT_RECIPIENTS', 'LETS_TALK_SALES_REPLY_TO', 'LETS_TALK_REPLY_CAP_PER_DAY', - 'SPACE_TECH_CHALLENGE_RECIPIENTS', 'SPACE_TECH_CHALLENGE_TEMPLATE_ID', -] - - -def secret_references(ssm): - """Resolve named SSM references in batches of ten, without decrypting SecureStrings. - - Takes an SSM client; returns name/valueFrom pairs. API Auth0 settings take - precedence over shared defaults. Kafka uses globals. Missing optional sales - settings permit a disabled-flow deployment. Raises for absent required values - or sales/Bus parameters that are not SecureStrings; AWS errors propagate after - client retries. GetParameters values are discarded; only names/types are used. - Avoids the low-throughput DescribeParameters scan of whole parameter prefixes. - """ - prefixes = {'api': '/config/forms-api-v6/appvar', - 'app': f'/config/{APP}/appvar', - 'common': '/config/common/global-appvar'} - sources = {'DATABASE_URL': ['api'], 'BUSAPI_URL': ['app'], - 'AUTH0_CLIENT_ID': ['api'], 'AUTH0_CLIENT_SECRET': ['api'], - 'AUTH0_URL': ['api', 'common'], 'AUTH0_AUDIENCE': ['api', 'common'], - 'AUTH0_PROXY_SERVER_URL': ['api', 'common'], - 'KAFKA_URL': ['common'], 'KAFKA_SSL_ENABLED': ['common'], - 'LETS_TALK_RECIPIENTS': ['app'], 'LETS_TALK_SENDGRID_TEMPLATE_ID': ['app'], - **{name: ['app'] for name in LABEL_ACTION_SETTINGS}} - names = [prefixes[source] + '/' + name - for name, candidates in sources.items() for source in candidates] - available = {} - for start in range(0, len(names), 10): - response = ssm.get_parameters(Names=names[start:start + 10], WithDecryption=False) - available.update({p['Name']: p['Type'] for p in response['Parameters']}) - optional = {'AUTH0_PROXY_SERVER_URL', 'KAFKA_SSL_ENABLED', - 'LETS_TALK_RECIPIENTS', 'LETS_TALK_SENDGRID_TEMPLATE_ID', *LABEL_ACTION_SETTINGS} - references = [] - for name, candidates in sources.items(): - path = next((prefixes[source] + '/' + name for source in candidates - if prefixes[source] + '/' + name in available), None) - if path is None: - if name not in optional: - raise RuntimeError('Missing required SSM parameter: ' + name) - continue - # Every processor-owned parameter (Bus URL, recipients, templates, AI settings) is a SecureString. - if path.startswith(prefixes['app'] + '/'): - if available[path] != 'SecureString': - raise RuntimeError('SecureString required: ' + path) - references.append({'name': name, 'valueFrom': path}) - return references - - -def task_request(ecs, current, image, references): - """Clone the live task's registerable fields and replace the processor image/managed secrets. - - Inputs are the ECS client, described task, ECR image URI and SSM references. - Returns a register-task-definition request, preserving roles/resources/sidecars. - Raises if family or application container identity differs from the fixed service. - """ - if current['family'] != APP: - raise RuntimeError('Unexpected task family') - allowed = ecs.meta.service_model.operation_model('RegisterTaskDefinition').input_shape.members - request = {key: deepcopy(value) for key, value in current.items() if key in allowed} - containers = [c for c in request['containerDefinitions'] if c['name'] == APP] - if len(containers) != 1: - raise RuntimeError('Expected one forms-processor-v6 container') - container = containers[0] - managed = {'DATABASE_URL', 'BUSAPI_URL', 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET', - 'AUTH0_URL', 'AUTH0_AUDIENCE', 'AUTH0_PROXY_SERVER_URL', 'KAFKA_URL', - 'LETS_TALK_RECIPIENTS', 'LETS_TALK_SENDGRID_TEMPLATE_ID', *LABEL_ACTION_SETTINGS} - # Preserve an existing TLS setting if the global store does not define it. - managed.update(ref['name'] for ref in references) - container['environment'] = [e for e in container.get('environment', []) if e['name'] not in managed] - container['secrets'] = [s for s in container.get('secrets', []) if s['name'] not in managed] + references - container['image'] = image - container['healthCheck'] = { - 'command': ['CMD', 'node', '-e', - "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"], - 'interval': 30, 'timeout': 5, 'retries': 3, 'startPeriod': 60, - } - return request - - -def verify_service(ecs, definition_arn): - """Observe one rollout using ecs and the requested ARN; return service when tasks are healthy. - - Returns None while converging; raises on rollback/replacement/failed rollout or - AWS errors. Used by the bounded release waiter, including activation from zero. - """ - service = read_service(ecs) - if service['taskDefinition'] != definition_arn: - raise RuntimeError('ECS rolled back or replaced the requested task definition') - deployments = service['deployments'] - if any(d.get('rolloutState') == 'FAILED' for d in deployments): - raise RuntimeError('ECS deployment failed') - if (len(deployments) != 1 or deployments[0].get('rolloutState') != 'COMPLETED' - or service['runningCount'] != service['desiredCount'] or service['desiredCount'] < 1 - or service['pendingCount']): - return None - arns = [] - for page in ecs.get_paginator('list_tasks').paginate( - cluster=CLUSTER, serviceName=APP, desiredStatus='RUNNING'): - arns.extend(page['taskArns']) - tasks = [] - for start in range(0, len(arns), 100): - tasks.extend(ecs.describe_tasks(cluster=CLUSTER, tasks=arns[start:start + 100])['tasks']) - healthy = [t for t in tasks if t['taskDefinitionArn'] == definition_arn - and t.get('healthStatus') == 'HEALTHY' and t['lastStatus'] == 'RUNNING'] - return service if len(healthy) >= service['desiredCount'] else None - - -def main(): - """Read CLI arguments, verify account/service, push image, register a revision and update ECS. - - Returns None after verified deployment and a non-secret release artifact. Raises - on invalid arguments/account, missing existing infrastructure, AWS/Docker errors, - concurrent service change, rollback or a 30-minute timeout. Never updates stacks, - creates services, modifies networking/IAM, runs migrations, or enables email flows. - """ - if len(sys.argv) not in (3, 4) or sys.argv[1] not in ACCOUNTS: - raise RuntimeError(__doc__) - environment, tag = sys.argv[1:3] - if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,110}', tag): - raise RuntimeError('Invalid image tag') - session = boto3.Session(region_name=os.environ.get('AWS_REGION', 'us-east-1')) - account = session.client('sts').get_caller_identity()['Account'] - if account != ACCOUNTS[environment]: - raise RuntimeError('AWS account does not match requested environment') - ecs, ecr = [session.client(name) for name in ('ecs', 'ecr')] - ssm = session.client('ssm', config=SSM_CLIENT_CONFIG) - service = read_service(ecs) - if len(service['deployments']) != 1 or service['deployments'][0].get('rolloutState') != 'COMPLETED': - raise RuntimeError('Existing ECS deployment must be stable before releasing') - current = ecs.describe_task_definition(taskDefinition=service['taskDefinition'])['taskDefinition'] - references = secret_references(ssm) - repository = ecr.describe_repositories(repositoryNames=[APP])['repositories'][0]['repositoryUri'] - image = f'{repository}:{tag}' - request = task_request(ecs, current, image, references) - authorization = ecr.get_authorization_token()['authorizationData'][0] - user, password = base64.b64decode(authorization['authorizationToken']).decode().split(':', 1) - subprocess.run(['docker', 'login', '--username', user, '--password-stdin', authorization['proxyEndpoint']], - input=password, text=True, check=True, stdout=subprocess.DEVNULL) - local_image = sys.argv[3] if len(sys.argv) == 4 else APP + ':candidate' - subprocess.run(['docker', 'tag', local_image, image], check=True) - subprocess.run(['docker', 'push', image], check=True) - if read_service(ecs)['taskDefinition'] != service['taskDefinition']: - raise RuntimeError('Service changed during image push; retry after inspecting concurrent release') - definition_arn = ecs.register_task_definition(**request)['taskDefinition']['taskDefinitionArn'] - ecs.update_service(cluster=CLUSTER, service=APP, taskDefinition=definition_arn, - desiredCount=max(1, service['desiredCount'])) - deadline = time.monotonic() + 1800 - while time.monotonic() < deadline: - retained = verify_service(ecs, definition_arn) - if retained: - break - print('Waiting for healthy requested ECS revision', flush=True) - time.sleep(15) - else: - raise RuntimeError('ECS deployment timed out; inspect before retrying') - evidence = {'environment': environment, 'cluster': CLUSTER, 'service': APP, 'image': image, - 'taskDefinition': definition_arn, 'previousTaskDefinition': service['taskDefinition'], - 'runningCount': retained['runningCount']} - Path(f'deploy/release-{environment}.json').write_text(json.dumps(evidence, indent=2) + '\n') - print(json.dumps(evidence, indent=2)) - -if __name__ == '__main__': - main() +manifest = Path(sys.argv[1]) +data = json.loads(manifest.read_text()) +command = [sys.executable, '-I', '/tmp/tc-deploy-scripts/tcdeploy.py', 'deploy', '--manifest', str(manifest)] +for container in data['images']: + command.extend(['--image', container + '=forms-processor-v6:candidate']) +subprocess.run(command, check=True) diff --git a/deploy/tests/test_release.py b/deploy/tests/test_release.py deleted file mode 100644 index c83e9a1..0000000 --- a/deploy/tests/test_release.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Exercise rollout guards and secret-reference selection without contacting AWS.""" -import importlib.util -import json -from pathlib import Path -from types import SimpleNamespace -import unittest -from unittest.mock import Mock, patch - -import boto3 -from botocore.awsrequest import AWSResponse -from botocore.exceptions import ClientError - -spec = importlib.util.spec_from_file_location('release', Path(__file__).parents[1] / 'release.py') -release = importlib.util.module_from_spec(spec) -spec.loader.exec_module(release) - - -class ReleaseTests(unittest.TestCase): - """Verify that releases update existing ECS tasks and cannot cross environment boundaries.""" - - def setUp(self): - """Build named-parameter fixtures for each test; takes no arguments and returns nothing.""" - entries = { - '/config/forms-api-v6/appvar': ['DATABASE_URL', 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET', 'AUTH0_URL'], - '/config/forms-processor-v6/appvar': ['BUSAPI_URL', 'LETS_TALK_RECIPIENTS', 'LETS_TALK_SENDGRID_TEMPLATE_ID', - *release.LABEL_ACTION_SETTINGS], - '/config/common/global-appvar': ['AUTH0_URL', 'AUTH0_AUDIENCE', 'AUTH0_PROXY_SERVER_URL', 'KAFKA_URL', 'KAFKA_SSL_ENABLED'], - } - self.parameters = { - path + '/' + name: {'Name': path + '/' + name, 'Type': 'SecureString', 'Value': 'unused-ciphertext'} - for path, names in entries.items() for name in names - } - self.ssm = Mock() - self.ssm.get_parameters.side_effect = self.parameter_response - - def parameter_response(self, *, Names, WithDecryption): - """Return the requested fixture parameters and absent names; rejects oversized/decrypting reads. - - Called by the SSM mock with AWS request arguments. Reverses result order to - verify lookup by name, and raises AssertionError for unsafe request options. - """ - self.assertFalse(WithDecryption) - self.assertGreater(len(Names), 0) - self.assertLessEqual(len(Names), 10) - return {'Parameters': [self.parameters[name] for name in reversed(Names) if name in self.parameters], - 'InvalidParameters': [name for name in Names if name not in self.parameters]} - - def test_missing_service_is_rejected(self): - """A missing service raises before release can provision anything; no inputs or return value.""" - ecs = Mock() - ecs.describe_services.return_value = {'services': [], 'failures': [{'reason': 'MISSING'}]} - with self.assertRaisesRegex(RuntimeError, 'Existing'): - release.read_service(ecs) - ecs.update_service.assert_not_called() - - def test_secret_sources_and_types(self): - """Two bounded non-decrypting reads return only named references with the intended precedence.""" - refs = {r['name']: r['valueFrom'] for r in release.secret_references(self.ssm)} - self.assertEqual(refs['AUTH0_URL'], '/config/forms-api-v6/appvar/AUTH0_URL') - self.assertEqual(refs['AUTH0_AUDIENCE'], '/config/common/global-appvar/AUTH0_AUDIENCE') - self.assertEqual(refs['DATABASE_URL'], '/config/forms-api-v6/appvar/DATABASE_URL') - self.assertEqual(refs['KAFKA_URL'], '/config/common/global-appvar/KAFKA_URL') - self.assertEqual(refs['BUSAPI_URL'], '/config/forms-processor-v6/appvar/BUSAPI_URL') - self.assertEqual(len(refs), 11 + len(release.LABEL_ACTION_SETTINGS)) - self.assertNotIn('unused-ciphertext', json.dumps(refs)) - requested = [name for call in self.ssm.get_parameters.call_args_list for name in call.kwargs['Names']] - expected = 14 + len(release.LABEL_ACTION_SETTINGS) - self.assertEqual(self.ssm.get_parameters.call_count, -(-expected // 10)) - self.assertEqual(len(requested), expected) - self.assertEqual(len(set(requested)), expected) - self.assertEqual(refs['AI_API_URL'], '/config/forms-processor-v6/appvar/AI_API_URL') - self.assertNotIn('/config/forms-api-v6/appvar/MIGRATION_DATABASE_URL', requested) - self.ssm.get_paginator.assert_not_called() - self.ssm.describe_parameters.assert_not_called() - - def test_optional_parameters_and_auth_fallback(self): - """Missing optional settings are omitted and absent API Auth0 settings use shared defaults.""" - del self.parameters['/config/forms-api-v6/appvar/AUTH0_URL'] - for name in ['LETS_TALK_RECIPIENTS', 'LETS_TALK_SENDGRID_TEMPLATE_ID', *release.LABEL_ACTION_SETTINGS]: - del self.parameters['/config/forms-processor-v6/appvar/' + name] - for name in ['KAFKA_SSL_ENABLED', 'AUTH0_PROXY_SERVER_URL']: - del self.parameters['/config/common/global-appvar/' + name] - refs = {r['name']: r['valueFrom'] for r in release.secret_references(self.ssm)} - self.assertEqual(refs['AUTH0_URL'], '/config/common/global-appvar/AUTH0_URL') - self.assertEqual(len(refs), 7) - - def test_required_parameter_missing_fails(self): - """A missing required database reference raises instead of publishing a broken task.""" - del self.parameters['/config/forms-api-v6/appvar/DATABASE_URL'] - with self.assertRaisesRegex(RuntimeError, 'Missing required'): - release.secret_references(self.ssm) - - def test_sales_parameters_must_remain_secure_strings(self): - """Wrong types for each required Bus or optional sales value raise without exposing its value.""" - for name in ['BUSAPI_URL', 'LETS_TALK_RECIPIENTS', 'LETS_TALK_SENDGRID_TEMPLATE_ID', - 'AI_API_URL', 'LETS_TALK_SALES_REPLY_TO']: - parameter = self.parameters['/config/forms-processor-v6/appvar/' + name] - with self.subTest(name=name): - parameter['Type'] = 'String' - with self.assertRaisesRegex(RuntimeError, 'SecureString') as raised: - release.secret_references(self.ssm) - self.assertNotIn('unused-ciphertext', str(raised.exception)) - parameter['Type'] = 'SecureString' - - def test_ssm_failures_stop_before_deployment(self): - """Access denial and exhausted throttling propagate before any ECR, Docker or ECS mutation.""" - for code in ['AccessDeniedException', 'ThrottlingException']: - with self.subTest(code=code): - session, ecs, ecr = Mock(), Mock(), Mock() - sts = Mock() - sts.get_caller_identity.return_value = {'Account': release.ACCOUNTS['production']} - session.client.side_effect = lambda name, **kwargs: { - 'sts': sts, 'ecs': ecs, 'ecr': ecr, 'ssm': self.ssm}[name] - ecs.describe_services.return_value = {'services': [{ - 'status': 'ACTIVE', 'taskDefinition': 'current', - 'deployments': [{'rolloutState': 'COMPLETED'}]}]} - ecs.describe_task_definition.return_value = {'taskDefinition': {'family': release.APP}} - self.ssm.get_parameters.side_effect = ClientError({'Error': { - 'Code': code, 'Message': 'Parameter read failed'}}, 'GetParameters') - with patch.object(release.boto3, 'Session', return_value=session), \ - patch.object(release.sys, 'argv', ['release.py', 'production', 'candidate']), \ - patch.object(release.subprocess, 'run') as run: - with self.assertRaises(ClientError): - release.main() - session.client.assert_any_call('ssm', config=release.SSM_CLIENT_CONFIG) - ecr.describe_repositories.assert_not_called() - ecs.register_task_definition.assert_not_called() - ecs.update_service.assert_not_called() - run.assert_not_called() - - def test_sdk_retries_throttling_and_has_a_finite_budget(self): - """Exercise real SDK retry handling offline: recover after five throttles and stop after ten attempts.""" - # GetParameters reads ten names per request. - batches = -(-(14 + len(release.LABEL_ACTION_SETTINGS)) // 10) - for throttle_count, expected_requests in [(5, 5 + batches), (100, 10)]: - with self.subTest(throttle_count=throttle_count): - ssm = boto3.client('ssm', region_name='us-east-1', aws_access_key_id='test', - aws_secret_access_key='test', config=release.SSM_CLIENT_CONFIG) - requests = [] - - def respond(request): - """Return an AWSResponse for a prepared HTTP request; fake throttles precede fixture reads.""" - requests.append(json.loads(request.body)) - self.assertEqual(request.headers['X-Amz-Target'], b'AmazonSSM.GetParameters') - throttled = len(requests) <= throttle_count - body = {'__type': 'ThrottlingException', 'message': 'Rate exceeded'} if throttled \ - else self.parameter_response(**requests[-1]) - raw = SimpleNamespace(stream=lambda: iter([json.dumps(body).encode()])) - return AWSResponse(request.url, 400 if throttled else 200, - {'content-type': 'application/x-amz-json-1.1'}, raw) - - with patch.object(ssm._endpoint.http_session, 'send', side_effect=respond), \ - patch('botocore.endpoint.time.sleep'): - if throttle_count == 5: - self.assertEqual(len(release.secret_references(ssm)), 11 + len(release.LABEL_ACTION_SETTINGS)) - else: - with self.assertRaises(ClientError): - release.secret_references(ssm) - self.assertEqual(len(requests), expected_requests) - self.assertTrue(all(not request['WithDecryption'] for request in requests)) - ssm.close() - - def test_clone_preserves_roles_resources_and_removes_stale_literals(self): - """Cloning returns a registerable copy with exact secret overrides and unchanged sidecars/roles.""" - ecs = boto3.client('ecs', region_name='us-east-1', aws_access_key_id='test', aws_secret_access_key='test') - current = {'family': release.APP, 'taskDefinitionArn': 'old', 'revision': 7, - 'executionRoleArn': 'execution', 'taskRoleArn': 'task', 'cpu': '256', 'memory': '512', - 'networkMode': 'awsvpc', 'requiresCompatibilities': ['FARGATE'], - 'containerDefinitions': [{'name': release.APP, 'image': 'old', 'readonlyRootFilesystem': True, - 'environment': [{'name': 'BUSAPI_URL', 'value': 'old'}, {'name': 'KAFKA_SSL_ENABLED', 'value': 'true'}], - 'secrets': [{'name': 'LETS_TALK_RECIPIENTS', 'valueFrom': 'old'}, {'name': 'OTHER', 'valueFrom': '/other'}]}, - {'name': 'sidecar', 'image': 'sidecar'}]} - refs = [{'name': 'BUSAPI_URL', 'valueFrom': '/config/forms-processor-v6/appvar/BUSAPI_URL'}] - result = release.task_request(ecs, current, 'new', refs) - self.assertNotIn('revision', result) - self.assertNotIn('taskDefinitionArn', result) - for key in ['executionRoleArn', 'taskRoleArn', 'cpu', 'memory', 'networkMode', 'requiresCompatibilities']: - self.assertEqual(result[key], current[key]) - container = result['containerDefinitions'][0] - self.assertEqual(container['environment'], [{'name': 'KAFKA_SSL_ENABLED', 'value': 'true'}]) - self.assertEqual(container['secrets'], [{'name': 'OTHER', 'valueFrom': '/other'}] + refs) - self.assertEqual(container['image'], 'new') - self.assertEqual(current['containerDefinitions'][0]['image'], 'old') - self.assertEqual(result['containerDefinitions'][1], current['containerDefinitions'][1]) - self.assertIn('healthCheck', container) - - def test_rollback_and_unhealthy_tasks_are_not_success(self): - """Only healthy tasks of the requested revision finish verification; rollback raises.""" - ecs = Mock() - service = {'status': 'ACTIVE', 'taskDefinition': 'new', 'desiredCount': 1, 'runningCount': 1, - 'pendingCount': 0, 'deployments': [{'rolloutState': 'COMPLETED'}]} - ecs.describe_services.return_value = {'services': [service]} - ecs.get_paginator.return_value.paginate.return_value = [{'taskArns': ['task']}] - task = {'taskDefinitionArn': 'new', 'healthStatus': 'UNHEALTHY', 'lastStatus': 'RUNNING'} - ecs.describe_tasks.return_value = {'tasks': [task]} - self.assertIsNone(release.verify_service(ecs, 'new')) - task['healthStatus'] = 'HEALTHY' - self.assertEqual(release.verify_service(ecs, 'new'), service) - service['deployments'][0]['rolloutState'] = 'FAILED' - with self.assertRaisesRegex(RuntimeError, 'failed'): - release.verify_service(ecs, 'new') - service['taskDefinition'] = 'old' - with self.assertRaisesRegex(RuntimeError, 'rolled back'): - release.verify_service(ecs, 'new') - - def test_wrong_account_stops_before_mutation(self): - """Production with dev credentials raises before ECR, ECS, SSM or Docker are called.""" - session = Mock(region_name='us-east-1') - session.client.return_value.get_caller_identity.return_value = {'Account': release.ACCOUNTS['dev']} - with patch.object(release.boto3, 'Session', return_value=session), \ - patch.object(release.sys, 'argv', ['release.py', 'production', 'candidate']), \ - patch.object(release.subprocess, 'run') as run: - with self.assertRaisesRegex(RuntimeError, 'account'): - release.main() - session.client.assert_called_once_with('sts') - run.assert_not_called() - - -if __name__ == '__main__': - unittest.main() From 66e90f663cb45d8d0fbd3a2632fcae465b222b29 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Nov 2026 21:21:55 +1100 Subject: [PATCH 7/9] Refresh deployment suite v2.0.5 and dev review prerequisites --- .circleci/config.yml | 25 +++++++++++++++++++------ deploy/MIGRATION.md | 17 +++++++++++++---- deploy/dev-healthcheck.json | 14 ++++++++++++++ deploy/dev.json | 19 +++++++++++++++++++ 4 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 deploy/dev-healthcheck.json create mode 100644 deploy/dev.json diff --git a/.circleci/config.yml b/.circleci/config.yml index 21bfab8..906ccc2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,22 +4,35 @@ executors: docker: - image: cimg/aws@sha256:4cf3768db00ddf9bc439b8c84e8a60c11a14edf81c8baf76f74132144f8340d5 commands: + checkout-source: + steps: + - run: + name: Check out the exact public source commit over HTTPS + command: | + set -euo pipefail + [[ "$CIRCLE_SHA1" =~ ^[0-9a-f]{40}$ ]] + export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 + git init --quiet . + git remote add origin https://github.com/topcoder-platform/forms-processor-v6.git + git -c credential.helper= fetch --quiet --depth 1 origin "$CIRCLE_SHA1" + git checkout --quiet --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "$CIRCLE_SHA1" checkout-suite: steps: - run: - name: Verify the reviewed deployment suite + name: Verify deployment suite v2.0.5 command: | set -euo pipefail git init --quiet /tmp/tc-deploy-scripts git -C /tmp/tc-deploy-scripts remote add origin https://github.com/topcoder-platform/tc-deploy-scripts.git - git -C /tmp/tc-deploy-scripts fetch --quiet --depth 1 origin bbc9b3d0c702947d26b6d9686d7b29104ebe461a + git -C /tmp/tc-deploy-scripts fetch --quiet --depth 1 origin refs/tags/v2.0.5 git -C /tmp/tc-deploy-scripts checkout --quiet --detach FETCH_HEAD - test "$(git -C /tmp/tc-deploy-scripts rev-parse HEAD)" = bbc9b3d0c702947d26b6d9686d7b29104ebe461a + test "$(git -C /tmp/tc-deploy-scripts rev-parse HEAD)" = bff574ab5c0b46773e25d2af1eff76675fba2ba5 jobs: build: executor: reviewed steps: - - checkout + - checkout-source - setup_remote_docker - checkout-suite - run: @@ -45,7 +58,7 @@ jobs: environment: DEPLOY_ENV: << parameters.environment >> steps: - - checkout + - checkout-source - setup_remote_docker - checkout-suite - attach_workspace: @@ -69,7 +82,7 @@ jobs: environment: TEST_DATABASE_URL: postgresql://postgres:forms_local@localhost:5432/forms_processor_test steps: - - checkout + - checkout-source - run: npm ci - run: name: Lint, type-check, test routing and retries, and compile diff --git a/deploy/MIGRATION.md b/deploy/MIGRATION.md index 8ca62d2..6432625 100644 --- a/deploy/MIGRATION.md +++ b/deploy/MIGRATION.md @@ -1,6 +1,6 @@ # OIDC deployment migration -This change replaces the Auth0 credential broker and SSM-to-shell deployment path with the reviewed [shared suite PR #40](https://github.com/topcoder-platform/tc-deploy-scripts/pull/40), pinned to `bbc9b3d0c702947d26b6d9686d7b29104ebe461a`. Build and deployment run in separate jobs. The deployer cannot read Parameter Store, Secrets Manager or decrypt KMS data, including through another assumed role. ECS continues resolving the existing runtime secret references using the execution role. +This change replaces the Auth0 credential broker and SSM-to-shell deployment path with [tc-deploy-scripts v2.0.5](https://github.com/topcoder-platform/tc-deploy-scripts/releases/tag/v2.0.5), fetched by tag and verified against commit `bff574ab5c0b46773e25d2af1eff76675fba2ba5`. Build and deployment run in separate jobs. The deployer cannot read Parameter Store, Secrets Manager or decrypt KMS data, including through another assumed role. ECS continues resolving the existing runtime secret references using the execution role. ## Production target @@ -9,14 +9,17 @@ The read-only inventory on 2026-11-03 verified account `409275337247`, cluster ` ## Infrastructure prerequisites, applied separately by an administrator 1. In each account, register the CircleCI OIDC issuer `https://oidc.circleci.com/org/947bef5e-1669-415d-b83c-4fc85155b7df` with client ID `947bef5e-1669-415d-b83c-4fc85155b7df`. The current production account has only the legacy Auth0 provider. Do not remove that provider until all its consumers have migrated. -2. Review and install [iam/prod-role.cloudformation.json](https://github.com/topcoder-platform/v6-services-iac/blob/main/services/forms-processor-v6/deploy/iam/prod-role.cloudformation.json) with an administrator/IaC identity. It creates the proposed deployment role and applies the same least-privilege policy as a permissions boundary. The trust binds this CircleCI project, repository, checkout branch and configuration branch. It does not grant infrastructure creation to CircleCI. -3. Configure ECR repository `forms-processor-v6` with unconditional `IMMUTABLE` tags and supported scan-on-push. Existing tags are not retagged. The release fails for MEDIUM/HIGH/CRITICAL/UNDEFINED findings, failed scans, or a mismatched digest. +2. Review and install [iam/prod-role.cloudformation.json](https://github.com/topcoder-platform/v6-services-iac/blob/main/services/forms-processor-v6/deploy/iam/prod-role.cloudformation.json) with an administrator/IaC identity. It creates the proposed deployment role and applies the same least-privilege policy as a permissions boundary. The trust binds this CircleCI project, repository, checkout branch and configuration branch. It does not grant infrastructure creation to CircleCI. The role and matching boundary include `inspector2:ListFindings` and `inspector2:ListCoverage` restricted to the deployment region; these scan-metadata APIs require `Resource: "*"`. Secret-access denies remain explicit. +3. Configure ECR repository `forms-processor-v6` with unconditional `IMMUTABLE` tags and supported scanning. Preserve existing enhanced scanning where enabled. Existing tags are not retagged. The release fails for MEDIUM/HIGH/CRITICAL/UNDEFINED findings, failed scans, or a mismatched digest. 4. In the infrastructure that owns the task definition, add the reviewed container check from `prod-healthcheck.json`, keeping all roles, runtime secret references, resources, mounts and sidecars. Verify the health endpoint against the running container in dev first. Preserve circuit-breaker rollback and positive desired capacity. Remove any `RESET_DB` secret binding and plaintext secret environment entries before adoption. Keep the execution role's runtime read permissions scoped to the references that service needs. 5. Create restricted `forms-processor-v6-dev-deploy` and `forms-processor-v6-prod-deploy` CircleCI contexts without Auth0/static AWS credentials. Restrict production to the reviewed `master` checkout and configuration ref, authorized operators, and approval job. Remove legacy credentials from project settings too: removing `org-global` alone does not remove project-level variables. Confirm `CIRCLE_OIDC_TOKEN_V3` is issued. Disable secret access for forks and SSH debugging in deployment jobs. ## Development acceptance before merge/release -The saved dev AWS session had expired during preparation. No dev cluster, role or family has been guessed. **This PR is a draft until an administrator verifies and commits `deploy/dev.json` for account `811668436784` and branch `develop`, with corresponding narrow IAM policy/trust.** Use the same schema as `prod.json`, but inspect the actual dev resources and roles; do not replace just the account number. The dev job fails closed while its manifest is absent. Branches other than `develop` and `master` build only and receive no deployment context. +Read-only inventory on 2026-11-04 verified `dev.json` against the actual development service, task/execution roles and image repositories. This PR targets `develop`. Matching IAM templates and detailed baselines remain private. **Keep this PR in draft until the service-specific prerequisites below are complete.** No AWS or CircleCI resources were provisioned by this refresh. + +- Install the service deployment role and matching permissions boundary. +- Create the restricted empty CircleCI deployment context. Run the shared offline suite, validate the CircleCI config and IAM policies, and test a dev release plus an unhealthy-image rollback. Verify CloudTrail contains no SSM/Secrets Manager calls from the deployment principal. Remove its old broker grants only after migration. Check the runtime secret references and roles stayed unchanged. Production requires the manual approval job and a passing digest/scan/health verification. @@ -29,3 +32,9 @@ Application lint/type checks, routing/retry unit tests, PostgreSQL integration t ## Infrastructure source IAM templates and detailed AWS baselines are maintained in the private [v6-services-iac service directory](https://github.com/topcoder-platform/v6-services-iac/tree/main/services/forms-processor-v6). Provision infrastructure separately with an administrator identity; application CI does not fetch that repository or install IAM. Required application manifests and health-check proposals remain here. + +## Dev branch refresh + +This PR targets `develop` and preserves its current application/dependency fixes. The shared suite is v2.0.5 (82 offline tests); service-specific adapters and their tests remain part of review. Keep this PR in draft until its documented infrastructure and integration prerequisites are met. Updating these files does not provision AWS or CircleCI resources. + +Source checkout uses the public HTTPS repository and verifies the exact CircleCI commit, avoiding dependency on failing SSH checkout keys. No GitHub token is added to build or deployment contexts. diff --git a/deploy/dev-healthcheck.json b/deploy/dev-healthcheck.json new file mode 100644 index 0000000..d23559b --- /dev/null +++ b/deploy/dev-healthcheck.json @@ -0,0 +1,14 @@ +{ + "forms-processor-v6": { + "command": [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + } +} diff --git a/deploy/dev.json b/deploy/dev.json new file mode 100644 index 0000000..a36697a --- /dev/null +++ b/deploy/dev.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "environment": "dev", + "accountId": "811668436784", + "region": "us-east-1", + "deployRoleArn": "arn:aws:iam::811668436784:role/forms-processor-v6-dev-deploy", + "taskRoleArn": "arn:aws:iam::811668436784:role/forms-processor-v6-dev-TaskRole-zeTo8YcPLgMU", + "executionRoleArn": "arn:aws:iam::811668436784:role/forms-processor-v6-dev-ExecutionRole-jvv8jQ05uWJh", + "cluster": "topcoder-infrastructure", + "service": "forms-processor-v6", + "taskFamily": "forms-processor-v6", + "circleciOrgId": "947bef5e-1669-415d-b83c-4fc85155b7df", + "circleciProjectId": "9abc16b0-0d52-490f-a4c0-6e3db3ede656", + "repository": "github.com/topcoder-platform/forms-processor-v6", + "branch": "develop", + "images": { + "forms-processor-v6": "forms-processor-v6" + } +} From 125ff4abb53281c6b8aa52a4c2788826c3a3473d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Nov 2026 21:22:54 +1100 Subject: [PATCH 8/9] Preserve npm link settings without credential-capable build inputs --- .npmrc | 1 - Dockerfile | 6 +++--- README.md | 2 +- deploy/MIGRATION.md | 2 ++ 4 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index c7d3517..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -install-links=true diff --git a/Dockerfile b/Dockerfile index 339e971..4bea4b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ ARG NODE_VERSION=26.12.1 FROM node:${NODE_VERSION}-alpine AS build WORKDIR /app -COPY package.json package-lock.json .npmrc ./ +COPY package.json package-lock.json ./ COPY tools ./tools -RUN npm ci +RUN npm ci --install-links=true COPY . . RUN npm run lint && npm run typecheck && npm test && npm run test:dependency-compat && npm run build FROM build AS production-dependencies -RUN npm prune --omit=dev +RUN npm prune --install-links=true --omit=dev FROM alpine:3.24 AS runtime ARG NODE_VERSION diff --git a/README.md b/README.md index 4a07de6..3d134d1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Submissions that do not opt in stay in the API and do not reach this processor. ```sh nvm use -npm ci +npm ci --install-links=true cp .env.example .env # Apply forms-api-v6 migrations to your local forms database first. npm run dev diff --git a/deploy/MIGRATION.md b/deploy/MIGRATION.md index 6432625..08cffb1 100644 --- a/deploy/MIGRATION.md +++ b/deploy/MIGRATION.md @@ -38,3 +38,5 @@ IAM templates and detailed AWS baselines are maintained in the private [v6-servi This PR targets `develop` and preserves its current application/dependency fixes. The shared suite is v2.0.5 (82 offline tests); service-specific adapters and their tests remain part of review. Keep this PR in draft until its documented infrastructure and integration prerequisites are met. Updating these files does not provision AWS or CircleCI resources. Source checkout uses the public HTTPS repository and verifies the exact CircleCI commit, avoiding dependency on failing SSH checkout keys. No GitHub token is added to build or deployment contexts. + +The credential-free builder rejects committed `.npmrc` files. Preserve the existing local compatibility dependencies with explicit `--install-links=true` npm options instead; do not reintroduce credential-capable npm configuration files. From c27f0054e1b81435c6dc59abd8fb65a19b213553 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 4 Nov 2026 21:30:42 +1100 Subject: [PATCH 9/9] Refresh deployment suite v2.0.5 and dev review prerequisites --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 906ccc2..cf20c95 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -83,7 +83,7 @@ jobs: TEST_DATABASE_URL: postgresql://postgres:forms_local@localhost:5432/forms_processor_test steps: - checkout-source - - run: npm ci + - run: npm ci --install-links=true - run: name: Lint, type-check, test routing and retries, and compile command: |