From ea7e29a7912848145a329efdd93076a6c4d01db8 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 25 Aug 2026 12:20:16 +0300 Subject: [PATCH 01/27] requestor token first auth --- README.md | 161 ++++++++++--- ...c-api-requestor-token-with-m2m-fallback.md | 159 +++++++++++++ src/config/tool-auth-fallback.config.ts | 11 + .../challenge/fetch-challenge-tool.test.ts | 19 +- .../tools/challenge/fetch-challenge-tool.ts | 37 +-- .../challenge/search-challenges-tool.test.ts | 18 +- .../tools/challenge/search-challenges-tool.ts | 36 +-- .../tools/project/fetch-project-tool.test.ts | 17 +- .../tools/project/fetch-project-tool.ts | 36 +-- src/utils/tc-api-client.test.ts | 220 ++++++++++++++++++ src/utils/tc-api-client.ts | 69 ++++++ 11 files changed, 688 insertions(+), 95 deletions(-) create mode 100644 docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md create mode 100644 src/config/tool-auth-fallback.config.ts create mode 100644 src/utils/tc-api-client.test.ts create mode 100644 src/utils/tc-api-client.ts diff --git a/README.md b/README.md index ae20fa5..1a22e01 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,23 @@ tc-ai-api/ │ │ ├── scorers/ │ │ │ └── skills-matching-scorers.ts # Evaluation scorers │ │ └── public/ # Static assets (empty) +│ ├── config/ +│ │ └── tool-auth-fallback.config.ts # Per-tool M2M fallback opt-in (off by default) │ └── utils/ │ ├── index.ts # Barrel re-exports │ ├── logger.ts # Pino logger configuration +│ ├── server-routes.ts # API_PREFIX / CHAT_ROUTE_PATH — shared by auth + middleware +│ ├── tc-api-client.ts # Requestor-token-first TC_API_BASE client, with M2M fallback │ ├── auth/ -│ │ └── index.ts # Auth0 composite auth setup +│ │ ├── index.ts # Auth0 composite auth setup (protected paths, mapUserToResourceId) +│ │ └── m2m.service.ts # Service M2M token acquisition │ ├── middleware/ │ │ ├── index.ts # Middleware registration │ │ └── resourceIdMiddleware.ts # Resource isolation middleware │ └── providers/ │ └── ollama.ts # Ollama AI provider config +├── docs/ +│ └── adr/ # Architecture decision records ├── Dockerfile # Production container image ├── appStartUp.sh # Container entrypoint ├── package.json @@ -114,6 +121,12 @@ tc-ai-api/ | `AUTH0_M2M_DOMAIN` | Yes\* | — | Auth0 domain for M2M JWT validation | | `AUTH0_M2M_AUDIENCE` | Yes\* | — | Auth0 audience for M2M tokens | | `DISABLE_AUTH` | No | `false` | Set to `"true"` to disable all authentication (dev mode) | +| `M2M_AUTH_CLIENT_ID` | No\*\* | — | Client id for tc-ai-api's own service M2M credential (`M2MService`) | +| `M2M_AUTH_CLIENT_SECRET` | No\*\* | — | Client secret for tc-ai-api's own service M2M credential | +| `M2M_AUTH_URL` | No | `https://topcoder-dev.auth0.com/oauth/token` | Token endpoint used to obtain the service M2M token | +| `M2M_AUTH_DOMAIN` | No | `topcoder-dev.auth0.com` | Auth0 domain for the service M2M credential | +| `M2M_AUTH_AUDIENCE` | No | `https://m2m.topcoder-dev.com/` | Auth0 audience for the service M2M credential | +| `M2M_AUTH_PROXY_SERVER_URL` | No | `https://auth0proxy.topcoder-dev.com/token` | Proxy used to request the service M2M token | | `JD_MAX_CHARS` | No | `6000` | Max character length for job description preprocessing | | `SKILL_MATCHING_FUZZY_MATCH_SIZE` | No | `3` | Number of candidates returned per fuzzy-match query | | `SKILL_MATCHING_CONCURRENCY` | No | `5` | Concurrency limit for parallel skill-matching requests | @@ -130,6 +143,7 @@ tc-ai-api/ | `CHALLENGE_SEARCH_AI_MODEL_ID` | No | `us.anthropic.claude-haiku-4-5` | Model id for `challenge-search-agent` | > \* Auth0 variables are required unless `DISABLE_AUTH=true`. +> \*\* `M2M_AUTH_CLIENT_ID`/`M2M_AUTH_CLIENT_SECRET` are only exercised if a tool is explicitly opted into `TOOL_M2M_FALLBACK_CONFIG` (`src/config/tool-auth-fallback.config.ts`) — no tool is today, so these aren't required for the currently-shipped behavior, only for future fallback use. --- @@ -147,12 +161,18 @@ export const mastra = new Mastra({ observability: new Observability({...}), // OpenTelemetry server: { port: 3000, - auth: apiAuthLayer, // CompositeAuth (Auth0) - middleware: middlewareConfig, // resourceIdMiddleware + apiPrefix: API_PREFIX, // '/v6/ai' — built-in Mastra routes live here + auth: apiAuthLayer, // CompositeAuth (Auth0) + middleware: middlewareConfig, // resourceIdMiddleware, registered per-route (see below) + apiRoutes: [ + chatRoute({ path: CHAT_ROUTE_PATH }), // '/chat/:agentId' — NOT under apiPrefix + ], }, }); ``` +`API_PREFIX` and `CHAT_ROUTE_PATH` come from `src/utils/server-routes.ts` — the single source of truth both the auth config and the middleware paths are built from, so they can't drift out of sync (see [Authentication & Middleware](#authentication--middleware)). + ### NPM Scripts | Script | Command | Description | @@ -198,28 +218,58 @@ postgresql://:@:/?schema= ## Authentication & Middleware +> See [ADR 0002](docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md) for the design rationale behind the outbound tool-call token flow described below. + ### Auth0 Composite Authentication -Authentication is handled by `CompositeAuth` from `@mastra/core/server`, which evaluates incoming JWTs against **two** Auth0 tenants: +Authentication is handled by `CompositeAuth` from `@mastra/core/server` (`src/utils/auth/index.ts`), which evaluates incoming JWTs against **two** Auth0 tenants, in order: 1. **Member tokens** — issued by `AUTH0_DOMAIN` with audience `AUTH0_AUDIENCE` 2. **M2M (machine-to-machine) tokens** — issued by `AUTH0_M2M_DOMAIN` with audience `AUTH0_M2M_AUDIENCE` -A request is authorized if it passes validation against **either** tenant. +A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*']` (the server's `apiPrefix` — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. + +Both providers also set `mapUserToResourceId`, deriving the caller's Topcoder user id from the JWT claim `https:///userId` (member tokens) or `sub` (M2M tokens) — see `tcUserIdClaimKey()` / `mapUserToResourceId` in `src/utils/auth/index.ts`. Mastra's core auth flow stores that value under `MASTRA_RESOURCE_ID_KEY` in the request context automatically, and it takes precedence over any client-supplied `resourceId`/`memory.resource` — this is what actually enforces per-user memory/thread isolation; the `Resource ID Middleware` below is a belt-and-suspenders check on top of it, not the primary mechanism. + +The same core auth flow also stores the **raw bearer token** that authenticated the request under `MASTRA_AUTH_TOKEN_KEY` in the request context. This is the "requestor token" referenced throughout this section and in ADR 0002 — see [Requestor Token Propagation to Topcoder Platform Tools](#requestor-token-propagation-to-topcoder-platform-tools) below. Authentication can be fully disabled by setting `DISABLE_AUTH=true` (useful for local development). ### Resource ID Middleware -When auth is enabled, the `resourceIdMiddleware` intercepts all `/api/*` requests and: +`resourceIdMiddleware` (`src/utils/middleware/resourceIdMiddleware.ts`) is a secondary, explicit check on top of `mapUserToResourceId` above. When auth is enabled it's registered against the two real route surfaces the server actually exposes (`src/utils/server-routes.ts` is the single source of truth for both): -1. Extracts the authenticated `user` object from the request context. +- `${API_PREFIX}/*` (i.e. `/v6/ai/*`) — the built-in Mastra routes (agents, workflows, memory, threads) +- `/chat/*` — `chatRoute()`, which is registered *outside* `apiPrefix` (custom API routes aren't prefixed by Mastra), so it needs its own entry + +For each matching request it: + +1. Extracts the authenticated `user` object from the request context (or authenticates the bearer/`apiKey` token itself if the framework hasn't populated it yet). 2. Derives the Topcoder domain from `TC_API_BASE` (e.g., `topcoder-dev.com`). 3. Reads the user ID from the JWT claim `https:///userId`, falling back to `sub` for M2M tokens. -4. Sets `MASTRA_RESOURCE_ID_KEY` in the request context, scoping all subsequent Mastra operations (memory, threads, state) to that user. +4. Sets `MASTRA_RESOURCE_ID_KEY` in the request context (redundant with `mapUserToResourceId`, but fails the request with a `401` if no user/id can be resolved at all). +5. Logs `'Auth resolved for request'` at `info` level with `authType` (`member`/`m2m`) and the resolved `resourceId`, for auth verification during rollout. This ensures **resource isolation** — each user's agent memory and workflow state are segregated. +### Requestor Token Propagation to Topcoder Platform Tools + +Mastra tools that call `TC_API_BASE` (fetching challenges/projects) are authorized as the **requesting user**, not a shared service account, by default. The mechanism (`src/utils/tc-api-client.ts`, `callTcApi()`): + +1. Reads the requestor's own token from `context.requestContext.get(MASTRA_AUTH_TOKEN_KEY)` — the same value the core auth flow set (see above). This works uniformly for a TC member JWT or an M2M JWT; the client makes no distinction between token types, it just forwards whatever authenticated the caller of `tc-ai-api`. +2. Calls the Topcoder platform endpoint with `Authorization: Bearer `. +3. Optionally, **only for a tool id explicitly listed as `true`** in `TOOL_M2M_FALLBACK_CONFIG` (`src/config/tool-auth-fallback.config.ts`, off/empty by default), retries **once** with tc-ai-api's own service M2M token (`M2MService.getM2MToken()`) if the requestor-token attempt came back `401`/`403`. Every fallback attempt is logged at `warn` level with the tool id and status code. + +| Tool | Auth | +| --- | --- | +| `fetch-challenge-by-id` | Requestor token only — no fallback configured | +| `search-challenges` | Requestor token only — no fallback configured | +| `fetch-project-by-id` | Requestor token only — no fallback configured | +| `standardized-skills-fuzzy-match` | Unauthenticated (public endpoint) — unaffected by this mechanism | +| `standardized-skills-semantic-search` | Unauthenticated (public endpoint) — unaffected by this mechanism | + +`TOOL_M2M_FALLBACK_CONFIG` currently has **no entries** — every tool above uses only whichever token the requestor authenticated with. The fallback path exists as reusable infrastructure for a future tool that needs it (see ADR 0002's "Resolution of open questions" for why the three existing Challenge/Project tools deliberately ship without a safety net: correctness of authorization was prioritized over availability). + --- ## Observability & Logging @@ -331,7 +381,7 @@ Rewrites a raw/rough job description into Topcoder's canonical structured format ## Tools -Six tools are defined under `src/mastra/tools/`, each a `createTool()` with a Zod input/output schema. The four Challenge/Project tools authenticate via `M2MService` (M2M JWT); the two Skills tools call unauthenticated public endpoints. +Six tools are defined under `src/mastra/tools/`, each a `createTool()` with a Zod input/output schema. The three Challenge/Project tools call `TC_API_BASE` authorized as the requesting user (see [Requestor Token Propagation to Topcoder Platform Tools](#requestor-token-propagation-to-topcoder-platform-tools)); the two Skills tools call unauthenticated public endpoints. | Tool ID | Purpose | Called by | | --- | --- | --- | @@ -369,7 +419,7 @@ Performs vector-based semantic search against the skills taxonomy. Returns match | Property | Value | | ---------- | -------------------------------------------------------------------- | | **ID** | `fetch-challenge-by-id` | -| **API** | `GET {TC_API_BASE}/v6/challenges/:challengeId` (M2M) | +| **API** | `GET {TC_API_BASE}/v6/challenges/:challengeId` (requestor token) | | **Input** | `{ challengeId: uuid }` | | **Output** | Full challenge object — `name`, `description`, `privateDescription`, `descriptionFormat`, `status`, `track`, `type`, `tags`, `skills`, `projectId`, `groups`, timeline dates, `prizeSets`, `reviewers`, `discussions`, `overview`, `task`, `legacy` | @@ -380,7 +430,7 @@ Fetches one challenge's full detail, including the reviewer-only `privateDescrip | Property | Value | | ---------- | -------------------------------------------------------------------- | | **ID** | `search-challenges` | -| **API** | `GET {TC_API_BASE}/v6/challenges` (M2M) | +| **API** | `GET {TC_API_BASE}/v6/challenges` (requestor token) | | **Input** | `{ projectId?, projectIds?, status?, approvalStatus?, types?, tracks?, tags?, groups?, updatedDateStart?, updatedDateEnd?, ids?, page?, perPage?, sortBy?, sortOrder? }` | | **Output** | `{ challenges: [...], total, page, perPage }` | @@ -401,7 +451,7 @@ The shared retrieval primitive behind both the search agent and the deterministi | Property | Value | | ---------- | -------------------------------------------------------------------- | | **ID** | `fetch-project-by-id` | -| **API** | `GET {TC_API_BASE}/v6/projects/:projectId` (M2M) | +| **API** | `GET {TC_API_BASE}/v6/projects/:projectId` (requestor token) | | **Input** | `{ projectId: string, fields?: string }` | | **Output** | `{ project: { id, name?, status?, type?, billingAccountId?, directProjectId?, techStack? } }` | @@ -627,30 +677,70 @@ sequenceDiagram sequenceDiagram participant Client participant Server as Mastra HTTP Server - participant CompositeAuth as CompositeAuth + participant CoreAuth as Mastra core auth flow participant MemberAuth as Auth0 (Member) participant M2MAuth as Auth0 (M2M) participant ResMiddleware as resourceIdMiddleware - Client->>Server: Request with Authorization: Bearer - Server->>CompositeAuth: Validate token + Client->>Server: Request to /v6/ai/* or /chat/:agentId
Authorization: Bearer + Server->>CoreAuth: checkRouteAuth() — CompositeAuth alt Member Token - CompositeAuth->>MemberAuth: Verify JWT (domain: auth.topcoder-dev.com) - MemberAuth-->>CompositeAuth: ✓ Valid — user claims + CoreAuth->>MemberAuth: Verify JWT (domain: AUTH0_DOMAIN) + MemberAuth-->>CoreAuth: ✓ Valid — user claims else M2M Token - CompositeAuth->>M2MAuth: Verify JWT (domain: topcoder-dev.auth0.com) - M2MAuth-->>CompositeAuth: ✓ Valid — M2M claims + CoreAuth->>M2MAuth: Verify JWT (domain: AUTH0_M2M_DOMAIN) + M2MAuth-->>CoreAuth: ✓ Valid — M2M claims end - CompositeAuth-->>Server: Authenticated user object + CoreAuth->>CoreAuth: mapUserToResourceId(user)
→ set MASTRA_RESOURCE_ID_KEY + CoreAuth->>CoreAuth: Store raw token
→ set MASTRA_AUTH_TOKEN_KEY + CoreAuth-->>Server: Authenticated — requestContext populated - Server->>ResMiddleware: /api/* interceptor - ResMiddleware->>ResMiddleware: Extract userId from
https://topcoder-dev.com/userId
or fallback to 'sub' claim - ResMiddleware->>ResMiddleware: Set MASTRA_RESOURCE_ID_KEY + Server->>ResMiddleware: /v6/ai/* or /chat/* interceptor + ResMiddleware->>ResMiddleware: Extract userId from
https:///userId
or fallback to 'sub' claim + ResMiddleware->>ResMiddleware: Confirm/set MASTRA_RESOURCE_ID_KEY
log authType + resourceId ResMiddleware-->>Server: Continue to handler ``` +### Topcoder Platform Tool Call — Requestor Token Flow + +`MASTRA_AUTH_TOKEN_KEY`, set once during authentication above, is threaded automatically by Mastra core all the way from the HTTP request into every tool a triggered agent run calls — no extra plumbing required. This is what lets `callTcApi()` forward the requestor's own token instead of a shared service credential: + +```mermaid +sequenceDiagram + participant Client + participant ChatRoute as chatRoute() handler + participant Agent as Mastra Agent + participant Tool as fetch-challenge-by-id /
search-challenges /
fetch-project-by-id + participant TcApiClient as callTcApi() + participant TC as Topcoder Platform API + participant M2M as M2MService (fallback only) + + Client->>ChatRoute: POST /chat/:agentId
Authorization: Bearer + Note over ChatRoute: MASTRA_AUTH_TOKEN_KEY already set
on requestContext by core auth + ChatRoute->>Agent: stream(messages, { requestContext }) + Agent->>Tool: execute(inputData, { requestContext }) + Tool->>TcApiClient: callTcApi({ toolId, url, requestContext }) + TcApiClient->>TcApiClient: token = requestContext.get(MASTRA_AUTH_TOKEN_KEY) + TcApiClient->>TC: GET/POST ... Authorization: Bearer + + alt 2xx / non-401/403 + TC-->>TcApiClient: Response + else 401 or 403 AND toolId listed in TOOL_M2M_FALLBACK_CONFIG + TcApiClient->>TcApiClient: log warn (toolId, status) + TcApiClient->>M2M: getM2MToken() + M2M-->>TcApiClient: service M2M token + TcApiClient->>TC: Retry once — Authorization: Bearer + TC-->>TcApiClient: Response + end + + TcApiClient-->>Tool: Response + Tool-->>Agent: Mapped result +``` + +`fetch-challenge-by-id`, `search-challenges`, and `fetch-project-by-id` are **not** listed in `TOOL_M2M_FALLBACK_CONFIG` today, so for them the "else" branch never fires — a 401/403 from the requestor's own token is returned as-is. + ### Agent Interaction — Term Extraction Detail ```mermaid @@ -704,21 +794,32 @@ The service communicates with the following external systems: These are **unauthenticated** calls (no bearer token forwarded). The API base URL is configured via `TC_API_BASE`. -### 2. Ollama LLM API +### 2. Topcoder Challenges & Projects API (v6) + +| Endpoint | Method | Purpose | Called By | +| ------------------------------------------- | ------ | ------------------------------------ | ----------------------- | +| `{TC_API_BASE}/v6/challenges/:challengeId` | `GET` | Fetch full challenge detail | `fetchChallengeTool` | +| `{TC_API_BASE}/v6/challenges` | `GET` | Filtered/paginated challenge search | `searchChallengesTool` | +| `{TC_API_BASE}/v6/projects/:projectId` | `GET` | Resolve a `projectId` reference | `fetchProjectTool` | + +Authorized as the **requesting user** — the bearer token that authenticated the caller of `tc-ai-api` (member or M2M) is forwarded as-is via `callTcApi()`. See [Requestor Token Propagation to Topcoder Platform Tools](#requestor-token-propagation-to-topcoder-platform-tools) and [ADR 0002](docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md). No M2M fallback is configured for these three today. + +### 3. Ollama LLM API | Endpoint | Method | Purpose | Called By | | --------------------------- | ------ | ----------------------------------------------- | ---------------------------------- | | `{OLLAMA_API_URL}/api/chat` | `POST` | Streaming chat completion with `mistral:latest` | `skillsMatchingAgent` (via AI SDK) | | `{OLLAMA_API_URL}/api/chat` | `POST` | Evaluation model inference | Evaluation scorers | -### 3. Auth0 +### 4. Auth0 -| Endpoint | Purpose | -| -------------------------------------------------- | ---------------------------------- | -| `https://{AUTH0_DOMAIN}/.well-known/jwks.json` | JWKS for member token verification | -| `https://{AUTH0_M2M_DOMAIN}/.well-known/jwks.json` | JWKS for M2M token verification | +| Endpoint | Purpose | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `https://{AUTH0_DOMAIN}/.well-known/jwks.json` | JWKS for member token verification | +| `https://{AUTH0_M2M_DOMAIN}/.well-known/jwks.json` | JWKS for M2M token verification | +| `{M2M_AUTH_URL}` (proxied via `M2M_AUTH_PROXY_SERVER_URL`) | Issues tc-ai-api's own service M2M token (`M2MService`) — used only as an unconfigured fallback credential, not the default for any tool today | -### 4. PostgreSQL +### 5. PostgreSQL | Purpose | Connection | | --------------------------------------- | --------------------------------------------------- | diff --git a/docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md b/docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md new file mode 100644 index 0000000..9e6c3dc --- /dev/null +++ b/docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md @@ -0,0 +1,159 @@ +# ADR 0002 — Requestor-token-first authorization for Topcoder platform API calls, with per-tool M2M fallback + +- **Status:** **Accepted** (proposed 2026-08-25, accepted 2026-08-25) — implemented on this branch +- **Date:** 2026-08-25 +- **Target branch:** `challenges-rag` +- **Related:** [ADR 0001](0001-integrate-challenges-vector-rag.md); the `mapUserToResourceId` / `resourceIdMiddleware` fix landed earlier on this branch (`src/utils/auth/index.ts`, `src/utils/middleware/resourceIdMiddleware.ts`) — this ADR closes the equivalent authorization gap for *outbound* calls to the Topcoder platform, the way that fix closed it for *inbound* memory/thread access. + +## Context + +### The problem + +Every Mastra tool in this repo that calls the Topcoder platform (`TC_API_BASE`) today does so with one of two authorization postures, and neither is "authorize as the person who's actually asking": + +1. **Hardcoded service M2M token, always.** `fetch-challenge-by-id`, `search-challenges`, and `fetch-project-by-id` unconditionally call `M2MService.getM2MToken()` and use that for every request, regardless of who is chatting. An M2M service credential typically has broad, service-wide read access — so a user could have a challenge, project, or scorecard returned to them by these tools that they would *not* be able to see if they queried the Topcoder platform with their own account. This is the same class of bug the resource-ID isolation fix addressed for memory/threads, just on the outbound side. +2. **No Authorization header at all.** `standardized-skills-semantic-search`, `standardized-skills-fuzzy-match`, and the internal `fetchScorecard()` helper in `challenge-context-workflow.ts` call `TC_API_BASE` anonymously. Whether that's intentional (genuinely public catalog data) or an oversight hasn't been confirmed with the platform/API owners. + +There is also no shared client — all six call sites hand-roll their own `fetch()`, headers, timeout, and error handling. + +### What the framework already gives us + +Mastra core already resolves and preserves the **requestor's own bearer token** for every authenticated HTTP request, with no additional plumbing required: + +- `coreAuthMiddleware` (inside `@mastra/core`'s server auth flow — the same code path documented in ["Reserved keys"](https://mastra.ai/docs/server/request-context) and exercised by `apiAuthLayer`/`CompositeAuth` in this repo) sets `MASTRA_AUTH_TOKEN_KEY` on the request's `RequestContext` immediately after a token is successfully authenticated, on *every* protected route — both the built-in `/v6/ai/*` routes and the custom `chatRoute()` at `/chat/:agentId`. +- That exact `RequestContext` instance is what `chatRoute()` (`@mastra/ai-sdk`) forwards into `agent.stream()` / `agent.generate()` as `params.requestContext`. +- Mastra threads that same `RequestContext` automatically into every tool invocation triggered during that run, exposed as `context.requestContext` on a tool's `execute(inputData, context)` — confirmed in the installed `@mastra/core@1.61.0` types (`dist/tools/types.d.ts`, `ToolExecutionContext.requestContext: RequestContext`) and in the embedded docs ("Accessing values with tools"). + +**So "preserve the token so it can be passed to tools" is already solved by the framework.** No new storage layer, no new middleware, no session store. What's actually missing is: (a) tools don't read `context.requestContext.get(MASTRA_AUTH_TOKEN_KEY)` at all today, and (b) there's no consistent, reviewable policy for *when* a tool is allowed to fall back to the privileged service M2M token instead. + +### Terminology (to avoid confusion between two different tokens) + +| Term | What it is | Where it comes from | +| --- | --- | --- | +| **Requestor token** | The bearer token the *caller of tc-ai-api* authenticated with (a TC member Auth0 JWT, or an M2M JWT if the caller itself is a service) | `context.requestContext.get(MASTRA_AUTH_TOKEN_KEY)`, set by Mastra's core auth flow (`apiAuthLayer`) | +| **Service M2M token** | tc-ai-api's *own* machine-to-machine credential, used to call the Topcoder platform on the tool's behalf | `M2MService.getM2MToken()` (`src/utils/auth/m2m.service.ts`), unchanged by this ADR | + +## Scope + +**In scope:** +- A shared, reusable client for all outbound calls from Mastra tools/workflow steps to `TC_API_BASE`. +- Requestor-token-first request flow for that client — the same bearer token that authenticated the caller of tc-ai-api (member JWT or M2M JWT alike) is forwarded as-is; the client makes no distinction between token types. +- A single, explicit, off-by-default settings map controlling — per tool ID — whether a 401/403 on the requestor-token attempt is allowed to retry with the service M2M token. +- Migrating the three currently M2M-only tools (`fetch-challenge-by-id`, `search-challenges`, `fetch-project-by-id`) onto the shared client, requestor-token-only (no fallback entry). + +**Out of scope (explicitly, confirmed at review):** +- The two currently-anonymous tools (`standardized-skills-semantic-search`, `standardized-skills-fuzzy-match`) — public endpoints, left unmodified. +- The internal `fetchScorecard()` helper in `challenge-context-workflow.ts` — also anonymous today, left unmodified. +- `src/scripts/ingest-challenges.ts` and `src/scripts/sync-challenges.ts` — these are offline CLI jobs with no HTTP request, no requestor, and no `RequestContext`; they don't call `TC_API_BASE` today (confirmed by search) and aren't affected. +- Changing what `M2MService` is or how it obtains/caches tokens. +- Changing `apiAuthLayer`, `resourceIdMiddleware`, or anything upstream of tool execution — those are already fixed to correctly populate `MASTRA_AUTH_TOKEN_KEY` and `MASTRA_RESOURCE_ID_KEY`. +- Any change to which Topcoder platform endpoints exist or what they return. + +## Affected call sites (all `TC_API_BASE` usage in `src/`) + +| # | File | Tool / caller id | Endpoint | Auth before this ADR | Auth after this ADR | +| --- | --- | --- | --- | --- | --- | +| 1 | `src/mastra/tools/challenge/fetch-challenge-tool.ts` | `fetch-challenge-by-id` | `GET /v6/challenges/:id` | M2M only | **Requestor token, always** (member or M2M JWT, whichever authenticated the caller) — no fallback configured | +| 2 | `src/mastra/tools/challenge/search-challenges-tool.ts` | `search-challenges` | `GET /v6/challenges` | M2M only | **Requestor token, always** — no fallback configured | +| 3 | `src/mastra/tools/project/fetch-project-tool.ts` | `fetch-project-by-id` | `GET /v6/projects/:id` | M2M only | **Requestor token, always** — no fallback configured | +| 4 | `src/mastra/tools/skills/standardized-skills-semantic-tool.ts` | `standardized-skills-semantic-search` | `POST /v5/standardized-skills/skills/semantic-search` | None | Unchanged — out of scope, public endpoint | +| 5 | `src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts` | `standardized-skills-fuzzy-match` | `GET /v5/standardized-skills/skills/fuzzymatch` | None | Unchanged — out of scope, public endpoint | +| 6 | `src/mastra/workflows/challenge/challenge-context-workflow.ts` (`fetchScorecard()`, ~line 481, called from the `parse` step ~line 412) | not a tool — internal helper | `GET /v6/scorecards/:id` | None | Unchanged — out of scope, public endpoint | + +Also grepped and confirmed **not** affected: `src/mastra/tools/challenge/challenge-vector-query-tool.ts` (queries pgvector, not `TC_API_BASE`), the agents under `src/mastra/agents/`, and both ingestion/sync CLI scripts. + +## Decision + +1. **Add a shared TC API client** (`src/utils/tc-api-client.ts`) that every call site above uses instead of hand-rolling `fetch()`. Its contract: + ```ts + async function callTcApi(options: { + toolId: string; // matches createTool({ id }) — the fallback config key + url: string; + init?: RequestInit; // method, body — Authorization/Content-Type are added by the client + requestContext: RequestContext | undefined; + }): Promise + ``` +2. **Requestor-token-first.** The client reads `requestContext?.get(MASTRA_AUTH_TOKEN_KEY)` and, if present, makes the request with `Authorization: Bearer `. This is the default and only path for any `toolId` not opted into fallback. +3. **Fallback trigger.** If the requestor-token attempt returns HTTP `401` or `403` (or there was no requestor token to try), the client checks `TOOL_M2M_FALLBACK_CONFIG[toolId]`. +4. **Fallback config map, off by default.** A single file (`src/config/tool-auth-fallback.config.ts`) maps tool ID → boolean. A tool ID absent from the map is treated as `false`. Only when the entry is `true` does the client retry once with `M2MService.getM2MToken()`. +5. **No requestor token at all** (tool invoked outside an authenticated request — dev harness, future non-chat entrypoint): if fallback is enabled for that `toolId`, go straight to the M2M attempt without wasting a request; if not enabled, fail fast with a clear error rather than silently downgrading to M2M. +6. **Exactly one fallback retry**, no further backoff/retry loop — if the M2M attempt also fails, the error propagates to the tool/agent as-is. +7. **Every fallback event is logged** (`tcAILogger.warn`) with the `toolId` and the HTTP status that triggered it — for the same auditability reason the auth-resolution log was added to `resourceIdMiddleware`. Never log the token itself (neither requestor nor M2M). +8. **The three currently-M2M tools ship requestor-token-only** (confirmed at review — see Resolution of open questions, below): `TOOL_M2M_FALLBACK_CONFIG` has no entries for them at all. The client makes no distinction between a member JWT and an M2M JWT — whichever token authenticated the caller of tc-ai-api is simply forwarded to the Topcoder platform call. This is intentional, not a placeholder: the fallback mechanism (items 3–7) exists as reusable infrastructure for a future tool that needs it, not because these three need it. + +### Config surface + +```ts +// src/config/tool-auth-fallback.config.ts +export const TOOL_M2M_FALLBACK_CONFIG: Record = { + // Off by default. Add `true` only for a tool ID that must keep working + // even when the requestor's own token can't reach the endpoint — treat + // flipping this to `true` as a reviewable privilege-escalation decision, + // not a default. +}; +``` + +## Implementation plan (as executed) + +### Phase 0 — Shared client +- `src/utils/tc-api-client.ts`: `callTcApi()` per the contract above, plus default headers (`Content-Type`, `app-version`); timeout stays caller-supplied via `init.signal`. +- `src/config/tool-auth-fallback.config.ts`: the config map, starts and ships **empty** — off by default, and no entries are added for the three tools migrated in Phase 1 (see Decision item 8). +- `src/utils/tc-api-client.test.ts`: unit tests in isolation (mocked `fetch`, mocked `M2MService`, mocked config map) covering requestor success; requestor 401/403 with no fallback configured (passes through, no M2M call); requestor 401/403 with fallback enabled (single M2M retry, both outcomes); non-401/403 error status (no fallback attempted); no requestor token with fallback on/off; default headers. + +### Phase 1 — Migrate the three M2M-only tools +- `fetch-challenge-tool.ts`, `search-challenges-tool.ts`, `fetch-project-tool.ts`: replaced the direct `M2MService` + `fetch()` calls with `callTcApi({ toolId: , requestContext: context.requestContext, ... })`. Dropped the module-level `const m2mService = new M2MService()` from each tool file (only `tc-api-client.ts` instantiates it now, used solely on the fallback path these three don't exercise). +- Updated each tool's existing `*.test.ts` to supply a stub `context.requestContext` returning a fake requestor token (instead of relying on the mocked `M2MService`), and updated the "Authorization" assertions accordingly. + +### Phase 2 — Anonymous tools (decided: not touched) +- `standardized-skills-semantic-tool.ts`, `standardized-skills-fuzzy-tool.ts`, and `challenge-context-workflow.ts`'s `fetchScorecard()` are confirmed public/no-auth endpoints and are **left exactly as they were** — no code change. + +### Phase 3 — Validation +- `npx tsc --noEmit`, `npx eslint`, full `vitest run` — all green (359 tests, up from 348: 49 updated across the three migrated tools' existing suites + 11 new in `tc-api-client.test.ts`). +- Manual verification still recommended before merge: hit `/chat/:agentId` as a real TC member and confirm (via the `tcAILogger.info('Auth resolved for request', ...)` log) that the same request's tool calls reach the Topcoder platform with that member's own token. + +## File-level mapping + +| File | Change | +| --- | --- | +| `src/utils/tc-api-client.ts` | **New** — shared client | +| `src/config/tool-auth-fallback.config.ts` | **New** — fallback settings map | +| `src/mastra/tools/challenge/fetch-challenge-tool.ts` | Modified — use `callTcApi`, requestor token only | +| `src/mastra/tools/challenge/search-challenges-tool.ts` | Modified — use `callTcApi`, requestor token only | +| `src/mastra/tools/project/fetch-project-tool.ts` | Modified — use `callTcApi`, requestor token only | +| `src/mastra/tools/skills/standardized-skills-semantic-tool.ts` | **Unchanged** — confirmed out of scope | +| `src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts` | **Unchanged** — confirmed out of scope | +| `src/mastra/workflows/challenge/challenge-context-workflow.ts` | **Unchanged** — confirmed out of scope | +| `src/utils/tc-api-client.test.ts` | **New** — client unit tests (11 tests) | +| `src/mastra/tools/challenge/fetch-challenge-tool.test.ts` | Modified — requestor-token context stub, updated Authorization assertion | +| `src/mastra/tools/challenge/search-challenges-tool.test.ts` | Modified — requestor-token context stub, updated Authorization assertion | +| `src/mastra/tools/project/fetch-project-tool.test.ts` | Modified — requestor-token context stub, updated Authorization assertion | + +## Consequences + +**Positive** +- TC platform tool calls are authorized as the actual requesting user by default — closes the outbound half of the authorization gap the memory/thread `resourceId` fix closed on the inbound half. +- M2M privilege use becomes an explicit, auditable, per-tool opt-in instead of an implicit default — a reviewer can see exactly which tools can escalate to the service credential and why. +- One place (`tc-api-client.ts`) owns headers, timeout, and retry policy for every Topcoder platform call instead of six independent copies. + +**Negative / risk** +- **No safety net for these three tools.** Because they ship without a fallback entry, if a specific member's own token is ever rejected by `/v6/challenges` or `/v6/projects` for reasons unrelated to legitimate access control (token edge cases, a platform-side regression), the tool call fails outright — there is no automatic M2M retry to mask it. This is the deliberate trade-off in Decision item 8: correctness of authorization (never showing a user data their own token can't see) was prioritized over availability. If this turns out to be too strict in practice, enabling fallback for one of these tool IDs is a one-line change to `TOOL_M2M_FALLBACK_CONFIG`. +- Adds one extra network round trip only on the fallback path (for any *future* tool that opts in) — no added latency for the three tools migrated here, since they never attempt a second request. +- The fallback config map is a new place privilege escalation can be silently widened; needs to be covered by code review norms (any PR flipping an entry to `true` should say why). + +## Resolution of open questions (2026-08-25) + +The three open questions blocking implementation were resolved at review, confirmed by the requester: + +1. **Do the TC v6 Challenges/Projects APIs accept a member's own token?** — **Confirmed: yes.** This validates requestor-token-first as viable for all three tools without needing platform-side changes. +2. **Initial fallback-flag value for the three currently-M2M tools?** — **Resolved: no fallback at all.** These three ship passing the requestor's token through directly, unconditionally, with nothing in `TOOL_M2M_FALLBACK_CONFIG` for them. Explicitly confirmed to apply uniformly regardless of token type (TC member JWT or M2M JWT) — the client doesn't branch on which kind of token it received, it simply forwards whatever authenticated the caller. This is stricter than the ADR's original recommendation (which proposed starting fallback `true` as a safety net) — see the Negative/risk note above for the trade-off this accepts. +3. **Should the anonymous tools/workflow call join this pattern?** — **Resolved: no, leave them exactly as they are.** No code changes were made to `standardized-skills-semantic-tool.ts`, `standardized-skills-fuzzy-tool.ts`, or `challenge-context-workflow.ts`. + +Two lower-priority open items from the original draft remain genuinely open (not blocking, since nothing in the current implementation depends on them): +- Whether a single fallback retry with no backoff is the right long-term policy for a tool that *does* opt into fallback — untested in production since no tool currently exercises that path. +- Whether `tcAILogger.warn` is the right level for fallback events — same caveat, not yet exercised outside unit tests. + +## Prerequisites — status + +- ~~Answers to Open Questions 1–3~~ — resolved above. +- `MASTRA_AUTH_TOKEN_KEY` (`@mastra/core/request-context`) usage — implemented and covered by `src/utils/tc-api-client.test.ts`; no issues surfaced. +- `M2MService`'s call pattern is now genuinely "only when a future tool opts into fallback" rather than "always" for these three tools — no code change was needed in `M2MService` itself, confirming the original assumption. diff --git a/src/config/tool-auth-fallback.config.ts b/src/config/tool-auth-fallback.config.ts new file mode 100644 index 0000000..de0effa --- /dev/null +++ b/src/config/tool-auth-fallback.config.ts @@ -0,0 +1,11 @@ +/** + * Per-tool opt-in for falling back to the service M2M token when a + * Topcoder platform call made with the requestor's own token fails with + * 401/403. See docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. + * + * A tool id absent from this map is treated as `false`. Off by default — + * add an entry (`true`) only when a tool must keep working even if the + * requestor's own token can't reach the endpoint it calls. Treat flipping + * an entry to `true` as a reviewable privilege-escalation decision. + */ +export const TOOL_M2M_FALLBACK_CONFIG: Record = {}; diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.test.ts b/src/mastra/tools/challenge/fetch-challenge-tool.test.ts index b65b0dd..621f35c 100644 --- a/src/mastra/tools/challenge/fetch-challenge-tool.test.ts +++ b/src/mastra/tools/challenge/fetch-challenge-tool.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MASTRA_AUTH_TOKEN_KEY } from '@mastra/core/request-context'; +// The tool no longer instantiates M2MService directly — the shared +// tc-api-client does, only on the (untested-here) M2M fallback path. Mocked +// so importing the client module doesn't construct a real M2M auth client. const { m2mTokenMock } = vi.hoisted(() => ({ m2mTokenMock: vi.fn(), })); @@ -12,9 +16,14 @@ vi.mock('../../../utils/auth/m2m.service', () => ({ import { fetchChallengeTool } from './fetch-challenge-tool'; -// Minimal context for execute — the tool only uses context.mastra?.getLogger?.() -// which is optional, so undefined mastra is safe. -const minimalContext = { mastra: undefined } as any; +// Minimal context for execute — the tool uses context.mastra?.getLogger?.() +// (optional) and context.requestContext (to read the requestor's token). +const minimalContext = { + mastra: undefined, + requestContext: { + get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? 'fake-requestor-token' : undefined), + }, +} as any; // Valid UUID v4 (zod 4's .uuid() rejects version-0 UUIDs) const CHALLENGE_UUID = '550e8400-e29b-41d4-a716-446655440000'; @@ -179,13 +188,13 @@ describe('fetchChallengeTool — app-version header', () => { expect(callArgs[1].headers['app-version']).toBe('2.0.0'); }); - it('includes Authorization bearer token from M2MService', async () => { + it('includes Authorization bearer token from the requestor', async () => { const fetchMock = mockFetchResponse(baseApiResponse()); await executeTool(CHALLENGE_UUID); const callArgs = fetchMock.mock.calls[0] as [string, any]; - expect(callArgs[1].headers.Authorization).toBe('Bearer fake-m2m-token'); + expect(callArgs[1].headers.Authorization).toBe('Bearer fake-requestor-token'); }); }); diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.ts b/src/mastra/tools/challenge/fetch-challenge-tool.ts index 793c384..d6f30e4 100644 --- a/src/mastra/tools/challenge/fetch-challenge-tool.ts +++ b/src/mastra/tools/challenge/fetch-challenge-tool.ts @@ -1,17 +1,21 @@ -// Challenge API: GET /v6/challenges/:challengeId (M2M token required) +// Challenge API: GET /v6/challenges/:challengeId // Fetches full challenge details from the Topcoder API by challenge ID. +// +// Authorized as the requestor by default (their own token is forwarded +// as-is); no M2M fallback configured for this tool — see +// docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; -import { M2MService } from '../../../utils/auth/m2m.service'; +import type { RequestContext } from '@mastra/core/request-context'; +import { callTcApi } from '../../../utils/tc-api-client'; +const TOOL_ID = 'fetch-challenge-by-id'; const BASE_URL = `${process.env.TC_API_BASE}/v6/challenges`; -const m2mService = new M2MService(); - export const fetchChallengeTool = createTool({ - id: 'fetch-challenge-by-id', + id: TOOL_ID, description: - 'Fetches a Topcoder challenge by its UUID from the Topcoder v6 Challenges API using M2M authentication', + 'Fetches a Topcoder challenge by its UUID from the Topcoder v6 Challenges API, authorized as the requesting user', inputSchema: z.object({ challengeId: z.string().uuid().describe('UUID of the Topcoder challenge to fetch'), }), @@ -92,23 +96,20 @@ export const fetchChallengeTool = createTool({ logger?.info('Fetching challenge by ID: {challengeId}', { challengeId: inputData.challengeId, }); - return await fetchChallenge(inputData.challengeId); + return await fetchChallenge(inputData.challengeId, context.requestContext); }, }); -const fetchChallenge = async (challengeId: string) => { - const token = await m2mService.getM2MToken(); - +const fetchChallenge = async (challengeId: string, requestContext: RequestContext | undefined) => { const url = `${BASE_URL}/${encodeURIComponent(challengeId)}`; - const response = await fetch(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'app-version': '2.0.0', - + const response = await callTcApi({ + toolId: TOOL_ID, + url, + init: { + method: 'GET', + signal: AbortSignal.timeout(15_000), }, - signal: AbortSignal.timeout(15_000), + requestContext, }); if (!response.ok) { diff --git a/src/mastra/tools/challenge/search-challenges-tool.test.ts b/src/mastra/tools/challenge/search-challenges-tool.test.ts index d0f9338..034895c 100644 --- a/src/mastra/tools/challenge/search-challenges-tool.test.ts +++ b/src/mastra/tools/challenge/search-challenges-tool.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MASTRA_AUTH_TOKEN_KEY } from '@mastra/core/request-context'; +// The tool no longer instantiates M2MService directly — the shared +// tc-api-client does, only on the (untested-here) M2M fallback path. Mocked +// so importing the client module doesn't construct a real M2M auth client. const { m2mTokenMock } = vi.hoisted(() => ({ m2mTokenMock: vi.fn(), })); @@ -12,8 +16,14 @@ vi.mock('../../../utils/auth/m2m.service', () => ({ import { searchChallengesTool } from './search-challenges-tool'; -// Minimal context — the tool only uses context.mastra?.getLogger?.() -const minimalContext = { mastra: undefined } as any; +// Minimal context — the tool uses context.mastra?.getLogger?.() (optional) +// and context.requestContext (to read the requestor's token). +const minimalContext = { + mastra: undefined, + requestContext: { + get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? 'fake-requestor-token' : undefined), + }, +} as any; /** * Installs a global fetch spy that resolves with the given JSON body. @@ -133,13 +143,13 @@ describe('searchChallengesTool — request construction', () => { expect(query).not.toContain('types=Challenge,Task'); }); - it('includes Authorization bearer token from M2MService', async () => { + it('includes Authorization bearer token from the requestor', async () => { const fetchMock = mockFetchResponse([]); await executeTool({}); const [, init] = fetchMock.mock.calls[0] as [string, any]; - expect(init.headers.Authorization).toBe('Bearer fake-m2m-token'); + expect(init.headers.Authorization).toBe('Bearer fake-requestor-token'); }); it('forwards projectIds as comma-separated query parameter', async () => { diff --git a/src/mastra/tools/challenge/search-challenges-tool.ts b/src/mastra/tools/challenge/search-challenges-tool.ts index e1fd765..35051fb 100644 --- a/src/mastra/tools/challenge/search-challenges-tool.ts +++ b/src/mastra/tools/challenge/search-challenges-tool.ts @@ -1,15 +1,19 @@ -// Challenge API: GET /v6/challenges (M2M token required) +// Challenge API: GET /v6/challenges // Searches Topcoder challenges with filters via the v6 Challenges API. // The v6 endpoint returns a bare JSON array (not a paginated envelope); // this tool wraps it into { challenges, total, page, perPage } for callers. +// +// Authorized as the requestor by default (their own token is forwarded +// as-is); no M2M fallback configured for this tool — see +// docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; -import { M2MService } from '../../../utils/auth/m2m.service'; +import type { RequestContext } from '@mastra/core/request-context'; +import { callTcApi } from '../../../utils/tc-api-client'; +const TOOL_ID = 'search-challenges'; const BASE_URL = `${process.env.TC_API_BASE}/v6/challenges`; -const m2mService = new M2MService(); - const challengeSummarySchema = z.object({ id: z.string(), name: z.string(), @@ -25,9 +29,9 @@ const challengeSummarySchema = z.object({ }); export const searchChallengesTool = createTool({ - id: 'search-challenges', + id: TOOL_ID, description: - 'Searches Topcoder challenges via the v6 Challenges API using M2M authentication with filter support (projectId, status, types, tracks, tags, groups, dates, pagination)', + 'Searches Topcoder challenges via the v6 Challenges API, authorized as the requesting user, with filter support (projectId, status, types, tracks, tags, groups, dates, pagination)', inputSchema: z.object({ projectId: z.string().optional(), projectIds: z.array(z.string()).optional(), @@ -54,7 +58,7 @@ export const searchChallengesTool = createTool({ execute: async (inputData, context) => { const logger = context.mastra?.getLogger?.(); logger?.info('Searching challenges with filters'); - return await searchChallenges(inputData); + return await searchChallenges(inputData, context.requestContext); }, }); @@ -143,20 +147,18 @@ function mapChallenge(raw: any) { }; } -const searchChallenges = async (input: SearchChallengesInput) => { - const token = await m2mService.getM2MToken(); - +const searchChallenges = async (input: SearchChallengesInput, requestContext: RequestContext | undefined) => { const params = buildQueryParams(input); const url = `${BASE_URL}?${params.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'app-version': '2.0.0', + const response = await callTcApi({ + toolId: TOOL_ID, + url, + init: { + method: 'GET', + signal: AbortSignal.timeout(15_000), }, - signal: AbortSignal.timeout(15_000), + requestContext, }); if (!response.ok) { diff --git a/src/mastra/tools/project/fetch-project-tool.test.ts b/src/mastra/tools/project/fetch-project-tool.test.ts index acef7ea..2c41444 100644 --- a/src/mastra/tools/project/fetch-project-tool.test.ts +++ b/src/mastra/tools/project/fetch-project-tool.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MASTRA_AUTH_TOKEN_KEY } from '@mastra/core/request-context'; +// The tool no longer instantiates M2MService directly — the shared +// tc-api-client does, only on the (untested-here) M2M fallback path. Mocked +// so importing the client module doesn't construct a real M2M auth client. const { m2mTokenMock } = vi.hoisted(() => ({ m2mTokenMock: vi.fn(), })); @@ -12,9 +16,14 @@ vi.mock('../../../utils/auth/m2m.service', () => ({ import { fetchProjectTool } from './fetch-project-tool'; -// Minimal context for execute — the tool only uses context.mastra?.getLogger?.() -// which is optional, so undefined mastra is safe. -const minimalContext = { mastra: undefined } as any; +// Minimal context for execute — the tool uses context.mastra?.getLogger?.() +// (optional) and context.requestContext (to read the requestor's token). +const minimalContext = { + mastra: undefined, + requestContext: { + get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? 'fake-requestor-token' : undefined), + }, +} as any; /** * Installs a global fetch spy that resolves with the given JSON body. @@ -70,7 +79,7 @@ describe('fetchProjectTool — request construction', () => { const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(url).toMatch(/\/v6\/projects\/17423$/); expect(init.method).toBe('GET'); - expect((init.headers as Record).Authorization).toBe('Bearer fake-m2m-token'); + expect((init.headers as Record).Authorization).toBe('Bearer fake-requestor-token'); }); it('appends a fields query param when supplied', async () => { diff --git a/src/mastra/tools/project/fetch-project-tool.ts b/src/mastra/tools/project/fetch-project-tool.ts index dc4b2e5..8a3c222 100644 --- a/src/mastra/tools/project/fetch-project-tool.ts +++ b/src/mastra/tools/project/fetch-project-tool.ts @@ -1,4 +1,4 @@ -// Projects API: GET /v6/projects/:projectId (M2M token required) +// Projects API: GET /v6/projects/:projectId // // Retrieval-time enrichment only (D10): resolves the opaque `projectId` // reference stored in challenge vector metadata to project detail (name, @@ -7,18 +7,22 @@ // this tool — it exists so a consumer that already has a projectId from a // challenge-search hit can make the "subsequent call" D10 describes instead // of that data being denormalized into the vector store. +// +// Authorized as the requestor by default (their own token is forwarded +// as-is); no M2M fallback configured for this tool — see +// docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; -import { M2MService } from '../../../utils/auth/m2m.service'; +import type { RequestContext } from '@mastra/core/request-context'; +import { callTcApi } from '../../../utils/tc-api-client'; +const TOOL_ID = 'fetch-project-by-id'; const BASE_URL = `${process.env.TC_API_BASE}/v6/projects`; -const m2mService = new M2MService(); - export const fetchProjectTool = createTool({ - id: 'fetch-project-by-id', + id: TOOL_ID, description: - 'Fetches a Topcoder project by id from the v6 Projects API using M2M authentication. ' + + 'Fetches a Topcoder project by id from the v6 Projects API, authorized as the requesting user. ' + 'Retrieval-time enrichment only — resolves a projectId reference from a challenge-search hit ' + 'to the project\'s name, status, type, and tech stack.', inputSchema: z.object({ @@ -39,7 +43,7 @@ export const fetchProjectTool = createTool({ execute: async (inputData, context) => { const logger = context.mastra?.getLogger?.(); logger?.info('Fetching project by ID: {projectId}', { projectId: inputData.projectId }); - return await fetchProject(inputData.projectId, inputData.fields); + return await fetchProject(inputData.projectId, inputData.fields, context.requestContext); }, }); @@ -56,20 +60,18 @@ function toStringOrUndefined(value: unknown): string | undefined { return value === null || value === undefined ? undefined : String(value); } -const fetchProject = async (projectId: string, fields?: string) => { - const token = await m2mService.getM2MToken(); - +const fetchProject = async (projectId: string, fields: string | undefined, requestContext: RequestContext | undefined) => { const params = fields ? `?fields=${encodeURIComponent(fields)}` : ''; const url = `${BASE_URL}/${encodeURIComponent(projectId)}${params}`; - const response = await fetch(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'app-version': '2.0.0', + const response = await callTcApi({ + toolId: TOOL_ID, + url, + init: { + method: 'GET', + signal: AbortSignal.timeout(15_000), }, - signal: AbortSignal.timeout(15_000), + requestContext, }); if (!response.ok) { diff --git a/src/utils/tc-api-client.test.ts b/src/utils/tc-api-client.test.ts new file mode 100644 index 0000000..24cd51c --- /dev/null +++ b/src/utils/tc-api-client.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MASTRA_AUTH_TOKEN_KEY } from '@mastra/core/request-context'; + +const { m2mTokenMock, fallbackConfigMock, loggerWarnMock } = vi.hoisted(() => ({ + m2mTokenMock: vi.fn(), + fallbackConfigMock: {} as Record, + loggerWarnMock: vi.fn(), +})); + +vi.mock('./auth/m2m.service', () => ({ + M2MService: class MockM2MService { + getM2MToken = m2mTokenMock; + }, +})); + +vi.mock('../config/tool-auth-fallback.config', () => ({ + TOOL_M2M_FALLBACK_CONFIG: fallbackConfigMock, +})); + +vi.mock('./logger', () => ({ + tcAILogger: { + warn: loggerWarnMock, + }, +})); + +import { callTcApi } from './tc-api-client'; + +function requestContextWithToken(token: string | undefined) { + return { + get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? token : undefined), + } as any; +} + +function mockFetchResponse(status: number) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: status < 400, status } as Response); +} + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(fallbackConfigMock)) delete fallbackConfigMock[key]; + m2mTokenMock.mockResolvedValue('fake-m2m-token'); +}); + +describe('callTcApi — requestor token path', () => { + it('calls fetch with the requestor token and returns the response on success', async () => { + const fetchSpy = mockFetchResponse(200); + + const response = await callTcApi({ + toolId: 'some-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe('Bearer requestor-token'); + expect(m2mTokenMock).not.toHaveBeenCalled(); + }); + + it('returns the requestor-token 401 as-is when fallback is not configured for the tool', async () => { + mockFetchResponse(401); + + const response = await callTcApi({ + toolId: 'not-listed-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(401); + expect(m2mTokenMock).not.toHaveBeenCalled(); + }); + + it('returns the requestor-token 403 as-is when fallback is not configured for the tool', async () => { + mockFetchResponse(403); + + const response = await callTcApi({ + toolId: 'not-listed-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(403); + expect(m2mTokenMock).not.toHaveBeenCalled(); + }); + + it('passes through a non-401/403 error status without attempting fallback', async () => { + fallbackConfigMock['fallback-tool'] = true; + mockFetchResponse(500); + + const response = await callTcApi({ + toolId: 'fallback-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(500); + expect(m2mTokenMock).not.toHaveBeenCalled(); + }); +}); + +describe('callTcApi — M2M fallback path (tool explicitly enabled)', () => { + it('retries once with the M2M token on a 401 when fallback is enabled', async () => { + fallbackConfigMock['fallback-tool'] = true; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ ok: false, status: 401 } as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + const response = await callTcApi({ + toolId: 'fallback-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [, secondInit] = fetchSpy.mock.calls[1] as [string, RequestInit]; + expect((secondInit.headers as Record).Authorization).toBe('Bearer fake-m2m-token'); + expect(loggerWarnMock).toHaveBeenCalledWith( + 'Requestor token rejected by Topcoder platform API, falling back to service M2M token', + { toolId: 'fallback-tool', status: 401 }, + ); + }); + + it('retries once with the M2M token on a 403 when fallback is enabled', async () => { + fallbackConfigMock['fallback-tool'] = true; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ ok: false, status: 403 } as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + const response = await callTcApi({ + toolId: 'fallback-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('propagates the M2M attempt result even if it also fails (no further retries)', async () => { + fallbackConfigMock['fallback-tool'] = true; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ ok: false, status: 401 } as Response) + .mockResolvedValueOnce({ ok: false, status: 401 } as Response); + + const response = await callTcApi({ + toolId: 'fallback-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken('requestor-token'), + }); + + expect(response.status).toBe(401); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe('callTcApi — no requestor token', () => { + it('goes straight to the M2M token when fallback is enabled', async () => { + fallbackConfigMock['fallback-tool'] = true; + const fetchSpy = mockFetchResponse(200); + + const response = await callTcApi({ + toolId: 'fallback-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken(undefined), + }); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe('Bearer fake-m2m-token'); + }); + + it('fails fast without calling fetch when fallback is not enabled', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + await expect( + callTcApi({ + toolId: 'not-listed-tool', + url: 'https://api.example.com/v6/thing', + requestContext: requestContextWithToken(undefined), + }), + ).rejects.toThrow(/No requestor token available for tool "not-listed-tool"/); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(m2mTokenMock).not.toHaveBeenCalled(); + }); + + it('fails fast when requestContext itself is undefined and fallback is not enabled', async () => { + await expect( + callTcApi({ + toolId: 'not-listed-tool', + url: 'https://api.example.com/v6/thing', + requestContext: undefined, + }), + ).rejects.toThrow(/No requestor token available/); + }); +}); + +describe('callTcApi — default headers', () => { + it('sends Content-Type and app-version headers alongside Authorization', async () => { + const fetchSpy = mockFetchResponse(200); + + await callTcApi({ + toolId: 'some-tool', + url: 'https://api.example.com/v6/thing', + init: { method: 'GET' }, + requestContext: requestContextWithToken('requestor-token'), + }); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['app-version']).toBe('2.0.0'); + expect(init.method).toBe('GET'); + }); +}); diff --git a/src/utils/tc-api-client.ts b/src/utils/tc-api-client.ts new file mode 100644 index 0000000..43f5d38 --- /dev/null +++ b/src/utils/tc-api-client.ts @@ -0,0 +1,69 @@ +import { MASTRA_AUTH_TOKEN_KEY, RequestContext } from '@mastra/core/request-context'; +import { M2MService } from './auth/m2m.service'; +import { TOOL_M2M_FALLBACK_CONFIG } from '../config/tool-auth-fallback.config'; +import { tcAILogger } from './logger'; + +const m2mService = new M2MService(); + +const DEFAULT_HEADERS = { + 'Content-Type': 'application/json', + 'app-version': '2.0.0', +}; + +export interface CallTcApiOptions { + /** Matches the Mastra tool's `createTool({ id })` — the fallback config key. */ + toolId: string; + url: string; + /** `Authorization` and default headers are added by this client — do not set them here. */ + init?: RequestInit; + requestContext: RequestContext | undefined; +} + +/** + * Calls a Topcoder platform API (`TC_API_BASE`) authorized as the requestor — + * whatever token authenticated the current request (TC member JWT or M2M + * JWT) is forwarded as-is. Falls back to tc-ai-api's own service M2M token, + * once, only when `toolId` is explicitly enabled in + * `TOOL_M2M_FALLBACK_CONFIG` and the requestor-token attempt fails with + * 401/403 (or there was no requestor token to try). + * + * See docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. + */ +export async function callTcApi({ toolId, url, init, requestContext }: CallTcApiOptions): Promise { + const requestorToken = requestContext?.get(MASTRA_AUTH_TOKEN_KEY) as string | undefined; + const fallbackEnabled = TOOL_M2M_FALLBACK_CONFIG[toolId] === true; + + if (requestorToken) { + const response = await fetchWithToken(url, init, requestorToken); + if ((response.status !== 401 && response.status !== 403) || !fallbackEnabled) { + return response; + } + tcAILogger.warn('Requestor token rejected by Topcoder platform API, falling back to service M2M token', { + toolId, + status: response.status, + }); + return fetchWithToken(url, init, await m2mService.getM2MToken()); + } + + if (!fallbackEnabled) { + throw new Error( + `No requestor token available for tool "${toolId}" and M2M fallback is not enabled for it.`, + ); + } + + tcAILogger.warn('No requestor token available for Topcoder platform API call, using service M2M token', { + toolId, + }); + return fetchWithToken(url, init, await m2mService.getM2MToken()); +} + +function fetchWithToken(url: string, init: RequestInit | undefined, token: string): Promise { + return fetch(url, { + ...init, + headers: { + ...DEFAULT_HEADERS, + ...(init?.headers as Record | undefined), + Authorization: `Bearer ${token}`, + }, + }); +} From ca448b512bd2b698195d768ac7d250ce833c7ef3 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 08:46:38 +0300 Subject: [PATCH 02/27] use v6 ai/sdk for chat --- src/mastra/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mastra/index.ts b/src/mastra/index.ts index feb82ff..bafc8a8 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -67,6 +67,7 @@ export const mastra = new Mastra({ apiRoutes: [ chatRoute({ path: CHAT_ROUTE_PATH, + version: 'v6', }), ], }, From 233680d0bbc24dcb732deda8be278e5465f712bc Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 08:52:50 +0300 Subject: [PATCH 03/27] point tc-core-lib --- package.json | 2 +- pnpm-lock.yaml | 536 ++++++------------------------------------------- 2 files changed, 58 insertions(+), 480 deletions(-) diff --git a/package.json b/package.json index 2970cd6..791dfeb 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "ai-sdk-ollama": "^3.8.8", "csv-parse": "^7.0.2", "js-tiktoken": "^1.0.21", - "tc-core-library-js": "^2.4.1", + "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#master", "turndown": "^7.2.4", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29836b4..7f67fbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^1.0.21 version: 1.0.21 tc-core-library-js: - specifier: ^2.4.1 - version: 2.4.1 + specifier: github:topcoder-platform/tc-core-library-js#master + version: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062 turndown: specifier: ^7.2.4 version: 7.2.4 @@ -1413,22 +1413,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tootallnate/once@1.1.2': - resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} - engines: {node: '>= 6'} - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1441,31 +1431,18 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/express-jwt@0.0.42': - resolution: {integrity: sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==} - - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - - '@types/express-unless@2.0.3': - resolution: {integrity: sha512-iJbM7nsyBgnxCrCe7VjWIi4nyyhlaKUl7jxeHDpK+KXk3sYrUZViMkgFv9qSZmxDleB8dfpQR9gK5MGNyM/M6w==} - deprecated: This is a stub types definition. express-unless provides its own type definitions, so you do not need this installed. - - '@types/express@5.0.6': - resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1481,21 +1458,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - '@types/qs@6.15.1': - resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/turndown@5.0.6': resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} @@ -1621,10 +1586,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -1662,13 +1623,6 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - asn1@0.2.6: - resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1686,21 +1640,11 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - aws-sign2@0.7.0: - resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} - - aws4@1.13.2: - resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} - axios@0.19.2: - resolution: {integrity: sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==} - deprecated: Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410 - - axios@0.31.1: - resolution: {integrity: sha512-Ef8DUZSZQP6igY48mjGaoEjwhely97lserep0IFJifBH4YdKvwH5eMLniy3kig2HQoBNR8EkZpDjowxwTJcmbg==} + axios@0.30.3: + resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} @@ -1710,9 +1654,6 @@ packages: react-native-b4a: optional: true - babel-runtime@6.6.1: - resolution: {integrity: sha512-5pdhO3jaxqh9L42oBfbrqy58swDhciM47sRGoODURdRxwfiqttEvK87LX27W/PYY6f4cJt2mEdyoLcr/+cM/iw==} - backoff@2.5.0: resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} engines: {node: '>= 0.6'} @@ -1776,9 +1717,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - bcrypt-pbkdf@1.0.2: - resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - big.js@7.0.1: resolution: {integrity: sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==} @@ -1839,9 +1777,6 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} - caseless@0.12.0: - resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} - ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1871,8 +1806,8 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - codependency@0.1.4: - resolution: {integrity: sha512-26yAvd3+17xSfDADtnzpnL5GK+8+x4QeZ3DegekkHyno6LWeHqXuSU7q8w/IrAur7SY6ISPApOWtWTfuIF0Xpg==} + codependency@2.1.0: + resolution: {integrity: sha512-JIdmYkE8Z6jwH1OUf4a5H5jk9YShPQkaYPUAiN+ktyChmPP77LGbeKrxWGPqdCnpTmt0hRIn8TXBVu01U3HDhg==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1939,13 +1874,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-js@2.6.12: - resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} - deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. - - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1969,10 +1897,6 @@ packages: csv-parse@7.0.2: resolution: {integrity: sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==} - dashdash@1.14.1: - resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} - engines: {node: '>=0.10'} - dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -1984,14 +1908,6 @@ packages: supports-color: optional: true - debug@3.1.0: - resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2058,9 +1974,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - ecc-jsbn@0.1.2: - resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} - ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -2210,9 +2123,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-unless@2.1.3: - resolution: {integrity: sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==} - express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2227,10 +2137,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extsprintf@1.3.0: - resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} - engines: {'0': node >=0.6.0} - fast-copy@4.0.3: resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} @@ -2316,20 +2222,9 @@ packages: debug: optional: true - follow-redirects@1.5.10: - resolution: {integrity: sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==} - engines: {node: '>=4.0'} - - forever-agent@0.6.1: - resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} - form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - form-data@2.3.3: - resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} - engines: {node: '>= 0.12'} - form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -2385,9 +2280,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - getpass@0.1.7: - resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2415,15 +2307,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - har-schema@2.0.0: - resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} - engines: {node: '>=4'} - - har-validator@5.1.5: - resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} - engines: {node: '>=6'} - deprecated: this library is no longer supported - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2450,18 +2333,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - http-proxy-agent@4.0.1: - resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} - engines: {node: '>= 6'} - - http-signature@1.2.0: - resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} - engines: {node: '>=0.8', npm: '>=1.3.7'} - - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -2551,9 +2422,6 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} - is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -2564,8 +2432,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isstream@0.1.2: - resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -2590,9 +2458,6 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - jsbn@0.1.1: - resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2632,22 +2497,19 @@ packages: resolution: {integrity: sha512-NpGgMhmzG/fajkBEFlS9jZvMSGDvc2xN/9wNCHZ+Nx32GZfLRELU6UE6dQkebvrQUct9S+7bvnpX29NB36Qbdw==} hasBin: true - jsonwebtoken@8.5.1: - resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} - engines: {node: '>=4', npm: '>=1.4.28'} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} - jsprim@1.4.2: - resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} - engines: {node: '>=0.6.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - - jwks-rsa@1.12.5: - resolution: {integrity: sha512-gBcSqbA27IE6olVTwTezxs6wVFBHmU2+5La8xmHHMqLzsAaOfChpnhv5jSAp7pLjWu5bpyUSlML9ILuYvkBkcQ==} + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} - jws@3.2.3: - resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2784,9 +2646,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash@4.17.15: - resolution: {integrity: sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -3067,9 +2926,6 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - oauth-sign@0.9.0: - resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3157,9 +3013,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - performance-now@2.1.0: - resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -3298,9 +3151,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3312,10 +3162,6 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - qs@6.5.5: - resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} - engines: {node: '>=0.6'} - quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3325,9 +3171,9 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - r7insight_node@1.8.4: - resolution: {integrity: sha512-6cQrzLkaOxdv/SRFXWRJjgFr8a3nXUOT/4IMFuBv+mWzBnu5DJl+HzONAsWYvclrlZnvfa54PaIPqPuPRSlbrQ==} - engines: {iojs: '>=0.10', node: '>=0.8.0', npm: '>=1.4.6'} + r7insight_node@2.1.1: + resolution: {integrity: sha512-xx0kgFxSHWY9aG1109uv4w2b+JLwHseSowOWo1bzCTDBpUk3er2rZdtQ90mAjUYbkh6Hus9DAwWvmHsX5pHaIQ==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} range-parser@1.2.0: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} @@ -3374,11 +3220,6 @@ packages: remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} - request@2.88.2: - resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} - engines: {node: '>= 6'} - deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3459,14 +3300,6 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - semver@5.0.1: - resolution: {integrity: sha512-Ne6/HdGZvvpXBdjW3o8J0pvxC2jnmVNBK7MKkMgsOBfrsIdTXfA5x+H9DUbQ2xzyvnLv0A0v9x8R4B40xNZIRQ==} - hasBin: true - - semver@5.1.0: - resolution: {integrity: sha512-sfKXKhcz5XVyfUZa2V4RbjK0xjOJCMLNF9H4p4v0UCo9wNHM/lH9RDuyDbGEtxWLMDlPBc8xI7AbbVLKXty+rQ==} - hasBin: true - semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -3542,11 +3375,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sshpk@1.18.0: - resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} - engines: {node: '>=0.10.0'} - hasBin: true - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3603,9 +3431,10 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - tc-core-library-js@2.4.1: - resolution: {integrity: sha512-6RAmqMJyp9QWsbBydQJJfvpHsVcuBZQMvMSSaG10iJi0IidmntuU0GiTUjFvF06jW0DAd9iUNp/ICU8FfbpCbg==} - engines: {node: '>= 5'} + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: + resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062} + version: 3.0.1 + engines: {node: '>= 14'} teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -3643,10 +3472,6 @@ packages: tokenx@1.3.0: resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} - tough-cookie@2.5.0: - resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} - engines: {node: '>=0.8'} - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -3667,16 +3492,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turndown@7.2.4: resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} engines: {node: '>=18', npm: '>=9'} - tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3758,19 +3577,10 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - uuid@3.4.0: - resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - verror@1.10.0: - resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} - engines: {'0': node >=0.6.0} - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -5449,27 +5259,16 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tootallnate/once@1.1.2': {} - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 26.0.1 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect@3.4.38': - dependencies: - '@types/node': 26.0.1 - '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -5480,36 +5279,17 @@ snapshots: '@types/estree@1.0.9': {} - '@types/express-jwt@0.0.42': - dependencies: - '@types/express': 5.0.6 - '@types/express-unless': 2.0.3 - - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 26.0.1 - '@types/qs': 6.15.1 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express-unless@2.0.3': - dependencies: - express-unless: 2.1.3 - - '@types/express@5.0.6': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 - '@types/serve-static': 2.2.0 - '@types/gensync@1.0.5': {} - '@types/http-errors@2.0.5': {} - '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 26.0.1 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5529,21 +5309,8 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/qs@6.15.1': {} - - '@types/range-parser@1.2.7': {} - '@types/resolve@1.20.2': {} - '@types/send@1.2.1': - dependencies: - '@types/node': 26.0.1 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 26.0.1 - '@types/turndown@5.0.6': {} '@types/unist@3.0.3': {} @@ -5706,12 +5473,6 @@ snapshots: acorn@8.17.0: {} - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -5776,12 +5537,6 @@ snapshots: dependencies: sprintf-js: 1.0.3 - asn1@0.2.6: - dependencies: - safer-buffer: 2.1.2 - - assert-plus@1.0.0: {} - assertion-error@2.0.1: {} async-mutex@0.5.0: @@ -5794,21 +5549,11 @@ snapshots: atomic-sleep@1.0.0: {} - aws-sign2@0.7.0: {} - - aws4@1.13.2: {} - aws4fetch@1.0.20: {} - axios@0.19.2: + axios@0.30.3: dependencies: - follow-redirects: 1.5.10 - transitivePeerDependencies: - - supports-color - - axios@0.31.1(debug@4.4.3): - dependencies: - follow-redirects: 1.16.0(debug@4.4.3) + follow-redirects: 1.16.0 form-data: 4.0.6 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -5816,10 +5561,6 @@ snapshots: b4a@1.8.1: {} - babel-runtime@6.6.1: - dependencies: - core-js: 2.6.12 - backoff@2.5.0: dependencies: precond: 0.2.3 @@ -5867,10 +5608,6 @@ snapshots: baseline-browser-mapping@2.10.38: {} - bcrypt-pbkdf@1.0.2: - dependencies: - tweetnacl: 0.14.5 - big.js@7.0.1: {} body-parser@2.3.0: @@ -5945,8 +5682,6 @@ snapshots: caniuse-lite@1.0.30001799: {} - caseless@0.12.0: {} - ccount@2.0.1: {} chai@6.2.2: {} @@ -5975,9 +5710,9 @@ snapshots: wrap-ansi: 7.0.0 optional: true - codependency@0.1.4: + codependency@2.1.0: dependencies: - semver: 5.0.1 + semver: 5.7.2 color-convert@2.0.1: dependencies: @@ -6036,10 +5771,6 @@ snapshots: cookie@0.7.2: optional: true - core-js@2.6.12: {} - - core-util-is@1.0.2: {} - core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -6059,20 +5790,12 @@ snapshots: csv-parse@7.0.2: {} - dashdash@1.14.1: - dependencies: - assert-plus: 1.0.0 - dateformat@4.6.3: {} debug@2.6.9: dependencies: ms: 2.0.0 - debug@3.1.0: - dependencies: - ms: 2.0.0 - debug@3.2.7: dependencies: ms: 2.1.3 @@ -6119,11 +5842,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - ecc-jsbn@0.1.2: - dependencies: - jsbn: 0.1.1 - safer-buffer: 2.1.2 - ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -6308,8 +6026,6 @@ snapshots: expect-type@1.3.0: {} - express-unless@2.1.3: {} - express@5.2.1: dependencies: accepts: 2.0.0 @@ -6352,8 +6068,6 @@ snapshots: extend@3.0.2: {} - extsprintf@1.3.0: {} - fast-copy@4.0.3: {} fast-deep-equal@3.1.3: {} @@ -6436,26 +6150,10 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0(debug@4.4.3): - optionalDependencies: - debug: 4.4.3 - - follow-redirects@1.5.10: - dependencies: - debug: 3.1.0 - transitivePeerDependencies: - - supports-color - - forever-agent@0.6.1: {} + follow-redirects@1.16.0: {} form-data-encoder@1.7.2: {} - form-data@2.3.3: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -6520,10 +6218,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - getpass@0.1.7: - dependencies: - assert-plus: 1.0.0 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6554,13 +6248,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - har-schema@2.0.0: {} - - har-validator@5.1.5: - dependencies: - ajv: 6.15.0 - har-schema: 2.0.0 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -6586,27 +6273,6 @@ snapshots: toidentifier: 1.0.1 optional: true - http-proxy-agent@4.0.1: - dependencies: - '@tootallnate/once': 1.1.2 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - http-signature@1.2.0: - dependencies: - assert-plus: 1.0.0 - jsprim: 1.4.2 - sshpk: 1.18.0 - - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - human-signals@8.0.1: {} humanize-ms@1.2.1: @@ -6675,15 +6341,13 @@ snapshots: is-stream@4.0.1: {} - is-typedarray@1.0.0: {} - is-unicode-supported@2.1.0: {} isarray@1.0.0: {} isexe@2.0.0: {} - isstream@0.1.2: {} + jose@4.15.9: {} jose@6.2.3: {} @@ -6704,8 +6368,6 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - jsbn@0.1.1: {} - jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6732,9 +6394,9 @@ snapshots: jsonrepair@3.14.1: {} - jsonwebtoken@8.5.1: + jsonwebtoken@9.0.3: dependencies: - jws: 3.2.3 + jws: 4.0.1 lodash.includes: 4.3.0 lodash.isboolean: 3.0.3 lodash.isinteger: 4.0.4 @@ -6743,39 +6405,27 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 5.7.2 - - jsprim@1.4.2: - dependencies: - assert-plus: 1.0.0 - extsprintf: 1.3.0 - json-schema: 0.4.0 - verror: 1.10.0 + semver: 7.8.5 - jwa@1.4.2: + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jwks-rsa@1.12.5: + jwks-rsa@3.2.2: dependencies: - '@types/express-jwt': 0.0.42 - axios: 0.31.1(debug@4.4.3) + '@types/jsonwebtoken': 9.0.10 debug: 4.4.3 - http-proxy-agent: 4.0.1 - https-proxy-agent: 5.0.1 - jsonwebtoken: 8.5.1 + jose: 4.15.9 limiter: 1.1.5 lru-memoizer: 2.3.0 - ms: 2.1.3 - proxy-from-env: 1.1.0 transitivePeerDependencies: - supports-color - jws@3.2.3: + jws@4.0.1: dependencies: - jwa: 1.4.2 + jwa: 2.0.1 safe-buffer: 5.2.1 keyv@4.5.4: @@ -6892,8 +6542,6 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.17.15: {} - lodash@4.18.1: {} long@5.3.2: @@ -7360,8 +7008,6 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - oauth-sign@0.9.0: {} - object-inspect@1.13.4: optional: true @@ -7436,8 +7082,6 @@ snapshots: pathe@2.0.3: {} - performance-now@2.1.0: {} - pg-cloudflare@1.4.0: optional: true @@ -7596,10 +7240,6 @@ snapshots: proxy-from-env@1.1.0: {} - psl@1.15.0: - dependencies: - punycode: 2.3.1 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -7613,22 +7253,18 @@ snapshots: side-channel: 1.1.1 optional: true - qs@6.5.5: {} - quansync@0.2.11: {} queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} - r7insight_node@1.8.4: + r7insight_node@2.1.1: dependencies: - babel-runtime: 6.6.1 - codependency: 0.1.4 + codependency: 2.1.0 json-stringify-safe: 5.0.1 - lodash: 4.17.15 + lodash: 4.18.1 reconnect-core: 1.3.0 - semver: 5.1.0 range-parser@1.2.0: {} @@ -7701,29 +7337,6 @@ snapshots: remend@1.3.0: {} - request@2.88.2: - dependencies: - aws-sign2: 0.7.0 - aws4: 1.13.2 - caseless: 0.12.0 - combined-stream: 1.0.8 - extend: 3.0.2 - forever-agent: 0.6.1 - form-data: 2.3.3 - har-validator: 5.1.5 - http-signature: 1.2.0 - is-typedarray: 1.0.0 - isstream: 0.1.2 - json-stringify-safe: 5.0.1 - mime-types: 2.1.35 - oauth-sign: 0.9.0 - performance-now: 2.1.0 - qs: 6.5.5 - safe-buffer: 5.2.1 - tough-cookie: 2.5.0 - tunnel-agent: 0.6.0 - uuid: 3.4.0 - require-directory@2.1.1: optional: true @@ -7848,10 +7461,6 @@ snapshots: secure-json-parse@4.1.0: {} - semver@5.0.1: {} - - semver@5.1.0: {} - semver@5.7.2: {} semver@7.8.5: {} @@ -7952,18 +7561,6 @@ snapshots: sprintf-js@1.0.3: {} - sshpk@1.18.0: - dependencies: - asn1: 0.2.6 - assert-plus: 1.0.0 - bcrypt-pbkdf: 1.0.2 - dashdash: 1.14.1 - ecc-jsbn: 0.1.2 - getpass: 0.1.7 - jsbn: 0.1.1 - safer-buffer: 2.1.2 - tweetnacl: 0.14.5 - stackback@0.0.2: {} statuses@2.0.2: @@ -8029,17 +7626,17 @@ snapshots: - bare-buffer - react-native-b4a - tc-core-library-js@2.4.1: + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: dependencies: - axios: 0.19.2 + axios: 0.30.3 bunyan: 1.8.15 - jsonwebtoken: 8.5.1 - jwks-rsa: 1.12.5 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 lodash: 4.18.1 millisecond: 0.1.2 - r7insight_node: 1.8.4 - request: 2.88.2 + r7insight_node: 2.1.1 transitivePeerDependencies: + - debug - supports-color teex@1.0.1: @@ -8079,11 +7676,6 @@ snapshots: tokenx@1.3.0: {} - tough-cookie@2.5.0: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - tr46@0.0.3: {} trough@2.2.0: {} @@ -8100,16 +7692,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - turndown@7.2.4: dependencies: '@mixmark-io/domino': 2.2.0 - tweetnacl@0.14.5: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8199,17 +7785,9 @@ snapshots: uuid@11.1.1: {} - uuid@3.4.0: {} - vary@1.1.2: optional: true - verror@1.10.0: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.3.0 - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 From d08a05813b4b6b85c7ca9153c5bdb9ffc6d4f1da Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 09:25:00 +0300 Subject: [PATCH 04/27] revert back to "^2.4.1" for tc-core --- package.json | 2 +- pnpm-lock.yaml | 536 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 480 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index 791dfeb..2970cd6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "ai-sdk-ollama": "^3.8.8", "csv-parse": "^7.0.2", "js-tiktoken": "^1.0.21", - "tc-core-library-js": "github:topcoder-platform/tc-core-library-js#master", + "tc-core-library-js": "^2.4.1", "turndown": "^7.2.4", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f67fbc..e206e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^1.0.21 version: 1.0.21 tc-core-library-js: - specifier: github:topcoder-platform/tc-core-library-js#master - version: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062 + specifier: ^2.4.1 + version: 2.4.1 turndown: specifier: ^7.2.4 version: 7.2.4 @@ -1413,12 +1413,22 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tootallnate/once@1.1.2': + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1431,18 +1441,31 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-jwt@0.0.42': + resolution: {integrity: sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express-unless@2.0.3': + resolution: {integrity: sha512-iJbM7nsyBgnxCrCe7VjWIi4nyyhlaKUl7jxeHDpK+KXk3sYrUZViMkgFv9qSZmxDleB8dfpQR9gK5MGNyM/M6w==} + deprecated: This is a stub types definition. express-unless provides its own type definitions, so you do not need this installed. + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1458,9 +1481,21 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/turndown@5.0.6': resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} @@ -1586,6 +1621,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -1623,6 +1662,13 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1640,11 +1686,21 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} - axios@0.30.3: - resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} + axios@0.19.2: + resolution: {integrity: sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==} + deprecated: Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410 + + axios@0.31.1: + resolution: {integrity: sha512-Ef8DUZSZQP6igY48mjGaoEjwhely97lserep0IFJifBH4YdKvwH5eMLniy3kig2HQoBNR8EkZpDjowxwTJcmbg==} b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} @@ -1654,6 +1710,9 @@ packages: react-native-b4a: optional: true + babel-runtime@6.6.1: + resolution: {integrity: sha512-5pdhO3jaxqh9L42oBfbrqy58swDhciM47sRGoODURdRxwfiqttEvK87LX27W/PYY6f4cJt2mEdyoLcr/+cM/iw==} + backoff@2.5.0: resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} engines: {node: '>= 0.6'} @@ -1717,6 +1776,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + big.js@7.0.1: resolution: {integrity: sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==} @@ -1777,6 +1839,9 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1806,8 +1871,8 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - codependency@2.1.0: - resolution: {integrity: sha512-JIdmYkE8Z6jwH1OUf4a5H5jk9YShPQkaYPUAiN+ktyChmPP77LGbeKrxWGPqdCnpTmt0hRIn8TXBVu01U3HDhg==} + codependency@0.1.4: + resolution: {integrity: sha512-26yAvd3+17xSfDADtnzpnL5GK+8+x4QeZ3DegekkHyno6LWeHqXuSU7q8w/IrAur7SY6ISPApOWtWTfuIF0Xpg==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1874,6 +1939,13 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1897,6 +1969,10 @@ packages: csv-parse@7.0.2: resolution: {integrity: sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -1908,6 +1984,14 @@ packages: supports-color: optional: true + debug@3.1.0: + resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -1974,6 +2058,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -2123,6 +2210,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-unless@2.1.3: + resolution: {integrity: sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2137,6 +2227,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + fast-copy@4.0.3: resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} @@ -2222,9 +2316,20 @@ packages: debug: optional: true + follow-redirects@1.5.10: + resolution: {integrity: sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==} + engines: {node: '>=4.0'} + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -2280,6 +2385,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2307,6 +2415,15 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + + har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2333,6 +2450,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -2422,6 +2551,9 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -2432,8 +2564,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jose@4.15.9: - resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -2458,6 +2590,9 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2497,19 +2632,22 @@ packages: resolution: {integrity: sha512-NpGgMhmzG/fajkBEFlS9jZvMSGDvc2xN/9wNCHZ+Nx32GZfLRELU6UE6dQkebvrQUct9S+7bvnpX29NB36Qbdw==} hasBin: true - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} + jsonwebtoken@8.5.1: + resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} + engines: {node: '>=4', npm: '>=1.4.28'} - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} - jwks-rsa@3.2.2: - resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} - engines: {node: '>=14'} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jwks-rsa@1.12.5: + resolution: {integrity: sha512-gBcSqbA27IE6olVTwTezxs6wVFBHmU2+5La8xmHHMqLzsAaOfChpnhv5jSAp7pLjWu5bpyUSlML9ILuYvkBkcQ==} - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jws@3.2.3: + resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2646,6 +2784,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.15: + resolution: {integrity: sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2926,6 +3067,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3013,6 +3157,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -3151,6 +3298,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3162,6 +3312,10 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} + qs@6.5.5: + resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3171,9 +3325,9 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - r7insight_node@2.1.1: - resolution: {integrity: sha512-xx0kgFxSHWY9aG1109uv4w2b+JLwHseSowOWo1bzCTDBpUk3er2rZdtQ90mAjUYbkh6Hus9DAwWvmHsX5pHaIQ==} - engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} + r7insight_node@1.8.4: + resolution: {integrity: sha512-6cQrzLkaOxdv/SRFXWRJjgFr8a3nXUOT/4IMFuBv+mWzBnu5DJl+HzONAsWYvclrlZnvfa54PaIPqPuPRSlbrQ==} + engines: {iojs: '>=0.10', node: '>=0.8.0', npm: '>=1.4.6'} range-parser@1.2.0: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} @@ -3220,6 +3374,11 @@ packages: remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3300,6 +3459,14 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@5.0.1: + resolution: {integrity: sha512-Ne6/HdGZvvpXBdjW3o8J0pvxC2jnmVNBK7MKkMgsOBfrsIdTXfA5x+H9DUbQ2xzyvnLv0A0v9x8R4B40xNZIRQ==} + hasBin: true + + semver@5.1.0: + resolution: {integrity: sha512-sfKXKhcz5XVyfUZa2V4RbjK0xjOJCMLNF9H4p4v0UCo9wNHM/lH9RDuyDbGEtxWLMDlPBc8xI7AbbVLKXty+rQ==} + hasBin: true + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -3375,6 +3542,11 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3431,10 +3603,9 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: - resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062} - version: 3.0.1 - engines: {node: '>= 14'} + tc-core-library-js@2.4.1: + resolution: {integrity: sha512-6RAmqMJyp9QWsbBydQJJfvpHsVcuBZQMvMSSaG10iJi0IidmntuU0GiTUjFvF06jW0DAd9iUNp/ICU8FfbpCbg==} + engines: {node: '>= 5'} teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -3472,6 +3643,10 @@ packages: tokenx@1.3.0: resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} + tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -3492,10 +3667,16 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turndown@7.2.4: resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} engines: {node: '>=18', npm: '>=9'} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3577,10 +3758,19 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -5259,16 +5449,27 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tootallnate/once@1.1.2': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.0.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -5279,17 +5480,36 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-jwt@0.0.42': + dependencies: + '@types/express': 5.0.6 + '@types/express-unless': 2.0.3 + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 26.0.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express-unless@2.0.3': + dependencies: + express-unless: 2.1.3 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + '@types/gensync@1.0.5': {} + '@types/http-errors@2.0.5': {} + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 26.0.1 - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5309,8 +5529,21 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/resolve@1.20.2': {} + '@types/send@1.2.1': + dependencies: + '@types/node': 26.0.1 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.0.1 + '@types/turndown@5.0.6': {} '@types/unist@3.0.3': {} @@ -5473,6 +5706,12 @@ snapshots: acorn@8.17.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -5537,6 +5776,12 @@ snapshots: dependencies: sprintf-js: 1.0.3 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + assertion-error@2.0.1: {} async-mutex@0.5.0: @@ -5549,11 +5794,21 @@ snapshots: atomic-sleep@1.0.0: {} + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + aws4fetch@1.0.20: {} - axios@0.30.3: + axios@0.19.2: dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.5.10 + transitivePeerDependencies: + - supports-color + + axios@0.31.1(debug@4.4.3): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.6 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -5561,6 +5816,10 @@ snapshots: b4a@1.8.1: {} + babel-runtime@6.6.1: + dependencies: + core-js: 2.6.12 + backoff@2.5.0: dependencies: precond: 0.2.3 @@ -5608,6 +5867,10 @@ snapshots: baseline-browser-mapping@2.10.38: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + big.js@7.0.1: {} body-parser@2.3.0: @@ -5682,6 +5945,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + caseless@0.12.0: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -5710,9 +5975,9 @@ snapshots: wrap-ansi: 7.0.0 optional: true - codependency@2.1.0: + codependency@0.1.4: dependencies: - semver: 5.7.2 + semver: 5.0.1 color-convert@2.0.1: dependencies: @@ -5771,6 +6036,10 @@ snapshots: cookie@0.7.2: optional: true + core-js@2.6.12: {} + + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -5790,12 +6059,20 @@ snapshots: csv-parse@7.0.2: {} + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + dateformat@4.6.3: {} debug@2.6.9: dependencies: ms: 2.0.0 + debug@3.1.0: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -5842,6 +6119,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -6026,6 +6308,8 @@ snapshots: expect-type@1.3.0: {} + express-unless@2.1.3: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -6068,6 +6352,8 @@ snapshots: extend@3.0.2: {} + extsprintf@1.3.0: {} + fast-copy@4.0.3: {} fast-deep-equal@3.1.3: {} @@ -6150,10 +6436,26 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + follow-redirects@1.5.10: + dependencies: + debug: 3.1.0 + transitivePeerDependencies: + - supports-color + + forever-agent@0.6.1: {} form-data-encoder@1.7.2: {} + form-data@2.3.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -6218,6 +6520,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6248,6 +6554,13 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + har-schema@2.0.0: {} + + har-validator@5.1.5: + dependencies: + ajv: 6.15.0 + har-schema: 2.0.0 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -6273,6 +6586,27 @@ snapshots: toidentifier: 1.0.1 optional: true + http-proxy-agent@4.0.1: + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@8.0.1: {} humanize-ms@1.2.1: @@ -6341,13 +6675,15 @@ snapshots: is-stream@4.0.1: {} + is-typedarray@1.0.0: {} + is-unicode-supported@2.1.0: {} isarray@1.0.0: {} isexe@2.0.0: {} - jose@4.15.9: {} + isstream@0.1.2: {} jose@6.2.3: {} @@ -6368,6 +6704,8 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + jsbn@0.1.1: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6394,9 +6732,9 @@ snapshots: jsonrepair@3.14.1: {} - jsonwebtoken@9.0.3: + jsonwebtoken@8.5.1: dependencies: - jws: 4.0.1 + jws: 3.2.3 lodash.includes: 4.3.0 lodash.isboolean: 3.0.3 lodash.isinteger: 4.0.4 @@ -6405,27 +6743,39 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.5 + semver: 5.7.2 - jwa@2.0.1: + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jwa@1.4.2: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jwks-rsa@3.2.2: + jwks-rsa@1.12.5: dependencies: - '@types/jsonwebtoken': 9.0.10 + '@types/express-jwt': 0.0.42 + axios: 0.31.1(debug@4.4.3) debug: 4.4.3 - jose: 4.15.9 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + jsonwebtoken: 8.5.1 limiter: 1.1.5 lru-memoizer: 2.3.0 + ms: 2.1.3 + proxy-from-env: 1.1.0 transitivePeerDependencies: - supports-color - jws@4.0.1: + jws@3.2.3: dependencies: - jwa: 2.0.1 + jwa: 1.4.2 safe-buffer: 5.2.1 keyv@4.5.4: @@ -6542,6 +6892,8 @@ snapshots: lodash.once@4.1.1: {} + lodash@4.17.15: {} + lodash@4.18.1: {} long@5.3.2: @@ -7008,6 +7360,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + oauth-sign@0.9.0: {} + object-inspect@1.13.4: optional: true @@ -7082,6 +7436,8 @@ snapshots: pathe@2.0.3: {} + performance-now@2.1.0: {} + pg-cloudflare@1.4.0: optional: true @@ -7240,6 +7596,10 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -7253,18 +7613,22 @@ snapshots: side-channel: 1.1.1 optional: true + qs@6.5.5: {} + quansync@0.2.11: {} queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} - r7insight_node@2.1.1: + r7insight_node@1.8.4: dependencies: - codependency: 2.1.0 + babel-runtime: 6.6.1 + codependency: 0.1.4 json-stringify-safe: 5.0.1 - lodash: 4.18.1 + lodash: 4.17.15 reconnect-core: 1.3.0 + semver: 5.1.0 range-parser@1.2.0: {} @@ -7337,6 +7701,29 @@ snapshots: remend@1.3.0: {} + request@2.88.2: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.5 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + require-directory@2.1.1: optional: true @@ -7461,6 +7848,10 @@ snapshots: secure-json-parse@4.1.0: {} + semver@5.0.1: {} + + semver@5.1.0: {} + semver@5.7.2: {} semver@7.8.5: {} @@ -7561,6 +7952,18 @@ snapshots: sprintf-js@1.0.3: {} + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stackback@0.0.2: {} statuses@2.0.2: @@ -7626,17 +8029,17 @@ snapshots: - bare-buffer - react-native-b4a - tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: + tc-core-library-js@2.4.1: dependencies: - axios: 0.30.3 + axios: 0.19.2 bunyan: 1.8.15 - jsonwebtoken: 9.0.3 - jwks-rsa: 3.2.2 + jsonwebtoken: 8.5.1 + jwks-rsa: 1.12.5 lodash: 4.18.1 millisecond: 0.1.2 - r7insight_node: 2.1.1 + r7insight_node: 1.8.4 + request: 2.88.2 transitivePeerDependencies: - - debug - supports-color teex@1.0.1: @@ -7676,6 +8079,11 @@ snapshots: tokenx@1.3.0: {} + tough-cookie@2.5.0: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + tr46@0.0.3: {} trough@2.2.0: {} @@ -7692,10 +8100,16 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turndown@7.2.4: dependencies: '@mixmark-io/domino': 2.2.0 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7785,9 +8199,17 @@ snapshots: uuid@11.1.1: {} + uuid@3.4.0: {} + vary@1.1.2: optional: true + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 From 38808c3dbc09baa162e66e5324561eaf387f6cff Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 09:55:38 +0300 Subject: [PATCH 05/27] use v7 ai/sdk for chats --- src/mastra/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mastra/index.ts b/src/mastra/index.ts index bafc8a8..4b92765 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -67,7 +67,7 @@ export const mastra = new Mastra({ apiRoutes: [ chatRoute({ path: CHAT_ROUTE_PATH, - version: 'v6', + version: 'v7', }), ], }, From bf8cb2e40a3407865492715fe5de09a77f6018e0 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 10:09:48 +0300 Subject: [PATCH 06/27] set chat path --- src/utils/server-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/server-routes.ts b/src/utils/server-routes.ts index 11fd78b..1fd4cf6 100644 --- a/src/utils/server-routes.ts +++ b/src/utils/server-routes.ts @@ -1,5 +1,5 @@ // Single source of truth for the server's route surfaces, so auth/middleware // path patterns can't drift out of sync with how routes are actually mounted. export const API_PREFIX = '/v6/ai'; -export const CHAT_ROUTE_BASE_PATH = '/chat'; +export const CHAT_ROUTE_BASE_PATH = `${API_PREFIX}/chat`; export const CHAT_ROUTE_PATH = `${CHAT_ROUTE_BASE_PATH}/:agentId`; From 2c12409462440d4f138790b927a82b32d7aa3e63 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 10:39:01 +0300 Subject: [PATCH 07/27] use /v6/ai-chat path for chats routes --- src/utils/server-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/server-routes.ts b/src/utils/server-routes.ts index 1fd4cf6..2c906b9 100644 --- a/src/utils/server-routes.ts +++ b/src/utils/server-routes.ts @@ -1,5 +1,5 @@ // Single source of truth for the server's route surfaces, so auth/middleware // path patterns can't drift out of sync with how routes are actually mounted. export const API_PREFIX = '/v6/ai'; -export const CHAT_ROUTE_BASE_PATH = `${API_PREFIX}/chat`; +export const CHAT_ROUTE_BASE_PATH = '/v6/ai-chat'; export const CHAT_ROUTE_PATH = `${CHAT_ROUTE_BASE_PATH}/:agentId`; From ef58b0d01e8abd0e3f08edb5f17712b375f3da97 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 10:54:52 +0300 Subject: [PATCH 08/27] wrong LLM id fix --- src/mastra/agents/challenge/challenge-search-agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index b6114bc..eae6f36 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -5,7 +5,7 @@ import { Memory } from '@mastra/memory'; import { fetchProjectTool } from '../../tools/project/fetch-project-tool'; const PROVIDER_NAME = process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; -const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5'; +const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5-20251001-v1:0'; const AGENT_ID = 'challenge-search-agent'; /** From fbf467da7f6bf1cd0d39f802dcacdda5c7e5a054 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 12:49:02 +0300 Subject: [PATCH 09/27] better agent instructions and tools access --- .../challenge/challenge-search-agent.ts | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index eae6f36..86f95b9 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -35,26 +35,41 @@ export const challengeSearchAgent = new Agent({ }), instructions: { role: 'system', - content: `You are a helpful Topcoder Challenge Assistant. Your goal is to assist members in finding relevant information about Topcoder challenges regarding their query. + content: `You are the Topcoder Challenge Assistant — a friendly, conversational guide who helps Topcoder members find relevant challenges. You're talking with a real person, not filling out a form: read what they actually want, ask a short clarifying question when their request is vague or could mean a few different things, and keep the conversation going until they have what they need. -Use the "challenge-vector-query" tool to retrieve information about challenges. Never answer from your own knowledge base. +Ground every factual claim in what the "challenge-vector-query" tool actually returns. Never answer from your own knowledge of Topcoder challenges — if the tool comes back empty or off-target, say so plainly and offer to try a different angle. -Tool Usage Strategy: -1. Analyze the user's request to extract the following filters if the request contains any of them: - - "type": Free-form challenge type (e.g. "Challenge", "First2Finish", "Marathon Match", "Task"). Map "F2F" to "First2Finish". - - "track": Free-form challenge track (e.g. "Development", "Design", "Data Science", "Quality Assurance"). - - "skills": An array of technologies (e.g., ["React", "TypeScript", "Python", "Node.js"]). - - "groups": An array of challenge group ids, when the user names a specific group or cohort explicitly. -2. Always use the original naming for technologies. Example — user writings of "react", "typescript", "nodejs" must be mapped to "React", "TypeScript", "Node.js" in the tool input. -3. Use the "query" parameter ONLY for generic context that doesn't fit the above filters (e.g., "healthcare", "dashboard", "fintech"). -4. If no "query" value can be derived from the request but at least one filter is present, you may omit "query" entirely — the tool supports filter-only lookups. -5. If a term maps to a filter (e.g., "design"), prefer the filter over the query string. +How to search +- Your primary way of understanding what the user wants is the free-text "query" parameter, not filters. Challenge descriptions are indexed for semantic search, so a well-written natural-language query (e.g. "a challenge involving a real-time chat feature with websockets" or "backend work modernizing a legacy payment system") usually surfaces better matches than reducing the request to a list of keywords. +- Don't default to extracting a skills list and filtering by it. That's a narrow reading of most requests — "help me find something to build a mobile banking app" is not "skills: [Swift, Kotlin]", it's a query about the domain and kind of work being asked for. +- When a search doesn't land well (too few results, results that miss the point, or the user says "not quite"), don't just report the miss — rewrite the query yourself and try again before involving the user. Loosen or tighten the wording, try a synonym or a different phrasing, add or drop detail. Iterating on the query is cheap; making the user reword it themselves every time is not friendly. +- Only reach for the structured filters (type, track, skills, groups) when the user explicitly asks to narrow by one of those dimensions — "just First2Finish challenges", "React only", "challenges in this group". A filter the user didn't ask for silently excludes results they might have wanted; if you think one would help, propose it and let them confirm rather than adding it unasked. + - "type": free-form challenge type (e.g. "Challenge", "First2Finish", "Marathon Match", "Task"). Map "F2F" to "First2Finish". + - "track": free-form challenge track (e.g. "Development", "Design", "Data Science", "Quality Assurance"). + - "skills": an array of technologies, using canonical names (e.g. "react" → "React", "nodejs" → "Node.js"). + - "groups": challenge group ids, only when the user names a specific group or cohort explicitly. + - Omit any filter you don't have a real value for. Never pass null or an empty string — leave the parameter out entirely. -**Critical:** type, track, skills, and groups are optional. If no value can be derived for one of them, do not include it in the tool input. Do not pass null or empty string — omit the parameter completely. +**Never infer "projectId" from the query text.** It is an opaque reference that only ever arrives from the caller's own context — never something to guess at from what the user writes, and not something to ask the user to supply directly either. -**Never infer "projectId" from the query text.** It is an opaque reference supplied by the caller's context, not something you should guess from natural language — omit it unless it has been explicitly provided to you as part of the conversation context. +When the request is unclear +If you can't tell what the user is actually looking for — too broad ("show me some challenges"), ambiguous between a few readings, or missing something you'd need to search well — ask a short, specific question before searching rather than guessing. A reasonable first attempt at a broad query is fine when that's faster than asking, but say what you searched for and invite the user to redirect you. -Ground your response SOLELY on the context returned by the tool. If no results are found, say "I couldn't find any challenges matching your criteria."`, +Keep projects separate +Every result carries a "projectId" in its metadata. Challenges from different projects are different engagements for different customers — the work, context, and skills involved can be completely unrelated even when the text looks similar. Never merge or summarize results across projects as if they were one pool: +- When results span more than one project, group your answer by project rather than presenting one flat list. +- Use the "fetch-project-by-id" tool to resolve a projectId to its name when that would make the grouping clearer (e.g. labeling "Project: Acme Storefront Redesign" instead of a bare id) — only for projects that actually showed up in results, not speculatively. +- If the user's question only makes sense answered within a single project's scope (e.g. "what's already been done here"), make sure you aren't quietly blending in matches from other projects. + +Answering +Base your answer only on what the tool actually returned — summarize and organize it, but don't add detail the results don't support. If nothing relevant turns up after a couple of query attempts, say so plainly and suggest what the user could try instead.`, }, tools: { challengeVectorQueryTool, fetchProjectTool }, + // Opts this agent out of the Mastra-instance-level `aiWorkspace` + // (src/mastra/workspaces/ai.workspace.ts), which otherwise gets injected + // into every agent that doesn't set its own `workspace`. A static + // `undefined` here would NOT do that — Agent.getWorkspace() only skips + // the instance-level fallback when `workspace` resolves through a + // function, so this stays a resolver rather than a plain value. + workspace: () => undefined, }); From 111d9087a33b74d1a7fd1ddd492ded0a5abff1a9 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 13:05:51 +0300 Subject: [PATCH 10/27] VECTOR_SEARCH_THRESHOLD 0.25 by default --- .env.sample | 2 +- README.md | 2 +- src/config/rag.config.test.ts | 2 +- src/config/rag.config.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.sample b/.env.sample index 26de05d..a21def8 100644 --- a/.env.sample +++ b/.env.sample @@ -24,7 +24,7 @@ MASTRA_STUDIO_PATH="[ABS PATH_TO_MASTRA_STUDIO_OUTPUT_FOLDER]" RAG_EMBEDDING_PROVIDER="[TC-Ollama|AWSBedrock — default TC-Ollama]" RAG_EMBEDDING_MODEL_ID="[nomic-embed-text|amazon.titan-embed-text-v2:0 — default nomic-embed-text]" VECTOR_INDEX_NAME="[SQL identifier — default challenge_embeddings]" -VECTOR_SEARCH_THRESHOLD="[0-1 — default 0.5]" +VECTOR_SEARCH_THRESHOLD="[0-1 — default 0.25]" RAG_CHUNK_MAX_SIZE="[characters — default 512]" RAG_CHUNK_OVERLAP="[characters — default 50]" RAG_TOP_K="[default 10]" diff --git a/README.md b/README.md index 1a22e01..4157a35 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ tc-ai-api/ | `RAG_EMBEDDING_PROVIDER` | No | `TC-Ollama` | Embedding provider for challenge RAG (`TC-Ollama` \| `AWSBedrock`) | | `RAG_EMBEDDING_MODEL_ID` | No | `nomic-embed-text` | Embedding model id (768d locally; `amazon.titan-embed-text-v2:0`, 1024d, in prod) | | `VECTOR_INDEX_NAME` | No | `challenge_embeddings` | Vector table name (SQL-identifier validated) — override per environment when reindexing | -| `VECTOR_SEARCH_THRESHOLD` | No | `0.5` | Minimum similarity score, applied after retrieval | +| `VECTOR_SEARCH_THRESHOLD` | No | `0.25` | Minimum similarity score, applied after retrieval | | `RAG_CHUNK_MAX_SIZE` | No | `512` | Max characters per chunk before recursive splitting | | `RAG_CHUNK_OVERLAP` | No | `50` | Character overlap between recursively-split chunks | | `RAG_TOP_K` | No | `10` | Default result count for challenge vector search | diff --git a/src/config/rag.config.test.ts b/src/config/rag.config.test.ts index 3a7acbc..08b4212 100644 --- a/src/config/rag.config.test.ts +++ b/src/config/rag.config.test.ts @@ -153,7 +153,7 @@ describe('rag.config — getRagConfig', () => { expect(config.chunkMaxSize).toBe(512); expect(config.chunkOverlap).toBe(50); expect(config.topK).toBe(10); - expect(config.vectorSearchThreshold).toBe(0.5); + expect(config.vectorSearchThreshold).toBe(0.25); }); it('throws actionable error for non-numeric RAG_CHUNK_MAX_SIZE', () => { diff --git a/src/config/rag.config.ts b/src/config/rag.config.ts index f41a338..f6d667a 100644 --- a/src/config/rag.config.ts +++ b/src/config/rag.config.ts @@ -121,7 +121,7 @@ export function getRagConfig(): RagConfig { const vectorSearchThreshold = parseNumber( process.env.VECTOR_SEARCH_THRESHOLD, 'VECTOR_SEARCH_THRESHOLD', - 0.5, + 0.25, ); const chunkMaxSize = parseNumber( From 0704138c9c7a973841ba1c8b08f193a8a40762c7 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 26 Aug 2026 13:45:41 +0300 Subject: [PATCH 11/27] markdown support --- .../challenge/challenge-search-agent.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index 86f95b9..9ea9f98 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -8,6 +8,27 @@ const PROVIDER_NAME = process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5-20251001-v1:0'; const AGENT_ID = 'challenge-search-agent'; +/** + * Derives the member-facing challenge details page origin from TC_API_BASE + * (mirrors the domain-derivation in ../../../utils/auth.ts), so the agent's + * instructions link to the right environment (dev vs prod) without a + * separate env var to keep in sync. + */ +function resolveChallengeDetailsBaseUrl(): string { + let domain = 'topcoder.com'; + try { + const tcApiBase = process.env.TC_API_BASE || ''; + if (tcApiBase) { + domain = new URL(tcApiBase).hostname.replace('api.', ''); + } + } catch { + // fall back to default domain + } + return `https://www.${domain}/challenges`; +} + +const CHALLENGE_DETAILS_BASE_URL = resolveChallengeDetailsBaseUrl(); + /** * "Topcoder Challenge Assistant" — synthesises natural-language answers over * indexed challenge descriptions via challengeVectorQueryTool. @@ -30,7 +51,7 @@ export const challengeSearchAgent = new Agent({ model: createModel(PROVIDER_NAME, MODEL_ID, AGENT_ID), memory: new Memory({ options: { - lastMessages: 10, + lastMessages: 25, }, }), instructions: { @@ -62,7 +83,7 @@ Every result carries a "projectId" in its metadata. Challenges from different pr - If the user's question only makes sense answered within a single project's scope (e.g. "what's already been done here"), make sure you aren't quietly blending in matches from other projects. Answering -Base your answer only on what the tool actually returned — summarize and organize it, but don't add detail the results don't support. If nothing relevant turns up after a couple of query attempts, say so plainly and suggest what the user could try instead.`, +Base your answer only on what the tool actually returned — summarize and organize it, but don't add detail the results don't support. Format your responses in markdown (bold, bullet lists, headings) where that makes the answer easier to scan — it renders properly for the user. Whenever you name a specific challenge, make its title a markdown link to \`${CHALLENGE_DETAILS_BASE_URL}/\`, using the challengeId from that result's metadata — e.g. \`[Member Profile Processor Enhancement](${CHALLENGE_DETAILS_BASE_URL}/abc123-def456)\`. If nothing relevant turns up after a couple of query attempts, say so plainly and suggest what the user could try instead.`, }, tools: { challengeVectorQueryTool, fetchProjectTool }, // Opts this agent out of the Mastra-instance-level `aiWorkspace` From d4e2a0065dc4ba8af019c71dc116aa0a3484937e Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 27 Aug 2026 07:18:22 +0300 Subject: [PATCH 12/27] gen thread titles --- .circleci/config.yml | 51 ++++++++++--------- .../challenge/challenge-search-agent.ts | 1 + 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 747bda7..618691a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2.1 defaults: &defaults docker: - - image: cimg/python:3.13.2-browsers + - image: cimg/python:3.13.2-browsers install_dependency: &install_dependency name: Installation of build and deployment dependencies. command: | @@ -35,21 +35,21 @@ builddeploy_steps: &builddeploy_steps jobs: # Build & Deploy against development backend - "build-dev": + 'build-dev': !!merge <<: *defaults environment: - DEPLOY_ENV: "DEV" - LOGICAL_ENV: "dev" - APPNAME: "tc-ai-api" + DEPLOY_ENV: 'DEV' + LOGICAL_ENV: 'dev' + APPNAME: 'tc-ai-api' DEPLOYMENT_ENVIRONMENT: 'dev' steps: *builddeploy_steps - "build-prod": + 'build-prod': !!merge <<: *defaults environment: - DEPLOY_ENV: "PROD" - LOGICAL_ENV: "prod" - APPNAME: "tc-ai-api" + DEPLOY_ENV: 'PROD' + LOGICAL_ENV: 'prod' + APPNAME: 'tc-ai-api' DEPLOYMENT_ENVIRONMENT: 'prod' steps: *builddeploy_steps @@ -57,19 +57,20 @@ workflows: version: 2 build: jobs: - # Development builds are executed on "develop" branch only. - - "build-dev": - context: org-global - filters: - branches: - only: - - develop - - # Production builds are exectuted only on tagged commits to the - # master branch. - - "build-prod": - context: org-global - filters: - branches: - only: - - master \ No newline at end of file + # Development builds are executed on "develop" branch only. + - 'build-dev': + context: org-global + filters: + branches: + only: + - develop + - challenges-rag + + # Production builds are exectuted only on tagged commits to the + # master branch. + - 'build-prod': + context: org-global + filters: + branches: + only: + - master diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index 9ea9f98..dbb657b 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -52,6 +52,7 @@ export const challengeSearchAgent = new Agent({ memory: new Memory({ options: { lastMessages: 25, + generateTitle: true, }, }), instructions: { From 9391e569e6aabb27d2957432dd3ab6bad1900e67 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 27 Aug 2026 08:47:50 +0300 Subject: [PATCH 13/27] namespace fix, npm update & tools --- .../0001-integrate-challenges-vector-rag.md | 18 +- .../0003-bedrock-prompt-caching-by-default.md | 326 ++++++++++++++++++ package.json | 16 +- pnpm-lock.yaml | 128 +++---- .../challenge-vector-query-tool.test.ts | 2 +- .../challenge/challenge-vector-query-tool.ts | 20 +- .../vector/challenge-vector-store.test.ts | 20 +- src/mastra/vector/challenge-vector-store.ts | 48 +-- .../challenge/challenge-search-workflow.ts | Bin 12149 -> 12548 bytes 9 files changed, 463 insertions(+), 115 deletions(-) create mode 100644 docs/adr/0003-bedrock-prompt-caching-by-default.md diff --git a/docs/adr/0001-integrate-challenges-vector-rag.md b/docs/adr/0001-integrate-challenges-vector-rag.md index 1c123de..9ea4a48 100644 --- a/docs/adr/0001-integrate-challenges-vector-rag.md +++ b/docs/adr/0001-integrate-challenges-vector-rag.md @@ -79,7 +79,7 @@ Neither can be copied as-is. | D9 | Event-driven ingestion (triggering on challenge activation/update) is **deferred**; the first release is on-demand invocation only. | Keeps the initial surface small; the workflow is already idempotent per challenge, so an event trigger can be layered on later without reworking it. | | D10 | Metadata carries **only `projectId`** as an opaque project reference (nullable, stored as a string). No project attributes are denormalized into metadata, no project text is indexed, and ingestion makes no call to projects-api. Consumers that need project detail resolve it in a **subsequent** call to `GET /v6/projects/:projectId`. | Keeps ingestion dependent on a single upstream API, and eliminates the denormalization staleness problem outright: there is no copied project field that can drift when a project is renamed, re-typed, or reassigned to a different billing account, so no refresh mechanism, no re-ingestion trigger, and no staleness signal are needed. It also keeps customer-identifying commercial data out of a store searched by similarity, leaving authorization where it belongs — on the projects-api call, which is already scope-guarded. `projectId` alone still supports project-scoped filtering (`{ projectId: { $in: [...] } }`) and roll-up of challenge hits by project. | | D11 | The **Challenge Search API** (`GET /v6/challenges`) is the primary bulk ingestion source, not CSV. It supports `projectId`/`projectIds`, `status`, `approvalStatus`, `types`/`tracks`, `tags`/`groups`, `updatedDateStart`/`updatedDateEnd`, and `page`/`perPage` pagination, enabling project-scoped fan-out, status-filtered corpus building, and incremental sync by `updatedDateStart`. CSV backfill remains as a secondary path for offline/air-gapped environments. | The search endpoint is already M2M-authenticated (`scopes: [READ]`) and returns the full challenge payload (including `description`) when `isLightweight` is false (the default). It eliminates the need to export and ship CSV files, and its `updatedDateStart` filter makes incremental sync a single paginated call rather than a full re-export. | -| D12 | `type` and `track` are stored and filtered as **free-form strings**, not Zod enums. `ChallengeType` is a reference table (`model ChallengeType` with `name`, `isActive`, `isTask`, `isLegacy`), not an enum — new types can be added at runtime. `ChallengeTrackEnum` has four values (`DESIGN`, `DATA_SCIENCE`, `DEVELOPMENT`, `QUALITY_ASSURANCE`) but the API returns `track.name` (human-readable, e.g. "Quality Assurance"), not the enum value, and tracks can be deactivated via `isActive`. | The prototype hardcoded `['Challenge', 'First2Finish', 'Marathon Match']` as a Zod enum for type and `['Data Science', 'Design', 'Development']` for track, missing `Quality Assurance` and rejecting any future type. Treating both as strings (with the known values documented for reference but not enforced) is forward-compatible with the reference-table model and avoids ingestion failures when a new type or track is added. | +| D12 | `type` and `track` are stored as **free-form strings**, not Zod enums. `ChallengeType` is a reference table (`model ChallengeType` with `name`, `isActive`, `isTask`, `isLegacy`), not an enum — new types can be added at runtime. `ChallengeTrackEnum` has four values (`DESIGN`, `DATA_SCIENCE`, `DEVELOPMENT`, `QUALITY_ASSURANCE`) but the API returns `track.name` (human-readable, e.g. "Quality Assurance"), not the enum value, and tracks can be deactivated via `isActive`. **Amended (2026-08-27):** both the `type` and `track` *query filters* (on `challengeVectorQueryTool` and the `challenge-search` workflow input) are now Zod enums — `type` restricted to `['Challenge', 'Marathon Match']`, `track` restricted to `['Data Science', 'Design', 'Quality Assurance', 'Development']` — a deliberate reversal of the free-form-filter half of this decision for those two callers. Storage/ingestion is unaffected: it still accepts and indexes any `ChallengeType` value (including `First2Finish`, `Task`) and any `track.name`. | The prototype hardcoded `['Challenge', 'First2Finish', 'Marathon Match']` as a Zod enum for type and `['Data Science', 'Design', 'Development']` for track, missing `Quality Assurance` and rejecting any future type. Treating both as strings (with the known values documented for reference but not enforced) is forward-compatible with the reference-table model and avoids ingestion failures when a new type or track is added. The amendment narrows the *query* surface to the values users actually filter by in practice, accepting that a future new `ChallengeType` (or `First2Finish`/`Task`) becomes unfilterable via `type` until the enum is revisited, and that a deactivated or renamed track (`ChallengeTrackEnum`'s `isActive` flag) would need the same revisit for `track` — it does not touch ingestion, so no re-indexing risk. | ## Implementation plan @@ -108,11 +108,15 @@ Neither can be copied as-is. default, per D2) — chunk sizes, `VECTOR_SEARCH_THRESHOLD`, `VECTOR_INDEX_NAME` (SQL-identifier validated, as in the original), `RAG_TOP_K`. Per D12, `type` and `track` are **not** hardcoded - enums — the config documents the known `ChallengeTrackEnum` values - (`DESIGN`, `DATA_SCIENCE`, `DEVELOPMENT`, `QUALITY_ASSURANCE`) and the current - `ChallengeType` reference-table names for readability, but the query tool - accepts any string. Database settings reuse `MASTRA_DB_CONNECTION` and - `MASTRA_DB_SCHEMA` (default `ai`). + enums at the storage/config layer — the config documents the known + `ChallengeTrackEnum` values (`DESIGN`, `DATA_SCIENCE`, `DEVELOPMENT`, + `QUALITY_ASSURANCE`) and the current `ChallengeType` reference-table names for + readability but does not enforce them at the storage/config layer. The `type` + and `track` query filters are Zod enums restricted to `['Challenge', 'Marathon + Match']` and `['Data Science', 'Design', 'Quality Assurance', 'Development']` + respectively (D12 amendment, 2026-08-27) — narrower than storage on purpose. + Database settings reuse `MASTRA_DB_CONNECTION` and `MASTRA_DB_SCHEMA` (default + `ai`). - **`src/utils/providers/embedding-factory.ts`** — `createEmbeddingModel(provider, modelId)` switch mirroring `createModel`, using `ollama.embedding(modelId)` and `createBedrockProvider().embedding(modelId)`, logging via `tcAILogger`. Re-exported @@ -263,7 +267,7 @@ Supporting changes: - **`src/mastra/workflows/challenge/challenge-search-workflow.ts`**, id `challenge-search`, registered under `workflows` — the deterministic path from D8, with no agent and no LLM call: - - Input `{ query?: string, skills?: string[], type?: string, track?: string, groups?: string[], projectId?: string | string[], groupBy?: 'chunk' | 'challenge' | 'project', topK?: number, minScore?: number }`. + - Input `{ query?: string, skills?: string[], type?: 'Challenge' | 'Marathon Match', track?: 'Data Science' | 'Design' | 'Quality Assurance' | 'Development', groups?: string[], projectId?: string | string[], groupBy?: 'chunk' | 'challenge' | 'project', topK?: number, minScore?: number }` (`type`/`track` enums per the D12 amendment above). Filters are supplied explicitly by the caller; unlike the agent path, nothing is inferred from natural language. - Single step `search-challenges` executing `challengeVectorQueryTool` with the diff --git a/docs/adr/0003-bedrock-prompt-caching-by-default.md b/docs/adr/0003-bedrock-prompt-caching-by-default.md new file mode 100644 index 0000000..e6718db --- /dev/null +++ b/docs/adr/0003-bedrock-prompt-caching-by-default.md @@ -0,0 +1,326 @@ +# ADR 0003 — Enable AWS Bedrock prompt caching by default + +- **Status:** **Proposed** (not yet implemented) — for review +- **Date:** 2026-08-26 +- **Target branch:** `challenges-rag` +- **Related:** none directly, but touches the same `createModel()` / provider-factory + infrastructure (`src/utils/providers/model-factory.ts`, `src/utils/providers/bedrock.ts`) + every existing agent goes through. + +## Context + +### The problem + +Every agent in this repo (`challengeSearchAgent`, `skillsMatchingAgent`, +`challengeParserAgent`, `jdRewriterAgent`) sends its full system-prompt instructions +to the model on **every single call**, unchanged. Some of these prompts are long, +multi-paragraph blocks (`challenge-search-agent.ts`'s instructions run to several +hundred words covering search strategy, filter rules, and project-separation +policy). For a multi-turn agent like `challengeSearchAgent` (`Memory.lastMessages: +25`), the growing conversation history is resent in full on every turn too. None of +this content is currently eligible for any form of reuse — each call is billed and +latency-charged for the entire prompt, every time. + +AWS Bedrock supports **prompt caching**: a provider can mark a point in the prompt +as a cache checkpoint, and if a subsequent request's prompt matches the cached +prefix exactly up to that point, Bedrock serves the cached KV-state instead of +reprocessing it — cutting both cost and time-to-first-token for the cached portion. +The static system-prompt block in every one of this repo's agents is close to the +textbook case for this feature: identical bytes, sent on every request, easily +above Bedrock's per-checkpoint minimum-token threshold. + +### What the AI SDK gives us (verified against installed `@ai-sdk/amazon-bedrock@4.0.121`, not just the docs page) + +- Cache control is a **per-message** flag, not a provider- or model-construction-time + setting: `providerOptions: { bedrock: { cachePoint: { type: 'default', ttl?: '5m' + | '1h' } } }`, attached to a system/user/assistant message. `ttl` defaults to + `'5m'`; `'1h'` costs more per cache write but survives longer idle gaps between + requests. +- **No typed field exists for this in the installed SDK version.** It's read via + untyped passthrough in the compiled provider + (`providerMetadata?.bedrock?.cachePoint` — confirmed at 5 call sites in + `node_modules/@ai-sdk/amazon-bedrock/dist/index.js`, covering system/user/assistant + messages). `AmazonBedrockLanguageModelOptions`, the SDK's typed provider-options + schema, has no cache field at all. This means there is no compile-time safety net + from the package itself — any local wrapper has to define its own types and keep + them honest by hand. +- **`createAmazonBedrock()`'s provider-construction settings have no cache option.** + Caching cannot be a provider-factory default in the sense of "set once when the + provider is created" — it has to be applied at the message-construction layer, on + every call. +- **Model support is real and enforced, not advisory.** Only current-generation + models accept `cachePoint` — modern Claude (3.5+, Sonnet 5, Haiku 4.5 — this + repo's current defaults) and Amazon Nova. Sending it to an unsupported model + (Claude 3 pre-3.5, Titan, Llama, Mistral, Cohere on Bedrock) is a **hard + `ValidationException`** — not a silent no-op. Below the per-checkpoint minimum + token threshold (~1024 tokens, model-dependent), Bedrock *does* silently skip + caching rather than error — that half is safe to ignore. +- **Up to 4 cache checkpoints per request**, and checkpoint placement must be + monotonic through the conversation. This ADR uses exactly one (the system + message); a second checkpoint over conversation history is identified below as + follow-on scope, not part of this decision. + +### Why this can't be "just always on" + +Every agent's provider and model are independently env-overridable +(`CHALLENGE_SEARCH_AI_PROVIDER` / `CHALLENGE_SEARCH_AI_MODEL_ID`, and the equivalent +pair per agent) to **any** Bedrock model ID string, or to a non-Bedrock provider +entirely (`TC-Ollama`, `WiproAI`, `OpenAI`). A middleware that unconditionally +stamps `cachePoint` onto every Bedrock call would turn an operator's routine model +swap (e.g. testing an older Claude 3 model, or a Titan-based experiment) into a hard +runtime failure on the very next request. The mechanism has to gate on which model +is actually in play. + +### What Mastra itself provides — nothing automatic + +Searched `@mastra/core`'s bundled docs (`node_modules/@mastra/core/dist/docs/**/*.md`) +for any built-in cache-injection feature. Mastra is aware of prompt caching as a +*concept* — `docs-guides-context-engineering.md` advises keeping stable prompt +content first so "the model provider may... reuse the same prompt cache prefix," +and describes Observational Memory as cache-friendly because it folds history into +stable chunks instead of an ever-changing raw tail — but there is no Agent option, +Memory option, or built-in processor that sets `providerOptions.bedrock.cachePoint` +automatically. None of the four agents in this repo currently use Observational +Memory (they use plain `lastMessages`). This has to be built as model middleware. + +## Scope + +**In scope:** + +- A shared, reusable mechanism that stamps a Bedrock cache checkpoint onto the + **system/instructions message** of every model call, applied centrally so it + covers all four existing agents without per-agent changes. +- A model-ID allowlist gating the mechanism to models confirmed to support prompt + caching, so an operator overriding an agent's model to something else never hits + the hard-error path. +- A global kill switch and TTL knob, both env-var configurable with the feature + **on** by default. + +**Out of scope (deferred, not rejected):** + +- A second cache checkpoint over conversation history (the "stable prefix grows one + turn at a time" pattern for long multi-turn threads like `challengeSearchAgent`'s + 25-message memory). Real value for long-running threads, but adds genuine + complexity — checkpoint-placement bookkeeping that shifts every turn, staying + under the 4-checkpoint limit, and its own test surface. Ship the system-prompt win + first, observe it in practice, revisit this as a follow-up. +- Per-agent cache configuration (e.g. a `*_AI_CACHE_ENABLED` env var per agent + mirroring the existing `*_AI_PROVIDER` / `*_AI_MODEL_ID` pattern). The global + switch proposed here is simpler and matches "on by default everywhere"; nothing + in the current agent set needs per-agent divergence. If that changes, promoting + the global flag to a per-agent override is additive, not a breaking change. +- Anthropic's own cache-control shape (`providerOptions.anthropic.cacheControl`). + That shape belongs to `@ai-sdk/amazon-bedrock`'s separate + `createBedrockAnthropic` / `bedrockAnthropic` provider (native Anthropic API via + Bedrock's `InvokeModel`, bypassing the Converse API). This repo's + `createBedrockProvider()` (`src/utils/providers/bedrock.ts`) uses the standard + `createAmazonBedrock` (Converse API) path, so the relevant shape is + `providerOptions.bedrock.cachePoint`, not the Anthropic one. +- Embedding calls (`createEmbeddingModel`, used for RAG ingestion/retrieval) — + prompt caching is a chat-completion concept; embeddings have no system prompt to + cache. +- Any change to `TC-Ollama`, `WiproAI`, or `OpenAI` provider paths in + `model-factory.ts` — this ADR only touches the `AWSBedrock` branch. + +## Affected agents (all `AWSBedrock`-capable model consumers) + +| Agent | Default provider | Default model ID | Cache-capable by default? | +| --- | --- | --- | --- | +| `challengeSearchAgent` | AWSBedrock | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Yes | +| `skillsMatchingAgent` | AWSBedrock (env-overridable) | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Yes | +| `challengeParserAgent` | AWSBedrock (env-overridable) | `us.anthropic.claude-sonnet-5` | Yes | +| `jdRewriterAgent` | AWSBedrock (env-overridable) | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Yes | + +All four current defaults are cache-capable Claude models. Every one of these is +env-overridable per agent to a different provider or Bedrock model ID — the concrete +reason the allowlist guard in the Decision below is load-bearing, not defensive +boilerplate. + +## Decision + +1. **Add cache-eligibility and middleware logic to `src/utils/providers/bedrock.ts`** + (the Bedrock-specific provider file — co-located with the rest of the + Bedrock-only code, rather than in the provider-agnostic `model-factory.ts`): + - `isCacheCapableBedrockModel(modelId: string): boolean` — an **allowlist**, + matching known cache-capable model ID patterns (modern Claude 3.5+/Sonnet + 4-5/Haiku 4.5, Amazon Nova). Allowlist, not denylist, deliberately: a false + negative here just means "no caching for this call" (silent, harmless); a + false positive means a hard `ValidationException` that breaks the agent's + next request outright. When in doubt, don't cache. + - `createCachedBedrockModel(agentId, modelName)` — wraps + `createBedrockProvider(agentId)(modelName)` in `wrapLanguageModel({ model, + middleware })` (from the `ai` package). The middleware's `transformParams` + locates the system message in `params.prompt` and, only when + `isCacheCapableBedrockModel(modelName)` is true and the feature is enabled, + returns a new params object with `providerOptions.bedrock.cachePoint = { + type: 'default', ttl: }` merged onto that message's existing + `providerOptions` (never clobbering anything already set there). Otherwise + returns `params` unchanged. `transformParams` returns a new object rather + than mutating the input, matching the AI SDK middleware contract. +2. **`src/utils/providers/model-factory.ts`'s `AWSBedrock` branch calls + `createCachedBedrockModel(agentId, modelName)`** instead of + `createBedrockProvider(agentId)(modelName)`. This is the one line that makes the + feature apply to all four agents automatically — no agent file changes. +3. **Two new environment variables**, both optional with defaults, following this + repo's existing env-var convention (`rag.config.ts`'s pattern of + validated-with-sane-default): + - `BEDROCK_PROMPT_CACHE_ENABLED` — default `true`. A global kill switch; set to + `false` to fully disable without a deploy rollback if something unexpected + shows up in production. + - `BEDROCK_PROMPT_CACHE_TTL` — default `'5m'`, validated to `'5m' | '1h'`. + Rejecting any other value the same way `rag.config.ts`'s `parseNumber` throws + an actionable error for a bad numeric env var. +4. **Cache only the system/instructions message in this pass** (see Scope). No + attempt to cache growing conversation history yet. +5. **Verify the flag is actually doing something, not just silently present.** + During implementation, check whether the Bedrock Converse response surfaces + cache read/write token counts through the AI SDK result (likely + `result.providerMetadata.bedrock` or a dedicated field on `result.usage`) and, if + so, log it at `tcAILogger.debug` (or `info`) per call — the same observability + instinct as the existing `tcAILogger.warn` in + `challenge-vector-query-tool.ts` when every result falls below threshold. A flag + that "does nothing detectably" is not meaningfully different from a bug. + +### Config surface + +```ts +// src/utils/providers/bedrock.ts (additive) +export function isCacheCapableBedrockModel(modelId: string): boolean { + // Allowlist of confirmed-capable model ID patterns — Claude 3.5+/Sonnet 4-5/ + // Haiku 4.5, Amazon Nova. Extend deliberately; a miss here just means no + // caching, a wrong inclusion means a hard ValidationException on the next call. +} + +export function createCachedBedrockModel(agentId: string | undefined, modelName: string) { + // wrapLanguageModel({ model: createBedrockProvider(agentId)(modelName), middleware }) +} +``` + +```bash +# .env.sample (additive) +BEDROCK_PROMPT_CACHE_ENABLED="[true|false — default true]" +BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" +``` + +## Implementation plan + +### Phase 0 — Middleware and eligibility check +- `src/utils/providers/bedrock.ts`: add `isCacheCapableBedrockModel()`, + `createCachedBedrockModel()`, and the local (untyped-upstream) TypeScript + interface describing the `cachePoint` shape, since the installed SDK doesn't + export one. +- Read the two new env vars once (module scope or lazily, matching the existing + style in this file) with validation matching `rag.config.ts`'s + `parseNumber`/`validateSqlIdentifier` pattern — throw an actionable error for an + invalid `BEDROCK_PROMPT_CACHE_TTL`, don't silently fall back. +- `src/utils/providers/bedrock.test.ts` (new or extended): unit tests for + `isCacheCapableBedrockModel` — positive cases for the four model IDs actually in + use today, negative cases for Titan/Llama/pre-3.5 Claude; and for + `createCachedBedrockModel`'s `transformParams` — cache point added for a capable + model with the feature enabled, absent when disabled via env, absent for a + non-capable model, existing `providerOptions` on the system message preserved + rather than overwritten. + +### Phase 1 — Wire into the model factory +- `src/utils/providers/model-factory.ts`: `AWSBedrock` branch calls + `createCachedBedrockModel(agentId, modelName)`. No other branch changes. +- Confirm none of the four agents' existing test suites assert on the exact shape + of the model instance returned by `createModel()` in a way `wrapLanguageModel`'s + wrapper would break (it still satisfies the same `LanguageModelV3` interface + `Agent({ model })` expects, so this is expected to be a non-issue, but worth + confirming against the actual test suites rather than assuming). + +### Phase 2 — Validation +- `npx tsc --noEmit`, `npx eslint`, full `vitest run`. +- Manual smoke test against real Bedrock: run `challenge-search-agent` twice in a + row with the same conversation via Studio or `/chat/:agentId`, and confirm via + the logging added in Decision item 5 that the second call reports a cache hit + (non-zero cache-read tokens) for the system-prompt portion. +- Manual negative test: temporarily override `CHALLENGE_SEARCH_AI_MODEL_ID` to a + known non-capable model (e.g. a Titan text model ID) and confirm the agent still + responds normally — i.e. the allowlist guard actually prevents the + `ValidationException` it exists to prevent, rather than that path being + untested. + +### Phase 3 — Documentation +- `README.md`: add `BEDROCK_PROMPT_CACHE_ENABLED` and `BEDROCK_PROMPT_CACHE_TTL` to + the environment-variables table, plus a short paragraph (matching the existing + "Retrieval" / agent-description sections in style) explaining what prompt + caching is, that it's on by default for cache-capable Bedrock models, and how to + disable it. +- `.env.sample`: the two new keys with the default-documented placeholder format + already used throughout that file. + +## File-level mapping + +| File | Change | +| --- | --- | +| `src/utils/providers/bedrock.ts` | Modified — adds `isCacheCapableBedrockModel()`, `createCachedBedrockModel()`, local cache-point type, env-var reads | +| `src/utils/providers/bedrock.test.ts` | New or modified — eligibility + middleware unit tests | +| `src/utils/providers/model-factory.ts` | Modified — one line, `AWSBedrock` branch calls the new wrapper | +| `README.md` | Modified — env var table + short explainer | +| `.env.sample` | Modified — two new keys | +| `src/mastra/agents/**` | **Unchanged** — the whole point of centralizing this in the provider factory | + +## Consequences + +**Positive** + +- Every current and future Bedrock-backed agent gets prompt caching automatically, + with no per-agent code — the same "one choke point" property ADR 0002 leaned on + for outbound TC API auth. +- Reduced cost and time-to-first-token on every call after the first, for the + (often large, always-static) system-prompt portion of every agent's request — + compounding with `challengeSearchAgent`'s `lastMessages: 25` memory, where the + system prompt is resent unchanged on every turn of a long conversation. +- The allowlist guard means an operator's routine `*_AI_MODEL_ID` override to test + a different model degrades gracefully (no caching) instead of breaking the agent. + +**Negative / risk** + +- **No compile-time type safety from the SDK.** `cachePoint` is read via untyped + passthrough in the installed `@ai-sdk/amazon-bedrock` version — a local + hand-written interface is the only thing keeping the shape honest, and it will + silently go stale if a future SDK upgrade changes the field name or nesting. + Mitigation: the unit tests in Phase 0 pin the expected shape, so an SDK upgrade + that breaks it fails tests rather than failing silently in production. +- **The allowlist needs manual upkeep.** A new Claude/Nova model released on + Bedrock with cache support won't benefit from this feature until someone adds it + to `isCacheCapableBedrockModel()`. Accepted trade-off given the alternative (a + denylist) risks a hard production error instead of a missed optimization. +- **System-prompt-only caching leaves value on the table for long conversations.** + A 25-message thread's growing history is not cached at all in this pass — see + Scope. Real but bounded: the system prompt is very likely the single largest + static block in most calls regardless of history length, so this pass captures + the majority of the available benefit even without the second checkpoint. +- **Below-threshold prompts pay for a cache write with no future reuse if the + conversation never continues** (single-shot calls, or agents whose system prompt + is short). Bounded cost, not a correctness issue — Bedrock still serves the + response normally either way. + +## Open questions + +- Exact field name/shape for cache read/write token counts in the AI SDK's result + object for Bedrock — needed for the observability step in Decision item 5 and the + Phase 2 manual smoke test. Not confirmed during this ADR's research; resolve by + inspecting a real response's `providerMetadata` during Phase 0/1 implementation. +- Whether `BEDROCK_PROMPT_CACHE_TTL: '1h'` is ever worth the higher cache-write + cost for this repo's actual call patterns (bursty interactive chat vs. steady + background workflow traffic) — no usage data exists yet to decide; `'5m'` default + is the safe starting point and this can be revisited once the observability from + item 5 gives real numbers. +- Whether the second (conversation-history) checkpoint from the Scope section is + worth the added complexity — deferred until the system-prompt-only win has been + observed in production for `challengeSearchAgent`'s long-running threads + specifically. + +## Prerequisites to confirm before implementation starts + +- Confirm the four model IDs currently in use are actually cache-enabled in the + target AWS account/region for Bedrock (prompt caching is a Bedrock account/model + feature that can require explicit enablement or be region-limited — not verified + against the live AWS account as part of this ADR's research, only against AI SDK + and Bedrock's general documentation). +- Reviewer sign-off on the allowlist-vs-denylist choice (Decision item 1) and the + global-vs-per-agent config choice (Scope) — both are judgement calls made + explicit here for review rather than settled facts. diff --git a/package.json b/package.json index 2970cd6..ef6fbc4 100644 --- a/package.json +++ b/package.json @@ -30,15 +30,15 @@ "@ai-sdk/amazon-bedrock": "^4.0.121", "@ai-sdk/openai": "^3.0.74", "@aws-sdk/credential-providers": "^3.1075.0", - "@mastra/ai-sdk": "^1.9.1", + "@mastra/ai-sdk": "^1.10.0", "@mastra/auth-auth0": "^1.2.2", - "@mastra/core": "^1.61.0", + "@mastra/core": "^1.63.0", "@mastra/evals": "^1.9.0", - "@mastra/libsql": "^1.21.1", - "@mastra/loggers": "^1.2.0", - "@mastra/memory": "^1.27.0", - "@mastra/observability": "^1.17.1", - "@mastra/pg": "^1.21.1", + "@mastra/libsql": "^1.22.0", + "@mastra/loggers": "^1.3.0", + "@mastra/memory": "^1.28.0", + "@mastra/observability": "^1.17.3", + "@mastra/pg": "^1.22.0", "@mastra/rag": "^2.6.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.221.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.221.0", @@ -55,7 +55,7 @@ "@types/node": "^26.0.1", "@types/turndown": "^5.0.6", "eslint": "^10.5.0", - "mastra": "^1.26.0", + "mastra": "^1.27.0", "prettier": "^3.8.4", "tsx": "^4.23.12", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e206e98..f56d322 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,35 +18,35 @@ importers: specifier: ^3.1075.0 version: 3.1075.0 '@mastra/ai-sdk': - specifier: ^1.9.1 - version: 1.9.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + specifier: ^1.10.0 + version: 1.10.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@mastra/auth-auth0': specifier: ^1.2.2 version: 1.2.2 '@mastra/core': - specifier: ^1.61.0 - version: 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + specifier: ^1.63.0 + version: 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) '@mastra/evals': specifier: ^1.9.0 - version: 1.9.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + version: 1.9.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/libsql': - specifier: ^1.21.1 - version: 1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.22.0 + version: 1.22.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/loggers': - specifier: ^1.2.0 - version: 1.2.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.3.0 + version: 1.3.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/memory': - specifier: ^1.27.0 - version: 1.27.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.28.0 + version: 1.28.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/observability': - specifier: ^1.17.1 - version: 1.17.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + specifier: ^1.17.3 + version: 1.17.3(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@mastra/pg': - specifier: ^1.21.1 - version: 1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.22.0 + version: 1.22.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/rag': specifier: ^2.6.0 - version: 2.6.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + version: 2.6.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1) @@ -88,8 +88,8 @@ importers: specifier: ^10.5.0 version: 10.5.0 mastra: - specifier: ^1.26.0 - version: 1.26.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) + specifier: ^1.27.0 + version: 1.27.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) prettier: specifier: ^3.8.4 version: 3.8.4 @@ -829,8 +829,8 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} - '@mastra/ai-sdk@1.9.1': - resolution: {integrity: sha512-93ptk2jsUG6HbDAZdcgodnewGp7B19A7/CmDh9Y64dTd25I3DqGmY4Y3KZRhCLJ/bDAIW9jMwo8N2UxHrMh/hg==} + '@mastra/ai-sdk@1.10.0': + resolution: {integrity: sha512-ZEtbGyTJsKwWkrGR9uKcxi4uQrIT30c0da8gGTtrhZg38yUXeC2mRKWJSl0J3VSFcmtDDRqciKQmNZvQuearuA==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.5.0-0 <2.0.0-0' @@ -840,14 +840,14 @@ packages: resolution: {integrity: sha512-Oggr7zNOZ64F1empufZLy7nzDCLo53XT/41ZatEmwPJpMs3Lv9wdq/IScMharTMnLKFPCpPVAlKOc6y1I3MXxg==} engines: {node: '>=22.13.0'} - '@mastra/core@1.61.0': - resolution: {integrity: sha512-HaarHi6tn8mgm9/wtD/H51rJ8YbiQFTZMbFP3zUfcpL6/v5uZcIa9KzNH6pyKVjMB3cWZU+2Np3yCei1LKf+aQ==} + '@mastra/core@1.63.0': + resolution: {integrity: sha512-mo5rbXpXLY7lf0zFPAarjsYCTf5hGbLrd678tZZ14/1MkJMlaWJUajECl9rJLseiPv+1wyn4LnmT8W2YQ5khLQ==} engines: {node: '>=22.13.0'} peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@mastra/deployer@1.61.0': - resolution: {integrity: sha512-xUTPHvb48jwCKLGucGWNUJp2EU1k1UhvqIFb8DBh2irKukqpPickIytc/1VHvV4PzIgJ9L7YoL+g/7OZN5xZWg==} + '@mastra/deployer@1.63.0': + resolution: {integrity: sha512-CiiD7j5hiUdg9BDpx3NtudHFTPCae/4HXfagAvZy45IUXoE845P90rbdjf5W2ApZj1c3H7iklwglc11DX3Jsog==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.50.0-0 <2.0.0-0' @@ -858,33 +858,33 @@ packages: peerDependencies: '@mastra/core': '>=1.0.0-0 <2.0.0-0' - '@mastra/libsql@1.21.1': - resolution: {integrity: sha512-QgS9c5XIFr/xoH1LsnH5WsgjZBt+jBDzE01tx6a1r2nNaj5Cb46aNbc+zOWx2NNASmQ7OhaGdWZVIJHHQXfauw==} + '@mastra/libsql@1.22.0': + resolution: {integrity: sha512-XzxXHOTNRSyjEHYXauLHbqQh9zAuq2pRsQN29atyWpLwtr7LrZSDHYd3gwn9QkWnFx7CDtpFkumcexuxE3y/Rg==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.51.0-0 <2.0.0-0' - '@mastra/loggers@1.2.0': - resolution: {integrity: sha512-1RJO8XsMgVsTC+NviJ0jGMK01Y5zCqSzFNnOH9D1swOUfX8DMviyAJzxJUmkWbhSrDxmeo/KKmFzRK8zzk4XYA==} + '@mastra/loggers@1.3.0': + resolution: {integrity: sha512-xqPce9tCQeWEuaFeCQwchnImtAuiSayWNb3d8kXlFEHSsXz6fdWvvvAsKFWiBZaJaC/vGPgB0Bhhjg7l8pJDJQ==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.0.0-0 <2.0.0-0' - '@mastra/memory@1.27.0': - resolution: {integrity: sha512-/So8OB4gh5DIdRZ/0EZz7M6lHB3TVlbPIbO8c3PrVygg2IFiHbfckUmA/im6qq2pSaB0ScfrouEHOkRsfxdwyQ==} + '@mastra/memory@1.28.0': + resolution: {integrity: sha512-zTmA780ddORXTtq0u0fx0L598OgZEddAFuA2yXpThwCU3hx73OgDJDJG0mYgPuZlY6ABqiOpv5c9uhcHRI/eDA==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.4.1-0 <2.0.0-0' - '@mastra/observability@1.17.1': - resolution: {integrity: sha512-Fk6tBZZMN56HFgudz+tCSH7XZkLXaHRENOybqsc5YwH3bTbmZpqoICumpoHGM3MI/0S8YYaEXdWiYKQ9MH+4kw==} + '@mastra/observability@1.17.3': + resolution: {integrity: sha512-s86/ufX2FmT0ptEBYEtrxxWgzmCd3p7W1U0wtqBSeg4+cDvxlEqG+76ZVjeQiH+DPd4p4pDQYNT6CPoXRfARHg==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.16.0-0 <2.0.0-0' zod: ^3.25.0 || ^4.0.0 - '@mastra/pg@1.21.1': - resolution: {integrity: sha512-HpMojerOULXj7aDCK1Xa/QUDgcNiq+XqYFlsjsWy3xun2vi9gcOYGMrgKaHBlXD4G86g6vO+w5aMgdJ6rNOChw==} + '@mastra/pg@1.22.0': + resolution: {integrity: sha512-RCg6GIqL37FSgjRyj8HVL2FBvbOJsQu0s4u/EcKsAd9o1Wv9FVSoE84uInKdznQZPGj9Ok2FiXbbbsZQvRNQ7A==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.53.0-0 <2.0.0-0' @@ -902,8 +902,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@mastra/server@1.61.0': - resolution: {integrity: sha512-0/St5fZ82Z/OZqFu3bdynfn6uNOUEQ2/TKGmtksMmILFTDZPpobpsI9m63qbMUwHAvBQm0omwWsysgymbC1kgQ==} + '@mastra/server@1.63.0': + resolution: {integrity: sha512-fMMlWU+khxmyhzAQze6QbQadBqJ1Y9HqRHhAJod38QOlb/X9yb7VcFaQBkmicIKRLjABRY2gZY2M/tN2Ggrl1g==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.50.0-0 <2.0.0-0' @@ -2813,8 +2813,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - mastra@1.26.0: - resolution: {integrity: sha512-0HEzz0v5akGHAXqJinj86PZDU3ixLIg7ex5ta3DAAo2PkpHFcAgqxluz6vKpy4nOQPPR+u2DNQOAXl0LD5zsIA==} + mastra@1.27.0: + resolution: {integrity: sha512-lLZhc77DhSyvK4iVJ7RW6phjH7MuA2TOMpZ/Zu+Rk9Os9TTQZ3ZpTPDzaV4FH1MhSJ0CxfmHarkUP4bjrarW5g==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: @@ -4848,16 +4848,16 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 - '@mastra/ai-sdk@1.9.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/ai-sdk@1.10.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) zod: 4.4.3 '@mastra/auth-auth0@1.2.2': dependencies: jose: 6.2.3 - '@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)': + '@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)': dependencies: '@a2a-js/sdk-v0_3': '@a2a-js/sdk@0.3.14(@grpc/grpc-js@1.14.4)(express@5.2.1)' '@a2a-js/sdk-v1': '@a2a-js/sdk@1.0.1(@grpc/grpc-js@1.14.4)(express@5.2.1)' @@ -4903,14 +4903,14 @@ snapshots: - utf-8-validate - workflow - '@mastra/deployer@1.61.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3)': + '@mastra/deployer@1.63.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3)': dependencies: '@babel/core': 8.0.1 '@babel/preset-typescript': 8.0.1(@babel/core@8.0.1) '@babel/traverse': 8.0.4 '@hono/node-ws': 1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27) - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) - '@mastra/server': 1.61.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/server': 1.63.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@optimize-lodash/rollup-plugin': 5.1.0(rollup@4.62.2) '@rollup/plugin-commonjs': 29.0.2(rollup@4.62.2) '@rollup/plugin-esm-shim': 0.1.8(rollup@4.62.2) @@ -4941,31 +4941,31 @@ snapshots: - utf-8-validate - zod - '@mastra/evals@1.9.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/evals@1.9.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) compromise: 14.15.1 keyword-extractor: 0.0.28 sentiment: 5.0.2 string-similarity: 4.0.4 - '@mastra/libsql@1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/libsql@1.22.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: '@libsql/client': 0.17.4 - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) transitivePeerDependencies: - bufferutil - utf-8-validate - '@mastra/loggers@1.2.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/loggers@1.3.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) pino: 10.3.1 pino-pretty: 13.1.3 - '@mastra/memory@1.27.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/memory@1.28.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) '@mastra/schema-compat': 1.3.7(zod@4.4.3) async-mutex: 0.5.0 diff: 8.0.4 @@ -4978,14 +4978,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@mastra/observability@1.17.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/observability@1.17.3(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) zod: 4.4.3 - '@mastra/pg@1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/pg@1.22.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) async-mutex: 0.5.0 pg: 8.22.0 pg-connection-string: 2.14.0 @@ -4993,10 +4993,10 @@ snapshots: transitivePeerDependencies: - pg-native - '@mastra/rag@2.6.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/rag@2.6.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: '@aws-sdk/client-bedrock-agent-runtime': 3.1113.0 - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) big.js: 7.0.1 js-tiktoken: 1.0.21 node-html-better-parser: 1.5.9 @@ -5011,9 +5011,9 @@ snapshots: zod: 4.4.3 zod-from-json-schema: 0.5.3 - '@mastra/server@1.61.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/server@1.63.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) hono: 4.12.27 zod: 4.4.3 @@ -6918,15 +6918,15 @@ snapshots: markdown-table@3.0.4: {} - mastra@1.26.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3): + mastra@1.27.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3): dependencies: '@babel/parser': 8.0.4 '@babel/types': 8.0.4 '@clack/prompts': 1.7.0 '@expo/devcert': 1.2.1 - '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) - '@mastra/deployer': 1.61.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) - '@mastra/loggers': 1.2.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + '@mastra/core': 1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/deployer': 1.63.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) + '@mastra/loggers': 1.3.0(@mastra/core@1.63.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) archiver: 8.0.0 commander: 14.0.3 dotenv: 17.4.2 diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts index b95480a..5203a53 100644 --- a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts @@ -27,7 +27,7 @@ vi.mock('../../../utils/providers/embedding-factory', () => ({ })); vi.mock('../../vector/challenge-vector-store', () => ({ - getChallengeVectorStore: () => ({ query: mocks.storeQuery }), + ensureChallengeIndex: async () => ({ query: mocks.storeQuery }), })); vi.mock('../../../config/rag.config', () => ({ diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.ts index 62e78d7..6f18457 100644 --- a/src/mastra/tools/challenge/challenge-vector-query-tool.ts +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.ts @@ -23,7 +23,7 @@ import { z } from 'zod'; import { getRagConfig } from '../../../config/rag.config'; import { tcAILogger } from '../../../utils/logger'; import { createEmbeddingModel } from '../../../utils/providers/embedding-factory'; -import { getChallengeVectorStore } from '../../vector/challenge-vector-store'; +import { ensureChallengeIndex } from '../../vector/challenge-vector-store'; // --------------------------------------------------------------------------- // Zod Schemas @@ -78,8 +78,20 @@ const inputSchema = z.preprocess( 'then becomes a metadata-only lookup with no embedding call.', ), skills: z.array(z.string()).optional().describe('Filter by challenge skills (e.g. ["TypeScript", "React"])'), - type: z.string().optional().describe('Filter by challenge type. Free-form (D12) — not an enum.'), - track: z.string().optional().describe('Filter by challenge track. Free-form (D12) — not an enum.'), + type: z + .enum(['Challenge', 'Marathon Match']) + .optional() + .describe( + 'Filter by challenge type. One of "Challenge" (standard challenge) or ' + + '"Marathon Match" (extended-duration competitive challenge). Omit to search across all types.', + ), + track: z + .enum(['Data Science', 'Design', 'Quality Assurance', 'Development']) + .optional() + .describe( + 'Filter by challenge track. One of "Data Science", "Design", "Quality Assurance", ' + + 'or "Development". Omit to search across all tracks.', + ), groups: z.array(z.string()).optional().describe('Filter by challenge group ids'), projectId: z .union([z.string(), z.array(z.string())]) @@ -169,7 +181,7 @@ export const challengeVectorQueryTool = createTool({ const minScore = inputData.minScore ?? config.vectorSearchThreshold; try { - const store = getChallengeVectorStore(); + const store = await ensureChallengeIndex(); let queryVector: number[] | undefined; if (query) { diff --git a/src/mastra/vector/challenge-vector-store.test.ts b/src/mastra/vector/challenge-vector-store.test.ts index 2b57466..8d9ea46 100644 --- a/src/mastra/vector/challenge-vector-store.test.ts +++ b/src/mastra/vector/challenge-vector-store.test.ts @@ -243,17 +243,19 @@ describe('challenge-vector-store', () => { ); }); - it('is idempotent — second call does not call createIndex', async () => { - // First call: index exists with correct dimension → skip create + it('is idempotent — calls createIndex every time even when the index already exists', async () => { + // createIndex is itself idempotent (CREATE TABLE/INDEX IF NOT EXISTS) + // and is the only place @mastra/pg's schema migrations run (e.g. the + // 1.22+ namespace column), so it must run on every call, not just + // when the index is missing. mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 0 }); mocks.createIndex.mockResolvedValue(undefined); await ensureChallengeIndex(); - expect(mocks.createIndex).toHaveBeenCalledTimes(0); + expect(mocks.createIndex).toHaveBeenCalledTimes(1); - // Second call: still exists with correct dimension → still skip await ensureChallengeIndex(); - expect(mocks.createIndex).toHaveBeenCalledTimes(0); + expect(mocks.createIndex).toHaveBeenCalledTimes(2); }); it('does not throw on second call', async () => { @@ -377,12 +379,13 @@ describe('challenge-vector-store', () => { await expect(ensureChallengeIndex()).resolves.not.toThrow(); }); - it('does not call createIndex when dimensions match', async () => { + it('still calls createIndex when dimensions match (idempotent schema check)', async () => { mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mocks.createIndex.mockResolvedValue(undefined); mockConfig({ dimension: 768 }); await ensureChallengeIndex(); - expect(mocks.createIndex).not.toHaveBeenCalled(); + expect(mocks.createIndex).toHaveBeenCalledTimes(1); }); it('names the index in the error message', async () => { @@ -413,8 +416,9 @@ describe('challenge-vector-store', () => { expect(mocks.disconnect).not.toHaveBeenCalled(); }); - it('does not call disconnect during ensureChallengeIndex (idempotent path)', async () => { + it('does not call disconnect during ensureChallengeIndex (index already exists)', async () => { mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); await ensureChallengeIndex(); expect(mocks.disconnect).not.toHaveBeenCalled(); diff --git a/src/mastra/vector/challenge-vector-store.ts b/src/mastra/vector/challenge-vector-store.ts index 843aff8..0d5c7bc 100644 --- a/src/mastra/vector/challenge-vector-store.ts +++ b/src/mastra/vector/challenge-vector-store.ts @@ -42,11 +42,20 @@ export function getChallengeVectorStore(): PgVector { /** * Idempotently ensures the challenge vector index exists with the correct - * dimension. Enforces the D7 dimension guard: if the index already exists - * with a different dimension than the configured embedding model, throws - * an actionable error naming both dimensions and pointing at + * dimension, and that its schema is up to date (e.g. the @mastra/pg 1.22+ + * `namespace` column/constraint migration, which only runs from inside + * createIndex()). Enforces the D7 dimension guard: if the index already + * exists with a different dimension than the configured embedding model, + * throws an actionable error naming both dimensions and pointing at * VECTOR_INDEX_NAME/reindex as remediation. * + * createIndex() is called unconditionally (not just when the index is + * missing) — it is safe to call every time: table/index creation is + * CREATE TABLE/INDEX IF NOT EXISTS, and its embedded schema migrations are + * themselves idempotent. Calling it is the only way to pick up schema + * changes @mastra/pg ships for tables that already existed before the + * change (see the namespace-column incident this guards against). + * * @returns The shared PgVector instance */ export async function ensureChallengeIndex(): Promise { @@ -66,30 +75,23 @@ export async function ensureChallengeIndex(): Promise { // Index/table doesn't exist — describeIndex threw } - if (existingDimension !== null) { - // Index exists — D7 dimension guard - if (existingDimension !== configuredDimension) { - throw new Error( - `Dimension mismatch: vector index "${indexName}" has ` + - `dimension ${existingDimension}, but the configured embedding ` + - `model (${config.embedding.provider}/${config.embedding.modelId}) ` + - `requires dimension ${configuredDimension}. ` + - `Set VECTOR_INDEX_NAME to use a new index name, or reindex ` + - `the existing index to match the configured model dimension.`, - ); - } - // Index exists with correct dimension — idempotent, nothing to do - tcAILogger.info( - `[challenge-vector-store] Index "${indexName}" already exists ` + - `with correct dimension ${configuredDimension}`, + if (existingDimension !== null && existingDimension !== configuredDimension) { + throw new Error( + `Dimension mismatch: vector index "${indexName}" has ` + + `dimension ${existingDimension}, but the configured embedding ` + + `model (${config.embedding.provider}/${config.embedding.modelId}) ` + + `requires dimension ${configuredDimension}. ` + + `Set VECTOR_INDEX_NAME to use a new index name, or reindex ` + + `the existing index to match the configured model dimension.`, ); - return store; } - // Index doesn't exist — create it with HNSW, cosine, and metadata indexes tcAILogger.info( - `[challenge-vector-store] Creating index "${indexName}" ` + - `with dimension ${configuredDimension}`, + existingDimension !== null + ? `[challenge-vector-store] Index "${indexName}" already exists ` + + `with correct dimension ${configuredDimension} — verifying schema` + : `[challenge-vector-store] Creating index "${indexName}" ` + + `with dimension ${configuredDimension}`, ); await store.createIndex({ diff --git a/src/mastra/workflows/challenge/challenge-search-workflow.ts b/src/mastra/workflows/challenge/challenge-search-workflow.ts index 1b5a8b6fad7ca444f547b726d53dacdb15d2aac0..d3ac07eada07360aac2d33fe5849e6c4d5c675fb 100644 GIT binary patch delta 467 zcmZXQu}T9$5QZD$1q(scCLoN5uopaot)&?3LJ(_(&EA+>yxTo?C&8=I_yV#|AmjlA zpToj;u(YwTcP}w#Vzyd#ng9F0`M!M|yu3BsG}t+~NO_i_8X}8<^|;}bY6U$`B4s!3 zYa?>4=C=E6v?hIppzk28CqjrUag}hBfLO$|K^tIjyah{#g{#Sa5+4Xvg2_oT@=WQO zLgxjNBss>VxDNDlNkIA(EOH}K;KFEY!Pf>V3$z^;*Rb~dN`{Mj_Z;kL!nw7+LH#|(OcyyS_Z}R%-RAz}Qf7n(2IXHVnC1IszZ+SGgfWD3 iNOfC-_{X*7_=WkA1(?EGxiUWduUx6EIKMt^endZa29)ps delta 109 zcmZok`Wm<4EbC+mUXjVZ9P;WVl?ADK3T{QIsk&+TMY#$ZE`~;$3XdA5DCFgrC?w`7 eq~?|8>ZwoG=2Zr%*C;7UOwJ}k!)67JCmI0lj3i?K From 53092f1e5cb3d4e3a949341d34946c7fa747e231 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 27 Aug 2026 10:14:24 +0300 Subject: [PATCH 14/27] grant access to fetch challenge by id --- .../agents/challenge/challenge-search-agent.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index dbb657b..c737c7a 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -3,6 +3,7 @@ import { createModel } from '../../../utils'; import { challengeVectorQueryTool } from '../../tools/challenge/challenge-vector-query-tool'; import { Memory } from '@mastra/memory'; import { fetchProjectTool } from '../../tools/project/fetch-project-tool'; +import { fetchChallengeTool } from '../../tools/challenge/fetch-challenge-tool'; const PROVIDER_NAME = process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5-20251001-v1:0'; @@ -57,17 +58,17 @@ export const challengeSearchAgent = new Agent({ }), instructions: { role: 'system', - content: `You are the Topcoder Challenge Assistant — a friendly, conversational guide who helps Topcoder members find relevant challenges. You're talking with a real person, not filling out a form: read what they actually want, ask a short clarifying question when their request is vague or could mean a few different things, and keep the conversation going until they have what they need. + content: `You are the Topcoder Challenge Assistant — a friendly, conversational guide who helps with intelligence about Topcoder challenges. You're talking with a real person, not filling out a form: read what they actually want, ask a short clarifying question when their request is vague or could mean a few different things, and keep the conversation going until they have what they need. -Ground every factual claim in what the "challenge-vector-query" tool actually returns. Never answer from your own knowledge of Topcoder challenges — if the tool comes back empty or off-target, say so plainly and offer to try a different angle. +Ground every factual claim in what the "challenge-vector-query" or "fetch-challenge-by-id" tools actually return. Never answer from your own knowledge of Topcoder challenges — if a tool comes back empty, off-target, or missing the specific detail asked about, say so plainly and offer to try a different angle. How to search - Your primary way of understanding what the user wants is the free-text "query" parameter, not filters. Challenge descriptions are indexed for semantic search, so a well-written natural-language query (e.g. "a challenge involving a real-time chat feature with websockets" or "backend work modernizing a legacy payment system") usually surfaces better matches than reducing the request to a list of keywords. - Don't default to extracting a skills list and filtering by it. That's a narrow reading of most requests — "help me find something to build a mobile banking app" is not "skills: [Swift, Kotlin]", it's a query about the domain and kind of work being asked for. - When a search doesn't land well (too few results, results that miss the point, or the user says "not quite"), don't just report the miss — rewrite the query yourself and try again before involving the user. Loosen or tighten the wording, try a synonym or a different phrasing, add or drop detail. Iterating on the query is cheap; making the user reword it themselves every time is not friendly. - Only reach for the structured filters (type, track, skills, groups) when the user explicitly asks to narrow by one of those dimensions — "just First2Finish challenges", "React only", "challenges in this group". A filter the user didn't ask for silently excludes results they might have wanted; if you think one would help, propose it and let them confirm rather than adding it unasked. - - "type": free-form challenge type (e.g. "Challenge", "First2Finish", "Marathon Match", "Task"). Map "F2F" to "First2Finish". - - "track": free-form challenge track (e.g. "Development", "Design", "Data Science", "Quality Assurance"). + - "type": one of "Challenge" or "Marathon Match" — the tool rejects any other value, so if the user names a type outside this pair, search without the filter rather than guessing. + - "track": one of "Development", "Design", "Data Science", or "Quality Assurance" — same rule: outside this set, search without the filter. - "skills": an array of technologies, using canonical names (e.g. "react" → "React", "nodejs" → "Node.js"). - "groups": challenge group ids, only when the user names a specific group or cohort explicitly. - Omit any filter you don't have a real value for. Never pass null or an empty string — leave the parameter out entirely. @@ -83,10 +84,17 @@ Every result carries a "projectId" in its metadata. Challenges from different pr - Use the "fetch-project-by-id" tool to resolve a projectId to its name when that would make the grouping clearer (e.g. labeling "Project: Acme Storefront Redesign" instead of a bare id) — only for projects that actually showed up in results, not speculatively. - If the user's question only makes sense answered within a single project's scope (e.g. "what's already been done here"), make sure you aren't quietly blending in matches from other projects. +Fetching full challenge details +The "challenge-vector-query" tool only returns indexed description chunks — it has no status, dates, prizes, registrant/submission counts, or reviewer info. Use the "fetch-challenge-by-id" tool to get those, passing the "challengeId" from a search result's metadata. +- Call it when the user asks about a specific challenge's status, winners, prizes, duration, registration/submission dates, number of registrants or submissions, tags, or reviewers — anything a search result's description chunk wouldn't contain. +- It only takes a single challengeId, so use it once you and the user have narrowed to one specific challenge, not a whole result set. +- Proactively offer it when it fits the conversation — e.g. after presenting a shortlist, ask "want the full details (prizes, dates, status) on any of these?" rather than waiting to be asked, but don't fetch every result's full details unprompted. +- If a result's status is already visible in the description text, don't re-fetch just to confirm it — reach for this tool when the user wants something the search result doesn't already show. + Answering Base your answer only on what the tool actually returned — summarize and organize it, but don't add detail the results don't support. Format your responses in markdown (bold, bullet lists, headings) where that makes the answer easier to scan — it renders properly for the user. Whenever you name a specific challenge, make its title a markdown link to \`${CHALLENGE_DETAILS_BASE_URL}/\`, using the challengeId from that result's metadata — e.g. \`[Member Profile Processor Enhancement](${CHALLENGE_DETAILS_BASE_URL}/abc123-def456)\`. If nothing relevant turns up after a couple of query attempts, say so plainly and suggest what the user could try instead.`, }, - tools: { challengeVectorQueryTool, fetchProjectTool }, + tools: { challengeVectorQueryTool, fetchProjectTool, fetchChallengeTool }, // Opts this agent out of the Mastra-instance-level `aiWorkspace` // (src/mastra/workspaces/ai.workspace.ts), which otherwise gets injected // into every agent that doesn't set its own `workspace`. A static From 32958c428341bcf2441806eee650e2dfb2d8704e Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 27 Aug 2026 12:01:02 +0300 Subject: [PATCH 15/27] project links --- .../challenge/challenge-search-agent.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index c737c7a..48500a6 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -10,12 +10,13 @@ const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claud const AGENT_ID = 'challenge-search-agent'; /** - * Derives the member-facing challenge details page origin from TC_API_BASE - * (mirrors the domain-derivation in ../../../utils/auth.ts), so the agent's - * instructions link to the right environment (dev vs prod) without a - * separate env var to keep in sync. + * Derives the member-facing domain from TC_API_BASE (mirrors the + * domain-derivation in ../../../utils/auth.ts), so the agent's instructions + * link to the right environment (dev vs prod) without a separate env var to + * keep in sync. Shared by both the challenge and project details base URLs + * below — they differ only in subdomain and path. */ -function resolveChallengeDetailsBaseUrl(): string { +function resolveDomain(): string { let domain = 'topcoder.com'; try { const tcApiBase = process.env.TC_API_BASE || ''; @@ -25,10 +26,12 @@ function resolveChallengeDetailsBaseUrl(): string { } catch { // fall back to default domain } - return `https://www.${domain}/challenges`; + return domain; } -const CHALLENGE_DETAILS_BASE_URL = resolveChallengeDetailsBaseUrl(); +const CHALLENGE_DETAILS_BASE_URL = `https://www.${resolveDomain()}/challenges`; +// e.g. https://work.topcoder.com/projects/1001025 +const PROJECT_DETAILS_BASE_URL = `https://work.${resolveDomain()}/projects`; /** * "Topcoder Challenge Assistant" — synthesises natural-language answers over @@ -82,6 +85,7 @@ Keep projects separate Every result carries a "projectId" in its metadata. Challenges from different projects are different engagements for different customers — the work, context, and skills involved can be completely unrelated even when the text looks similar. Never merge or summarize results across projects as if they were one pool: - When results span more than one project, group your answer by project rather than presenting one flat list. - Use the "fetch-project-by-id" tool to resolve a projectId to its name when that would make the grouping clearer (e.g. labeling "Project: Acme Storefront Redesign" instead of a bare id) — only for projects that actually showed up in results, not speculatively. +- Every time you mention a project — by its bare id or by its resolved name/title — link it, the same way challenge titles are linked (see "Answering" below): \`[Acme Storefront Redesign](${PROJECT_DETAILS_BASE_URL}/17423)\` or, if you haven't resolved a name, \`[17423](${PROJECT_DETAILS_BASE_URL}/17423)\`. Never mention a project as bare, unlinked text. - If the user's question only makes sense answered within a single project's scope (e.g. "what's already been done here"), make sure you aren't quietly blending in matches from other projects. Fetching full challenge details @@ -92,7 +96,7 @@ The "challenge-vector-query" tool only returns indexed description chunks — it - If a result's status is already visible in the description text, don't re-fetch just to confirm it — reach for this tool when the user wants something the search result doesn't already show. Answering -Base your answer only on what the tool actually returned — summarize and organize it, but don't add detail the results don't support. Format your responses in markdown (bold, bullet lists, headings) where that makes the answer easier to scan — it renders properly for the user. Whenever you name a specific challenge, make its title a markdown link to \`${CHALLENGE_DETAILS_BASE_URL}/\`, using the challengeId from that result's metadata — e.g. \`[Member Profile Processor Enhancement](${CHALLENGE_DETAILS_BASE_URL}/abc123-def456)\`. If nothing relevant turns up after a couple of query attempts, say so plainly and suggest what the user could try instead.`, +Base your answer only on what the tool actually returned — summarize and organize it, but don't add detail the results don't support. Format your responses in markdown (bold, bullet lists, headings) where that makes the answer easier to scan — it renders properly for the user, and every link below opens in a new tab. Whenever you name a specific challenge, make its title a markdown link to \`${CHALLENGE_DETAILS_BASE_URL}/\`, using the challengeId from that result's metadata — e.g. \`[Member Profile Processor Enhancement](${CHALLENGE_DETAILS_BASE_URL}/abc123-def456)\`. Do the same for every project id or project name/title you mention, linking to \`${PROJECT_DETAILS_BASE_URL}/\` — e.g. \`[Acme Storefront Redesign](${PROJECT_DETAILS_BASE_URL}/17423)\` or \`[17423](${PROJECT_DETAILS_BASE_URL}/17423)\` when you don't have a resolved name. If nothing relevant turns up after a couple of query attempts, say so plainly and suggest what the user could try instead.`, }, tools: { challengeVectorQueryTool, fetchProjectTool, fetchChallengeTool }, // Opts this agent out of the Mastra-instance-level `aiWorkspace` From 01e49640e85033de165f53de0ba6aeb16e48d8b8 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 27 Aug 2026 13:30:33 +0300 Subject: [PATCH 16/27] add adr 4 --- ...based-access-for-agents-workflows-tools.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/adr/0004-role-based-access-for-agents-workflows-tools.md diff --git a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md new file mode 100644 index 0000000..9fb918f --- /dev/null +++ b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md @@ -0,0 +1,352 @@ +# ADR 0004 — Role-based access control for Agents, Workflows, and Tools + +- **Status:** **Proposed** (not yet implemented) — for review +- **Date:** 2026-08-27 +- **Target branch:** `challenges-rag` +- **Related:** ADR 0001 (D10 — "any scope restriction MUST be enforced server-side, never left to the model"), ADR 0002 (existing inbound-vs-outbound auth split), `src/utils/auth/index.ts` (`apiAuthLayer`), `src/utils/middleware/resourceIdMiddleware.ts`, `src/config/tool-auth-fallback.config.ts` (precedent for a per-tool, opt-in-by-default code registry) + +## Context + +### What exists today + +Authentication (not authorization) is already in place: + +- `apiAuthLayer` (`src/utils/auth/index.ts`) is a `CompositeAuth` of two `MastraAuthAuth0` providers: one validates **member** JWTs (`AUTH0_DOMAIN`/`AUTH0_AUDIENCE`), one validates **inbound M2M** JWTs (`AUTH0_M2M_DOMAIN`/`AUTH0_M2M_AUDIENCE` — a *separate* Auth0 API resource from the *outbound* M2M identity in `src/config/m2m.config.ts`/`M2MService`, which this service uses to call *other* TC APIs per ADR 0002. Easy to conflate; they are opposite directions). +- `resourceIdMiddleware`/`chatResourceIdMiddleware` (`src/utils/middleware/resourceIdMiddleware.ts`) run on every request under `${API_PREFIX}/*` (`/v6/ai/*`) and `${CHAT_ROUTE_BASE_PATH}/*` (`/v6/ai-chat/*`), resolve the authenticated user (from context or by calling `apiAuthLayer.authenticateToken` directly), and pin `MASTRA_RESOURCE_ID_KEY` to the caller's TC userId (member) or `sub` (M2M) so Mastra memory is scoped per-caller. This 401s on missing/invalid auth. +- `DISABLE_AUTH=true` turns the whole thing off (local dev/test). + +**What's missing:** nothing today distinguishes *who* the caller is once they're authenticated. Any valid member JWT or M2M JWT — regardless of role or granted scope — can call any agent, run any workflow, or (transitively, as an agent tool call) invoke any tool. Concretely: `challenge-bulk-ingestion` re-embeds and rewrites the shared vector index for every challenge matching a filter — currently callable by any authenticated principal, member or M2M, with zero privilege check. ADR 0001's own D10 said scope restriction "MUST be enforced server-side, never left to the model" — that principle currently has no RBAC layer to stand on. + +### The existing auth layer already works correctly — this ADR extends it, not replaces it + +To be precise about what's already proven to work in production versus what's actually missing: **authentication is correctly enforced today on all three HTTP surfaces** — native Mastra routes (`/v6/ai/agents/*`, `/v6/ai/workflows/*`) and the custom `chatRoute()` (`/v6/ai-chat/:agentId`) all reject missing/invalid tokens with `401`. That's confirmed working and this ADR does not touch it. The gap is narrower and one level up: **nothing today checks *which* authenticated caller is allowed to do *which* thing** — there is no authorization/RBAC layer on top of a correctly-functioning authentication layer. This section documents exactly which existing mechanism this ADR plugs into, verified by reading the installed packages' actual compiled code (`@mastra/core@1.63.0`, `@mastra/server@1.63.0`, `@mastra/deployer@1.63.0`, `@mastra/auth-auth0@1.2.2`), not assumed from docs alone. + +**Mastra's own per-route auth check already runs for every registered route, built-in or custom.** `registerRoute()` (`@mastra/deployer`'s server adapter) wraps *every* route it mounts — including `apiRoutes` entries like `chatRoute()` — in an identical call to `this.checkRouteAuth(route, {...})` before invoking the route's own handler. `checkRouteAuth` (`@mastra/server`'s `server-adapter/index.js`) is a thin wrapper around the exported `coreAuthMiddleware` (`@mastra/server`'s `helpers.ts`): it re-authenticates the token via `authConfig.authenticateToken`, populates `requestContext` (`user`, `MASTRA_RESOURCE_ID_KEY` via `authConfig.mapUserToResourceId`, and — if a `server.rbac` provider is configured, which this repo doesn't use — `MASTRA_USER_ROLES_KEY`/`MASTRA_USER_PERMISSIONS_KEY`), and then — this is the load-bearing part — calls `authConfig.authorizeUser(user, request)` if the auth config implements it, denying with `403` on `false`. So `authorizeUser` isn't a hook this ADR has to newly wire into the request path; **it is already invoked on every request Mastra considers "protected," today, and simply defaults to allow-all** because neither `MastraAuthAuth0` in `apiAuthLayer` currently supplies a custom `authorizeUser` (each falls back to `MastraAuthAuth0`'s own default: *"allows access to all authenticated users"*, confirmed in `@mastra/auth-auth0`'s reference doc). Extending `apiAuthLayer`'s two providers with a role/scope-checking `authorizeUser` therefore isn't new plumbing — it's supplying the one missing argument to plumbing that's already firing on every protected request. `CompositeAuth.authorizeUser` (confirmed by reading `@mastra/core`'s compiled `CompositeAuth` class) ORs its providers' `authorizeUser` results, so the same function can be passed to both the member and M2M `MastraAuthAuth0` instances without conflict — each just evaluates the same `(user, request)` pair independently. + +(Mastra also ships two other, more elaborate authorization primitives on `MastraAuthConfig` — a declarative `rules: [{ path, methods, condition, allow }]` array and an `authorize(path, method, user, ctx)` function — plus a wholly separate `server.rbac`/`server.fga` provider concept (`getPermissions`/`getRoles`, EE-gated). None of these apply here: `coreAuthMiddleware` checks `"authorizeUser" in authConfig"` **first**, and falls through to `authorize`/`rules` only when `authorizeUser` is absent. Since `apiAuthLayer` is a `CompositeAuth`, which always implements `authorizeUser`, those other branches are unreachable for this setup regardless. `authorizeUser` is correctly the one mechanism to build on here, not a preference among several equally-live options.) + +**"Protected" is a path-pattern decision, evaluated once per route, made *before* `authorizeUser` ever gets a chance to run — and this is the actual, narrow gap.** `coreAuthMiddleware` only proceeds to authenticate/authorize a request at all if `isProtectedPath(path, method, authConfig, customRouteAuthConfig)` is true. That function ORs two things: (a) the path matching an entry in `defaultAuthConfig.protected` (Mastra's own built-in default, `["/api/*"]` — unrelated to this repo's `API_PREFIX`) or `authConfig.protected` (this repo's `${API_PREFIX}/*`, i.e. `/v6/ai/*`, merged from both `MastraAuthAuth0` instances by `CompositeAuth`'s constructor), or (b) the specific custom route being registered with `requiresAuth: true` in its own definition (`isProtectedCustomRoute`, keyed off a `customRouteAuthConfig` map built from each `apiRoutes` entry's own `requiresAuth` field). `chatRoute()` (`@mastra/ai-sdk`) does neither: its path is `/v6/ai-chat/:agentId`, which matches neither `/api/*` nor `/v6/ai/*`, and its `registerApiRoute(...)` call (confirmed by reading the installed `@mastra/ai-sdk@1.10.0` source) never sets `requiresAuth`. So `checkRouteAuth`/`coreAuthMiddleware` **does get invoked** for every chatRoute request (it's registered through the exact same `registerRoute()` path as everything else), but `isProtectedPath` returns `false` for it, and the function returns "allow, do nothing" before ever reaching `authenticateToken` or `authorizeUser`. **This is why `authorizeUser` alone, even once populated with a real policy, would never fire for chatRoute** — not because chatRoute lacks auth (it doesn't; see next paragraph), but because Mastra's native per-route check never gets past its own "is this path protected" gate for it. + +**What actually authenticates chatRoute today is this repo's own code, running earlier in the request pipeline, independently of the mechanism above.** `resourceIdMiddleware`/`chatResourceIdMiddleware` (`src/utils/middleware/resourceIdMiddleware.ts`) are registered as Hono `server.middleware` entries (`${API_PREFIX}/*` and `${CHAT_ROUTE_BASE_PATH}/*`), which Hono runs *before* the specific route handler — i.e. before `checkRouteAuth` ever executes inside that handler. `resourceIdMiddlewareHandler` calls `apiAuthLayer.authenticateToken(...)` directly and 401s on failure; that's genuinely why chatRoute correctly rejects bad tokens today, and this ADR changes none of it. But this custom middleware only calls `authenticateToken` — never `authorizeUser` — so even with a real role/scope check wired into `apiAuthLayer`, nothing on the chatRoute path evaluates it, from either mechanism, until this ADR closes that specific, narrow gap. + +**The fix, given all of the above, is one line, not new middleware:** add `${CHAT_ROUTE_BASE_PATH}/*` to the `protected` array already passed to both `MastraAuthAuth0` providers in `apiAuthLayer`. That's the only thing standing between chatRoute and the exact same native `checkRouteAuth`/`coreAuthMiddleware`/`authorizeUser` path every other protected route already goes through — since `checkRouteAuth` already runs for chatRoute on every request (confirmed above), it only needs `isProtectedPath` to say yes. No new middleware, no second copy of the authorization check, no risk of the two mechanisms drifting apart. `resourceIdMiddleware.ts` itself needs **no changes** for this — it keeps doing exactly what it does today (pre-emptive authentication + resourceId scoping); `coreAuthMiddleware` will now additionally run its own (redundant, harmless — same token, same result) authentication and, newly, its authorization check, immediately afterward, inside the route handler. + +Tools remain the one case genuinely outside this entire mechanism: this codebase never registers a top-level `tools: {}` map on the `Mastra` instance, so a tool is never itself a route — it's invoked from inside an agent's tool-calling loop (`generate`/`stream`/chatRoute, all already covered by the above) or directly from workflow step code (`tool.execute(...)`, e.g. `challenge-search-workflow.ts:249`, `challenge-bulk-ingestion-workflow.ts`). There's no path/route for `coreAuthMiddleware` to gate for a tool specifically — enforcement for that category has to happen inside the tool itself, using the `user` that `coreAuthMiddleware`/`resourceIdMiddleware` already placed on `RequestContext` by the time any tool runs. See Decision 5. + +### Route addressing — `.id` wins, not the registry key (verified, not assumed) + +`Mastra.getAgentById()`'s own docs: *"It first searches registered agents by `agent.id`. If no agent matches, it falls back to... the agent registry key."* Confirmed the same holds for workflows by reading this repo's code: `challenge-bulk-ingestion-workflow.ts:318` calls `registry.getWorkflowById('challenge-ingestion')` (the workflow's own `id:` field) to invoke the nested ingestion run, and `challenge-context-workflow.ts:947` calls `mastra.getAgentById('challenge-parser-agent')` (the agent's own `id:` field) — neither uses the object-property name it's registered under in `src/mastra/index.ts`. + +This matters because **5 of the 10 agent/workflow registrations in this repo have a registry key that differs from the resource's own `.id`**: + +| Registered as (object key in `src/mastra/index.ts`) | Resource's own `.id` | +| --- | --- | +| `challengeParserAgent` | `challenge-parser-agent` | +| `challengeSearchAgent` | `challenge-search-agent` | +| `jdRewriterAgent` | `jd-rewriter-agent` | +| `challengeIngestionWorkflow` | `challenge-ingestion` | +| `challengeBulkIngestionWorkflow` | `challenge-bulk-ingestion` | +| `challengeSearchWorkflow` | `challenge-search` | +| `challengeContextWorkflow` | `challenge-context` | +| `skillExtractionWorkflow` | `skill-extraction-workflow` | +| `jdAutowriteWorkflow` | `jd-autowrite` | +| `skillsMatchingAgent` | `skillsMatchingAgent` *(matches — the one exception)* | + +Any policy keyed on the wrong one silently never matches, and a miss defaults to "no policy configured" — a false sense of security, not a loud failure. This ADR keys every policy on the resource's own `.id` (the value used at `createTool`/`createStep`/`new Agent`/`createWorkflow` call sites — literally the `AGENT_ID`/`TOOL_ID`/`id:` constants already visible in each file), and flags getting this right as a Prerequisite to double-check during implementation, not an assumption to carry forward silently. + +### Nested, in-process invocations are transitively covered, not separately gated + +`challenge-bulk-ingestion` invokes `challenge-ingestion` via `getWorkflowById → createRun → run.start` (in-process, not a second HTTP round-trip); `challenge-context` invokes `challenge-parser-agent` via `mastra.getAgentById(...).generate(...)` (also in-process). Neither nested call re-enters Mastra's HTTP router, so an HTTP-boundary policy check does **not** run a second time for the nested call — it inherits whatever authorization already happened at the outer HTTP entry point. This is intentional, not a gap: you cannot reach the nested call without already having passed the outer one. + +### No existing role/scope claim convention — greenfield, needs to be flexible + +Nothing in this codebase reads a roles or scope claim today. The only comparable prior art is the TC userId claim (`https:///userId`, domain derived from `TC_API_BASE`, dev vs prod) used by `mapUserToResourceId`/`resourceIdMiddleware` — and that exact domain-derivation snippet is **already duplicated three times** (`src/utils/auth/index.ts`, `src/utils/middleware/resourceIdMiddleware.ts`, `src/mastra/agents/challenge/challenge-search-agent.ts`). This ADR adds a parallel `https:///roles` claim by the same convention and takes the opportunity to consolidate the domain-resolution snippet into one shared helper, since this would otherwise be a fourth copy. + +**Confirmed against a real decoded member JWT (prod, `iss: https://auth.topcoder.com/`):** the roles claim is `https://topcoder.com/roles`, a plain string array — exactly the guessed convention, domain-derived the same way as `.../userId` (so `https://topcoder-dev.com/roles` on dev, per the same `resolveTcDomain()` logic). The same token's array includes `"administrator"` verbatim — confirming both the claim key and the exact role string this ADR's default policy checks for. `ACCESS_CONTROL_ROLES_CLAIM` remains available as an override, but its default (`https://${resolveTcDomain()}/roles`) is now verified correct, not a guess. (Note: this evidence is a **member** token; it carries no `scope` claim, so it says nothing about the M2M side — `challengesRAG:admin` still needs to be created in Auth0 as a permission on the M2M audience, see Prerequisites.) + +## Scope + +**In scope:** + +- A policy model (`public` / `deny` / `restricted { roles?, scopes? }`) and a resolution order (env override → code-level default → global default) shared across all three categories. +- Enforcement for **agents and workflows** by supplying a real `authorizeUser` to `apiAuthLayer` — the hook Mastra's native `coreAuthMiddleware` already invokes on every protected request today (currently a no-op default-allow) — plus extending the `protected` path list so that existing, already-running mechanism also covers `chatRoute` (`/v6/ai-chat/:agentId`), the one surface it doesn't reach today. +- Enforcement for **tools** in-process, via the authenticated user already carried on `RequestContext` (set by Mastra's own `coreAuthMiddleware` and by this repo's `resourceIdMiddleware` before any agent/workflow body runs) — since tools have no HTTP route of their own for any auth middleware to gate. +- An env-var configuration surface following this repo's existing convention (`rag.config.ts`'s validated-with-sane-default pattern), plus a code-level default registry following `TOOL_M2M_FALLBACK_CONFIG`'s existing precedent (explicit map, absent entry = default, flipping an entry is a reviewable privilege decision). +- A baked-in default policy — **`roles: ['administrator']`, `scopes: ['challengesRAG:admin']`** — for `challenge-ingestion` and `challenge-bulk-ingestion`, so a fresh deploy is safe before any operator sets an env var. +- Unit tests verifying correct allow/deny behavior for each category (agent, workflow, tool) and each credential type (member role, M2M scope), plus the policy-resolution precedence itself. + +**Out of scope (explicitly deferred, not rejected):** + +- Fine-grained, per-resource-**instance** policy (e.g. "this member may only ingest projectId X"). This ADR is resource-**category** RBAC — which agent/workflow/tool a caller may invoke at all — not row-level ABAC. ADR 0001's D10 project-isolation model is unaffected and unrelated. +- Any UI/admin surface for managing policies. Configuration is env vars + a code registry, matching every other config surface in this repo (`rag.config.ts`, `TOOL_M2M_FALLBACK_CONFIG`). +- Auditing beyond a single structured log line per denial (reusing `tcAILogger`, matching the existing `tcAILogger.warn`/`.error` usage elsewhere). +- Rate limiting — a separate, unrelated concern. +- Changing anything about `DISABLE_AUTH`'s existing behavior (dev/test escape hatch, unchanged: RBAC is inert whenever auth is disabled, since there's no authenticated user to check). +- A separate Studio auth design — not needed. Confirmed: Studio routes agent/workflow interactions through the same `/v6/ai/agents/*`/`/v6/ai/workflows/*` HTTP paths as any other caller, so this ADR's enforcement automatically covers Studio too, with no Studio-specific code. In practice this mostly matters when auth is enabled with `DISABLE_AUTH=false` — a developer using Studio against such an environment needs the same role/scope as any other caller to reach a restricted resource (`challenge-ingestion`/`challenge-bulk-ingestion` by default); Studio itself doesn't get a bypass. + +## Resource inventory (as of this ADR; the config registry must track any future addition) + +| Category | Registered as (object key) | `.id` used for routing/policy | Caller surface | +| --- | --- | --- | --- | +| Agent | `challengeSearchAgent` | `challenge-search-agent` | `/v6/ai/agents/challenge-search-agent/*`, `/v6/ai-chat/challenge-search-agent` | +| Agent | `challengeParserAgent` | `challenge-parser-agent` | `/v6/ai/agents/challenge-parser-agent/*`; also invoked in-process by `challenge-context` | +| Agent | `jdRewriterAgent` | `jd-rewriter-agent` | `/v6/ai/agents/jd-rewriter-agent/*`; also invoked in-process by `jd-autowrite` | +| Agent | `skillsMatchingAgent` | `skillsMatchingAgent` | `/v6/ai/agents/skillsMatchingAgent/*`; also invoked in-process by `skill-extraction-workflow` | +| Workflow | `challengeIngestionWorkflow` | `challenge-ingestion` | `/v6/ai/workflows/challenge-ingestion/*` — **default-restricted (this ADR)** | +| Workflow | `challengeBulkIngestionWorkflow` | `challenge-bulk-ingestion` | `/v6/ai/workflows/challenge-bulk-ingestion/*` — **default-restricted (this ADR)** | +| Workflow | `challengeSearchWorkflow` | `challenge-search` | `/v6/ai/workflows/challenge-search/*` | +| Workflow | `challengeContextWorkflow` | `challenge-context` | `/v6/ai/workflows/challenge-context/*` | +| Workflow | `skillExtractionWorkflow` | `skill-extraction-workflow` | `/v6/ai/workflows/skill-extraction-workflow/*` | +| Workflow | `jdAutowriteWorkflow` | `jd-autowrite` | `/v6/ai/workflows/jd-autowrite/*` | +| Tool | `challengeVectorQueryTool` | `challenge-vector-query` | Agent-callable (`challengeSearchAgent`); also called directly by `challenge-search` workflow steps | +| Tool | `fetchChallengeTool` | `fetch-challenge-by-id` | Agent-callable (`challengeSearchAgent`) | +| Tool | `fetchProjectTool` | `fetch-project-by-id` | Agent-callable (`challengeSearchAgent`) | +| Tool | `searchChallengesTool` | `search-challenges` | Workflow-internal only (`challenge-bulk-ingestion` step) — not agent-exposed | +| Tool | `standardizedSkillsFuzzyTool` | `standardized-skills-fuzzy-match` | Workflow-internal only (`skill-extraction-workflow` step) | +| Tool | `standardizedSkillsSemanticTool` | `standardized-skills-semantic-search` | Workflow-internal only (`skill-extraction-workflow` step) | + +Every resource not explicitly listed with a restricted default falls through to `public` (any authenticated caller) — unchanged from today's behavior. Only the two ingestion workflows change behavior out of the box. + +## Decision + +### 1. Policy model and resolution (`src/config/access-control.config.ts`) + +```ts +export type AccessPolicy = + | { mode: 'public' } // any authenticated caller + | { mode: 'deny' } // nobody, regardless of role/scope + | { mode: 'restricted'; roles?: string[]; scopes?: string[] }; // see checkAccess below + +export type AccessCategory = 'agent' | 'workflow' | 'tool'; + +/** + * Code-level defaults, mirroring TOOL_M2M_FALLBACK_CONFIG's convention: + * a target id absent here falls through to ACCESS_CONTROL_DEFAULT_POLICY + * (public unless overridden). Flipping an existing entry, or adding a new + * `deny`/`restricted` entry, is a reviewable privilege decision. + */ +export const DEFAULT_ACCESS_POLICIES: Record> = { + agent: {}, + workflow: { + 'challenge-ingestion': { mode: 'restricted', roles: ['administrator'], scopes: ['challengesRAG:admin'] }, + 'challenge-bulk-ingestion': { mode: 'restricted', roles: ['administrator'], scopes: ['challengesRAG:admin'] }, + }, + tool: {}, +}; +``` + +Resolution order, per `(category, targetId)`, computed once and cached (mirrors `getRagConfig()`'s lazy-resolve-once style, just keyed instead of singleton): + +1. **Env override** — `ACCESS_POLICY___MODE` / `_ROLES` / `_SCOPES` (see Env surface below), if any of the three is set for that target. +2. **Code default** — `DEFAULT_ACCESS_POLICIES[category][targetId]`, if present. +3. **Global default** — `ACCESS_CONTROL_DEFAULT_POLICY` env var, `public` (default) or `deny`. + +`` is the target's `.id` upper-snake-cased (`toEnvKey('challenge-ingestion') === 'CHALLENGE_INGESTION'`, `toEnvKey('challenge-vector-query') === 'CHALLENGE_VECTOR_QUERY'`) — same transform in both directions, unit-tested directly (see Testing). + +### 2. Claim extraction — consolidate the existing domain-derivation, add a parallel roles claim + +Extract the domain-derivation snippet duplicated in `src/utils/auth/index.ts`, `resourceIdMiddleware.ts`, and `challenge-search-agent.ts` into one shared helper (proposed: `src/utils/auth/tc-domain.ts`, `resolveTcDomain(): string`), and have all three existing call sites — plus this ADR's new code — import it. This ADR would otherwise add a fourth near-identical copy; consolidating now is a small, low-risk, directly-motivated cleanup, not a separate refactor. + +```ts +// src/utils/auth/tc-domain.ts (new, extracted — behavior-identical to the 3 existing copies) +export function resolveTcDomain(): string { /* TC_API_BASE hostname, minus "api.", default "topcoder.com" */ } + +// src/utils/auth/access-control.ts (new) +const rolesClaimKey = () => + process.env.ACCESS_CONTROL_ROLES_CLAIM || `https://${resolveTcDomain()}/roles`; + +interface AuthenticatedCaller { + isM2M: boolean; // mirrors resourceIdMiddleware's existing userId-claim-absent test + roles: string[]; // from rolesClaimKey(), [] if absent/not an array + scopes: string[]; // from the standard OAuth `scope` claim, space-delimited, [] if absent +} + +export function toAuthenticatedCaller(user: Record): AuthenticatedCaller { + const userIdKey = `https://${resolveTcDomain()}/userId`; + const isM2M = !user[userIdKey]; + const rawRoles = user[rolesClaimKey()]; + const roles = Array.isArray(rawRoles) ? rawRoles.filter((r): r is string => typeof r === 'string') : []; + const scopes = typeof user.scope === 'string' ? user.scope.split(' ').filter(Boolean) : []; + return { isM2M, roles, scopes }; +} +``` + +`ACCESS_CONTROL_ROLES_CLAIM` exists as an override point in case a different environment's Auth0 tenant ever uses a different claim name — its default (`https://${resolveTcDomain()}/roles`) is confirmed correct against a real decoded prod token (see Context), so this is a safety valve for the unexpected, not a stand-in for an unverified guess. + +### 3. The shared check (`checkAccess`) + +```ts +export function checkAccess(caller: AuthenticatedCaller, policy: AccessPolicy): boolean { + if (policy.mode === 'public') return true; + if (policy.mode === 'deny') return false; + // mode === 'restricted': each credential type is checked against its own dimension only. + if (caller.isM2M) { + return !!policy.scopes?.length && policy.scopes.some(s => caller.scopes.includes(s)); + } + return !!policy.roles?.length && policy.roles.some(r => caller.roles.includes(r)); +} +``` + +Deliberately **not** "either dimension satisfies either credential type" — an M2M caller is checked only against `scopes`, a member caller only against `roles`. A `restricted` policy that configures only `roles` (no `scopes`) implicitly denies all M2M callers for that target, and vice versa; this is the direct implementation of the requirement *"based on JWT token or M2M access should be determined by verifying presence of a specific role[s] for JWT and/or scope[s] for M2M."* + +### 4. Enforcement — agents & workflows (plug into the existing, already-firing `authorizeUser` hook — one call site) + +`authorizeAccessPolicy(user, request): boolean`: + +1. Parse `request.url`'s path against three patterns: `^${API_PREFIX}/agents/([^/]+)` → `('agent', match[1])`; `^${API_PREFIX}/workflows/([^/]+)` → `('workflow', match[1])`; `^${CHAT_ROUTE_BASE_PATH}/([^/]+)` → `('agent', match[1])` (chatRoute's `:agentId`). No match (memory/threads/telemetry/scorers/other `apiPrefix` routes) → `true` (out of this ADR's scope; unaffected). +2. Resolve the policy for `(category, targetId)` per the resolution order above. +3. `return checkAccess(toAuthenticatedCaller(user), policy)`. + +Two small edits to **one existing file**, `src/utils/auth/index.ts` — no new middleware, no second copy of the check: + +- Pass `authorizeUser: authorizeAccessPolicy` to **both** `MastraAuthAuth0` constructors in `apiAuthLayer`. This is the exact extension point Mastra's own docs recommend (`docs-auth-custom-auth-provider.md`'s "Role-based Authorization" example) and, per the previous section, the one already being invoked by `coreAuthMiddleware` on every protected request today — currently just evaluating to "true" by default. `CompositeAuth.authorizeUser` ORs both providers' results, so passing the identical function to each is correct and not redundant logic to maintain twice — it's one function definition, evaluated twice (once per provider) by `CompositeAuth` itself, not two definitions this ADR has to keep in sync. +- Extend the `protected` array passed to both providers from `[`${API_PREFIX}/*`]` to `[`${API_PREFIX}/*`, `${CHAT_ROUTE_BASE_PATH}/*`]`. This is the one thing needed for `coreAuthMiddleware`'s own `isProtectedPath` check to say "yes" for chatRoute — at which point it runs the exact same authenticate-then-`authorizeUser` sequence it already runs for `/v6/ai/agents/*` and `/v6/ai/workflows/*`, with no additional code. `resourceIdMiddleware.ts` is untouched by this change (see previous section for why it doesn't need to be) — its own pre-emptive authentication continues to run first and unaffected; `coreAuthMiddleware`'s newly-enabled check for chatRoute simply runs a moment later, inside the route handler, as it already structurally does for every other protected route. + +One accepted side effect: once chatRoute's path is in `protected`, `coreAuthMiddleware` will re-run `authenticateToken` a second time for every chatRoute request (it always does its own authentication rather than trusting `requestContext`'s already-set `user`) — a harmless, redundant network/verification round-trip on the same token, not a correctness issue, and not something introduced by this ADR's own code (it's `coreAuthMiddleware`'s existing behavior for every route it protects, including the native agent/workflow routes already). + +### 5. Enforcement — tools (in-process, via `RequestContext`) + +Tools have no HTTP route, but every tool's `execute(inputData, context)` already receives `context.requestContext` (confirmed: `fetch-challenge-tool.ts` reads it today; `challenge-search-workflow.ts:249` and `challenge-bulk-ingestion-workflow.ts` both pass `{ requestContext, observe: noopObserve }` explicitly when calling a tool's `.execute()` directly from a workflow step) — and `resourceIdMiddleware`/`chatResourceIdMiddleware` already populate `requestContext.set('user', user)` before any agent or workflow body runs, for both the apiPrefix and chatRoute surfaces alike. That makes `RequestContext` the one enforcement point that already uniformly covers every way a tool can be invoked in this codebase — no path parsing needed. + +```ts +// src/utils/auth/access-control.ts (new) +export class ToolAccessDeniedError extends Error {} + +export function withAccessPolicy any }>(tool: T): T { + const originalExecute = tool.execute; + if (!originalExecute) return tool; + return { + ...tool, + execute: async (inputData: unknown, context: { requestContext?: { get(key: string): unknown } }) => { + if (process.env.DISABLE_AUTH === 'true') return originalExecute(inputData, context); + const user = context?.requestContext?.get('user') as Record | undefined; + const policy = resolveAccessPolicy('tool', tool.id); + if (!user || !checkAccess(toAuthenticatedCaller(user), policy)) { + tcAILogger.warn(`[access-control] denied tool "${tool.id}"`, { hasUser: !!user }); + throw new ToolAccessDeniedError(`Access denied for tool "${tool.id}"`); + } + return originalExecute(inputData, context); + }, + }; +} +``` + +Applied **at each tool's own export site** — e.g. `challenge-vector-query-tool.ts`'s last line becomes `export const challengeVectorQueryTool = withAccessPolicy(createTool({ ... }));` — not at each place a tool happens to get wired into an agent's `tools:` map or a workflow step. This means the guard travels with the exported tool object itself: a future agent that imports `challengeVectorQueryTool` and adds it to its own `tools:` map gets the same protection automatically, with no way to "forget" to wrap it at the call site. + +`ToolAccessDeniedError` thrown from inside a tool's `execute()` propagates through Mastra's existing tool-call error handling the same way any other thrown tool error does today (e.g. `challenge-vector-query-tool.ts`'s own try/catch around store errors) — surfaced to the LLM as a failed tool call it can report on, or to a workflow step as a rejected `tool.execute()` call it already has to handle. + +### 6. Env var surface (all optional, all with defaults — matches `rag.config.ts`'s convention) + +```bash +# Global +ACCESS_CONTROL_DEFAULT_POLICY="[public|deny — default public]" +ACCESS_CONTROL_ROLES_CLAIM="[JWT claim key for member roles — default https:///roles]" + +# Per-target override — only needed to diverge from the code default / global default. +# = AGENT | WORKFLOW | TOOL, = the target's own .id, upper-snake-cased. +ACCESS_POLICY___MODE="[public|deny — omit to use ROLES/SCOPES below]" +ACCESS_POLICY___ROLES="[comma-separated member roles]" +ACCESS_POLICY___SCOPES="[comma-separated M2M scopes]" + +# Example — this is also the code-level default, so setting these is redundant +# unless overriding it (e.g. loosening for a staging environment): +ACCESS_POLICY_WORKFLOW_CHALLENGE_INGESTION_ROLES="administrator" +ACCESS_POLICY_WORKFLOW_CHALLENGE_INGESTION_SCOPES="challengesRAG:admin" +ACCESS_POLICY_WORKFLOW_CHALLENGE_BULK_INGESTION_ROLES="administrator" +ACCESS_POLICY_WORKFLOW_CHALLENGE_BULK_INGESTION_SCOPES="challengesRAG:admin" +``` + +An invalid `_MODE` value (anything other than `public`/`deny`) throws an actionable error at first resolution, the same way `rag.config.ts`'s `parseNumber`/`validateSqlIdentifier` reject a bad value today — not a silent fallback. + +**Opt-in/opt-out, concretely:** +- Open up a currently-restricted target (e.g. loosen ingestion for a staging env without a redeploy): set its `_MODE=public` env var, or clear `_ROLES`/`_SCOPES` and rely on the global default if it's already `public`. +- Restrict a currently-open target (e.g. lock down `challenge-search` to a specific role later): set `ACCESS_POLICY_WORKFLOW_CHALLENGE_SEARCH_ROLES=...` — zero code change. +- Hard-block a target regardless of any role/scope (e.g. temporarily disable a tool): `ACCESS_POLICY_TOOL__MODE=deny`. +- Tighten the whole system's unconfigured-target default from open to closed: `ACCESS_CONTROL_DEFAULT_POLICY=deny` (then every target needs an explicit `public`/`restricted` entry — a deliberate, visible posture change, not a per-target migration). + +### 7. Logging on denial + +Every denial (both enforcement points) logs one `tcAILogger.warn` line with category, targetId, and whether a user was even present — matching the existing `tcAILogger.warn` used in `challenge-vector-query-tool.ts` for the below-threshold case. No new logging infrastructure. + +## Implementation plan + +### Phase 0 — Policy core +- `src/utils/auth/tc-domain.ts` (new): extract `resolveTcDomain()`; update `src/utils/auth/index.ts` and `resourceIdMiddleware.ts` to import it instead of their inline copies (behavior-identical, confirmed by their existing tests continuing to pass unmodified). +- `src/config/access-control.config.ts` (new): `AccessPolicy`, `AccessCategory`, `DEFAULT_ACCESS_POLICIES` (with the two ingestion-workflow entries), `toEnvKey()`. +- `src/utils/auth/access-control.ts` (new): `toAuthenticatedCaller()`, `checkAccess()`, `resolveAccessPolicy()` (env → code default → global default, with `_MODE`/`_ROLES`/`_SCOPES` parsing and validation), `ToolAccessDeniedError`, `withAccessPolicy()`, `authorizeAccessPolicy()` (path-parsing entry point for the HTTP boundary). +- `src/utils/auth/access-control.test.ts` (new): the core unit test surface — + - `checkAccess`: public always allows; deny always denies; restricted+member+matching role allows; restricted+member+non-matching role denies; restricted+M2M+matching scope allows; restricted+M2M+non-matching scope denies; restricted with only `roles` configured + M2M caller denies (no scopes to check); restricted with only `scopes` configured + member caller denies. + - `toEnvKey()`: round-trips every id in the Resource inventory table above. + - `resolveAccessPolicy()`: env override beats code default beats global default; an invalid `_MODE` throws; `_ROLES`/`_SCOPES` are comma-split and trimmed; the two ingestion workflow ids resolve to the baked-in restricted policy with **no env vars set at all** (guards the "safe by default on a fresh deploy" property directly). + - `withAccessPolicy()`: against a synthetic test-double tool — allows when `DISABLE_AUTH=true` regardless of user; denies with no user in `requestContext` when auth is enabled; denies a member without the required role; allows a member with it; denies M2M without the required scope; allows M2M with it; a `public`-policy tool is callable by any authenticated caller with no role/scope at all. + +### Phase 1 — Wire into agents & workflows +- `src/utils/auth/index.ts`: add `authorizeUser: authorizeAccessPolicy` to both `MastraAuthAuth0` constructors; extend each provider's `protected` array to also include `${CHAT_ROUTE_BASE_PATH}/*`. `resourceIdMiddleware.ts` is **not** touched by this phase. +- New `src/utils/auth/access-control.test.ts` additions (or a dedicated file) covering `authorizeAccessPolicy` directly against constructed `Request` objects for all three path shapes: member lacking `administrator` requesting a `challenge-ingestion`-shaped workflow path → denied; member with the role → allowed; M2M lacking `challengesRAG:admin` → denied; M2M with the scope → allowed; a `/v6/ai/agents/*`-shaped path with no configured policy → allowed regardless of role/scope (default-public unaffected); a `${CHAT_ROUTE_BASE_PATH}/:agentId`-shaped path resolves to category `'agent'` and applies that agent's policy the same as its native `/v6/ai/agents/:agentId` counterpart would — this is the test that actually exercises the fix for the documented chatRoute gap, not just asserts it in prose. +- Confirm (existing tests, unmodified) that `resourceIdMiddleware.test.ts` still passes as-is — this phase doesn't change that file's behavior, only `apiAuthLayer`'s `protected`/`authorizeUser` configuration, which `resourceIdMiddleware.test.ts` already mocks out entirely (`vi.mock('../auth', ...)`). + +### Phase 2 — Wire into tools +- Wrap each of the 6 tools' exports with `withAccessPolicy(...)` at their own definition site (`challenge-vector-query-tool.ts`, `fetch-challenge-tool.ts`, `fetch-project-tool.ts`, `search-challenges-tool.ts`, `standardized-skills-fuzzy-tool.ts`, `standardized-skills-semantic-tool.ts`). All six resolve to `public` today (no code-default entries), so this is behavior-preserving until a policy is actually configured for one of them. +- Confirm each tool's existing test suite (`*.test.ts`, all of which already construct a `context`/`minimalContext` object per the pattern in `challenge-vector-query-tool.test.ts`) still passes by extending that context with a `requestContext` stub exposing `.get('user')` — since `DISABLE_AUTH` isn't set in the unit-test process, the wrapper must not require it to be; tests instead supply a `requestContext` whose `.get('user')` returns an authenticated stub, or rely on the wrapper allowing public-policy tools through with a plain authenticated user. + +### Phase 3 — Validation +- `npx tsc --noEmit`, `npx eslint`, full `vitest run`. +- Manual smoke test: with `DISABLE_AUTH=false` and real Auth0 config, confirm a member token **without** `administrator` gets `403` from `POST /v6/ai/workflows/challenge-ingestion/start-async`, and the same member **with** it succeeds; confirm an M2M token without `challengesRAG:admin` gets `403` from the same route, and with it succeeds. +- Manual smoke test: confirm `challenge-search-agent` (public by default) is still reachable via `/v6/ai-chat/challenge-search-agent` by any authenticated member — proves the chatRoute fix didn't regress the unrestricted default case. + +### Phase 4 — Documentation +- `README.md`: env var table entries for `ACCESS_CONTROL_DEFAULT_POLICY`, `ACCESS_CONTROL_ROLES_CLAIM`, and the `ACCESS_POLICY___*` pattern (with the ingestion-workflow example), plus a short "Access control" section explaining the three-layer resolution order. +- `.env.sample`: the two global keys, plus the ingestion-workflow example pair (commented, since they're already the code default and don't need to be set). + +## File-level mapping + +| File | Change | +| --- | --- | +| `src/utils/auth/tc-domain.ts` | New — extracted `resolveTcDomain()` | +| `src/utils/auth/index.ts` | Modified — imports `resolveTcDomain()`; adds `authorizeUser: authorizeAccessPolicy` to both Auth0 providers; extends each provider's `protected` list with `${CHAT_ROUTE_BASE_PATH}/*` | +| `src/utils/middleware/resourceIdMiddleware.ts` | Modified — imports `resolveTcDomain()` only (the tc-domain consolidation from Decision 2); **no RBAC logic added here** | +| `src/utils/middleware/resourceIdMiddleware.test.ts` | **Unchanged** — behavior of this file is untouched by this ADR | +| `src/config/access-control.config.ts` | New — policy types, `DEFAULT_ACCESS_POLICIES`, `toEnvKey()` | +| `src/utils/auth/access-control.ts` | New — `toAuthenticatedCaller`, `checkAccess`, `resolveAccessPolicy`, `withAccessPolicy`, `authorizeAccessPolicy`, `ToolAccessDeniedError` | +| `src/utils/auth/access-control.test.ts` | New — core policy/claim/wrapper unit tests | +| `src/mastra/tools/challenge/challenge-vector-query-tool.ts` | Modified — export wrapped in `withAccessPolicy(...)` | +| `src/mastra/tools/challenge/fetch-challenge-tool.ts` | Modified — same | +| `src/mastra/tools/project/fetch-project-tool.ts` | Modified — same | +| `src/mastra/tools/challenge/search-challenges-tool.ts` | Modified — same | +| `src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts` | Modified — same | +| `src/mastra/tools/skills/standardized-skills-semantic-tool.ts` | Modified — same | +| `README.md` | Modified — env var table + "Access control" section | +| `.env.sample` | Modified — new keys | +| `src/mastra/agents/**`, `src/mastra/workflows/**` (bodies) | **Unchanged** — enforcement is centralized, not per-resource code | + +## Consequences + +**Positive** +- `challenge-ingestion`/`challenge-bulk-ingestion` are safe by default the moment this ships — no operator action required, matching the explicit requirement. +- One shared `checkAccess`/`resolveAccessPolicy` implementation for all three categories — no drift between "how agents are gated" and "how tools are gated." +- Opting a new resource in or out of restriction is an env var (no redeploy) or a one-line code-registry entry (reviewable, `git blame`-able, same pattern as `TOOL_M2M_FALLBACK_CONFIG`) — never a per-resource code change to the resource itself. +- Closes a real, previously-silent gap: chatRoute — the actual primary agent entry point — gets RBAC coverage it structurally lacked before this ADR. +- Agent/workflow enforcement is **one function, wired at one existing extension point** (`authorizeUser`, already invoked by Mastra's own `coreAuthMiddleware` on every protected request) plus a one-line path-list extension — not a parallel authorization system. `resourceIdMiddleware.ts` needed zero RBAC-related changes because the existing, working pipeline already had the right hook; it just needed a real function instead of the default allow-all, and one more path pattern to reach chatRoute. + +**Negative / risk** +- **`coreAuthMiddleware` re-authenticates chatRoute requests a second time** once its path is added to `protected` (it always calls `authenticateToken` itself rather than trusting `requestContext`'s already-set `user`) — a harmless, pre-existing pattern for every route it protects (native routes already pay this cost too), not a new correctness issue, but worth knowing about if chatRoute latency is ever profiled. +- **Only the prod (`topcoder.com`) roles claim has been directly confirmed.** The dev-domain equivalent (`https://topcoder-dev.com/roles`) is inferred by the same `resolveTcDomain()` convention already relied on for the `userId` claim, not independently checked against a dev-issued token. If it ever diverges, every `restricted` policy would silently deny every member caller in dev (empty roles array) until `ACCESS_CONTROL_ROLES_CLAIM` is corrected — mitigated by that env var existing as an override, but worth a quick dev-token spot-check during Phase 3 rather than assumed identical to prod. +- **`challengesRAG:admin` must exist in Auth0 before this ships**, as a permission on the `AUTH0_M2M_AUDIENCE` API resource, granted to whichever M2M client(s) should trigger ingestion — an out-of-repo, dashboard-side prerequisite this ADR cannot satisfy by itself. +- **Registry-key vs `.id` mismatch is a standing footgun beyond this ADR's own code** — any future contributor adding a policy entry keyed on the object-property name instead of the resource's own `.id` gets a silent no-op, not an error. The `toEnvKey` round-trip test in Phase 0 catches it for the *known* resources at write time, but not for a resource added later without a matching test update. +- **`withAccessPolicy` changes the tool object's `execute` reference.** Any existing test or code that does identity comparison on a tool's `execute` function (none currently observed in this repo, but not exhaustively verified) would break. Flagged for confirmation in Phase 2, not assumed safe. + +## Decisions confirmed in review + +- **Studio uses the same paths.** Confirmed: Mastra Studio (`studioBase: '/studio'`) routes agent/workflow interactions through the same `/v6/ai/agents/*`/`/v6/ai/workflows/*` HTTP paths as any other caller — no separate Studio auth design needed (folded into Scope above). +- **`ACCESS_CONTROL_DEFAULT_POLICY=public` is the default**, confirmed, not merely a proposed starting point — unconfigured agents/workflows/tools stay open to any authenticated caller unless explicitly restricted (env override or a `DEFAULT_ACCESS_POLICIES` entry). `deny` remains available as an opt-in, whole-system hardening posture (Decision 6) for an environment that wants closed-by-default, but that is not what ships here. +- **`checkAccess` keeps member roles and M2M scopes on separate dimensions**, confirmed as the intended design, not a placeholder: an M2M caller is checked only against a policy's `scopes`, a member caller only against its `roles`. A `restricted` policy with only one dimension configured implicitly denies the other credential type for that target — this is the direct implementation of "role[s] for JWT and/or scope[s] for M2M," not an oversight to revisit. +- **The roles claim key and the `administrator` role string are confirmed**, not guessed — verified against a real decoded prod member JWT (`iss: https://auth.topcoder.com/`): `https://topcoder.com/roles` is a plain string array, and it includes `"administrator"` verbatim among the caller's actual roles (alongside e.g. `"copilot"`, `"Topcoder Staff"`, `"Connect Manager"` — confirming the claim really is a flat role-name array, not a structured object). `ACCESS_CONTROL_ROLES_CLAIM`'s default (`https://${resolveTcDomain()}/roles`) is now a verified default, not a best guess — no override needed for this claim on either environment. + +## Prerequisites to confirm before implementation starts + +- **Create the `challengesRAG:admin` scope/permission in Auth0** on the `AUTH0_M2M_AUDIENCE` API resource, and grant it to the M2M client(s) that should be able to trigger ingestion. Still open — the confirmed payload above is a member token and carries no `scope` claim, so it confirms the JWT-role side only, not the M2M side. +- A manual smoke test against a real deployment for the `authorizeUser` + extended `protected`-list design closing the chatRoute gap (Decision 4) — the mechanism was verified by reading `@mastra/deployer`'s and `@mastra/server`'s compiled source, not by an end-to-end request, so Phase 3's smoke test (chatRoute `403` before/after the role check) is the first live confirmation. +- Reviewer sign-off on the tool-export-site wrapping approach (Decision 5) — specifically: + - **One policy per tool, globally, not per-usage.** Wrapping at the tool's own export site means a tool has exactly one access policy regardless of caller — no per-agent/per-workflow variance without a second wrapped export. A non-issue today (every tool in the Resource inventory has exactly one caller), but a real constraint on future flexibility to accept knowingly, not discover later. + - **Failure surfaces as a thrown `ToolAccessDeniedError`, breaking each tool's own `{success: false, error}` convention.** Fine for the agent tool-calling loop (a thrown tool error becomes a failed tool-call result the LLM sees), but needs confirming for the workflow-step call sites that invoke `tool.execute()` directly (`challenge-search-workflow.ts:249`, `challenge-bulk-ingestion-workflow.ts`) — their existing try/catch handles the tool's own return shape, not necessarily a thrown error from inside it. + - **The `{...tool, execute: wrappedExecute}` shallow clone is assumed behaviorally identical to the original `createTool(...)` result** — not verified against Mastra's own tool-handling internals (schema introspection, tool-calling dispatch), only asserted by "the shape looks the same." From 88e9b2ae0aa1d673c55f4423ff4823574f26a372 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Fri, 28 Aug 2026 07:48:17 +0300 Subject: [PATCH 17/27] bedrock request metadata --- .../challenge-vector-query-tool.test.ts | 2 +- .../challenge/challenge-vector-query-tool.ts | 2 +- .../challenge/challenge-ingestion-workflow.ts | 2 +- src/utils/providers/bedrock.ts | 58 ++++++++++++++++--- src/utils/providers/embedding-factory.ts | 11 +++- src/utils/providers/model-factory.ts | 4 +- 6 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts index 5203a53..c1821d7 100644 --- a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts @@ -168,7 +168,7 @@ describe('challengeVectorQueryTool — semantic query path', () => { const result = await executeTool({ query: 'realtime dashboard' }); - expect(mocks.createEmbeddingModel).toHaveBeenCalledWith('AWSBedrock', 'amazon.titan-embed-text-v2:0'); + expect(mocks.createEmbeddingModel).toHaveBeenCalledWith('AWSBedrock', 'amazon.titan-embed-text-v2:0', 'challenge-vector-query-tool'); expect(mocks.embed).toHaveBeenCalledWith( expect.objectContaining({ value: 'realtime dashboard' }), ); diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.ts index 6f18457..f922bba 100644 --- a/src/mastra/tools/challenge/challenge-vector-query-tool.ts +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.ts @@ -186,7 +186,7 @@ export const challengeVectorQueryTool = createTool({ let queryVector: number[] | undefined; if (query) { const { embedding } = await embed({ - model: createEmbeddingModel(config.embedding.provider, config.embedding.modelId), + model: createEmbeddingModel(config.embedding.provider, config.embedding.modelId, 'challenge-vector-query-tool'), value: query, }); queryVector = embedding; diff --git a/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts b/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts index 2729d6b..b0e16f8 100644 --- a/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts +++ b/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts @@ -354,7 +354,7 @@ const chunkAndEmbedStep = createStep({ try { const result = await withRetry(() => embedMany({ - model: createEmbeddingModel(config.embedding.provider, config.embedding.modelId), + model: createEmbeddingModel(config.embedding.provider, config.embedding.modelId, 'challenge-ingestion-workflow'), values: chunkTexts, }), ); diff --git a/src/utils/providers/bedrock.ts b/src/utils/providers/bedrock.ts index ee27100..e4dcb9f 100644 --- a/src/utils/providers/bedrock.ts +++ b/src/utils/providers/bedrock.ts @@ -1,24 +1,36 @@ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { defaultSettingsMiddleware, wrapLanguageModel } from 'ai'; import { tcAILogger } from '../logger'; +// `ai`'s own `LanguageModel` type (from wrapLanguageModel) resolves against a +// different `@ai-sdk/provider` instance than the one Mastra vendors internally, +// so TS sees them as structurally incompatible even though they're the same +// interface at runtime. Anchor to the provider's own return type instead, which +// Mastra already accepts, and cast the wrapped model back onto it below. +type BedrockLanguageModel = ReturnType>; + +function requestMetadataFor(agentId: string) { + return { department: 'ai_api', role: `agent-${agentId}` }; +} + /** - * Creates a Bedrock provider with optional per-agent request metadata header. + * Creates a Bedrock provider with an optional per-agent request metadata header. * - * When `agentId` is provided, every LLM request includes: - * X-Amzn-Bedrock-Request-Metadata: {"department":"ai_api","role":"agent-"} + * This header is only honored by Bedrock's InvokeModel/InvokeModelWithResponseStream + * API (used by the embedding and image models below). The Converse/ConverseStream API + * (used by chat language models) ignores this header entirely and instead requires a + * `requestMetadata` field in the JSON request body — see `createBedrockChatModel`. + * https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-request-metadata.html * - * The header is set at the provider level (AmazonBedrockProviderSettings.headers) - * and is merged into every HTTP request via combineHeaders, surviving SigV4 signing. + * When `agentId` is provided, every InvokeModel-based request includes: + * X-Amzn-Bedrock-Request-Metadata: {"department":"ai_api","role":"agent-"} */ export function createBedrockProvider(agentId?: string) { const headers: Record = {}; if (agentId) { - headers['X-Amzn-Bedrock-Request-Metadata'] = JSON.stringify({ - department: 'ai_api', - role: `agent-${agentId}`, - }); + headers['X-Amzn-Bedrock-Request-Metadata'] = JSON.stringify(requestMetadataFor(agentId)); tcAILogger.debug(`[Bedrock] Provider created with request metadata for agent: ${agentId}`); } @@ -29,5 +41,33 @@ export function createBedrockProvider(agentId?: string) { }); } +/** + * Creates a Bedrock chat language model with per-agent request metadata. + * + * Chat models call Bedrock's Converse/ConverseStream API, which reads request + * metadata from a `requestMetadata` body field rather than an HTTP header. We inject + * it via `providerOptions.bedrock.requestMetadata` on every call using + * `defaultSettingsMiddleware`, so call sites (generateText/streamText/Agent.generate) + * don't need to set it themselves. + */ +export function createBedrockChatModel(modelId: string, agentId?: string): BedrockLanguageModel { + const model = createBedrockProvider(agentId)(modelId); + + if (!agentId) { + return model; + } + + return wrapLanguageModel({ + model, + middleware: defaultSettingsMiddleware({ + settings: { + providerOptions: { + bedrock: { requestMetadata: requestMetadataFor(agentId) }, + }, + }, + }), + }) as BedrockLanguageModel; +} + // Singleton export for backward compatibility (no metadata header) export const bedrock = createBedrockProvider(); diff --git a/src/utils/providers/embedding-factory.ts b/src/utils/providers/embedding-factory.ts index 3421a27..fdeec03 100644 --- a/src/utils/providers/embedding-factory.ts +++ b/src/utils/providers/embedding-factory.ts @@ -7,13 +7,18 @@ import { tcAILogger } from '../logger'; * createModel pattern. Uses ollama.embedding() for TC-Ollama and * createBedrockProvider().embedding() for AWSBedrock. * + * Bedrock embedding calls go through InvokeModel, which reads request metadata + * from the X-Amzn-Bedrock-Request-Metadata header — so passing `agentId` tags + * embedding requests the same way chat requests are tagged (see bedrock.ts). + * * @param provider - Provider name (e.g. 'TC-Ollama', 'AWSBedrock') * @param modelId - Embedding model ID (e.g. 'nomic-embed-text') + * @param agentId - Optional caller identifier for Bedrock request metadata tagging * @returns An AI SDK v6 embedding model usable with embed/embedMany */ -export function createEmbeddingModel(provider: string, modelId: string) { +export function createEmbeddingModel(provider: string, modelId: string, agentId?: string) { tcAILogger.info( - `[Embedding Factory] PROVIDER: ${provider}, MODEL: ${modelId}`, + `[Embedding Factory] PROVIDER: ${provider}, MODEL: ${modelId} for AGENT: ${agentId ?? 'N/A'}`, ); switch (provider) { @@ -21,7 +26,7 @@ export function createEmbeddingModel(provider: string, modelId: string) { return ollama.embedding(modelId); case 'AWSBedrock': - return createBedrockProvider().embedding(modelId); + return createBedrockProvider(agentId).embedding(modelId); default: tcAILogger.error( diff --git a/src/utils/providers/model-factory.ts b/src/utils/providers/model-factory.ts index 9d331a7..82adade 100644 --- a/src/utils/providers/model-factory.ts +++ b/src/utils/providers/model-factory.ts @@ -1,5 +1,5 @@ import { ollama } from './ollama'; -import { createBedrockProvider } from './bedrock'; +import { createBedrockChatModel } from './bedrock'; import { tcAILogger } from '../logger'; import { openai } from './openai'; @@ -19,7 +19,7 @@ export function createModel(providerName: string, modelName: string, agentId?: s }); case 'AWSBedrock': - return createBedrockProvider(agentId)(modelName); + return createBedrockChatModel(modelName, agentId); case 'OpenAI': return openai(modelName); From 76ac6c93d4fef116495bd23f44eae19f77e5f62a Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Fri, 28 Aug 2026 08:21:48 +0300 Subject: [PATCH 18/27] adr 3 - enable cache --- .env.sample | 4 +- README.md | 4 + .../0003-bedrock-prompt-caching-by-default.md | 179 +++++++--- src/utils/providers/bedrock.test.ts | 314 ++++++++++++++++++ src/utils/providers/bedrock.ts | 157 ++++++++- 5 files changed, 593 insertions(+), 65 deletions(-) create mode 100644 src/utils/providers/bedrock.test.ts diff --git a/.env.sample b/.env.sample index a21def8..9b471dd 100644 --- a/.env.sample +++ b/.env.sample @@ -29,4 +29,6 @@ RAG_CHUNK_MAX_SIZE="[characters — default 512]" RAG_CHUNK_OVERLAP="[characters — default 50]" RAG_TOP_K="[default 10]" CHALLENGE_SEARCH_AI_PROVIDER="[TC-Ollama|WiproAI|AWSBedrock|OpenAI — default AWSBedrock]" -CHALLENGE_SEARCH_AI_MODEL_ID="[default us.anthropic.claude-haiku-4-5]" \ No newline at end of file +CHALLENGE_SEARCH_AI_MODEL_ID="[default us.anthropic.claude-haiku-4-5]" +BEDROCK_PROMPT_CACHE_ENABLED="[true|false — default true]" +BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" \ No newline at end of file diff --git a/README.md b/README.md index 4157a35..5b14068 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ tc-ai-api/ | `RAG_TOP_K` | No | `10` | Default result count for challenge vector search | | `CHALLENGE_SEARCH_AI_PROVIDER` | No | `AWSBedrock` | Model provider for `challenge-search-agent` | | `CHALLENGE_SEARCH_AI_MODEL_ID` | No | `us.anthropic.claude-haiku-4-5` | Model id for `challenge-search-agent` | +| `BEDROCK_PROMPT_CACHE_ENABLED` | No | `true` | Global kill switch for Bedrock prompt caching (see below) | +| `BEDROCK_PROMPT_CACHE_TTL` | No | `5m` | Bedrock cache checkpoint TTL — `5m` or `1h` | > \* Auth0 variables are required unless `DISABLE_AUTH=true`. > \*\* `M2M_AUTH_CLIENT_ID`/`M2M_AUTH_CLIENT_SECRET` are only exercised if a tool is explicitly opted into `TOOL_M2M_FALLBACK_CONFIG` (`src/config/tool-auth-fallback.config.ts`) — no tool is today, so these aren't required for the currently-shipped behavior, only for future fallback use. @@ -324,6 +326,8 @@ Four agents are registered in `src/mastra/index.ts`, all built via the shared `c Every default is overridable per-agent via `_AI_PROVIDER` / `_AI_MODEL_ID` env vars (e.g. `SKILLS_EXTRACTOR_AI_PROVIDER`, `CHALLENGE_PARSER_AI_PROVIDER`, `CHALLENGE_SEARCH_AI_PROVIDER`, `JD_REWRITER_AI_PROVIDER`). +**Bedrock prompt caching:** every agent's static system-prompt instructions are cached automatically via AWS Bedrock prompt caching, applied centrally by `createBedrockChatModel` (`src/utils/providers/bedrock.ts`) — no per-agent code. This cuts cost and time-to-first-token for the (often large) system-prompt portion on every call after the first cached one. It's gated by an allowlist of confirmed cache-capable model IDs (current-generation Claude 3.5+/Sonnet 4-5/Haiku 4.5 and Amazon Nova), so overriding an agent's model to something else (e.g. an older Claude 3 model, or Titan) degrades gracefully to no caching rather than erroring. Set `BEDROCK_PROMPT_CACHE_ENABLED=false` to disable it globally, or `BEDROCK_PROMPT_CACHE_TTL=1h` to trade a higher cache-write cost for a longer idle window between requests (default `5m`). Cache read/write token counts are logged at `debug` level per call (`[Bedrock cache] agent=... model=... cacheReadTokens=... cacheWriteTokens=...`). + ### `skillsMatchingAgent` | Property | Value | diff --git a/docs/adr/0003-bedrock-prompt-caching-by-default.md b/docs/adr/0003-bedrock-prompt-caching-by-default.md index e6718db..8c14459 100644 --- a/docs/adr/0003-bedrock-prompt-caching-by-default.md +++ b/docs/adr/0003-bedrock-prompt-caching-by-default.md @@ -1,11 +1,13 @@ # ADR 0003 — Enable AWS Bedrock prompt caching by default -- **Status:** **Proposed** (not yet implemented) — for review -- **Date:** 2026-08-26 +- **Status:** **Accepted** (proposed 2026-08-26, refreshed 2026-08-28, accepted and implemented 2026-08-28) — implemented on this branch +- **Date:** 2026-08-26 (refreshed 2026-08-28 — see "Update" in Context) - **Target branch:** `challenges-rag` -- **Related:** none directly, but touches the same `createModel()` / provider-factory - infrastructure (`src/utils/providers/model-factory.ts`, `src/utils/providers/bedrock.ts`) - every existing agent goes through. +- **Related:** `src/utils/providers/bedrock.ts` / `src/utils/providers/model-factory.ts` + already carry a `wrapLanguageModel`-based Bedrock **request-metadata** middleware + (`createBedrockChatModel`), landed on this branch ahead of this ADR. See "Update" + below — this ADR's caching middleware composes with that existing wrapper rather + than wrapping a bare provider model. ## Context @@ -83,6 +85,43 @@ Memory option, or built-in processor that sets `providerOptions.bedrock.cachePoi automatically. None of the four agents in this repo currently use Observational Memory (they use plain `lastMessages`). This has to be built as model middleware. +### Update (2026-08-28) — a Bedrock request-metadata middleware landed first + +Since this ADR was drafted, `src/utils/providers/bedrock.ts` and +`src/utils/providers/model-factory.ts` were changed to fix an unrelated bug: the +per-agent `X-Amzn-Bedrock-Request-Metadata` HTTP header set at provider-construction +time had no effect on chat calls, because Bedrock's Converse/ConverseStream API +(what `@ai-sdk/amazon-bedrock`'s chat model actually calls) reads request metadata +from a `requestMetadata` field in the JSON **body**, not from that HTTP header — the +header only applies to the InvokeModel/InvokeModelWithResponseStream API, which is +what this repo's embedding/image models use. The fix, already merged: + +- `bedrock.ts` now exports `createBedrockChatModel(modelId, agentId?)`, which wraps + `createBedrockProvider(agentId)(modelId)` in `wrapLanguageModel({ model, + middleware: defaultSettingsMiddleware({ settings: { providerOptions: { bedrock: { + requestMetadata: { department: 'ai_api', role: \`agent-${agentId}\` } } } } }) })` + — i.e. exactly the `wrapLanguageModel`-based middleware pattern this ADR proposes + to add for caching, already established for a different purpose. +- `model-factory.ts`'s `AWSBedrock` branch already calls `createBedrockChatModel(modelName, + agentId)`, **not** `createBedrockProvider(agentId)(modelName)` directly. Every + current agent's model is therefore already `wrapLanguageModel`-wrapped by the time + this ADR's caching feature would be added — Decision items 1–2 below need to + extend that existing wrapper, not introduce a second, separate one. +- A TypeScript workaround was needed and is now established precedent: `ai@6.0.209`'s + own `LanguageModel`/`wrapLanguageModel` return type resolves against a different + `@ai-sdk/provider` instance than the one Mastra vendors internally (its bundled + `_types/@ai-sdk_provider-v5`), so TS treats them as structurally incompatible even + though they implement the same interface at runtime. `bedrock.ts` anchors to + `type BedrockLanguageModel = ReturnType>` + and casts the `wrapLanguageModel(...)` result back onto it. This ADR's caching + middleware should reuse that same anchor type rather than re-derive its own. +- Practically, this **retires the specific risk the original Phase 1 plan called out + for verification** ("confirm `wrapLanguageModel`'s wrapper doesn't break the + agents' existing test suites") — that's no longer a hypothetical to check during + this ADR's implementation, it's already true in production code today: all four + agents already receive a `wrapLanguageModel`-wrapped model (for request-metadata + tagging), and their existing test suites already pass against it unmodified. + ## Scope **In scope:** @@ -147,20 +186,28 @@ boilerplate. negative here just means "no caching for this call" (silent, harmless); a false positive means a hard `ValidationException` that breaks the agent's next request outright. When in doubt, don't cache. - - `createCachedBedrockModel(agentId, modelName)` — wraps - `createBedrockProvider(agentId)(modelName)` in `wrapLanguageModel({ model, - middleware })` (from the `ai` package). The middleware's `transformParams` - locates the system message in `params.prompt` and, only when - `isCacheCapableBedrockModel(modelName)` is true and the feature is enabled, - returns a new params object with `providerOptions.bedrock.cachePoint = { - type: 'default', ttl: }` merged onto that message's existing - `providerOptions` (never clobbering anything already set there). Otherwise - returns `params` unchanged. `transformParams` returns a new object rather - than mutating the input, matching the AI SDK middleware contract. -2. **`src/utils/providers/model-factory.ts`'s `AWSBedrock` branch calls - `createCachedBedrockModel(agentId, modelName)`** instead of - `createBedrockProvider(agentId)(modelName)`. This is the one line that makes the - feature apply to all four agents automatically — no agent file changes. + - A cache middleware (`transformParams`) that locates the system message in + `params.prompt` and, only when `isCacheCapableBedrockModel(modelName)` is true + and the feature is enabled, returns a new params object with + `providerOptions.bedrock.cachePoint = { type: 'default', ttl: }` + merged onto that message's existing `providerOptions` (never clobbering + anything already set there — this matters concretely now, since the + request-metadata middleware already populates `providerOptions.bedrock` on + every call). Otherwise returns `params` unchanged. `transformParams` returns a + new object rather than mutating the input, matching the AI SDK middleware + contract. + - This middleware is added as a **second entry** in `createBedrockChatModel`'s + existing `wrapLanguageModel({ model, middleware })` call (`middleware` becomes + an array: `[requestMetadataMiddleware, cacheMiddleware]`, gated independently — + the cache entry only activates for cache-capable models with the feature + enabled, exactly as the request-metadata entry already only activates when + `agentId` is provided). One `wrapLanguageModel` call, one `BedrockLanguageModel` + cast, both concerns composed — not two nested wraps of the provider model. +2. **`src/utils/providers/model-factory.ts`'s `AWSBedrock` branch keeps calling + `createBedrockChatModel(modelName, agentId)`** — unchanged from its current + form. No factory-level plumbing changes; the caching behavior activates because + `createBedrockChatModel` itself now composes the cache middleware alongside the + request-metadata one. No agent file changes either way. 3. **Two new environment variables**, both optional with defaults, following this repo's existing env-var convention (`rag.config.ts`'s pattern of validated-with-sane-default): @@ -184,16 +231,21 @@ boilerplate. ### Config surface ```ts -// src/utils/providers/bedrock.ts (additive) +// src/utils/providers/bedrock.ts (additive — extends the existing +// createBedrockChatModel, does not replace or duplicate it) export function isCacheCapableBedrockModel(modelId: string): boolean { // Allowlist of confirmed-capable model ID patterns — Claude 3.5+/Sonnet 4-5/ // Haiku 4.5, Amazon Nova. Extend deliberately; a miss here just means no // caching, a wrong inclusion means a hard ValidationException on the next call. } -export function createCachedBedrockModel(agentId: string | undefined, modelName: string) { - // wrapLanguageModel({ model: createBedrockProvider(agentId)(modelName), middleware }) -} +// Inside createBedrockChatModel's existing wrapLanguageModel({ model, middleware }) +// call: middleware becomes an array, e.g. +// middleware: [ +// ...(agentId ? [requestMetadataMiddleware(agentId)] : []), +// ...(cacheEnabled && isCacheCapableBedrockModel(modelId) ? [cacheMiddleware] : []), +// ] +// still cast once to the existing BedrockLanguageModel anchor type. ``` ```bash @@ -205,30 +257,42 @@ BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" ## Implementation plan ### Phase 0 — Middleware and eligibility check -- `src/utils/providers/bedrock.ts`: add `isCacheCapableBedrockModel()`, - `createCachedBedrockModel()`, and the local (untyped-upstream) TypeScript +- `src/utils/providers/bedrock.ts`: add `isCacheCapableBedrockModel()`, a cache + `transformParams` middleware, and the local (untyped-upstream) TypeScript interface describing the `cachePoint` shape, since the installed SDK doesn't - export one. + export one. Compose the middleware into `createBedrockChatModel`'s existing + `wrapLanguageModel({ model, middleware })` call as an additional array entry + (gated on `isCacheCapableBedrockModel(modelId)` and the enabled flag) rather than + adding a second, separate wrapper function — `createBedrockChatModel` is already + the one `wrapLanguageModel` choke point every agent goes through (see "Update" in + Context), and it already resolves the `BedrockLanguageModel` type-cast this would + otherwise need to re-derive. - Read the two new env vars once (module scope or lazily, matching the existing style in this file) with validation matching `rag.config.ts`'s `parseNumber`/`validateSqlIdentifier` pattern — throw an actionable error for an invalid `BEDROCK_PROMPT_CACHE_TTL`, don't silently fall back. -- `src/utils/providers/bedrock.test.ts` (new or extended): unit tests for +- `src/utils/providers/bedrock.test.ts` (new — doesn't exist yet): unit tests for `isCacheCapableBedrockModel` — positive cases for the four model IDs actually in - use today, negative cases for Titan/Llama/pre-3.5 Claude; and for - `createCachedBedrockModel`'s `transformParams` — cache point added for a capable - model with the feature enabled, absent when disabled via env, absent for a - non-capable model, existing `providerOptions` on the system message preserved - rather than overwritten. + use today, negative cases for Titan/Llama/pre-3.5 Claude; and for the cache + middleware's `transformParams` — cache point added for a capable model with the + feature enabled, absent when disabled via env, absent for a non-capable model, + existing `providerOptions` on the system message preserved rather than + overwritten (this last case is no longer hypothetical — the request-metadata + middleware already sets `providerOptions.bedrock` on every tagged call, so the + non-clobbering behavior is exercised by real, existing traffic, not just a + synthetic test fixture). Also add a case with both middleware entries present + (agentId set *and* a cache-capable model) confirming both `requestMetadata` and + `cachePoint` land in the final params. ### Phase 1 — Wire into the model factory -- `src/utils/providers/model-factory.ts`: `AWSBedrock` branch calls - `createCachedBedrockModel(agentId, modelName)`. No other branch changes. -- Confirm none of the four agents' existing test suites assert on the exact shape - of the model instance returned by `createModel()` in a way `wrapLanguageModel`'s - wrapper would break (it still satisfies the same `LanguageModelV3` interface - `Agent({ model })` expects, so this is expected to be a non-issue, but worth - confirming against the actual test suites rather than assuming). +- No `model-factory.ts` changes needed — its `AWSBedrock` branch already calls + `createBedrockChatModel(modelName, agentId)`; the caching behavior activates + purely from Phase 0's change to that function's internals. +- The original risk here — "confirm `wrapLanguageModel`'s wrapper doesn't break the + four agents' existing test suites" — is already resolved, not just de-risked: all + four agents already run against a `wrapLanguageModel`-wrapped model today (for + request-metadata tagging), and their test suites already pass unmodified against + it. Nothing new to verify here beyond re-running that same suite after Phase 0. ### Phase 2 — Validation - `npx tsc --noEmit`, `npx eslint`, full `vitest run`. @@ -255,9 +319,9 @@ BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" | File | Change | | --- | --- | -| `src/utils/providers/bedrock.ts` | Modified — adds `isCacheCapableBedrockModel()`, `createCachedBedrockModel()`, local cache-point type, env-var reads | -| `src/utils/providers/bedrock.test.ts` | New or modified — eligibility + middleware unit tests | -| `src/utils/providers/model-factory.ts` | Modified — one line, `AWSBedrock` branch calls the new wrapper | +| `src/utils/providers/bedrock.ts` | Modified further (already contains `createBedrockChatModel`/`requestMetadataFor`/`BedrockLanguageModel` from the request-metadata fix) — adds `isCacheCapableBedrockModel()`, a cache `transformParams` middleware composed into `createBedrockChatModel`'s existing `wrapLanguageModel` call, local cache-point type, env-var reads | +| `src/utils/providers/bedrock.test.ts` | New — doesn't exist yet; eligibility + middleware unit tests (including composition with the existing request-metadata middleware) | +| `src/utils/providers/model-factory.ts` | **Unchanged** — its `AWSBedrock` branch already calls `createBedrockChatModel(modelName, agentId)`; nothing here needs to change for caching | | `README.md` | Modified — env var table + short explainer | | `.env.sample` | Modified — two new keys | | `src/mastra/agents/**` | **Unchanged** — the whole point of centralizing this in the provider factory | @@ -268,7 +332,8 @@ BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" - Every current and future Bedrock-backed agent gets prompt caching automatically, with no per-agent code — the same "one choke point" property ADR 0002 leaned on - for outbound TC API auth. + for outbound TC API auth, and the same property the request-metadata middleware + already proved out in `createBedrockChatModel` for a different concern. - Reduced cost and time-to-first-token on every call after the first, for the (often large, always-static) system-prompt portion of every agent's request — compounding with `challengeSearchAgent`'s `lastMessages: 25` memory, where the @@ -278,12 +343,15 @@ BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" **Negative / risk** -- **No compile-time type safety from the SDK.** `cachePoint` is read via untyped - passthrough in the installed `@ai-sdk/amazon-bedrock` version — a local - hand-written interface is the only thing keeping the shape honest, and it will - silently go stale if a future SDK upgrade changes the field name or nesting. - Mitigation: the unit tests in Phase 0 pin the expected shape, so an SDK upgrade - that breaks it fails tests rather than failing silently in production. +- **No compile-time type safety from the SDK for the `cachePoint` field itself.** + It's read via untyped passthrough in the installed `@ai-sdk/amazon-bedrock` + version — a local hand-written interface is the only thing keeping the shape + honest, and it will silently go stale if a future SDK upgrade changes the field + name or nesting. Mitigation: the unit tests in Phase 0 pin the expected shape, so + an SDK upgrade that breaks it fails tests rather than failing silently in + production. (This is a separate concern from the `wrapLanguageModel`-return-type + skew against Mastra's vendored types — that one is already solved by the + `BedrockLanguageModel` anchor type this ADR reuses; see "Update" in Context.) - **The allowlist needs manual upkeep.** A new Claude/Nova model released on Bedrock with cache support won't benefit from this feature until someone adds it to `isCacheCapableBedrockModel()`. Accepted trade-off given the alternative (a @@ -316,11 +384,14 @@ BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" ## Prerequisites to confirm before implementation starts -- Confirm the four model IDs currently in use are actually cache-enabled in the - target AWS account/region for Bedrock (prompt caching is a Bedrock account/model - feature that can require explicit enablement or be region-limited — not verified - against the live AWS account as part of this ADR's research, only against AI SDK - and Bedrock's general documentation). +- ~~Confirm the four model IDs currently in use are actually cache-enabled in the + target AWS account/region for Bedrock~~ — **Confirmed 2026-08-28** by the repo + owner: the two distinct model IDs actually in play + (`us.anthropic.claude-haiku-4-5-20251001-v1:0`, + `us.anthropic.claude-sonnet-5`) are cache-enabled in the target account/region. + (Not independently re-verified against the live AWS account in this session — no + AWS credentials were available in this environment to query Bedrock directly — + this is an owner attestation, not a CLI-verified fact.) - Reviewer sign-off on the allowlist-vs-denylist choice (Decision item 1) and the global-vs-per-agent config choice (Scope) — both are judgement calls made explicit here for review rather than settled facts. diff --git a/src/utils/providers/bedrock.test.ts b/src/utils/providers/bedrock.test.ts new file mode 100644 index 0000000..58274f7 --- /dev/null +++ b/src/utils/providers/bedrock.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before importing the module under test +// --------------------------------------------------------------------------- + +const { mockCreateAmazonBedrock, mockWrapLanguageModel, mockDefaultSettingsMiddleware } = vi.hoisted(() => ({ + mockCreateAmazonBedrock: vi.fn(), + mockWrapLanguageModel: vi.fn(), + mockDefaultSettingsMiddleware: vi.fn(), +})); + +vi.mock('@ai-sdk/amazon-bedrock', () => ({ + createAmazonBedrock: (...args: unknown[]) => mockCreateAmazonBedrock(...args), +})); + +vi.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: vi.fn(() => 'mock-credential-provider'), +})); + +vi.mock('ai', () => ({ + wrapLanguageModel: (...args: unknown[]) => mockWrapLanguageModel(...args), + defaultSettingsMiddleware: (...args: unknown[]) => mockDefaultSettingsMiddleware(...args), +})); + +vi.mock('../logger', () => ({ + tcAILogger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +import { createBedrockChatModel, isCacheCapableBedrockModel } from './bedrock'; +import { tcAILogger } from '../logger'; + +const REQUEST_METADATA_MARKER = '__requestMetadataMiddleware'; + +describe('bedrock — isCacheCapableBedrockModel', () => { + it.each([ + 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + 'us.anthropic.claude-sonnet-5', + 'anthropic.claude-sonnet-5', + 'us.anthropic.claude-opus-4-20250514-v1:0', + 'us.anthropic.claude-opus-4-1-20250805-v1:0', + 'anthropic.claude-3-5-sonnet-20241022-v2:0', + 'anthropic.claude-3-5-haiku-20241022-v1:0', + 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + 'us.amazon.nova-pro-v1:0', + 'amazon.nova-lite-v1:0', + ])('returns true for cache-capable model %s', (modelId) => { + expect(isCacheCapableBedrockModel(modelId)).toBe(true); + }); + + it.each([ + 'anthropic.claude-v2', + 'anthropic.claude-v2:1', + 'anthropic.claude-instant-v1', + 'us.anthropic.claude-3-sonnet-20240229-v1:0', + 'anthropic.claude-3-haiku-20240307-v1:0', + 'anthropic.claude-3-opus-20240229-v1:0', + 'amazon.titan-text-express-v1', + 'meta.llama3-70b-instruct-v1:0', + 'mistral.mistral-large-2402-v1:0', + 'cohere.command-r-v1:0', + ])('returns false for non-cache-capable model %s', (modelId) => { + expect(isCacheCapableBedrockModel(modelId)).toBe(false); + }); +}); + +describe('bedrock — createBedrockChatModel middleware composition', () => { + const CACHE_CAPABLE_MODEL = 'us.anthropic.claude-haiku-4-5-20251001-v1:0'; + const NON_CACHE_CAPABLE_MODEL = 'amazon.titan-text-express-v1'; + + let originalCacheEnabled: string | undefined; + let originalCacheTtl: string | undefined; + let fakeModel: { modelId: string }; + + beforeEach(() => { + vi.clearAllMocks(); + originalCacheEnabled = process.env.BEDROCK_PROMPT_CACHE_ENABLED; + originalCacheTtl = process.env.BEDROCK_PROMPT_CACHE_TTL; + delete process.env.BEDROCK_PROMPT_CACHE_ENABLED; + delete process.env.BEDROCK_PROMPT_CACHE_TTL; + + mockCreateAmazonBedrock.mockImplementation(() => (modelId: string) => { + fakeModel = { modelId }; + return fakeModel; + }); + mockWrapLanguageModel.mockImplementation((opts: { model: unknown; middleware: unknown[] }) => ({ + __wrapped: true, + ...opts, + })); + mockDefaultSettingsMiddleware.mockImplementation((opts: unknown) => ({ + [REQUEST_METADATA_MARKER]: true, + opts, + })); + }); + + afterEach(() => { + if (originalCacheEnabled === undefined) { + delete process.env.BEDROCK_PROMPT_CACHE_ENABLED; + } else { + process.env.BEDROCK_PROMPT_CACHE_ENABLED = originalCacheEnabled; + } + if (originalCacheTtl === undefined) { + delete process.env.BEDROCK_PROMPT_CACHE_TTL; + } else { + process.env.BEDROCK_PROMPT_CACHE_TTL = originalCacheTtl; + } + }); + + it('returns the raw model, unwrapped, when there is no agentId and the model is not cache-capable', () => { + const result = createBedrockChatModel(NON_CACHE_CAPABLE_MODEL); + + expect(mockWrapLanguageModel).not.toHaveBeenCalled(); + expect(result).toBe(fakeModel); + }); + + it('wraps with only the request-metadata middleware when agentId is set but the model is not cache-capable', () => { + createBedrockChatModel(NON_CACHE_CAPABLE_MODEL, 'test-agent'); + + expect(mockWrapLanguageModel).toHaveBeenCalledTimes(1); + const { middleware } = mockWrapLanguageModel.mock.calls[0][0]; + expect(middleware).toHaveLength(1); + expect(middleware[0]).toHaveProperty(REQUEST_METADATA_MARKER, true); + }); + + it('wraps with only the cache middleware when the model is cache-capable but no agentId is set', () => { + createBedrockChatModel(CACHE_CAPABLE_MODEL); + + expect(mockWrapLanguageModel).toHaveBeenCalledTimes(1); + const { middleware } = mockWrapLanguageModel.mock.calls[0][0]; + expect(middleware).toHaveLength(1); + expect(middleware[0]).not.toHaveProperty(REQUEST_METADATA_MARKER); + expect(middleware[0]).toHaveProperty('transformParams'); + expect(middleware[0]).toHaveProperty('wrapGenerate'); + expect(middleware[0]).toHaveProperty('wrapStream'); + }); + + it('composes both middleware entries — request-metadata then cache — when agentId is set and the model is cache-capable', () => { + createBedrockChatModel(CACHE_CAPABLE_MODEL, 'test-agent'); + + expect(mockWrapLanguageModel).toHaveBeenCalledTimes(1); + const { middleware } = mockWrapLanguageModel.mock.calls[0][0]; + expect(middleware).toHaveLength(2); + expect(middleware[0]).toHaveProperty(REQUEST_METADATA_MARKER, true); + expect(middleware[1]).toHaveProperty('transformParams'); + }); + + it('omits the cache middleware entirely when BEDROCK_PROMPT_CACHE_ENABLED=false, even for a cache-capable model', () => { + process.env.BEDROCK_PROMPT_CACHE_ENABLED = 'false'; + + const result = createBedrockChatModel(CACHE_CAPABLE_MODEL); + + expect(mockWrapLanguageModel).not.toHaveBeenCalled(); + expect(result).toBe(fakeModel); + }); + + it('throws an actionable error for an invalid BEDROCK_PROMPT_CACHE_ENABLED value', () => { + process.env.BEDROCK_PROMPT_CACHE_ENABLED = 'yes'; + + expect(() => createBedrockChatModel(CACHE_CAPABLE_MODEL)).toThrow( + /BEDROCK_PROMPT_CACHE_ENABLED/, + ); + }); + + it('throws an actionable error for an invalid BEDROCK_PROMPT_CACHE_TTL value', () => { + process.env.BEDROCK_PROMPT_CACHE_TTL = '15m'; + + expect(() => createBedrockChatModel(CACHE_CAPABLE_MODEL)).toThrow( + /BEDROCK_PROMPT_CACHE_TTL/, + ); + }); + + it('accepts BEDROCK_PROMPT_CACHE_TTL=1h and uses it as the cachePoint ttl', async () => { + process.env.BEDROCK_PROMPT_CACHE_TTL = '1h'; + + createBedrockChatModel(CACHE_CAPABLE_MODEL); + + const { middleware } = mockWrapLanguageModel.mock.calls[0][0]; + const cacheMiddleware = middleware[0]; + const params = { prompt: [{ role: 'system', content: 'instructions' }] }; + + const result = await cacheMiddleware.transformParams({ params }); + + expect(result.prompt[0].providerOptions.bedrock.cachePoint).toEqual({ + type: 'default', + ttl: '1h', + }); + }); +}); + +describe('bedrock — cache middleware behavior', () => { + const CACHE_CAPABLE_MODEL = 'us.anthropic.claude-haiku-4-5-20251001-v1:0'; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.BEDROCK_PROMPT_CACHE_ENABLED; + delete process.env.BEDROCK_PROMPT_CACHE_TTL; + + mockCreateAmazonBedrock.mockImplementation(() => (modelId: string) => ({ modelId })); + mockWrapLanguageModel.mockImplementation((opts: unknown) => opts); + mockDefaultSettingsMiddleware.mockImplementation((opts: unknown) => opts); + }); + + function getCacheMiddleware(agentId?: string) { + createBedrockChatModel(CACHE_CAPABLE_MODEL, agentId); + const { middleware } = mockWrapLanguageModel.mock.calls[0][0] as { middleware: any[] }; + return middleware[middleware.length - 1]; + } + + it('adds a cachePoint to the system message, defaulting ttl to 5m', async () => { + const cacheMiddleware = getCacheMiddleware(); + const params = { + prompt: [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ], + }; + + const result = await cacheMiddleware.transformParams({ params }); + + expect(result.prompt[0].providerOptions.bedrock.cachePoint).toEqual({ + type: 'default', + ttl: '5m', + }); + // Untouched messages pass through unchanged + expect(result.prompt[1]).toBe(params.prompt[1]); + }); + + it('preserves existing providerOptions on the system message rather than overwriting them', async () => { + const cacheMiddleware = getCacheMiddleware(); + const params = { + prompt: [ + { + role: 'system', + content: 'be helpful', + providerOptions: { + bedrock: { requestMetadata: { department: 'ai_api', role: 'agent-test' } }, + anthropic: { someOtherFlag: true }, + }, + }, + ], + }; + + const result = await cacheMiddleware.transformParams({ params }); + const systemProviderOptions = result.prompt[0].providerOptions; + + expect(systemProviderOptions.bedrock.requestMetadata).toEqual({ + department: 'ai_api', + role: 'agent-test', + }); + expect(systemProviderOptions.bedrock.cachePoint).toEqual({ type: 'default', ttl: '5m' }); + expect(systemProviderOptions.anthropic).toEqual({ someOtherFlag: true }); + }); + + it('returns params unchanged when there is no system message', async () => { + const cacheMiddleware = getCacheMiddleware(); + const params = { prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] }; + + const result = await cacheMiddleware.transformParams({ params }); + + expect(result).toBe(params); + }); + + it('logs cache read/write token counts after a non-streaming call', async () => { + const cacheMiddleware = getCacheMiddleware('test-agent'); + const doGenerate = vi.fn().mockResolvedValue({ + usage: { inputTokens: { cacheRead: 120, cacheWrite: 0 } }, + text: 'ok', + }); + + const result = await cacheMiddleware.wrapGenerate({ doGenerate }); + + expect(result.text).toBe('ok'); + expect(tcAILogger.debug).toHaveBeenCalledWith( + expect.stringMatching(/agent=test-agent.*cacheReadTokens=120 cacheWriteTokens=0/), + ); + }); + + it('logs cache read/write token counts from the finish chunk of a streamed call, passing chunks through unchanged', async () => { + const cacheMiddleware = getCacheMiddleware('test-agent'); + const chunks = [ + { type: 'text-delta', id: '1', delta: 'hi' }, + { type: 'finish', usage: { inputTokens: { cacheRead: 0, cacheWrite: 340 } }, finishReason: 'stop' }, + ]; + const sourceStream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + const doStream = vi.fn().mockResolvedValue({ stream: sourceStream }); + + const { stream } = await cacheMiddleware.wrapStream({ doStream }); + + const seen: unknown[] = []; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + seen.push(value); + } + + expect(seen).toEqual(chunks); + expect(tcAILogger.debug).toHaveBeenCalledWith( + expect.stringMatching(/agent=test-agent.*cacheReadTokens=0 cacheWriteTokens=340/), + ); + }); +}); diff --git a/src/utils/providers/bedrock.ts b/src/utils/providers/bedrock.ts index e4dcb9f..b53a56a 100644 --- a/src/utils/providers/bedrock.ts +++ b/src/utils/providers/bedrock.ts @@ -1,6 +1,7 @@ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { defaultSettingsMiddleware, wrapLanguageModel } from 'ai'; +import type { LanguageModelMiddleware } from 'ai'; import { tcAILogger } from '../logger'; // `ai`'s own `LanguageModel` type (from wrapLanguageModel) resolves against a @@ -41,31 +42,167 @@ export function createBedrockProvider(agentId?: string) { }); } +// --------------------------------------------------------------------------- +// Prompt caching (ADR 0003) +// --------------------------------------------------------------------------- + +const CACHE_TTL_VALUES = ['5m', '1h'] as const; +type BedrockCacheTtl = (typeof CACHE_TTL_VALUES)[number]; + +// Not exported by the installed @ai-sdk/amazon-bedrock version — cachePoint is read +// via untyped passthrough there (providerOptions.bedrock.cachePoint). This keeps the +// shape honest locally; see ADR 0003's "No compile-time type safety" risk note. +interface BedrockCachePoint { + type: 'default'; + ttl?: BedrockCacheTtl; +} + +// Allowlist, not denylist: a false negative here just means "no caching for this +// call" (silent, harmless); a false positive means a hard `ValidationException` on +// the very next request. When in doubt, don't cache. Extend deliberately as new +// cache-capable models ship. +const CACHE_CAPABLE_MODEL_PATTERNS: RegExp[] = [ + // Claude 5 family (Sonnet/Opus/Haiku/Fable) — bare alias, no date suffix + /^(?:[a-z]{2,5}\.)?anthropic\.claude-(?:sonnet|opus|haiku|fable)-5(?:[-:]|$)/, + // Claude 4.x family (Opus/Sonnet/Haiku 4, 4-1 .. 4-8, incl. dated variants) + /^(?:[a-z]{2,5}\.)?anthropic\.claude-(?:opus|sonnet|haiku)-4(?:-\d+)?(?:-|$)/, + // Claude 3.5 / 3.7 — explicitly NOT bare Claude 3 (pre-3.5 doesn't support caching) + /^(?:[a-z]{2,5}\.)?anthropic\.claude-3-(?:5|7)-(?:sonnet|haiku)(?:-|$)/, + // Amazon Nova + /^(?:[a-z]{2,5}\.)?amazon\.nova-(?:pro|lite|micro|premier)(?:-|$)/, +]; + +export function isCacheCapableBedrockModel(modelId: string): boolean { + return CACHE_CAPABLE_MODEL_PATTERNS.some((pattern) => pattern.test(modelId)); +} + +function isPromptCacheEnabled(): boolean { + const raw = process.env.BEDROCK_PROMPT_CACHE_ENABLED; + if (raw === undefined || raw === '') { + return true; + } + if (raw === 'true' || raw === 'false') { + return raw === 'true'; + } + throw new Error(`Invalid BEDROCK_PROMPT_CACHE_ENABLED: "${raw}". Expected "true" or "false".`); +} + +function getPromptCacheTtl(): BedrockCacheTtl { + const raw = process.env.BEDROCK_PROMPT_CACHE_TTL; + if (raw === undefined || raw === '') { + return '5m'; + } + if ((CACHE_TTL_VALUES as readonly string[]).includes(raw)) { + return raw as BedrockCacheTtl; + } + throw new Error(`Invalid BEDROCK_PROMPT_CACHE_TTL: "${raw}". Expected one of: ${CACHE_TTL_VALUES.join(', ')}.`); +} + +// Structurally narrowed to just what's read here, rather than importing 'ai's own +// usage type — avoids depending on a type name that may not survive an SDK upgrade +// (see the cachePoint type-safety note above) while staying self-contained. +interface BedrockCacheUsage { + inputTokens: { cacheRead?: number; cacheWrite?: number }; +} + +function logCacheUsage(agentId: string | undefined, modelId: string, usage: BedrockCacheUsage) { + tcAILogger.debug( + `[Bedrock cache] agent=${agentId ?? 'N/A'} model=${modelId} ` + + `cacheReadTokens=${usage.inputTokens.cacheRead ?? 0} cacheWriteTokens=${usage.inputTokens.cacheWrite ?? 0}`, + ); +} + /** - * Creates a Bedrock chat language model with per-agent request metadata. + * Stamps a cache checkpoint onto the system/instructions message of every call, and + * logs cache read/write token counts so the feature's effect is observable rather + * than silently present. Only added to the middleware chain when the model is + * cache-capable and the feature is enabled — see `isCacheCapableBedrockModel`. + */ +function createCacheMiddleware(ttl: BedrockCacheTtl, agentId: string | undefined, modelId: string): LanguageModelMiddleware { + return { + specificationVersion: 'v3', + transformParams: async ({ params }) => { + const systemIndex = params.prompt.findIndex((message) => message.role === 'system'); + if (systemIndex === -1) { + return params; + } + + const systemMessage = params.prompt[systemIndex]; + const prompt = [...params.prompt]; + prompt[systemIndex] = { + ...systemMessage, + providerOptions: { + ...systemMessage.providerOptions, + bedrock: { + ...systemMessage.providerOptions?.bedrock, + cachePoint: { type: 'default', ttl } satisfies BedrockCachePoint, + }, + }, + }; + + return { ...params, prompt }; + }, + wrapGenerate: async ({ doGenerate }) => { + const result = await doGenerate(); + logCacheUsage(agentId, modelId, result.usage); + return result; + }, + wrapStream: async ({ doStream }) => { + const { stream, ...rest } = await doStream(); + return { + ...rest, + stream: stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk.type === 'finish') { + logCacheUsage(agentId, modelId, chunk.usage); + } + controller.enqueue(chunk); + }, + }), + ), + }; + }, + }; +} + +/** + * Creates a Bedrock chat language model with per-agent request metadata and (for + * cache-capable models, see ADR 0003) prompt-cache injection on the system message. * * Chat models call Bedrock's Converse/ConverseStream API, which reads request * metadata from a `requestMetadata` body field rather than an HTTP header. We inject * it via `providerOptions.bedrock.requestMetadata` on every call using * `defaultSettingsMiddleware`, so call sites (generateText/streamText/Agent.generate) - * don't need to set it themselves. + * don't need to set it themselves. Prompt caching is injected the same way, as a + * second middleware entry, composed into the same `wrapLanguageModel` call rather + * than a separate wrapper — one choke point, one `BedrockLanguageModel` cast. */ export function createBedrockChatModel(modelId: string, agentId?: string): BedrockLanguageModel { const model = createBedrockProvider(agentId)(modelId); - if (!agentId) { + const shouldCache = isPromptCacheEnabled() && isCacheCapableBedrockModel(modelId); + + if (!agentId && !shouldCache) { return model; } return wrapLanguageModel({ model, - middleware: defaultSettingsMiddleware({ - settings: { - providerOptions: { - bedrock: { requestMetadata: requestMetadataFor(agentId) }, - }, - }, - }), + middleware: [ + ...(agentId + ? [ + defaultSettingsMiddleware({ + settings: { + providerOptions: { + bedrock: { requestMetadata: requestMetadataFor(agentId) }, + }, + }, + }), + ] + : []), + ...(shouldCache ? [createCacheMiddleware(getPromptCacheTtl(), agentId, modelId)] : []), + ], }) as BedrockLanguageModel; } From edcf7a50aca9cdc5514c37d4a47b17c18e757105 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 1 Sep 2026 14:16:23 +0300 Subject: [PATCH 19/27] PM-6000 - RBAC for agents, workflows, tools --- .env.sample | 20 +- README.md | 69 ++- ...based-access-for-agents-workflows-tools.md | 51 ++- src/config/access-control.config.ts | 70 +++ .../challenge/challenge-search-agent.ts | 29 +- .../challenge-vector-query-tool.test.ts | 5 +- .../challenge/challenge-vector-query-tool.ts | 5 +- .../challenge/fetch-challenge-tool.test.ts | 7 +- .../tools/challenge/fetch-challenge-tool.ts | 5 +- .../challenge/search-challenges-tool.test.ts | 7 +- .../tools/challenge/search-challenges-tool.ts | 5 +- .../tools/project/fetch-project-tool.test.ts | 7 +- .../tools/project/fetch-project-tool.ts | 5 +- .../skills/standardized-skills-fuzzy-tool.ts | 5 +- .../standardized-skills-semantic-tool.ts | 5 +- src/utils/auth/access-control.test.ts | 428 ++++++++++++++++++ src/utils/auth/access-control.ts | 276 +++++++++++ src/utils/auth/index.ts | 31 +- src/utils/auth/tc-domain.ts | 27 ++ src/utils/middleware/resourceIdMiddleware.ts | 15 +- 20 files changed, 999 insertions(+), 73 deletions(-) create mode 100644 src/config/access-control.config.ts create mode 100644 src/utils/auth/access-control.test.ts create mode 100644 src/utils/auth/access-control.ts create mode 100644 src/utils/auth/tc-domain.ts diff --git a/.env.sample b/.env.sample index 9b471dd..1fbca74 100644 --- a/.env.sample +++ b/.env.sample @@ -31,4 +31,22 @@ RAG_TOP_K="[default 10]" CHALLENGE_SEARCH_AI_PROVIDER="[TC-Ollama|WiproAI|AWSBedrock|OpenAI — default AWSBedrock]" CHALLENGE_SEARCH_AI_MODEL_ID="[default us.anthropic.claude-haiku-4-5]" BEDROCK_PROMPT_CACHE_ENABLED="[true|false — default true]" -BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" \ No newline at end of file +BEDROCK_PROMPT_CACHE_TTL="[5m|1h — default 5m]" + +# ============== Access control (all optional, defaults shown) ============== +# See README "Access control" and docs/adr/0004-role-based-access-for-agents-workflows-tools.md +ACCESS_CONTROL_DEFAULT_POLICY="[public|deny — default public]" +ACCESS_CONTROL_ROLES_CLAIM="[JWT claim key for member roles — default https:///roles]" + +# Per-target override — only needed to diverge from the code default / global default. +# = AGENT | WORKFLOW | TOOL, = the target's own .id, upper-snake-cased. +# ACCESS_POLICY___MODE="[public|deny — omit to use ROLES/SCOPES below]" +# ACCESS_POLICY___ROLES="[comma-separated member roles]" +# ACCESS_POLICY___SCOPES="[comma-separated M2M scopes]" +# +# The two ingestion workflows are restricted by DEFAULT_ACCESS_POLICIES in code, so the +# following are redundant unless you are overriding them (e.g. loosening for staging): +# ACCESS_POLICY_WORKFLOW_CHALLENGE_INGESTION_ROLES="administrator" +# ACCESS_POLICY_WORKFLOW_CHALLENGE_INGESTION_SCOPES="challengesRAG:admin" +# ACCESS_POLICY_WORKFLOW_CHALLENGE_BULK_INGESTION_ROLES="administrator" +# ACCESS_POLICY_WORKFLOW_CHALLENGE_BULK_INGESTION_SCOPES="challengesRAG:admin" diff --git a/README.md b/README.md index 5b14068..22f0b5e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,11 @@ tc-ai-api/ | `CHALLENGE_SEARCH_AI_MODEL_ID` | No | `us.anthropic.claude-haiku-4-5` | Model id for `challenge-search-agent` | | `BEDROCK_PROMPT_CACHE_ENABLED` | No | `true` | Global kill switch for Bedrock prompt caching (see below) | | `BEDROCK_PROMPT_CACHE_TTL` | No | `5m` | Bedrock cache checkpoint TTL — `5m` or `1h` | +| `ACCESS_CONTROL_DEFAULT_POLICY` | No | `public` | Global fallback policy for any agent/workflow/tool with no explicit policy — `public` or `deny` | +| `ACCESS_CONTROL_ROLES_CLAIM` | No | `https:///roles` | JWT claim key carrying member role names — override only if a tenant diverges from the convention | +| `ACCESS_POLICY___MODE` | No | — | Per-target override: `public` or `deny`. See [Access control](#access-control) | +| `ACCESS_POLICY___ROLES` | No | — | Per-target override: comma-separated member roles (implies `restricted`) | +| `ACCESS_POLICY___SCOPES` | No | — | Per-target override: comma-separated M2M scopes (implies `restricted`) | > \* Auth0 variables are required unless `DISABLE_AUTH=true`. > \*\* `M2M_AUTH_CLIENT_ID`/`M2M_AUTH_CLIENT_SECRET` are only exercised if a tool is explicitly opted into `TOOL_M2M_FALLBACK_CONFIG` (`src/config/tool-auth-fallback.config.ts`) — no tool is today, so these aren't required for the currently-shipped behavior, only for future fallback use. @@ -229,7 +234,7 @@ Authentication is handled by `CompositeAuth` from `@mastra/core/server` (`src/ut 1. **Member tokens** — issued by `AUTH0_DOMAIN` with audience `AUTH0_AUDIENCE` 2. **M2M (machine-to-machine) tokens** — issued by `AUTH0_M2M_DOMAIN` with audience `AUTH0_M2M_AUDIENCE` -A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*']` (the server's `apiPrefix` — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. +A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*', '/v6/ai-chat/*']` (the server's `apiPrefix` plus the `chatRoute()` base path — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. `/v6/ai-chat/*` has to be listed explicitly because `chatRoute()` is registered outside `apiPrefix` and never sets `requiresAuth`, so Mastra's `isProtectedPath` check would otherwise skip it — including its authorization step (see [Access control](#access-control)). Both providers also set `mapUserToResourceId`, deriving the caller's Topcoder user id from the JWT claim `https:///userId` (member tokens) or `sub` (M2M tokens) — see `tcUserIdClaimKey()` / `mapUserToResourceId` in `src/utils/auth/index.ts`. Mastra's core auth flow stores that value under `MASTRA_RESOURCE_ID_KEY` in the request context automatically, and it takes precedence over any client-supplied `resourceId`/`memory.resource` — this is what actually enforces per-user memory/thread isolation; the `Resource ID Middleware` below is a belt-and-suspenders check on top of it, not the primary mechanism. @@ -242,7 +247,7 @@ Authentication can be fully disabled by setting `DISABLE_AUTH=true` (useful for `resourceIdMiddleware` (`src/utils/middleware/resourceIdMiddleware.ts`) is a secondary, explicit check on top of `mapUserToResourceId` above. When auth is enabled it's registered against the two real route surfaces the server actually exposes (`src/utils/server-routes.ts` is the single source of truth for both): - `${API_PREFIX}/*` (i.e. `/v6/ai/*`) — the built-in Mastra routes (agents, workflows, memory, threads) -- `/chat/*` — `chatRoute()`, which is registered *outside* `apiPrefix` (custom API routes aren't prefixed by Mastra), so it needs its own entry +- `${CHAT_ROUTE_BASE_PATH}/*` (i.e. `/v6/ai-chat/*`) — `chatRoute()`, which is registered *outside* `apiPrefix` (custom API routes aren't prefixed by Mastra), so it needs its own entry For each matching request it: @@ -254,6 +259,66 @@ For each matching request it: This ensures **resource isolation** — each user's agent memory and workflow state are segregated. +### Access control + +> See [ADR 0004](docs/adr/0004-role-based-access-for-agents-workflows-tools.md) for the design rationale. + +Authentication answers *"is this a valid caller?"*; access control answers *"may **this** caller invoke **this** agent / workflow / tool?"*. Both are off the same policy core in `src/utils/auth/access-control.ts`. + +**Policy model** — every target resolves to exactly one policy: + +| Mode | Meaning | +| --- | --- | +| `public` | Any authenticated caller (the default) | +| `deny` | Nobody, regardless of role or scope | +| `restricted` | Member callers must hold one of `roles`; M2M callers must hold one of `scopes` | + +`restricted` keeps the two dimensions **separate**: a member token is checked only against `roles`, an M2M token only against `scopes`. A policy that configures just one dimension therefore implicitly denies the other credential type. + +**Three-layer resolution**, per `(category, targetId)`, resolved lazily and memoised: + +1. **Env override** — `ACCESS_POLICY___MODE` / `_ROLES` / `_SCOPES` +2. **Code default** — `DEFAULT_ACCESS_POLICIES` in `src/config/access-control.config.ts` +3. **Global default** — `ACCESS_CONTROL_DEFAULT_POLICY` (`public` unless set to `deny`) + +`` is `AGENT`, `WORKFLOW` or `TOOL`. `` is the resource's own **`.id`**, upper-snake-cased — `challenge-bulk-ingestion` → `CHALLENGE_BULK_INGESTION`, `skillsMatchingAgent` → `SKILLS_MATCHING_AGENT`. Always the `.id` passed to `new Agent`/`createWorkflow`/`createTool`, **never** the object-property name it's registered under in `src/mastra/index.ts` — 9 of the 10 registrations differ, and a policy keyed on the wrong one silently never matches. An invalid `_MODE` throws an actionable error on first resolution rather than falling back silently. + +**Shipped defaults.** Only two targets are restricted out of the box — both rewrite the shared challenge vector index: + +``` +challenge-ingestion roles: [administrator] scopes: [challengesRAG:admin] +challenge-bulk-ingestion roles: [administrator] scopes: [challengesRAG:admin] +``` + +Everything else is `public`, i.e. unchanged from pre-ADR-0004 behavior. Note that `challengesRAG:admin` must exist as a permission on the `AUTH0_M2M_AUDIENCE` API resource in Auth0 and be granted to the relevant M2M client(s), otherwise every M2M caller is denied on those two workflows. + +**Two enforcement points:** + +- **Agents & workflows** — `authorizeAccessPolicy` is supplied as `authorizeUser` to both Auth0 providers. Mastra's own `coreAuthMiddleware` already invokes that hook on every protected request and returns **403** when it returns `false`. It parses the request path into `('agent', id)` / `('workflow', id)`, covering `/v6/ai/agents/:id/*`, `/v6/ai/workflows/:id/*` and `/v6/ai-chat/:agentId`. Non-invocation paths (memory, threads, telemetry, scorers) are out of scope and pass through. Mastra Studio uses these same paths, so it gets no bypass. +- **Tools** — tools have no HTTP route of their own, so `withAccessPolicy()` wraps each tool's `execute` at its **export site** (e.g. the last line of `challenge-vector-query-tool.ts`). The guard travels with the exported tool object, so a future agent that adds the tool to its `tools:` map can't forget it. It reads the `user` already on `RequestContext` and throws `ToolAccessDeniedError` on denial — surfaced to the LLM as a failed tool call, or to a workflow step as a rejected `execute()`. + +Nested, in-process invocations (`challenge-bulk-ingestion` → `challenge-ingestion`, `challenge-context` → `challenge-parser-agent`) are **not** re-gated: they never re-enter the HTTP router, and you can't reach them without passing the outer check first. + +Every denial logs one `tcAILogger.warn` line with the category, target id and whether a user was present. + +**Common operations, all zero-code-change:** + +```bash +# Loosen ingestion for a staging environment +ACCESS_POLICY_WORKFLOW_CHALLENGE_INGESTION_MODE="public" + +# Lock down a currently-open workflow +ACCESS_POLICY_WORKFLOW_CHALLENGE_SEARCH_ROLES="administrator,copilot" + +# Temporarily hard-block a tool +ACCESS_POLICY_TOOL_SEARCH_CHALLENGES_MODE="deny" + +# Flip the whole system to closed-by-default +ACCESS_CONTROL_DEFAULT_POLICY="deny" +``` + +Access control is inert when `DISABLE_AUTH=true` — there is no authenticated caller to check. + ### Requestor Token Propagation to Topcoder Platform Tools Mastra tools that call `TC_API_BASE` (fetching challenges/projects) are authorized as the **requesting user**, not a shared service account, by default. The mechanism (`src/utils/tc-api-client.ts`, `callTcApi()`): diff --git a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md index 9fb918f..dc94a08 100644 --- a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md +++ b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md @@ -1,6 +1,6 @@ # ADR 0004 — Role-based access control for Agents, Workflows, and Tools -- **Status:** **Proposed** (not yet implemented) — for review +- **Status:** **Accepted — implemented** (see *Implementation notes* at the end for three corrections found while building it) - **Date:** 2026-08-27 - **Target branch:** `challenges-rag` - **Related:** ADR 0001 (D10 — "any scope restriction MUST be enforced server-side, never left to the model"), ADR 0002 (existing inbound-vs-outbound auth split), `src/utils/auth/index.ts` (`apiAuthLayer`), `src/utils/middleware/resourceIdMiddleware.ts`, `src/config/tool-auth-fallback.config.ts` (precedent for a per-tool, opt-in-by-default code registry) @@ -350,3 +350,52 @@ Every denial (both enforcement points) logs one `tcAILogger.warn` line with cate - **One policy per tool, globally, not per-usage.** Wrapping at the tool's own export site means a tool has exactly one access policy regardless of caller — no per-agent/per-workflow variance without a second wrapped export. A non-issue today (every tool in the Resource inventory has exactly one caller), but a real constraint on future flexibility to accept knowingly, not discover later. - **Failure surfaces as a thrown `ToolAccessDeniedError`, breaking each tool's own `{success: false, error}` convention.** Fine for the agent tool-calling loop (a thrown tool error becomes a failed tool-call result the LLM sees), but needs confirming for the workflow-step call sites that invoke `tool.execute()` directly (`challenge-search-workflow.ts:249`, `challenge-bulk-ingestion-workflow.ts`) — their existing try/catch handles the tool's own return shape, not necessarily a thrown error from inside it. - **The `{...tool, execute: wrappedExecute}` shallow clone is assumed behaviorally identical to the original `createTool(...)` result** — not verified against Mastra's own tool-handling internals (schema introspection, tool-calling dispatch), only asserted by "the shape looks the same." + +## Implementation notes (added at implementation time) + +Three things surfaced while building this against the installed `@mastra/*@1.63.0` packages. The +first is a correctness bug in Decision 4 as written; the other two close open items above. + +**1. `authorizeUser`'s second argument is not a `Request` — Decision 4's `request.url` is `undefined`.** +`coreAuthMiddleware` passes `adaptToMastraAuthRequest(rawRequest)`, which returns a +`HonoRequestLike` (`{ raw, headers, header() }`) — the `MastraAuthRequest` union is +`Request | HonoRequestLike`, and the adapter always produces the latter for a real `Request` input. +Reading `.url` off it yields `undefined`, no path pattern matches, and `authorizeAccessPolicy` +returns its "not an agent/workflow path" allow — i.e. **the policy would silently never apply**. +The implementation uses Mastra's own exported `getWebRequest(request): Request | undefined` +(`@mastra/core/server`) to recover the underlying `Request`, and **fails closed** (returns `false`) +if it can't. `access-control.test.ts` exercises `authorizeAccessPolicy` against the +`HonoRequestLike` shape specifically, so this can't silently regress. + +**2. Supplying `authorizeUser` in the provider options *replaces* `MastraAuthAuth0`'s own check.** +`MastraAuthAuth0`'s constructor ends with `this.registerOptions(options)`, which assigns +`this.authorizeUser = opts.authorizeUser.bind(this)` — an own property that shadows the class's +prototype `authorizeUser`, whose baseline behavior is to reject users lacking `sub`/`id` and users +whose `exp` has passed. `authorizeAccessPolicy` therefore re-asserts both checks before evaluating +any policy, so nothing is lost. (Because Mastra `.bind(this)`s it, the function must also never +read `this` — it doesn't.) + +**3. The `{...tool, execute}` shallow clone is safe — resolves the third open Prerequisite.** +`createTool` returns a `Tool` class instance, but the class body declares **no prototype methods**: +every field, including `this[MASTRA_TOOL_MARKER] = true` where +`MASTRA_TOOL_MARKER = Symbol.for("mastra.core.tool.Tool")`, is an own enumerable property, and +object spread copies own enumerable symbol keys. Mastra's `isMastraTool()` accepts +`MASTRA_TOOL_MARKER in tool` without requiring `instanceof Tool`, and the only other `instanceof Tool` +check in `@mastra/core` is guarded by `typeof tool === "function"`. Wrapping the *instance's* +`execute` (rather than the `opts.execute` passed into `createTool`) also means Mastra's +input/output/resume/requestContext validation still runs — it lives inside the wrapped call. + +**Other Prerequisites resolved during implementation:** + +- *Thrown `ToolAccessDeniedError` at direct `tool.execute()` workflow call sites* — confirmed safe. + All six direct call sites already wrap the call in a `try`/`catch` that either rethrows a + descriptive error or converts it into a failed step result. The nested + `challenge-bulk-ingestion` → `challenge-ingestion` run forwards `requestContext` into + `run.start()`, so the nested run's tools still see the authenticated user. +- *Existing tool test suites* — the four tool `*.test.ts` files needed their `minimalContext` + extended with a `requestContext.get('user')` stub, since the wrapper is fail-closed on a missing + user even under a `public` policy. That was the only test churn; no assertions changed. + +**Still open (unchanged):** creating `challengesRAG:admin` in Auth0 on the `AUTH0_M2M_AUDIENCE` API +resource, the live smoke tests in Phase 3, and the dev-token spot-check of the +`https://topcoder-dev.com/roles` claim key. diff --git a/src/config/access-control.config.ts b/src/config/access-control.config.ts new file mode 100644 index 0000000..cc70e9e --- /dev/null +++ b/src/config/access-control.config.ts @@ -0,0 +1,70 @@ +/** + * Role/scope-based access control policy model and code-level defaults. + * See docs/adr/0004-role-based-access-for-agents-workflows-tools.md. + * + * Policies are keyed on the resource's OWN `.id` (the value passed to + * `new Agent({ id })` / `createWorkflow({ id })` / `createTool({ id })`) — + * NOT the object-property name it happens to be registered under in + * src/mastra/index.ts. Mastra's getAgentById/getWorkflowById resolve by `.id` + * first, and 9 of the 10 registrations in this repo differ from their key. + * A policy keyed on the wrong one silently never matches. + */ + +export type AccessPolicy = + /** Any authenticated caller. */ + | { mode: 'public' } + /** Nobody, regardless of role/scope. */ + | { mode: 'deny' } + /** + * Member callers are checked against `roles` only, M2M callers against + * `scopes` only. A policy configuring only one dimension implicitly + * denies the other credential type. + */ + | { mode: 'restricted'; roles?: string[]; scopes?: string[] }; + +export type AccessCategory = 'agent' | 'workflow' | 'tool'; + +/** + * Code-level defaults, mirroring TOOL_M2M_FALLBACK_CONFIG's convention: + * a target id absent here falls through to ACCESS_CONTROL_DEFAULT_POLICY + * (public unless overridden). Flipping an existing entry, or adding a new + * `deny`/`restricted` entry, is a reviewable privilege decision. + * + * Env vars (ACCESS_POLICY___MODE/_ROLES/_SCOPES) take + * precedence over anything here — see resolveAccessPolicy(). + */ +export const DEFAULT_ACCESS_POLICIES: Record> = { + agent: {}, + workflow: { + // Both rewrite the shared vector index — restricted out of the box so a + // fresh deploy is safe before any operator sets an env var. + 'challenge-ingestion': { + mode: 'restricted', + roles: ['administrator'], + scopes: ['challengesRAG:admin'], + }, + 'challenge-bulk-ingestion': { + mode: 'restricted', + roles: ['administrator'], + scopes: ['challengesRAG:admin'], + }, + }, + tool: {}, +}; + +/** + * Upper-snake-cases a target's `.id` into the env var fragment used by the + * per-target override keys. + * + * toEnvKey('challenge-bulk-ingestion') === 'CHALLENGE_BULK_INGESTION' + * toEnvKey('skillsMatchingAgent') === 'SKILLS_MATCHING_AGENT' + */ +export function toEnvKey(targetId: string): string { + return targetId + // camelCase / PascalCase boundaries -> underscore + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + // any run of non-alphanumerics (-, :, ., space, ...) -> single underscore + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toUpperCase(); +} diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index 48500a6..5d7d998 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -4,34 +4,19 @@ import { challengeVectorQueryTool } from '../../tools/challenge/challenge-vector import { Memory } from '@mastra/memory'; import { fetchProjectTool } from '../../tools/project/fetch-project-tool'; import { fetchChallengeTool } from '../../tools/challenge/fetch-challenge-tool'; +import { resolveTcDomain } from '../../../utils/auth/tc-domain'; const PROVIDER_NAME = process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5-20251001-v1:0'; const AGENT_ID = 'challenge-search-agent'; -/** - * Derives the member-facing domain from TC_API_BASE (mirrors the - * domain-derivation in ../../../utils/auth.ts), so the agent's instructions - * link to the right environment (dev vs prod) without a separate env var to - * keep in sync. Shared by both the challenge and project details base URLs - * below — they differ only in subdomain and path. - */ -function resolveDomain(): string { - let domain = 'topcoder.com'; - try { - const tcApiBase = process.env.TC_API_BASE || ''; - if (tcApiBase) { - domain = new URL(tcApiBase).hostname.replace('api.', ''); - } - } catch { - // fall back to default domain - } - return domain; -} - -const CHALLENGE_DETAILS_BASE_URL = `https://www.${resolveDomain()}/challenges`; +// resolveTcDomain() derives the member-facing domain from TC_API_BASE, so the +// agent's instructions link to the right environment (dev vs prod) without a +// separate env var to keep in sync. Shared by both base URLs below — they +// differ only in subdomain and path. +const CHALLENGE_DETAILS_BASE_URL = `https://www.${resolveTcDomain()}/challenges`; // e.g. https://work.topcoder.com/projects/1001025 -const PROJECT_DETAILS_BASE_URL = `https://work.${resolveDomain()}/projects`; +const PROJECT_DETAILS_BASE_URL = `https://work.${resolveTcDomain()}/projects`; /** * "Topcoder Challenge Assistant" — synthesises natural-language answers over diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts index c1821d7..26b2e44 100644 --- a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts @@ -42,7 +42,10 @@ import { challengeVectorQueryTool, _testing } from './challenge-vector-query-too const { buildMetadataFilter } = _testing; -const minimalContext = { mastra: undefined } as any; +const minimalContext = { + mastra: undefined, + requestContext: { get: (key: string) => (key === 'user' ? { sub: 'test-user' } : undefined) }, +} as any; async function executeTool(input: Record): Promise { return challengeVectorQueryTool.execute?.(input, minimalContext) as Promise; diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.ts index f922bba..42ce127 100644 --- a/src/mastra/tools/challenge/challenge-vector-query-tool.ts +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.ts @@ -18,6 +18,7 @@ */ import { createTool } from '@mastra/core/tools'; +import { withAccessPolicy } from '../../../utils/auth/access-control'; import { embed } from 'ai'; import { z } from 'zod'; import { getRagConfig } from '../../../config/rag.config'; @@ -156,7 +157,7 @@ function errorMessage(error: unknown): string { // Tool Definition // --------------------------------------------------------------------------- -export const challengeVectorQueryTool = createTool({ +export const challengeVectorQueryTool = withAccessPolicy(createTool({ id: 'challenge-vector-query', description: 'Searches indexed Topcoder challenge descriptions by semantic similarity, with optional ' + @@ -238,7 +239,7 @@ export const challengeVectorQueryTool = createTool({ return { success: false, error: message }; } }, -}); +})); // --------------------------------------------------------------------------- // Testing Exports diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.test.ts b/src/mastra/tools/challenge/fetch-challenge-tool.test.ts index 621f35c..9ef04fa 100644 --- a/src/mastra/tools/challenge/fetch-challenge-tool.test.ts +++ b/src/mastra/tools/challenge/fetch-challenge-tool.test.ts @@ -21,7 +21,12 @@ import { fetchChallengeTool } from './fetch-challenge-tool'; const minimalContext = { mastra: undefined, requestContext: { - get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? 'fake-requestor-token' : undefined), + get: (key: string) => + key === MASTRA_AUTH_TOKEN_KEY + ? 'fake-requestor-token' + : key === 'user' + ? { sub: 'test-user' } + : undefined, }, } as any; diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.ts b/src/mastra/tools/challenge/fetch-challenge-tool.ts index d6f30e4..cb175bb 100644 --- a/src/mastra/tools/challenge/fetch-challenge-tool.ts +++ b/src/mastra/tools/challenge/fetch-challenge-tool.ts @@ -5,6 +5,7 @@ // as-is); no M2M fallback configured for this tool — see // docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. import { createTool } from '@mastra/core/tools'; +import { withAccessPolicy } from '../../../utils/auth/access-control'; import { z } from 'zod'; import type { RequestContext } from '@mastra/core/request-context'; import { callTcApi } from '../../../utils/tc-api-client'; @@ -12,7 +13,7 @@ import { callTcApi } from '../../../utils/tc-api-client'; const TOOL_ID = 'fetch-challenge-by-id'; const BASE_URL = `${process.env.TC_API_BASE}/v6/challenges`; -export const fetchChallengeTool = createTool({ +export const fetchChallengeTool = withAccessPolicy(createTool({ id: TOOL_ID, description: 'Fetches a Topcoder challenge by its UUID from the Topcoder v6 Challenges API, authorized as the requesting user', @@ -98,7 +99,7 @@ export const fetchChallengeTool = createTool({ }); return await fetchChallenge(inputData.challengeId, context.requestContext); }, -}); +})); const fetchChallenge = async (challengeId: string, requestContext: RequestContext | undefined) => { const url = `${BASE_URL}/${encodeURIComponent(challengeId)}`; diff --git a/src/mastra/tools/challenge/search-challenges-tool.test.ts b/src/mastra/tools/challenge/search-challenges-tool.test.ts index 034895c..591f306 100644 --- a/src/mastra/tools/challenge/search-challenges-tool.test.ts +++ b/src/mastra/tools/challenge/search-challenges-tool.test.ts @@ -21,7 +21,12 @@ import { searchChallengesTool } from './search-challenges-tool'; const minimalContext = { mastra: undefined, requestContext: { - get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? 'fake-requestor-token' : undefined), + get: (key: string) => + key === MASTRA_AUTH_TOKEN_KEY + ? 'fake-requestor-token' + : key === 'user' + ? { sub: 'test-user' } + : undefined, }, } as any; diff --git a/src/mastra/tools/challenge/search-challenges-tool.ts b/src/mastra/tools/challenge/search-challenges-tool.ts index 35051fb..67e32ad 100644 --- a/src/mastra/tools/challenge/search-challenges-tool.ts +++ b/src/mastra/tools/challenge/search-challenges-tool.ts @@ -7,6 +7,7 @@ // as-is); no M2M fallback configured for this tool — see // docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. import { createTool } from '@mastra/core/tools'; +import { withAccessPolicy } from '../../../utils/auth/access-control'; import { z } from 'zod'; import type { RequestContext } from '@mastra/core/request-context'; import { callTcApi } from '../../../utils/tc-api-client'; @@ -28,7 +29,7 @@ const challengeSummarySchema = z.object({ groups: z.array(z.string()).optional(), }); -export const searchChallengesTool = createTool({ +export const searchChallengesTool = withAccessPolicy(createTool({ id: TOOL_ID, description: 'Searches Topcoder challenges via the v6 Challenges API, authorized as the requesting user, with filter support (projectId, status, types, tracks, tags, groups, dates, pagination)', @@ -60,7 +61,7 @@ export const searchChallengesTool = createTool({ logger?.info('Searching challenges with filters'); return await searchChallenges(inputData, context.requestContext); }, -}); +})); interface SearchChallengesInput { projectId?: string; diff --git a/src/mastra/tools/project/fetch-project-tool.test.ts b/src/mastra/tools/project/fetch-project-tool.test.ts index 2c41444..512af1d 100644 --- a/src/mastra/tools/project/fetch-project-tool.test.ts +++ b/src/mastra/tools/project/fetch-project-tool.test.ts @@ -21,7 +21,12 @@ import { fetchProjectTool } from './fetch-project-tool'; const minimalContext = { mastra: undefined, requestContext: { - get: (key: string) => (key === MASTRA_AUTH_TOKEN_KEY ? 'fake-requestor-token' : undefined), + get: (key: string) => + key === MASTRA_AUTH_TOKEN_KEY + ? 'fake-requestor-token' + : key === 'user' + ? { sub: 'test-user' } + : undefined, }, } as any; diff --git a/src/mastra/tools/project/fetch-project-tool.ts b/src/mastra/tools/project/fetch-project-tool.ts index 8a3c222..1fc0a56 100644 --- a/src/mastra/tools/project/fetch-project-tool.ts +++ b/src/mastra/tools/project/fetch-project-tool.ts @@ -12,6 +12,7 @@ // as-is); no M2M fallback configured for this tool — see // docs/adr/0002-tc-api-requestor-token-with-m2m-fallback.md. import { createTool } from '@mastra/core/tools'; +import { withAccessPolicy } from '../../../utils/auth/access-control'; import { z } from 'zod'; import type { RequestContext } from '@mastra/core/request-context'; import { callTcApi } from '../../../utils/tc-api-client'; @@ -19,7 +20,7 @@ import { callTcApi } from '../../../utils/tc-api-client'; const TOOL_ID = 'fetch-project-by-id'; const BASE_URL = `${process.env.TC_API_BASE}/v6/projects`; -export const fetchProjectTool = createTool({ +export const fetchProjectTool = withAccessPolicy(createTool({ id: TOOL_ID, description: 'Fetches a Topcoder project by id from the v6 Projects API, authorized as the requesting user. ' + @@ -45,7 +46,7 @@ export const fetchProjectTool = createTool({ logger?.info('Fetching project by ID: {projectId}', { projectId: inputData.projectId }); return await fetchProject(inputData.projectId, inputData.fields, context.requestContext); }, -}); +})); /** * Project.id / billingAccountId / directProjectId are Prisma BigInt on the diff --git a/src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts b/src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts index 599a8cf..6a6071b 100644 --- a/src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts +++ b/src/mastra/tools/skills/standardized-skills-fuzzy-tool.ts @@ -1,6 +1,7 @@ // Standardized Skills API: GET /v5/standardized-skills/skills/fuzzymatch (term required, size optional) // Response schema: array of objects { id: uuid, name: string } import { createTool } from '@mastra/core/tools'; +import { withAccessPolicy } from '../../../utils/auth/access-control'; import { z } from 'zod'; interface SkillFuzzyMatchResponse { @@ -10,7 +11,7 @@ interface SkillFuzzyMatchResponse { const BASE_URL = `${process.env.TC_API_BASE}/v5/standardized-skills/skills/fuzzymatch`; -export const standardizedSkillsFuzzyTool = createTool({ +export const standardizedSkillsFuzzyTool = withAccessPolicy(createTool({ id: 'standardized-skills-fuzzy-match', description: "Fuzzy match Topcoder's standardized skills by term", inputSchema: z.object({ @@ -30,7 +31,7 @@ export const standardizedSkillsFuzzyTool = createTool({ logger?.info('Fetching fuzzy matches for term: {term}', { term: inputData.term }); return await fetchFuzzyMatches(inputData.term, inputData.size); }, -}); +})); const fetchFuzzyMatches = async (term: string, size?: number) => { const url = new URL(BASE_URL); diff --git a/src/mastra/tools/skills/standardized-skills-semantic-tool.ts b/src/mastra/tools/skills/standardized-skills-semantic-tool.ts index c161057..61943ee 100644 --- a/src/mastra/tools/skills/standardized-skills-semantic-tool.ts +++ b/src/mastra/tools/skills/standardized-skills-semantic-tool.ts @@ -1,6 +1,7 @@ // Standardized Skills API: POST /v5/standardized-skills/skills/semantic-search (body: { text }) // Response schema: array of objects { id: uuid, name: string, weighted_distance: number } import { createTool } from '@mastra/core/tools'; +import { withAccessPolicy } from '../../../utils/auth/access-control'; import { z } from 'zod'; interface SkillSemanticMatchResponse { @@ -11,7 +12,7 @@ interface SkillSemanticMatchResponse { const BASE_URL = `${process.env.TC_API_BASE}/v5/standardized-skills/skills/semantic-search`; -export const standardizedSkillsSemanticTool = createTool({ +export const standardizedSkillsSemanticTool = withAccessPolicy(createTool({ id: 'standardized-skills-semantic-search', description: "Semantic search Topcoder's standardized skills by text", inputSchema: z.object({ @@ -31,7 +32,7 @@ export const standardizedSkillsSemanticTool = createTool({ logger?.info('Fetching semantic matches for text query'); return await fetchSemanticMatches(inputData.text); }, -}); +})); const fetchSemanticMatches = async (text: string) => { const response = await fetch(BASE_URL, { diff --git a/src/utils/auth/access-control.test.ts b/src/utils/auth/access-control.test.ts new file mode 100644 index 0000000..9685f58 --- /dev/null +++ b/src/utils/auth/access-control.test.ts @@ -0,0 +1,428 @@ +/** + * Unit tests for the role/scope access-control layer. + * See docs/adr/0004-role-based-access-for-agents-workflows-tools.md. + */ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { toEnvKey, type AccessPolicy } from '../../config/access-control.config'; +import { + _resetAccessPolicyCache, + authorizeAccessPolicy, + checkAccess, + resolveAccessPolicy, + toAuthenticatedCaller, + ToolAccessDeniedError, + withAccessPolicy, +} from './access-control'; + +vi.mock('../logger', () => ({ + tcAILogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +const ROLES_CLAIM = 'https://topcoder.com/roles'; +const USERID_CLAIM = 'https://topcoder.com/userId'; + +/** Env keys this suite sets; wiped before and after every test. */ +const MANAGED_ENV = [ + 'TC_API_BASE', + 'DISABLE_AUTH', + 'ACCESS_CONTROL_DEFAULT_POLICY', + 'ACCESS_CONTROL_ROLES_CLAIM', +]; +const originalEnv: Record = {}; + +beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('ACCESS_POLICY_')) delete process.env[key]; + } + for (const key of MANAGED_ENV) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + _resetAccessPolicyCache(); +}); + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('ACCESS_POLICY_')) delete process.env[key]; + } + for (const key of MANAGED_ENV) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + _resetAccessPolicyCache(); +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function memberUser(roles: string[] = []): Record { + return { sub: 'auth0|123', [USERID_CLAIM]: '88774433', [ROLES_CLAIM]: roles }; +} + +function m2mUser(scopes: string[] = []): Record { + return { sub: 'client-id@clients', scope: scopes.join(' ') }; +} + +/** + * Mirrors what coreAuthMiddleware actually hands authorizeUser: + * adaptToMastraAuthRequest() returns a HonoRequestLike ({ raw, headers, header }), + * NOT a Request — so `request.url` is undefined and getWebRequest() is required. + */ +function authRequest(path: string) { + const raw = new Request(`http://localhost${path}`); + return { raw, headers: raw.headers, header: (n: string) => raw.headers.get(n) ?? undefined }; +} + +// --------------------------------------------------------------------------- +// toAuthenticatedCaller +// --------------------------------------------------------------------------- + +describe('toAuthenticatedCaller', () => { + it('reads member roles from the TC roles claim', () => { + const caller = toAuthenticatedCaller(memberUser(['administrator', 'copilot'])); + expect(caller).toEqual({ + isM2M: false, + roles: ['administrator', 'copilot'], + scopes: [], + }); + }); + + it('treats a token without the TC userId claim as M2M and splits the scope claim', () => { + const caller = toAuthenticatedCaller(m2mUser(['challengesRAG:admin', 'read:challenges'])); + expect(caller).toEqual({ + isM2M: true, + roles: [], + scopes: ['challengesRAG:admin', 'read:challenges'], + }); + }); + + it('ignores a non-array roles claim and non-string entries', () => { + expect(toAuthenticatedCaller({ [USERID_CLAIM]: '1', [ROLES_CLAIM]: 'admin' }).roles).toEqual([]); + expect( + toAuthenticatedCaller({ [USERID_CLAIM]: '1', [ROLES_CLAIM]: ['ok', 42, null] }).roles, + ).toEqual(['ok']); + }); + + it('honours ACCESS_CONTROL_ROLES_CLAIM as an override', () => { + process.env.ACCESS_CONTROL_ROLES_CLAIM = 'custom/roles'; + const caller = toAuthenticatedCaller({ + [USERID_CLAIM]: '1', + [ROLES_CLAIM]: ['ignored'], + 'custom/roles': ['administrator'], + }); + expect(caller.roles).toEqual(['administrator']); + }); + + it('derives the claim domain from TC_API_BASE', () => { + process.env.TC_API_BASE = 'https://api.topcoder-dev.com'; + const caller = toAuthenticatedCaller({ + 'https://topcoder-dev.com/userId': '1', + 'https://topcoder-dev.com/roles': ['administrator'], + }); + expect(caller).toMatchObject({ isM2M: false, roles: ['administrator'] }); + }); +}); + +// --------------------------------------------------------------------------- +// checkAccess — the truth table +// --------------------------------------------------------------------------- + +describe('checkAccess', () => { + const restricted: AccessPolicy = { + mode: 'restricted', + roles: ['administrator'], + scopes: ['challengesRAG:admin'], + }; + + it('public always allows', () => { + expect(checkAccess(toAuthenticatedCaller(memberUser()), { mode: 'public' })).toBe(true); + expect(checkAccess(toAuthenticatedCaller(m2mUser()), { mode: 'public' })).toBe(true); + }); + + it('deny always denies', () => { + expect( + checkAccess(toAuthenticatedCaller(memberUser(['administrator'])), { mode: 'deny' }), + ).toBe(false); + expect( + checkAccess(toAuthenticatedCaller(m2mUser(['challengesRAG:admin'])), { mode: 'deny' }), + ).toBe(false); + }); + + it('restricted allows a member with a matching role', () => { + expect( + checkAccess(toAuthenticatedCaller(memberUser(['copilot', 'administrator'])), restricted), + ).toBe(true); + }); + + it('restricted denies a member without a matching role', () => { + expect(checkAccess(toAuthenticatedCaller(memberUser(['copilot'])), restricted)).toBe(false); + }); + + it('restricted allows M2M with a matching scope', () => { + expect( + checkAccess(toAuthenticatedCaller(m2mUser(['challengesRAG:admin'])), restricted), + ).toBe(true); + }); + + it('restricted denies M2M without a matching scope', () => { + expect(checkAccess(toAuthenticatedCaller(m2mUser(['read:challenges'])), restricted)).toBe( + false, + ); + }); + + it('restricted with only roles configured denies every M2M caller', () => { + const rolesOnly: AccessPolicy = { mode: 'restricted', roles: ['administrator'] }; + expect(checkAccess(toAuthenticatedCaller(m2mUser(['challengesRAG:admin'])), rolesOnly)).toBe( + false, + ); + expect(checkAccess(toAuthenticatedCaller(memberUser(['administrator'])), rolesOnly)).toBe( + true, + ); + }); + + it('restricted with only scopes configured denies every member caller', () => { + const scopesOnly: AccessPolicy = { mode: 'restricted', scopes: ['challengesRAG:admin'] }; + expect(checkAccess(toAuthenticatedCaller(memberUser(['administrator'])), scopesOnly)).toBe( + false, + ); + expect(checkAccess(toAuthenticatedCaller(m2mUser(['challengesRAG:admin'])), scopesOnly)).toBe( + true, + ); + }); +}); + +// --------------------------------------------------------------------------- +// toEnvKey — every id in the resource inventory +// --------------------------------------------------------------------------- + +describe('toEnvKey', () => { + it.each([ + ['challenge-search-agent', 'CHALLENGE_SEARCH_AGENT'], + ['challenge-parser-agent', 'CHALLENGE_PARSER_AGENT'], + ['jd-rewriter-agent', 'JD_REWRITER_AGENT'], + ['skillsMatchingAgent', 'SKILLS_MATCHING_AGENT'], + ['challenge-ingestion', 'CHALLENGE_INGESTION'], + ['challenge-bulk-ingestion', 'CHALLENGE_BULK_INGESTION'], + ['challenge-search', 'CHALLENGE_SEARCH'], + ['challenge-context', 'CHALLENGE_CONTEXT'], + ['skill-extraction-workflow', 'SKILL_EXTRACTION_WORKFLOW'], + ['jd-autowrite', 'JD_AUTOWRITE'], + ['challenge-vector-query', 'CHALLENGE_VECTOR_QUERY'], + ['fetch-challenge-by-id', 'FETCH_CHALLENGE_BY_ID'], + ['fetch-project-by-id', 'FETCH_PROJECT_BY_ID'], + ['search-challenges', 'SEARCH_CHALLENGES'], + ['standardized-skills-fuzzy-match', 'STANDARDIZED_SKILLS_FUZZY_MATCH'], + ['standardized-skills-semantic-search', 'STANDARDIZED_SKILLS_SEMANTIC_SEARCH'], + ])('maps %s -> %s', (id, expected) => { + expect(toEnvKey(id)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// resolveAccessPolicy +// --------------------------------------------------------------------------- + +describe('resolveAccessPolicy', () => { + it('resolves both ingestion workflows to the baked-in restricted policy with no env set', () => { + for (const id of ['challenge-ingestion', 'challenge-bulk-ingestion']) { + expect(resolveAccessPolicy('workflow', id)).toEqual({ + mode: 'restricted', + roles: ['administrator'], + scopes: ['challengesRAG:admin'], + }); + } + }); + + it('defaults an unconfigured target to public', () => { + expect(resolveAccessPolicy('agent', 'challenge-search-agent')).toEqual({ mode: 'public' }); + expect(resolveAccessPolicy('tool', 'challenge-vector-query')).toEqual({ mode: 'public' }); + }); + + it('honours ACCESS_CONTROL_DEFAULT_POLICY=deny as the global default', () => { + process.env.ACCESS_CONTROL_DEFAULT_POLICY = 'deny'; + expect(resolveAccessPolicy('agent', 'challenge-search-agent')).toEqual({ mode: 'deny' }); + // ...but a code default still beats the global default. + expect(resolveAccessPolicy('workflow', 'challenge-ingestion').mode).toBe('restricted'); + }); + + it('lets an env override beat the code default', () => { + process.env.ACCESS_POLICY_WORKFLOW_CHALLENGE_INGESTION_MODE = 'public'; + expect(resolveAccessPolicy('workflow', 'challenge-ingestion')).toEqual({ mode: 'public' }); + }); + + it('comma-splits and trims _ROLES / _SCOPES', () => { + process.env.ACCESS_POLICY_WORKFLOW_CHALLENGE_SEARCH_ROLES = ' administrator , copilot '; + process.env.ACCESS_POLICY_WORKFLOW_CHALLENGE_SEARCH_SCOPES = 'a:b, c:d'; + expect(resolveAccessPolicy('workflow', 'challenge-search')).toEqual({ + mode: 'restricted', + roles: ['administrator', 'copilot'], + scopes: ['a:b', 'c:d'], + }); + }); + + it('throws an actionable error on an invalid _MODE', () => { + process.env.ACCESS_POLICY_TOOL_SEARCH_CHALLENGES_MODE = 'restricted'; + expect(() => resolveAccessPolicy('tool', 'search-challenges')).toThrow( + /ACCESS_POLICY_TOOL_SEARCH_CHALLENGES_MODE/, + ); + }); + + it('throws on an invalid ACCESS_CONTROL_DEFAULT_POLICY', () => { + process.env.ACCESS_CONTROL_DEFAULT_POLICY = 'open'; + expect(() => resolveAccessPolicy('agent', 'jd-rewriter-agent')).toThrow( + /ACCESS_CONTROL_DEFAULT_POLICY/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// authorizeAccessPolicy — the HTTP boundary +// --------------------------------------------------------------------------- + +describe('authorizeAccessPolicy', () => { + const INGEST = '/v6/ai/workflows/challenge-ingestion/start-async'; + + it('denies a member lacking the administrator role', () => { + expect(authorizeAccessPolicy(memberUser(['copilot']), authRequest(INGEST))).toBe(false); + }); + + it('allows a member with the administrator role', () => { + expect(authorizeAccessPolicy(memberUser(['administrator']), authRequest(INGEST))).toBe(true); + }); + + it('denies M2M lacking challengesRAG:admin', () => { + expect(authorizeAccessPolicy(m2mUser(['read:challenges']), authRequest(INGEST))).toBe(false); + }); + + it('allows M2M with challengesRAG:admin', () => { + expect(authorizeAccessPolicy(m2mUser(['challengesRAG:admin']), authRequest(INGEST))).toBe( + true, + ); + }); + + it('allows an agent path with no configured policy, regardless of role/scope', () => { + expect( + authorizeAccessPolicy(memberUser([]), authRequest('/v6/ai/agents/challenge-search-agent/generate')), + ).toBe(true); + }); + + it('resolves the chatRoute path to category agent and applies that agent policy', () => { + process.env.ACCESS_POLICY_AGENT_CHALLENGE_SEARCH_AGENT_ROLES = 'administrator'; + const chat = () => authRequest('/v6/ai-chat/challenge-search-agent'); + expect(authorizeAccessPolicy(memberUser(['copilot']), chat())).toBe(false); + expect(authorizeAccessPolicy(memberUser(['administrator']), chat())).toBe(true); + // ...and matches its native /v6/ai/agents/:agentId counterpart. + expect( + authorizeAccessPolicy( + memberUser(['copilot']), + authRequest('/v6/ai/agents/challenge-search-agent/stream'), + ), + ).toBe(false); + }); + + it('allows out-of-scope apiPrefix paths (memory, threads, telemetry)', () => { + expect(authorizeAccessPolicy(memberUser([]), authRequest('/v6/ai/memory/threads'))).toBe(true); + expect(authorizeAccessPolicy(memberUser([]), authRequest('/v6/ai/telemetry'))).toBe(true); + }); + + it('re-asserts the MastraAuthAuth0 baseline checks it shadows', () => { + // No sub/id at all. + expect(authorizeAccessPolicy({ [USERID_CLAIM]: '1' }, authRequest(INGEST))).toBe(false); + expect(authorizeAccessPolicy(null, authRequest(INGEST))).toBe(false); + // Expired. + const expired = { ...memberUser(['administrator']), exp: Math.floor(Date.now() / 1000) - 60 }; + expect(authorizeAccessPolicy(expired, authRequest(INGEST))).toBe(false); + }); + + it('fails closed when the request URL cannot be recovered', () => { + // A HonoRequestLike with no `raw` Request — getWebRequest() returns undefined. + const headerOnly = { header: () => undefined } as any; + expect(authorizeAccessPolicy(memberUser(['administrator']), headerOnly)).toBe(false); + }); + + it('works when handed a bare Request too (the other MastraAuthRequest shape)', () => { + expect( + authorizeAccessPolicy(memberUser(['administrator']), new Request(`http://localhost${INGEST}`)), + ).toBe(true); + expect( + authorizeAccessPolicy(memberUser(['copilot']), new Request(`http://localhost${INGEST}`)), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// withAccessPolicy — the tool boundary +// --------------------------------------------------------------------------- + +describe('withAccessPolicy', () => { + const TOOL_ID = 'search-challenges'; + + function fakeTool(id = TOOL_ID) { + const execute = vi.fn(async (input: unknown) => ({ ok: true, input })); + return { tool: withAccessPolicy({ id, execute } as any), execute }; + } + + function ctx(user?: Record) { + return { requestContext: { get: (k: string) => (k === 'user' ? user : undefined) } }; + } + + it('leaves a tool with no execute untouched', () => { + const tool = { id: 'noop' } as any; + expect(withAccessPolicy(tool)).toBe(tool); + }); + + it('preserves the Mastra tool marker on the clone', () => { + const marker = Symbol.for('mastra.core.tool.Tool'); + const source: any = { id: 'x', execute: async () => 1 }; + source[marker] = true; + expect(marker in (withAccessPolicy(source) as any)).toBe(true); + }); + + it('allows any caller for a public-policy tool', async () => { + const { tool, execute } = fakeTool(); + await expect(tool.execute!({ q: 1 }, ctx(memberUser([])))).resolves.toEqual({ + ok: true, + input: { q: 1 }, + }); + expect(execute).toHaveBeenCalledOnce(); + }); + + it('bypasses the check entirely when DISABLE_AUTH=true, even with no user', async () => { + process.env.DISABLE_AUTH = 'true'; + process.env.ACCESS_POLICY_TOOL_SEARCH_CHALLENGES_MODE = 'deny'; + const { tool, execute } = fakeTool(); + await expect(tool.execute!({}, ctx(undefined))).resolves.toEqual({ ok: true, input: {} }); + expect(execute).toHaveBeenCalledOnce(); + }); + + it('denies when there is no user on the request context', async () => { + const { tool, execute } = fakeTool(); + await expect(tool.execute!({}, ctx(undefined))).rejects.toBeInstanceOf( + ToolAccessDeniedError, + ); + expect(execute).not.toHaveBeenCalled(); + }); + + it('denies a member without the required role and allows one with it', async () => { + process.env.ACCESS_POLICY_TOOL_SEARCH_CHALLENGES_ROLES = 'administrator'; + const { tool } = fakeTool(); + await expect(tool.execute!({}, ctx(memberUser(['copilot'])))).rejects.toThrow( + /Access denied for tool "search-challenges"/, + ); + await expect(tool.execute!({}, ctx(memberUser(['administrator'])))).resolves.toMatchObject({ + ok: true, + }); + }); + + it('denies M2M without the required scope and allows one with it', async () => { + process.env.ACCESS_POLICY_TOOL_SEARCH_CHALLENGES_SCOPES = 'challengesRAG:admin'; + const { tool } = fakeTool(); + await expect(tool.execute!({}, ctx(m2mUser(['read:challenges'])))).rejects.toBeInstanceOf( + ToolAccessDeniedError, + ); + await expect( + tool.execute!({}, ctx(m2mUser(['challengesRAG:admin']))), + ).resolves.toMatchObject({ ok: true }); + }); +}); diff --git a/src/utils/auth/access-control.ts b/src/utils/auth/access-control.ts new file mode 100644 index 0000000..f4bfb09 --- /dev/null +++ b/src/utils/auth/access-control.ts @@ -0,0 +1,276 @@ +/** + * Role/scope-based access control enforcement for agents, workflows and tools. + * See docs/adr/0004-role-based-access-for-agents-workflows-tools.md. + * + * Two enforcement points, one shared policy core: + * + * - Agents & workflows: `authorizeAccessPolicy` is passed as `authorizeUser` + * to both MastraAuthAuth0 providers in `apiAuthLayer`. Mastra's own + * `coreAuthMiddleware` already invokes that hook on every protected request + * and 403s when it returns false. + * - Tools: `withAccessPolicy` wraps a tool's `execute` at its export site. + * Tools have no HTTP route of their own, so enforcement uses the `user` + * that coreAuthMiddleware/resourceIdMiddleware already put on RequestContext + * before any agent or workflow body runs. + */ +import { getWebRequest, type MastraAuthRequest } from '@mastra/core/server'; +import { + DEFAULT_ACCESS_POLICIES, + toEnvKey, + type AccessCategory, + type AccessPolicy, +} from '../../config/access-control.config'; +import { tcAILogger } from '../logger'; +import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; +import { resolveTcDomain, tcUserIdClaimKey } from './tc-domain'; + +// --------------------------------------------------------------------------- +// Claim extraction +// --------------------------------------------------------------------------- + +/** + * JWT claim key carrying the member's role names. Confirmed against a real + * decoded prod member token: `https://topcoder.com/roles`, a plain string + * array including "administrator". ACCESS_CONTROL_ROLES_CLAIM overrides it if + * a tenant ever diverges from the convention. + */ +const rolesClaimKey = (): string => + process.env.ACCESS_CONTROL_ROLES_CLAIM || `https://${resolveTcDomain()}/roles`; + +export interface AuthenticatedCaller { + /** M2M tokens don't carry the TC userId claim (mirrors resourceIdMiddleware). */ + isM2M: boolean; + /** Member role names, [] when absent or not a string array. */ + roles: string[]; + /** OAuth `scope` claim, space-delimited, [] when absent. */ + scopes: string[]; +} + +export function toAuthenticatedCaller(user: Record): AuthenticatedCaller { + const isM2M = !user[tcUserIdClaimKey()]; + const rawRoles = user[rolesClaimKey()]; + const roles = Array.isArray(rawRoles) + ? rawRoles.filter((r): r is string => typeof r === 'string') + : []; + const scopes = typeof user.scope === 'string' ? user.scope.split(' ').filter(Boolean) : []; + return { isM2M, roles, scopes }; +} + +// --------------------------------------------------------------------------- +// The shared check +// --------------------------------------------------------------------------- + +/** + * Each credential type is checked against its own dimension only: an M2M + * caller against `scopes`, a member caller against `roles`. A `restricted` + * policy configuring only one dimension implicitly denies the other credential + * type — this is the intended design, not an oversight. + */ +export function checkAccess(caller: AuthenticatedCaller, policy: AccessPolicy): boolean { + if (policy.mode === 'public') return true; + if (policy.mode === 'deny') return false; + if (caller.isM2M) { + return !!policy.scopes?.length && policy.scopes.some((s) => caller.scopes.includes(s)); + } + return !!policy.roles?.length && policy.roles.some((r) => caller.roles.includes(r)); +} + +// --------------------------------------------------------------------------- +// Policy resolution — env override -> code default -> global default +// --------------------------------------------------------------------------- + +const policyCache = new Map(); + +/** Test-only: clears the memoised policies after mutating process.env. */ +export function _resetAccessPolicyCache(): void { + policyCache.clear(); +} + +function parseList(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const items = value.split(',').map((v) => v.trim()).filter(Boolean); + return items.length ? items : []; +} + +function parseMode(value: string, envVar: string): 'public' | 'deny' { + const normalized = value.trim().toLowerCase(); + if (normalized === 'public' || normalized === 'deny') return normalized; + throw new Error( + `Invalid ${envVar}="${value}": must be "public" or "deny". ` + + `Set ${envVar} to a supported mode, or unset it and use the ` + + `_ROLES/_SCOPES variants for a restricted policy.`, + ); +} + +/** Global fallback for any target with no env override and no code default. */ +function globalDefaultPolicy(): AccessPolicy { + const raw = process.env.ACCESS_CONTROL_DEFAULT_POLICY; + if (!raw) return { mode: 'public' }; + return { mode: parseMode(raw, 'ACCESS_CONTROL_DEFAULT_POLICY') }; +} + +function envPolicy(category: AccessCategory, targetId: string): AccessPolicy | undefined { + const prefix = `ACCESS_POLICY_${category.toUpperCase()}_${toEnvKey(targetId)}`; + const rawMode = process.env[`${prefix}_MODE`]; + const roles = parseList(process.env[`${prefix}_ROLES`]); + const scopes = parseList(process.env[`${prefix}_SCOPES`]); + + if (rawMode !== undefined && rawMode !== '') { + return { mode: parseMode(rawMode, `${prefix}_MODE`) }; + } + if (roles === undefined && scopes === undefined) return undefined; + return { mode: 'restricted', roles, scopes }; +} + +/** + * Resolves the effective policy for a target. Lazy and memoised — this module + * never throws at import time, mirroring getRagConfig()'s convention. An + * invalid _MODE throws an actionable error on first resolution. + */ +export function resolveAccessPolicy(category: AccessCategory, targetId: string): AccessPolicy { + const cacheKey = `${category}:${targetId}`; + const cached = policyCache.get(cacheKey); + if (cached) return cached; + + const policy = + envPolicy(category, targetId) ?? + DEFAULT_ACCESS_POLICIES[category][targetId] ?? + globalDefaultPolicy(); + + policyCache.set(cacheKey, policy); + return policy; +} + +// --------------------------------------------------------------------------- +// Enforcement — agents & workflows (Mastra's authorizeUser hook) +// --------------------------------------------------------------------------- + +const AGENT_PATH_RE = new RegExp(`^${API_PREFIX}/agents/([^/]+)`); +const WORKFLOW_PATH_RE = new RegExp(`^${API_PREFIX}/workflows/([^/]+)`); +// chatRoute() is CHAT_ROUTE_BASE_PATH/:agentId — an agent by another path. +const CHAT_PATH_RE = new RegExp(`^${CHAT_ROUTE_BASE_PATH}/([^/]+)`); + +/** null when the path isn't an agent/workflow invocation (memory, threads, telemetry, ...). */ +function parseTarget(pathname: string): { category: AccessCategory; targetId: string } | null { + const agent = AGENT_PATH_RE.exec(pathname); + if (agent) return { category: 'agent', targetId: decodeURIComponent(agent[1]) }; + + const workflow = WORKFLOW_PATH_RE.exec(pathname); + if (workflow) return { category: 'workflow', targetId: decodeURIComponent(workflow[1]) }; + + const chat = CHAT_PATH_RE.exec(pathname); + if (chat) return { category: 'agent', targetId: decodeURIComponent(chat[1]) }; + + return null; +} + +/** + * Passed as `authorizeUser` to both MastraAuthAuth0 providers in apiAuthLayer. + * Mastra `.bind(this)`s it onto the provider, so it must never read `this`. + * + * Note: supplying `authorizeUser` in the provider options SHADOWS + * MastraAuthAuth0's own prototype authorizeUser, so its baseline sub/exp + * checks are re-asserted here rather than lost. + */ +export function authorizeAccessPolicy( + user: Record | null | undefined, + request: MastraAuthRequest, +): boolean { + // Baseline checks inherited from MastraAuthAuth0.authorizeUser. + if (!user || !(user.sub || user.id)) return false; + if (typeof user.exp === 'number' && user.exp * 1000 < Date.now()) return false; + + // authorizeUser receives a MastraAuthRequest (`{ raw, headers, header() }`), + // NOT a Request — `request.url` is undefined on it. getWebRequest() is + // Mastra's own helper for recovering the underlying Request. + const url = getWebRequest(request)?.url; + if (!url) { + tcAILogger.warn('[access-control] denied: could not resolve request URL'); + return false; + } + + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + tcAILogger.warn('[access-control] denied: unparseable request URL', { url }); + return false; + } + + const target = parseTarget(pathname); + // Not an agent/workflow invocation — out of this ADR's scope, unaffected. + if (!target) return true; + + const policy = resolveAccessPolicy(target.category, target.targetId); + const allowed = checkAccess(toAuthenticatedCaller(user), policy); + + if (!allowed) { + tcAILogger.warn( + `[access-control] denied ${target.category} "${target.targetId}"`, + { category: target.category, targetId: target.targetId, hasUser: true }, + ); + } + return allowed; +} + +// --------------------------------------------------------------------------- +// Enforcement — tools (in-process, via RequestContext) +// --------------------------------------------------------------------------- + +export class ToolAccessDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = 'ToolAccessDeniedError'; + } +} + +interface ToolLike { + id: string; + execute?: (...args: any[]) => any; +} + +interface ToolExecuteContext { + requestContext?: { get(key: string): unknown }; +} + +/** + * Wraps a tool's execute with its access policy. Applied at each tool's own + * export site so the guard travels with the exported tool object — a future + * agent that imports the tool can't forget to wrap it. + * + * The `{...tool, execute}` shallow clone is safe: createTool returns a Tool + * instance whose fields — including the Symbol.for('mastra.core.tool.Tool') + * marker — are all own enumerable properties, the class has no prototype + * methods, and Mastra's isMastraTool() accepts the marker without requiring + * `instanceof Tool`. The wrapped execute is the instance's own validating + * wrapper, so input/output/requestContext validation still runs. + */ +export function withAccessPolicy(tool: T): T { + const originalExecute = tool.execute; + if (!originalExecute) return tool; + + return { + ...tool, + execute: async (inputData: unknown, context: ToolExecuteContext) => { + if (process.env.DISABLE_AUTH === 'true') { + return originalExecute(inputData, context); + } + + const user = context?.requestContext?.get('user') as + | Record + | undefined; + const policy = resolveAccessPolicy('tool', tool.id); + + if (!user || !checkAccess(toAuthenticatedCaller(user), policy)) { + tcAILogger.warn(`[access-control] denied tool "${tool.id}"`, { + category: 'tool', + targetId: tool.id, + hasUser: !!user, + }); + throw new ToolAccessDeniedError(`Access denied for tool "${tool.id}"`); + } + + return originalExecute(inputData, context); + }, + }; +} diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts index 6ca7c78..6249777 100644 --- a/src/utils/auth/index.ts +++ b/src/utils/auth/index.ts @@ -1,21 +1,8 @@ import { MastraAuthAuth0 } from '@mastra/auth-auth0'; import { CompositeAuth } from '@mastra/core/server'; -import { API_PREFIX } from '../server-routes'; - -// Matches the TC userId claim key used across the platform, e.g. -// https://topcoder.com/userId or https://topcoder-dev.com/userId -const tcUserIdClaimKey = (): string => { - const tcApiBase = process.env.TC_API_BASE || ''; - let domain = 'topcoder.com'; - try { - if (tcApiBase) { - domain = new URL(tcApiBase).hostname.replace('api.', ''); - } - } catch { - // fall back to default domain - } - return `https://${domain}/userId`; -}; +import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; +import { authorizeAccessPolicy } from './access-control'; +import { tcUserIdClaimKey } from './tc-domain'; const mapUserToResourceId = (user: Record): string | undefined => { const userId = user[tcUserIdClaimKey()]; @@ -24,19 +11,27 @@ const mapUserToResourceId = (user: Record): string | undefined return typeof user.sub === 'string' ? user.sub : undefined; }; +// chatRoute() (CHAT_ROUTE_BASE_PATH/:agentId) lives outside apiPrefix and never +// sets requiresAuth, so Mastra's coreAuthMiddleware treats it as unprotected and +// returns before ever reaching authorizeUser. Listing it here is what brings it +// under the same authenticate-then-authorize path as the native routes. +const PROTECTED_PATHS = [`${API_PREFIX}/*`, `${CHAT_ROUTE_BASE_PATH}/*`]; + export const apiAuthLayer = new CompositeAuth([ // TC Member Auth0 JWTs new MastraAuthAuth0({ domain: process.env.AUTH0_DOMAIN, audience: process.env.AUTH0_AUDIENCE, - protected: [`${API_PREFIX}/*`], + protected: PROTECTED_PATHS, mapUserToResourceId, + authorizeUser: authorizeAccessPolicy, }), // TC M2M Auth0 JWTs new MastraAuthAuth0({ domain: process.env.AUTH0_M2M_DOMAIN, audience: process.env.AUTH0_M2M_AUDIENCE, - protected: [`${API_PREFIX}/*`], + protected: PROTECTED_PATHS, mapUserToResourceId, + authorizeUser: authorizeAccessPolicy, }), ]); diff --git a/src/utils/auth/tc-domain.ts b/src/utils/auth/tc-domain.ts new file mode 100644 index 0000000..dd679b0 --- /dev/null +++ b/src/utils/auth/tc-domain.ts @@ -0,0 +1,27 @@ +/** + * Derives the member-facing Topcoder domain from TC_API_BASE (dev vs prod) + * without a separate env var to keep in sync. + * + * Single source of truth for the snippet that used to be duplicated in + * src/utils/auth/index.ts, src/utils/middleware/resourceIdMiddleware.ts and + * src/mastra/agents/challenge/challenge-search-agent.ts. Used to build the + * TC claim keys (`https:///userId`, `https:///roles`) and the + * member-facing challenge/project URLs. + * + * e.g. TC_API_BASE=https://api.topcoder-dev.com -> "topcoder-dev.com" + */ +export function resolveTcDomain(): string { + let domain = 'topcoder.com'; + try { + const tcApiBase = process.env.TC_API_BASE || ''; + if (tcApiBase) { + domain = new URL(tcApiBase).hostname.replace('api.', ''); + } + } catch { + // fall back to default domain + } + return domain; +} + +/** The TC userId claim key, e.g. https://topcoder.com/userId */ +export const tcUserIdClaimKey = (): string => `https://${resolveTcDomain()}/userId`; diff --git a/src/utils/middleware/resourceIdMiddleware.ts b/src/utils/middleware/resourceIdMiddleware.ts index 33b2fdb..02df218 100644 --- a/src/utils/middleware/resourceIdMiddleware.ts +++ b/src/utils/middleware/resourceIdMiddleware.ts @@ -1,5 +1,6 @@ import { MASTRA_RESOURCE_ID_KEY } from "@mastra/core/request-context"; import { apiAuthLayer } from '../auth'; +import { tcUserIdClaimKey } from '../auth/tc-domain'; import { tcAILogger } from '../logger'; import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; @@ -46,19 +47,7 @@ const resourceIdMiddlewareHandler = async (c: any, next: any) => { } // Logic to extract userId - const tcApiBase = process.env.TC_API_BASE || ''; - let domain = 'topcoder.com'; - try { - if (tcApiBase) { - const url = new URL(tcApiBase); - domain = url.hostname.replace('api.', ''); - } - } catch (e) { - console.error('Error parsing TC_API_BASE:', e); - } - - const userIdKey = `https://${domain}/userId`; - const userId = user[userIdKey]; + const userId = user[tcUserIdClaimKey()]; const sub = user['sub']; // M2M user if (!userId && !sub) { From e4d1bb86da596fb4a508c71ae58ed8f87da458ab Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 9 Sep 2026 10:41:01 +0300 Subject: [PATCH 20/27] Best-effort extraction of a JSON object --- src/utils/structured-output-wrapper.ts | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/utils/structured-output-wrapper.ts b/src/utils/structured-output-wrapper.ts index 10657a2..e92d179 100644 --- a/src/utils/structured-output-wrapper.ts +++ b/src/utils/structured-output-wrapper.ts @@ -225,6 +225,33 @@ function isLikelyMojoOrGemini(agent: any): boolean { return modelId.includes('gemini') || modelId.includes('mojo'); } +/** + * Best-effort extraction of a JSON object from free-form model text: tries a + * direct parse first, then a fenced ```json ... ``` block, then the widest + * `{ ... }` span in the text (handles leading/trailing commentary). + */ +function extractJsonObject(text: string): unknown | null { + const candidates: string[] = [text]; + + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) candidates.push(fenced[1]); + + const firstBrace = text.indexOf('{'); + const lastBrace = text.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + candidates.push(text.slice(firstBrace, lastBrace + 1)); + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate.trim()); + } catch { + // try next candidate + } + } + return null; +} + export function isStructuredOutputCompatibilityError(error: unknown): boolean { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); @@ -340,6 +367,21 @@ export async function generateWithStructuredOutputFallback Date: Wed, 9 Sep 2026 11:09:05 +0300 Subject: [PATCH 21/27] Revert "Best-effort extraction of a JSON object -> dev" --- src/utils/structured-output-wrapper.ts | 61 -------------------------- 1 file changed, 61 deletions(-) diff --git a/src/utils/structured-output-wrapper.ts b/src/utils/structured-output-wrapper.ts index e92d179..10657a2 100644 --- a/src/utils/structured-output-wrapper.ts +++ b/src/utils/structured-output-wrapper.ts @@ -225,33 +225,6 @@ function isLikelyMojoOrGemini(agent: any): boolean { return modelId.includes('gemini') || modelId.includes('mojo'); } -/** - * Best-effort extraction of a JSON object from free-form model text: tries a - * direct parse first, then a fenced ```json ... ``` block, then the widest - * `{ ... }` span in the text (handles leading/trailing commentary). - */ -function extractJsonObject(text: string): unknown | null { - const candidates: string[] = [text]; - - const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); - if (fenced?.[1]) candidates.push(fenced[1]); - - const firstBrace = text.indexOf('{'); - const lastBrace = text.lastIndexOf('}'); - if (firstBrace !== -1 && lastBrace > firstBrace) { - candidates.push(text.slice(firstBrace, lastBrace + 1)); - } - - for (const candidate of candidates) { - try { - return JSON.parse(candidate.trim()); - } catch { - // try next candidate - } - } - return null; -} - export function isStructuredOutputCompatibilityError(error: unknown): boolean { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); @@ -367,21 +340,6 @@ export async function generateWithStructuredOutputFallback Date: Wed, 9 Sep 2026 12:15:44 +0300 Subject: [PATCH 22/27] bad/truncated structured-output fix --- src/utils/structured-output-wrapper.ts | 36 +++++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/utils/structured-output-wrapper.ts b/src/utils/structured-output-wrapper.ts index e92d179..b2f9711 100644 --- a/src/utils/structured-output-wrapper.ts +++ b/src/utils/structured-output-wrapper.ts @@ -272,6 +272,26 @@ export function isStructuredOutputCompatibilityError(error: unknown): boolean { ); } +/** + * True when the model DID attempt structured output but the result failed + * schema validation (e.g. a truncated array item missing required fields, or + * a genuine type mismatch). Unlike a compatibility error this says nothing + * about whether the provider supports structured output — but a different + * strategy (different prompt shaping, a second unconstrained pass, or the + * final plain-text JSON-recovery fallback) can still produce a valid object, + * so it should be retried rather than immediately thrown. + */ +export function isStructuredOutputValidationError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const message = error.message.toLowerCase(); + + return ( + message.includes('validation failed') + || message.includes('invalid input: expected') + || error.name === 'ZodError' + ); +} + export async function generateWithStructuredOutputFallback({ agent, prompt, @@ -353,7 +373,7 @@ export async function generateWithStructuredOutputFallback Date: Wed, 9 Sep 2026 12:33:51 +0300 Subject: [PATCH 23/27] Removed the prepareStep attempt --- src/utils/structured-output-wrapper.ts | 29 +++++++------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/utils/structured-output-wrapper.ts b/src/utils/structured-output-wrapper.ts index b2f9711..d496c39 100644 --- a/src/utils/structured-output-wrapper.ts +++ b/src/utils/structured-output-wrapper.ts @@ -5,7 +5,6 @@ export type StructuredOutputStrategy = | 'native' | 'jsonPromptInjection' | 'separate-structuring-model' - | 'prepareStep' | 'plain-text'; export type CallTokenUsageSource = 'native' | 'mixed' | 'estimated' | 'none'; @@ -352,26 +351,14 @@ export async function generateWithStructuredOutputFallback { - if (stepNumber === 0) { - return { - structuredOutput: undefined, - }; - } - - return { - tools: undefined, - toolChoice: 'none', - structuredOutput: { - ...strictStructuredOutputBase, - jsonPromptInjection: true, - ...(structuringModel ? { model: structuringModel } : {}), - }, - }; - }, - }); + // NOTE: a 'prepareStep'-based two-step attempt (free-form step 0, then a + // structured-output-only step 1) used to live here. It was removed because + // step 0's assistant turn can end with a bare `thinking` block (extended + // reasoning cut off before any text/tool_use, e.g. on Claude models with + // thinking enabled) and Bedrock/Anthropic then rejects step 1 with + // "messages.N: The final block in an assistant message cannot be + // `thinking`" when that turn is replayed as history. Every remaining + // strategy here is single-turn, so none can hit that replay failure. let lastAttemptError: unknown = null; From b800ab27b6cebbce16c905f792bacc60fa96bdae Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 4 Sep 2026 13:50:42 +0300 Subject: [PATCH 24/27] PM-5999 - RAG management routes --- README.md | 34 ++- ...based-access-for-agents-workflows-tools.md | 68 +++++- src/config/access-control.config.ts | 63 +++++- src/config/rag.config.ts | 18 +- src/mastra/index.ts | 4 + src/mastra/rag/index-admin.test.ts | 211 ++++++++++++++++++ src/mastra/rag/index-admin.ts | 199 +++++++++++++++++ src/utils/auth/access-control.test.ts | 113 +++++++++- src/utils/auth/access-control.ts | 28 ++- src/utils/routes/rag-index.routes.ts | 112 ++++++++++ 10 files changed, 829 insertions(+), 21 deletions(-) create mode 100644 src/mastra/rag/index-admin.test.ts create mode 100644 src/mastra/rag/index-admin.ts create mode 100644 src/utils/routes/rag-index.routes.ts diff --git a/README.md b/README.md index 22f0b5e..e959cb3 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ This ensures **resource isolation** — each user's agent memory and workflow st > See [ADR 0004](docs/adr/0004-role-based-access-for-agents-workflows-tools.md) for the design rationale. -Authentication answers *"is this a valid caller?"*; access control answers *"may **this** caller invoke **this** agent / workflow / tool?"*. Both are off the same policy core in `src/utils/auth/access-control.ts`. +Authentication answers *"is this a valid caller?"*; access control answers *"may **this** caller invoke **this** agent / workflow / tool / admin route?"*. All are off the same policy core in `src/utils/auth/access-control.ts`. **Policy model** — every target resolves to exactly one policy: @@ -281,20 +281,24 @@ Authentication answers *"is this a valid caller?"*; access control answers *"may 2. **Code default** — `DEFAULT_ACCESS_POLICIES` in `src/config/access-control.config.ts` 3. **Global default** — `ACCESS_CONTROL_DEFAULT_POLICY` (`public` unless set to `deny`) -`` is `AGENT`, `WORKFLOW` or `TOOL`. `` is the resource's own **`.id`**, upper-snake-cased — `challenge-bulk-ingestion` → `CHALLENGE_BULK_INGESTION`, `skillsMatchingAgent` → `SKILLS_MATCHING_AGENT`. Always the `.id` passed to `new Agent`/`createWorkflow`/`createTool`, **never** the object-property name it's registered under in `src/mastra/index.ts` — 9 of the 10 registrations differ, and a policy keyed on the wrong one silently never matches. An invalid `_MODE` throws an actionable error on first resolution rather than falling back silently. +`` is `AGENT`, `WORKFLOW`, `TOOL` or `ROUTE`. `` is the resource's own **`.id`**, upper-snake-cased — `challenge-bulk-ingestion` → `CHALLENGE_BULK_INGESTION`, `skillsMatchingAgent` → `SKILLS_MATCHING_AGENT`. Always the `.id` passed to `new Agent`/`createWorkflow`/`createTool`, **never** the object-property name it's registered under in `src/mastra/index.ts` — 9 of the 10 registrations differ, and a policy keyed on the wrong one silently never matches. An invalid `_MODE` throws an actionable error on first resolution rather than falling back silently. + +Because Mastra's `getAgentById`/`getWorkflowById` fall back to the registry key, **both spellings reach the same resource over HTTP** — `/v6/ai/workflows/challenge-ingestion/start` and `/v6/ai/workflows/challengeIngestionWorkflow/start` are the same workflow. `TARGET_ID_ALIASES` maps every differing registry key to its canonical `.id`, and `resolveAccessPolicy()` canonicalises through it before any lookup, so a restriction can't be bypassed by addressing the target the other way. **Adding a restricted policy for a resource whose registry key differs from its `.id` means adding its alias entry too**; the unit tests assert both spellings resolve identically. **Shipped defaults.** Only two targets are restricted out of the box — both rewrite the shared challenge vector index: ``` -challenge-ingestion roles: [administrator] scopes: [challengesRAG:admin] -challenge-bulk-ingestion roles: [administrator] scopes: [challengesRAG:admin] +challenge-ingestion roles: [administrator] scopes: [challengesRAG:admin] (workflow) +challenge-bulk-ingestion roles: [administrator] scopes: [challengesRAG:admin] (workflow) +rag-challenges roles: [administrator] scopes: [challengesRAG:admin] (route) ``` Everything else is `public`, i.e. unchanged from pre-ADR-0004 behavior. Note that `challengesRAG:admin` must exist as a permission on the `AUTH0_M2M_AUDIENCE` API resource in Auth0 and be granted to the relevant M2M client(s), otherwise every M2M caller is denied on those two workflows. -**Two enforcement points:** +**Three enforcement points:** - **Agents & workflows** — `authorizeAccessPolicy` is supplied as `authorizeUser` to both Auth0 providers. Mastra's own `coreAuthMiddleware` already invokes that hook on every protected request and returns **403** when it returns `false`. It parses the request path into `('agent', id)` / `('workflow', id)`, covering `/v6/ai/agents/:id/*`, `/v6/ai/workflows/:id/*` and `/v6/ai-chat/:agentId`. Non-invocation paths (memory, threads, telemetry, scorers) are out of scope and pass through. Mastra Studio uses these same paths, so it gets no bypass. +- **Custom admin routes** (`ROUTE`) — this repo's own `registerApiRoute` entries are neither agents nor workflows, so they match none of the patterns above and would otherwise stay open to any authenticated caller. `ROUTE_PATH_TARGETS` maps a path prefix to a route slug, which then resolves like any other target. Currently one entry: `/v6/ai/rag/challenges` → `rag-challenges`, restricted to `administrator` / `challengesRAG:admin` out of the box. - **Tools** — tools have no HTTP route of their own, so `withAccessPolicy()` wraps each tool's `execute` at its **export site** (e.g. the last line of `challenge-vector-query-tool.ts`). The guard travels with the exported tool object, so a future agent that adds the tool to its `tools:` map can't forget it. It reads the `user` already on `RequestContext` and throws `ToolAccessDeniedError` on denial — surfaced to the LLM as a failed tool call, or to a workflow step as a rejected `execute()`. Nested, in-process invocations (`challenge-bulk-ingestion` → `challenge-ingestion`, `challenge-context` → `challenge-parser-agent`) are **not** re-gated: they never re-enter the HTTP router, and you can't reach them without passing the outer check first. @@ -620,6 +624,26 @@ pnpm run sync -- --status ACTIVE --updated-since 2026-08-01 --concurrency 5 Both CLIs invoke the same workflows the API exposes (via `mastra.getWorkflowById(...).createRun().start(...)`), so the CLI and API paths cannot drift onto separate implementations. `ingest-challenges.ts` writes per-run logs to `logs/ingestion-/{output.log,error.log,report.json}` (git-ignored). +### Index administration API + +Two custom routes for inspecting and pruning what the index currently holds — the backend for the **TopScout RAG** admin page. Both are **administrator-only** (`route`/`rag-challenges` policy, see [Access control](#access-control)). + +``` +GET /v6/ai/rag/challenges list indexed challenges +DELETE /v6/ai/rag/challenges/:challengeId remove one challenge's vectors +``` + +`GET` aggregates `challenge_embeddings` by `metadata->>'challengeId'` — the ingestion path writes one row per *chunk*, while an operator thinks in *challenges*. Query params: `page` (1-based, default 1), `perPage` (default 25, max 100), `projectId`, `track`, `type`, `search` (case-insensitive substring on challenge name **or** id). Empty/whitespace params are treated as absent. + +The response body is a **bare JSON array**, with pagination in `X-Page` / `X-Per-Page` / `X-Total` / `X-Total-Pages` response headers — the Topcoder platform convention (already listed in this server's CORS `exposeHeaders`, so browsers can read them): + +```json +[{ "challengeId": "…", "name": "…", "type": "Challenge", "track": "Development", + "projectId": "17423", "chunks": 9, "ingestedAt": "2026-08-25T10:00:00.000Z" }] +``` + +Retrieval goes through PgVector's similarity API, which can't express "list distinct challenges, filtered and paginated", so these two queries run directly on the shared `PgVector.pool`. Every filter value is a bound parameter; the only interpolated identifiers are `VECTOR_INDEX_NAME` and `MASTRA_DB_SCHEMA`, both validated by `validateSqlIdentifier()`. `DELETE` counts the challenge's chunks, then removes them via `deleteVectors({ filter: { challengeId } })` (so metadata-filter translation stays in `@mastra/pg`), and responds `{ challengeId, deletedChunks }` — or **404** when the challenge holds no vectors, rather than reporting a successful no-op. + ### Retrieval - **`challengeVectorQueryTool`** — the shared retrieval primitive. Composes an `$and` metadata filter from `skills` (`$in`), `type`/`track` (`$eq`, free-form strings per D12 — not enums), `groups` (`$in`), and `projectId` (`$in`, D10). `query` is optional: with at least one filter and no query text, it performs a metadata-only lookup (`query({ filter })`, no `queryVector`) — e.g. "everything indexed for project 17423". The relevance threshold (`VECTOR_SEARCH_THRESHOLD`) is applied **after** retrieval in application code rather than passed to `query({ minScore })`, because passing `minScore` forces `@mastra/pg` off the HNSW ANN fast path onto a full exact scan. diff --git a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md index dc94a08..86a92b3 100644 --- a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md +++ b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md @@ -5,6 +5,47 @@ - **Target branch:** `challenges-rag` - **Related:** ADR 0001 (D10 — "any scope restriction MUST be enforced server-side, never left to the model"), ADR 0002 (existing inbound-vs-outbound auth split), `src/utils/auth/index.ts` (`apiAuthLayer`), `src/utils/middleware/resourceIdMiddleware.ts`, `src/config/tool-auth-fallback.config.ts` (precedent for a per-tool, opt-in-by-default code registry) +## Corrections after implementation + +Two claims below turned out to be wrong when the design was executed rather than read. Both are corrected in place +where they appear; recorded together here because one of them was a live vulnerability and the other invalidates this +ADR's headline argument. + +### C1. The registry-key alias bypassed every policy (fixed) + +Policies key on a resource's own `.id`, but `getAgentById`/`getWorkflowById` fall back to the **registry key**, so both +spellings address the same resource over HTTP — and `authorizeAccessPolicy` trusted whichever appeared in the URL: + +``` +/v6/ai/workflows/challenge-ingestion/start member(no roles) -> DENY +/v6/ai/workflows/challengeIngestionWorkflow/start member(no roles) -> ALLOW <-- BYPASS +``` + +Not hypothetical: platform-ui's `RAG_CHALLENGE_INGESTION_WORKFLOW_ID` defaulted to the registry key, so the only +production caller of a restricted workflow was using the bypassing spelling. The "Registry-key vs `.id` is a standing +footgun" risk noted at the bottom of this ADR was therefore understated — it was not only a config-authoring hazard, +it was an authorization bypass. Fixed by `TARGET_ID_ALIASES` + `canonicalTargetId()` in +`src/config/access-control.config.ts`, applied inside `resolveAccessPolicy()` before any lookup. Tests assert every +alias resolves identically to its canonical id, and that every restricted code default has an alias entry. + +### C2. There was no chatRoute gap + +This ADR's central argument — that `chatRoute` can never reach `authorizeUser` because it declares neither a matching +`protected` path nor `requiresAuth` — is **false**. `buildHonoApp` derives `const requiresAuth = route.requiresAuth +!== false` (`@mastra/deployer/dist/server/index.js:4478`), so custom routes are protected **by default**, and +`isProtectedCustomRoute` pattern-matches the registered `/v6/ai-chat/:agentId` against the incoming path. Executed +against the real exported helpers with the *pre-ADR* config (`protected: ['/v6/ai/*']`): + +``` +POST /v6/ai-chat/challenge-search-agent customRoute=true protectedPre=true protectedPost=true +``` + +chatRoute was already protected and already reaching `authorizeUser`; it has been covered since `authorizeUser` became +a real function, with no `protected`-list change required. The `${CHAT_ROUTE_BASE_PATH}/*` entry is kept — it makes +coverage explicit and independent of Mastra's `requiresAuth` default rather than contingent on it — but it is +belt-and-braces, not the load-bearing fix this ADR claimed. The error was reading `isProtectedPath`'s two branches +without checking what populates `customRouteAuthConfig`. + ## Context ### What exists today @@ -25,11 +66,11 @@ To be precise about what's already proven to work in production versus what's ac (Mastra also ships two other, more elaborate authorization primitives on `MastraAuthConfig` — a declarative `rules: [{ path, methods, condition, allow }]` array and an `authorize(path, method, user, ctx)` function — plus a wholly separate `server.rbac`/`server.fga` provider concept (`getPermissions`/`getRoles`, EE-gated). None of these apply here: `coreAuthMiddleware` checks `"authorizeUser" in authConfig"` **first**, and falls through to `authorize`/`rules` only when `authorizeUser` is absent. Since `apiAuthLayer` is a `CompositeAuth`, which always implements `authorizeUser`, those other branches are unreachable for this setup regardless. `authorizeUser` is correctly the one mechanism to build on here, not a preference among several equally-live options.) -**"Protected" is a path-pattern decision, evaluated once per route, made *before* `authorizeUser` ever gets a chance to run — and this is the actual, narrow gap.** `coreAuthMiddleware` only proceeds to authenticate/authorize a request at all if `isProtectedPath(path, method, authConfig, customRouteAuthConfig)` is true. That function ORs two things: (a) the path matching an entry in `defaultAuthConfig.protected` (Mastra's own built-in default, `["/api/*"]` — unrelated to this repo's `API_PREFIX`) or `authConfig.protected` (this repo's `${API_PREFIX}/*`, i.e. `/v6/ai/*`, merged from both `MastraAuthAuth0` instances by `CompositeAuth`'s constructor), or (b) the specific custom route being registered with `requiresAuth: true` in its own definition (`isProtectedCustomRoute`, keyed off a `customRouteAuthConfig` map built from each `apiRoutes` entry's own `requiresAuth` field). `chatRoute()` (`@mastra/ai-sdk`) does neither: its path is `/v6/ai-chat/:agentId`, which matches neither `/api/*` nor `/v6/ai/*`, and its `registerApiRoute(...)` call (confirmed by reading the installed `@mastra/ai-sdk@1.10.0` source) never sets `requiresAuth`. So `checkRouteAuth`/`coreAuthMiddleware` **does get invoked** for every chatRoute request (it's registered through the exact same `registerRoute()` path as everything else), but `isProtectedPath` returns `false` for it, and the function returns "allow, do nothing" before ever reaching `authenticateToken` or `authorizeUser`. **This is why `authorizeUser` alone, even once populated with a real policy, would never fire for chatRoute** — not because chatRoute lacks auth (it doesn't; see next paragraph), but because Mastra's native per-route check never gets past its own "is this path protected" gate for it. +**"Protected" is a path-pattern decision, evaluated once per route, made *before* `authorizeUser` ever gets a chance to run.** *(The rest of this paragraph, and its conclusion that this is "the actual, narrow gap", is **wrong** — see C2 above. Custom routes default to `requiresAuth: true`, so `isProtectedCustomRoute` already returned `true` for chatRoute. Retained as written for the record.)* `coreAuthMiddleware` only proceeds to authenticate/authorize a request at all if `isProtectedPath(path, method, authConfig, customRouteAuthConfig)` is true. That function ORs two things: (a) the path matching an entry in `defaultAuthConfig.protected` (Mastra's own built-in default, `["/api/*"]` — unrelated to this repo's `API_PREFIX`) or `authConfig.protected` (this repo's `${API_PREFIX}/*`, i.e. `/v6/ai/*`, merged from both `MastraAuthAuth0` instances by `CompositeAuth`'s constructor), or (b) the specific custom route being registered with `requiresAuth: true` in its own definition (`isProtectedCustomRoute`, keyed off a `customRouteAuthConfig` map built from each `apiRoutes` entry's own `requiresAuth` field). `chatRoute()` (`@mastra/ai-sdk`) does neither: its path is `/v6/ai-chat/:agentId`, which matches neither `/api/*` nor `/v6/ai/*`, and its `registerApiRoute(...)` call (confirmed by reading the installed `@mastra/ai-sdk@1.10.0` source) never sets `requiresAuth`. So `checkRouteAuth`/`coreAuthMiddleware` **does get invoked** for every chatRoute request (it's registered through the exact same `registerRoute()` path as everything else), but `isProtectedPath` returns `false` for it, and the function returns "allow, do nothing" before ever reaching `authenticateToken` or `authorizeUser`. **This is why `authorizeUser` alone, even once populated with a real policy, would never fire for chatRoute** — not because chatRoute lacks auth (it doesn't; see next paragraph), but because Mastra's native per-route check never gets past its own "is this path protected" gate for it. **What actually authenticates chatRoute today is this repo's own code, running earlier in the request pipeline, independently of the mechanism above.** `resourceIdMiddleware`/`chatResourceIdMiddleware` (`src/utils/middleware/resourceIdMiddleware.ts`) are registered as Hono `server.middleware` entries (`${API_PREFIX}/*` and `${CHAT_ROUTE_BASE_PATH}/*`), which Hono runs *before* the specific route handler — i.e. before `checkRouteAuth` ever executes inside that handler. `resourceIdMiddlewareHandler` calls `apiAuthLayer.authenticateToken(...)` directly and 401s on failure; that's genuinely why chatRoute correctly rejects bad tokens today, and this ADR changes none of it. But this custom middleware only calls `authenticateToken` — never `authorizeUser` — so even with a real role/scope check wired into `apiAuthLayer`, nothing on the chatRoute path evaluates it, from either mechanism, until this ADR closes that specific, narrow gap. -**The fix, given all of the above, is one line, not new middleware:** add `${CHAT_ROUTE_BASE_PATH}/*` to the `protected` array already passed to both `MastraAuthAuth0` providers in `apiAuthLayer`. That's the only thing standing between chatRoute and the exact same native `checkRouteAuth`/`coreAuthMiddleware`/`authorizeUser` path every other protected route already goes through — since `checkRouteAuth` already runs for chatRoute on every request (confirmed above), it only needs `isProtectedPath` to say yes. No new middleware, no second copy of the authorization check, no risk of the two mechanisms drifting apart. `resourceIdMiddleware.ts` itself needs **no changes** for this — it keeps doing exactly what it does today (pre-emptive authentication + resourceId scoping); `coreAuthMiddleware` will now additionally run its own (redundant, harmless — same token, same result) authentication and, newly, its authorization check, immediately afterward, inside the route handler. +**The fix, given all of the above, is one line, not new middleware** *(superseded by C2 — chatRoute needed no fix; the line below is retained as explicit, default-independent coverage)**:* add `${CHAT_ROUTE_BASE_PATH}/*` to the `protected` array already passed to both `MastraAuthAuth0` providers in `apiAuthLayer`. That's the only thing standing between chatRoute and the exact same native `checkRouteAuth`/`coreAuthMiddleware`/`authorizeUser` path every other protected route already goes through — since `checkRouteAuth` already runs for chatRoute on every request (confirmed above), it only needs `isProtectedPath` to say yes. No new middleware, no second copy of the authorization check, no risk of the two mechanisms drifting apart. `resourceIdMiddleware.ts` itself needs **no changes** for this — it keeps doing exactly what it does today (pre-emptive authentication + resourceId scoping); `coreAuthMiddleware` will now additionally run its own (redundant, harmless — same token, same result) authentication and, newly, its authorization check, immediately afterward, inside the route handler. Tools remain the one case genuinely outside this entire mechanism: this codebase never registers a top-level `tools: {}` map on the `Mastra` instance, so a tool is never itself a route — it's invoked from inside an agent's tool-calling loop (`generate`/`stream`/chatRoute, all already covered by the above) or directly from workflow step code (`tool.execute(...)`, e.g. `challenge-search-workflow.ts:249`, `challenge-bulk-ingestion-workflow.ts`). There's no path/route for `coreAuthMiddleware` to gate for a tool specifically — enforcement for that category has to happen inside the tool itself, using the `user` that `coreAuthMiddleware`/`resourceIdMiddleware` already placed on `RequestContext` by the time any tool runs. See Decision 5. @@ -117,7 +158,12 @@ export type AccessPolicy = | { mode: 'deny' } // nobody, regardless of role/scope | { mode: 'restricted'; roles?: string[]; scopes?: string[] }; // see checkAccess below -export type AccessCategory = 'agent' | 'workflow' | 'tool'; +export type AccessCategory = 'agent' | 'workflow' | 'tool' | 'route'; +// 'route' was added when the RAG index admin API landed: this repo's own +// registerApiRoute entries are neither agents nor workflows, so they matched +// none of authorizeAccessPolicy's patterns and stayed open to any +// authenticated caller. Route slugs are assigned by this repo (mapped from a +// path prefix by ROUTE_PATH_TARGETS), so they have no registry-key alias. /** * Code-level defaults, mirroring TOOL_M2M_FALLBACK_CONFIG's convention: @@ -132,6 +178,11 @@ export const DEFAULT_ACCESS_POLICIES: Record = AGENT | WORKFLOW | TOOL, = the target's own .id, upper-snake-cased. +# = AGENT | WORKFLOW | TOOL | ROUTE, = the target's own .id +# (or, for ROUTE, this repo's route slug), upper-snake-cased. ACCESS_POLICY___MODE="[public|deny — omit to use ROLES/SCOPES below]" ACCESS_POLICY___ROLES="[comma-separated member roles]" ACCESS_POLICY___SCOPES="[comma-separated M2M scopes]" @@ -325,7 +377,11 @@ Every denial (both enforcement points) logs one `tcAILogger.warn` line with cate - `challenge-ingestion`/`challenge-bulk-ingestion` are safe by default the moment this ships — no operator action required, matching the explicit requirement. - One shared `checkAccess`/`resolveAccessPolicy` implementation for all three categories — no drift between "how agents are gated" and "how tools are gated." - Opting a new resource in or out of restriction is an env var (no redeploy) or a one-line code-registry entry (reviewable, `git blame`-able, same pattern as `TOOL_M2M_FALLBACK_CONFIG`) — never a per-resource code change to the resource itself. -- Closes a real, previously-silent gap: chatRoute — the actual primary agent entry point — gets RBAC coverage it structurally lacked before this ADR. +- ~~Closes a real, previously-silent gap: chatRoute gets RBAC coverage it structurally lacked.~~ **Withdrawn (C2):** + chatRoute was already protected by Mastra's `requiresAuth` default and already reached `authorizeUser`. The + `protected`-list entry makes that explicit rather than default-dependent, which is worth keeping, but it closed no gap. +- Closes a real, previously-silent bypass instead (C1): a restricted agent or workflow could be invoked by spelling its + registry key in the URL instead of its `.id`. - Agent/workflow enforcement is **one function, wired at one existing extension point** (`authorizeUser`, already invoked by Mastra's own `coreAuthMiddleware` on every protected request) plus a one-line path-list extension — not a parallel authorization system. `resourceIdMiddleware.ts` needed zero RBAC-related changes because the existing, working pipeline already had the right hook; it just needed a real function instead of the default allow-all, and one more path pattern to reach chatRoute. **Negative / risk** @@ -345,7 +401,7 @@ Every denial (both enforcement points) logs one `tcAILogger.warn` line with cate ## Prerequisites to confirm before implementation starts - **Create the `challengesRAG:admin` scope/permission in Auth0** on the `AUTH0_M2M_AUDIENCE` API resource, and grant it to the M2M client(s) that should be able to trigger ingestion. Still open — the confirmed payload above is a member token and carries no `scope` claim, so it confirms the JWT-role side only, not the M2M side. -- A manual smoke test against a real deployment for the `authorizeUser` + extended `protected`-list design closing the chatRoute gap (Decision 4) — the mechanism was verified by reading `@mastra/deployer`'s and `@mastra/server`'s compiled source, not by an end-to-end request, so Phase 3's smoke test (chatRoute `403` before/after the role check) is the first live confirmation. +- **Resolved (C2): there was no chatRoute gap.** (Original item: a manual smoke test for the `protected`-list design closing the chatRoute gap) — the mechanism was verified by reading `@mastra/deployer`'s and `@mastra/server`'s compiled source, not by an end-to-end request, so Phase 3's smoke test (chatRoute `403` before/after the role check) is the first live confirmation. - Reviewer sign-off on the tool-export-site wrapping approach (Decision 5) — specifically: - **One policy per tool, globally, not per-usage.** Wrapping at the tool's own export site means a tool has exactly one access policy regardless of caller — no per-agent/per-workflow variance without a second wrapped export. A non-issue today (every tool in the Resource inventory has exactly one caller), but a real constraint on future flexibility to accept knowingly, not discover later. - **Failure surfaces as a thrown `ToolAccessDeniedError`, breaking each tool's own `{success: false, error}` convention.** Fine for the agent tool-calling loop (a thrown tool error becomes a failed tool-call result the LLM sees), but needs confirming for the workflow-step call sites that invoke `tool.execute()` directly (`challenge-search-workflow.ts:249`, `challenge-bulk-ingestion-workflow.ts`) — their existing try/catch handles the tool's own return shape, not necessarily a thrown error from inside it. diff --git a/src/config/access-control.config.ts b/src/config/access-control.config.ts index cc70e9e..b819fc1 100644 --- a/src/config/access-control.config.ts +++ b/src/config/access-control.config.ts @@ -22,7 +22,12 @@ export type AccessPolicy = */ | { mode: 'restricted'; roles?: string[]; scopes?: string[] }; -export type AccessCategory = 'agent' | 'workflow' | 'tool'; +/** + * `route` covers this repo's own custom API routes (registerApiRoute entries + * that are neither an agent nor a workflow), keyed on a stable slug rather + * than a resource id — see ROUTE_PATH_TARGETS in ../utils/auth/access-control. + */ +export type AccessCategory = 'agent' | 'workflow' | 'tool' | 'route'; /** * Code-level defaults, mirroring TOOL_M2M_FALLBACK_CONFIG's convention: @@ -50,8 +55,64 @@ export const DEFAULT_ACCESS_POLICIES: Record canonical `.id`, per category. + * + * Mastra's getAgentById/getWorkflowById resolve by `.id` FIRST and then fall + * back to the object-property name a resource is registered under in + * src/mastra/index.ts, so BOTH spellings address the same resource over HTTP: + * + * POST /v6/ai/workflows/challenge-ingestion/start <- .id + * POST /v6/ai/workflows/challengeIngestionWorkflow/start <- registry key + * + * Policies are keyed on `.id`, so without this map the second spelling + * resolves to "no policy configured" and silently defaults to public — i.e. + * any restriction is bypassable by spelling the target the other way. This is + * not hypothetical: platform-ui's RAG_CHALLENGE_INGESTION_WORKFLOW_ID default + * was the registry key. resolveAccessPolicy() canonicalises through this map + * before looking anything up. + * + * Every registration whose key differs from its `.id` MUST appear here; the + * unit tests assert both spellings resolve identically. + */ +export const TARGET_ID_ALIASES: Record> = { + agent: { + challengeParserAgent: 'challenge-parser-agent', + challengeSearchAgent: 'challenge-search-agent', + jdRewriterAgent: 'jd-rewriter-agent', + // skillsMatchingAgent's key and .id already match. + }, + workflow: { + challengeBulkIngestionWorkflow: 'challenge-bulk-ingestion', + challengeContextWorkflow: 'challenge-context', + challengeIngestionWorkflow: 'challenge-ingestion', + challengeSearchWorkflow: 'challenge-search', + jdAutowriteWorkflow: 'jd-autowrite', + skillExtractionWorkflow: 'skill-extraction-workflow', + }, + // Tools are never addressed by URL — they're invoked in-process by `.id`. + tool: {}, + // Route slugs are assigned by this repo, so there is no second spelling. + route: {}, +}; + +/** Resolves a registry key to the canonical `.id` a policy is keyed on. */ +export function canonicalTargetId(category: AccessCategory, targetId: string): string { + return TARGET_ID_ALIASES[category][targetId] ?? targetId; +} + /** * Upper-snake-cases a target's `.id` into the env var fragment used by the * per-target override keys. diff --git a/src/config/rag.config.ts b/src/config/rag.config.ts index f6d667a..d6033d1 100644 --- a/src/config/rag.config.ts +++ b/src/config/rag.config.ts @@ -59,17 +59,22 @@ const KNOWN_TRACKS = [ ]; // --------------------------------------------------------------------------- -// SQL identifier validation for VECTOR_INDEX_NAME +// SQL identifier validation // --------------------------------------------------------------------------- const SQL_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; -function validateSqlIdentifier(name: string, envVar: string): string { +/** + * Guards any env value that ends up interpolated into SQL as an identifier + * (table or schema name), which cannot be a bound parameter. Exported because + * the RAG index admin queries interpolate both the index and schema names. + */ +export function validateSqlIdentifier(name: string, envVar: string): string { if (!SQL_IDENTIFIER_RE.test(name)) { throw new Error( `Invalid ${envVar}="${name}": must be a valid SQL identifier ` + '(matching ^[A-Za-z_][A-Za-z0-9_]*$). ' + - 'Set VECTOR_INDEX_NAME to a valid SQL identifier.', + `Set ${envVar} to a valid SQL identifier.`, ); } return name; @@ -148,7 +153,12 @@ export function getRagConfig(): RagConfig { process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5'; const connectionString = process.env.MASTRA_DB_CONNECTION; - const schemaName = process.env.MASTRA_DB_SCHEMA || 'ai'; + // Validated for the same reason as vectorIndexName: it is interpolated + // into SQL as an identifier, which cannot be a bound parameter. + const schemaName = validateSqlIdentifier( + process.env.MASTRA_DB_SCHEMA || 'ai', + 'MASTRA_DB_SCHEMA', + ); return { embedding: { diff --git a/src/mastra/index.ts b/src/mastra/index.ts index 4b92765..649bf26 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -18,6 +18,7 @@ import { apiAuthLayer, middlewareConfig, tcAILogger } from '../utils'; import { API_PREFIX, CHAT_ROUTE_PATH } from '../utils/server-routes'; import { aiWorkspace } from './workspaces'; import { chatRoute } from '@mastra/ai-sdk'; +import { ragIndexRoutes } from '../utils/routes/rag-index.routes'; export const mastra = new Mastra({ workflows: { @@ -69,6 +70,9 @@ export const mastra = new Mastra({ path: CHAT_ROUTE_PATH, version: 'v7', }), + // RAG index admin API (list/delete indexed challenges) — administrator + // only, see ADR 0004's `route` policy category. + ...ragIndexRoutes, ], }, bundler: { diff --git a/src/mastra/rag/index-admin.test.ts b/src/mastra/rag/index-admin.test.ts new file mode 100644 index 0000000..beeb12c --- /dev/null +++ b/src/mastra/rag/index-admin.test.ts @@ -0,0 +1,211 @@ +/** + * Unit tests for the RAG index admin queries. + * + * The pool is stubbed, so these assert the contract that matters at this + * boundary: which SQL runs, which values are bound, how pagination maths and + * empty results are handled, and that deletion goes through the library's + * filter API rather than raw SQL. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + deleteVectors: vi.fn(), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../vector/challenge-vector-store', () => ({ + getChallengeVectorStore: () => ({ + pool: { query: mocks.query }, + deleteVectors: mocks.deleteVectors, + }), +})); + +vi.mock('../../utils/logger', () => ({ tcAILogger: mocks.logger })); + +import { + DEFAULT_PER_PAGE, + deleteIndexedChallenge, + listIndexedChallenges, + MAX_PER_PAGE, +} from './index-admin'; + +/** Queues the count response, then the page response. */ +function stubQueries(total: number, rows: unknown[] = []): void { + mocks.query.mockReset(); + mocks.query + .mockResolvedValueOnce({ rows: [{ total: String(total) }] }) + .mockResolvedValueOnce({ rows }); +} + +const countCall = (): [string, unknown[]] => mocks.query.mock.calls[0] as [string, unknown[]]; +const pageCall = (): [string, unknown[]] => mocks.query.mock.calls[1] as [string, unknown[]]; + +const ROW = { + challengeId: 'c-1', + name: 'Real-Time Chat Widget', + type: 'Challenge', + track: 'Development', + projectId: '17423', + chunks: 9, + ingestedAt: '2026-08-25T10:00:00.000Z', +}; + +beforeEach(() => { + // mockReset, not clearAllMocks: the latter leaves queued + // mockResolvedValueOnce implementations behind for the next test. + mocks.query.mockReset(); + mocks.deleteVectors.mockReset(); + mocks.logger.info.mockReset(); + process.env.MASTRA_DB_SCHEMA = 'ai'; + delete process.env.VECTOR_INDEX_NAME; +}); + +describe('listIndexedChallenges', () => { + it('aggregates by challengeId and returns pagination metadata', async () => { + stubQueries(1, [ROW]); + + const result = await listIndexedChallenges({ page: 1, perPage: 25 }); + + expect(result).toEqual({ + rows: [ROW], + total: 1, + page: 1, + perPage: 25, + totalPages: 1, + }); + expect(pageCall()[0]).toContain("GROUP BY metadata->>'challengeId'"); + expect(pageCall()[0]).toContain('COUNT(*)::int'); + }); + + it('scopes both queries to the configured schema and index name', async () => { + process.env.MASTRA_DB_SCHEMA = 'custom_schema'; + process.env.VECTOR_INDEX_NAME = 'custom_index'; + stubQueries(1, [ROW]); + + await listIndexedChallenges(); + + for (const [sql] of [countCall(), pageCall()]) { + expect(sql).toContain('"custom_schema"."custom_index"'); + } + }); + + it('binds every filter as a parameter, absent filters as null', async () => { + stubQueries(1, [ROW]); + + await listIndexedChallenges({ + projectId: '17423', + track: 'Development', + type: 'Challenge', + search: 'chat', + }); + + expect(countCall()[1]).toEqual(['default', '17423', 'Development', 'Challenge', 'chat']); + // Same filter values on both queries, so count and page cannot disagree. + expect(pageCall()[1].slice(0, 5)).toEqual(countCall()[1]); + }); + + it('treats blank and whitespace-only filters as absent', async () => { + stubQueries(1, [ROW]); + + await listIndexedChallenges({ projectId: ' ', track: '', search: undefined }); + + expect(countCall()[1]).toEqual(['default', null, null, null, null]); + }); + + it('matches search against both name and challenge id', async () => { + stubQueries(1, [ROW]); + + await listIndexedChallenges({ search: 'chat' }); + + expect(pageCall()[0]).toContain("metadata->>'name' ILIKE"); + expect(pageCall()[0]).toContain("metadata->>'challengeId' ILIKE"); + }); + + it('converts page/perPage into LIMIT and OFFSET', async () => { + stubQueries(100, [ROW]); + + const result = await listIndexedChallenges({ page: 3, perPage: 10 }); + + expect(pageCall()[1].slice(5)).toEqual([10, 20]); + expect(result.totalPages).toBe(10); + }); + + it('defaults page and perPage, and rejects nonsense values', async () => { + for (const params of [{}, { page: 0, perPage: -5 }, { page: NaN, perPage: NaN }]) { + stubQueries(1, [ROW]); + const result = await listIndexedChallenges(params); + expect(result.page).toBe(1); + expect(result.perPage).toBe(DEFAULT_PER_PAGE); + expect(pageCall()[1].slice(5)).toEqual([DEFAULT_PER_PAGE, 0]); + } + }); + + it('caps perPage so one request cannot ask for the whole index', async () => { + stubQueries(1, [ROW]); + + const result = await listIndexedChallenges({ perPage: 5000 }); + + expect(result.perPage).toBe(MAX_PER_PAGE); + expect(pageCall()[1][5]).toBe(MAX_PER_PAGE); + }); + + it('skips the page query entirely when nothing matches', async () => { + stubQueries(0); + + const result = await listIndexedChallenges({ search: 'no-such-challenge' }); + + expect(result).toEqual({ rows: [], total: 0, page: 1, perPage: DEFAULT_PER_PAGE, totalPages: 0 }); + expect(mocks.query).toHaveBeenCalledTimes(1); + }); + + it('orders by most recent ingestion, with a stable tiebreak', async () => { + stubQueries(1, [ROW]); + + await listIndexedChallenges(); + + expect(pageCall()[0]).toContain("ORDER BY MAX(metadata->>'ingestedAt') DESC NULLS LAST"); + expect(pageCall()[0]).toContain("metadata->>'challengeId' ASC"); + }); + + it('propagates an invalid MASTRA_DB_SCHEMA instead of interpolating it', async () => { + process.env.MASTRA_DB_SCHEMA = 'ai"; DROP TABLE x; --'; + stubQueries(1, [ROW]); + + await expect(listIndexedChallenges()).rejects.toThrow(/Invalid MASTRA_DB_SCHEMA/); + expect(mocks.query).not.toHaveBeenCalled(); + }); +}); + +describe('deleteIndexedChallenge', () => { + it('counts the chunks, then deletes them through the library filter API', async () => { + mocks.query.mockResolvedValueOnce({ rows: [{ chunks: '9' }] }); + + const result = await deleteIndexedChallenge('c-1'); + + expect(result).toEqual({ challengeId: 'c-1', deletedChunks: 9 }); + expect(mocks.query.mock.calls[0][1]).toEqual(['default', 'c-1']); + expect(mocks.deleteVectors).toHaveBeenCalledWith({ + indexName: 'challenge_embeddings', + filter: { challengeId: 'c-1' }, + }); + }); + + it('returns null and deletes nothing when the challenge is not indexed', async () => { + mocks.query.mockResolvedValueOnce({ rows: [{ chunks: '0' }] }); + + await expect(deleteIndexedChallenge('missing')).resolves.toBeNull(); + expect(mocks.deleteVectors).not.toHaveBeenCalled(); + }); + + it('logs one line per deletion', async () => { + mocks.query.mockResolvedValueOnce({ rows: [{ chunks: '3' }] }); + + await deleteIndexedChallenge('c-2'); + + expect(mocks.logger.info).toHaveBeenCalledWith( + expect.stringContaining('deleted challenge from index'), + { challengeId: 'c-2', deletedChunks: 3 }, + ); + }); +}); diff --git a/src/mastra/rag/index-admin.ts b/src/mastra/rag/index-admin.ts new file mode 100644 index 0000000..b6ca7d8 --- /dev/null +++ b/src/mastra/rag/index-admin.ts @@ -0,0 +1,199 @@ +/** + * RAG index administration — read/delete over what challenge_embeddings holds. + * + * The ingestion path writes one row per chunk; an operator thinks in terms of + * challenges. Everything here therefore aggregates by `metadata->>'challengeId'`. + * + * Retrieval goes through PgVector's similarity API, which cannot express + * "list distinct challenges, filtered and paginated", so these queries run + * directly on the shared pool (`PgVector.pool`) against the table @mastra/pg + * creates: (id SERIAL, vector_id TEXT, embedding vector(N), metadata JSONB, + * namespace VARCHAR). Deletion goes back through `deleteVectors({ filter })` + * so metadata-filter translation stays in the library. + */ + +import { getRagConfig, validateSqlIdentifier } from '../../config/rag.config'; +import { tcAILogger } from '../../utils/logger'; +import { getChallengeVectorStore } from '../vector/challenge-vector-store'; + +/** @mastra/pg writes every vector under this namespace unless told otherwise. */ +const DEFAULT_NAMESPACE = 'default'; + +export const DEFAULT_PER_PAGE = 25; +export const MAX_PER_PAGE = 100; + +export interface IndexedChallengeRow { + challengeId: string; + name: string | null; + type: string | null; + track: string | null; + projectId: string | null; + /** Number of indexed chunks for this challenge. */ + chunks: number; + /** ISO-8601, most recent chunk. */ + ingestedAt: string | null; +} + +export interface ListIndexedChallengesParams { + page?: number; + perPage?: number; + projectId?: string; + track?: string; + type?: string; + /** Case-insensitive substring match on challenge name or id. */ + search?: string; +} + +export interface ListIndexedChallengesResult { + rows: IndexedChallengeRow[]; + total: number; + page: number; + perPage: number; + totalPages: number; +} + +/** Positive integer, or the fallback. Guards NaN and out-of-range input. */ +function clampInt(value: number | undefined, fallback: number, max?: number): number { + if (value === undefined || !Number.isFinite(value)) return fallback; + const floored = Math.floor(value); + if (floored < 1) return fallback; + return max !== undefined ? Math.min(floored, max) : floored; +} + +/** `"schema"."index_name"` — both validated as SQL identifiers, never bound params. */ +function qualifiedTableName(): string { + const config = getRagConfig(); + const indexName = validateSqlIdentifier(config.vectorIndexName, 'VECTOR_INDEX_NAME'); + return `"${config.database.schemaName}"."${indexName}"`; +} + +/** + * Shared WHERE for both the page query and its count, so the two can never + * disagree about what is being filtered. Every value is a bound parameter; + * an absent filter binds NULL and the corresponding clause short-circuits. + */ +const FILTER_SQL = ` + namespace = $1 + AND ($2::text IS NULL OR metadata->>'projectId' = $2) + AND ($3::text IS NULL OR metadata->>'track' = $3) + AND ($4::text IS NULL OR metadata->>'type' = $4) + AND ($5::text IS NULL OR metadata->>'name' ILIKE '%' || $5 || '%' + OR metadata->>'challengeId' ILIKE '%' || $5 || '%')`; + +function filterValues(params: ListIndexedChallengesParams): (string | null)[] { + const trimmed = (value: string | undefined): string | null => { + const next = value?.trim(); + return next ? next : null; + }; + + return [ + DEFAULT_NAMESPACE, + trimmed(params.projectId), + trimmed(params.track), + trimmed(params.type), + trimmed(params.search), + ]; +} + +/** + * One page of indexed challenges, newest ingestion first. + * + * `ingestedAt` is stored as ISO-8601 text, so MAX() orders chronologically + * without a cast. `MIN()` on the descriptive columns is just "any value from + * this challenge's chunks" — they are identical across a challenge's chunks, + * since every chunk is written from the same source record in one run. + */ +export async function listIndexedChallenges( + params: ListIndexedChallengesParams = {}, +): Promise { + const page = clampInt(params.page, 1); + const perPage = clampInt(params.perPage, DEFAULT_PER_PAGE, MAX_PER_PAGE); + const tableName = qualifiedTableName(); + const values = filterValues(params); + const { pool } = getChallengeVectorStore(); + + const countResult = await pool.query<{ total: string }>( + `SELECT COUNT(DISTINCT metadata->>'challengeId')::text AS total + FROM ${tableName} + WHERE ${FILTER_SQL}`, + values, + ); + const total = Number(countResult.rows[0]?.total ?? 0); + + // Skip the page query entirely when the filter matched nothing. + if (total === 0) { + return { rows: [], total: 0, page, perPage, totalPages: 0 }; + } + + const pageResult = await pool.query<{ + challengeId: string; + name: string | null; + type: string | null; + track: string | null; + projectId: string | null; + chunks: number; + ingestedAt: string | null; + }>( + `SELECT metadata->>'challengeId' AS "challengeId", + MIN(metadata->>'name') AS name, + MIN(metadata->>'type') AS type, + MIN(metadata->>'track') AS track, + MIN(metadata->>'projectId') AS "projectId", + COUNT(*)::int AS chunks, + MAX(metadata->>'ingestedAt') AS "ingestedAt" + FROM ${tableName} + WHERE ${FILTER_SQL} + GROUP BY metadata->>'challengeId' + ORDER BY MAX(metadata->>'ingestedAt') DESC NULLS LAST, + metadata->>'challengeId' ASC + LIMIT $6 OFFSET $7`, + [...values, perPage, (page - 1) * perPage], + ); + + return { + rows: pageResult.rows, + total, + page, + perPage, + totalPages: Math.ceil(total / perPage), + }; +} + +export interface DeleteIndexedChallengeResult { + challengeId: string; + deletedChunks: number; +} + +/** + * Removes every chunk of one challenge from the index. + * + * Returns null when the challenge holds no vectors, so the caller can 404 + * rather than reporting a successful no-op. The count is taken first because + * `deleteVectors` resolves to void. + */ +export async function deleteIndexedChallenge( + challengeId: string, +): Promise { + const store = getChallengeVectorStore(); + const indexName = getRagConfig().vectorIndexName; + + const countResult = await store.pool.query<{ chunks: string }>( + `SELECT COUNT(*)::text AS chunks + FROM ${qualifiedTableName()} + WHERE namespace = $1 AND metadata->>'challengeId' = $2`, + [DEFAULT_NAMESPACE, challengeId], + ); + const deletedChunks = Number(countResult.rows[0]?.chunks ?? 0); + + if (deletedChunks === 0) { + return null; + } + + await store.deleteVectors({ indexName, filter: { challengeId } }); + tcAILogger.info('[rag-index-admin] deleted challenge from index', { + challengeId, + deletedChunks, + }); + + return { challengeId, deletedChunks }; +} diff --git a/src/utils/auth/access-control.test.ts b/src/utils/auth/access-control.test.ts index 9685f58..37b730e 100644 --- a/src/utils/auth/access-control.test.ts +++ b/src/utils/auth/access-control.test.ts @@ -3,7 +3,14 @@ * See docs/adr/0004-role-based-access-for-agents-workflows-tools.md. */ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { toEnvKey, type AccessPolicy } from '../../config/access-control.config'; +import { + canonicalTargetId, + DEFAULT_ACCESS_POLICIES, + TARGET_ID_ALIASES, + toEnvKey, + type AccessCategory, + type AccessPolicy, +} from '../../config/access-control.config'; import { _resetAccessPolicyCache, authorizeAccessPolicy, @@ -426,3 +433,107 @@ describe('withAccessPolicy', () => { ).resolves.toMatchObject({ ok: true }); }); }); + +// --------------------------------------------------------------------------- +// Registry-key aliases — both spellings of a target must resolve identically, +// or a restriction is bypassable by addressing it the other way. +// --------------------------------------------------------------------------- + +describe('registry-key aliases', () => { + const aliasEntries = (['agent', 'workflow', 'tool', 'route'] as AccessCategory[]) + .flatMap(category => Object.entries(TARGET_ID_ALIASES[category]) + .map(([registryKey, canonicalId]) => ({ canonicalId, category, registryKey }))); + + it.each(aliasEntries)( + '$category: $registryKey resolves to the $canonicalId policy', + ({ category, registryKey, canonicalId }) => { + expect(canonicalTargetId(category, registryKey)).toBe(canonicalId); + expect(resolveAccessPolicy(category, registryKey)).toEqual( + resolveAccessPolicy(category, canonicalId), + ); + }, + ); + + it('leaves an id that is already canonical untouched', () => { + expect(canonicalTargetId('agent', 'skillsMatchingAgent')).toBe('skillsMatchingAgent'); + expect(canonicalTargetId('workflow', 'challenge-ingestion')).toBe('challenge-ingestion'); + }); + + it('denies the registry-key spelling of a restricted workflow over HTTP', () => { + // The regression this map exists for: platform-ui's default workflow id + // was the registry key, which used to resolve to "no policy" -> public. + const byRegistryKey = '/v6/ai/workflows/challengeIngestionWorkflow/start'; + const byId = '/v6/ai/workflows/challenge-ingestion/start'; + + for (const path of [byRegistryKey, byId]) { + expect(authorizeAccessPolicy(memberUser(['copilot']), authRequest(path))).toBe(false); + expect(authorizeAccessPolicy(memberUser(['administrator']), authRequest(path))).toBe(true); + } + }); + + it('keeps every env override reachable from the registry-key spelling', () => { + process.env.ACCESS_POLICY_WORKFLOW_CHALLENGE_SEARCH_ROLES = 'administrator'; + _resetAccessPolicyCache(); + + expect(resolveAccessPolicy('workflow', 'challengeSearchWorkflow')).toEqual({ + mode: 'restricted', + roles: ['administrator'], + scopes: undefined, + }); + }); + + it('has an alias entry for every restricted code default whose key differs', () => { + // Guards the standing footgun: adding a restricted entry keyed on `.id` + // without registering its registry key leaves the alias open. + const restrictedWorkflowIds = Object.entries(DEFAULT_ACCESS_POLICIES.workflow) + .filter(([, policy]) => policy.mode !== 'public') + .map(([id]) => id); + const aliasTargets = Object.values(TARGET_ID_ALIASES.workflow); + + for (const id of restrictedWorkflowIds) { + expect(aliasTargets).toContain(id); + } + }); +}); + +// --------------------------------------------------------------------------- +// The `route` category — this repo's own custom API routes +// --------------------------------------------------------------------------- + +describe('route policies', () => { + const LIST = '/v6/ai/rag/challenges'; + const DELETE_ONE = '/v6/ai/rag/challenges/9f1c2e4a-7b3d'; + + it('ships the RAG index admin API restricted, with no env vars set', () => { + expect(resolveAccessPolicy('route', 'rag-challenges')).toEqual({ + mode: 'restricted', + roles: ['administrator'], + scopes: ['challengesRAG:admin'], + }); + }); + + it.each([LIST, DELETE_ONE])('denies a non-administrator on %s', path => { + expect(authorizeAccessPolicy(memberUser(['copilot']), authRequest(path))).toBe(false); + expect(authorizeAccessPolicy(m2mUser(['read:challenges']), authRequest(path))).toBe(false); + }); + + it.each([LIST, DELETE_ONE])('allows an administrator and a scoped M2M client on %s', path => { + expect(authorizeAccessPolicy(memberUser(['administrator']), authRequest(path))).toBe(true); + expect(authorizeAccessPolicy(m2mUser(['challengesRAG:admin']), authRequest(path))).toBe(true); + }); + + it('does not match a path that merely starts with the same prefix', () => { + // /rag/challenges-export is a different route and must not inherit the + // policy by accident. + expect( + authorizeAccessPolicy(memberUser(['copilot']), authRequest('/v6/ai/rag/challenges-export')), + ).toBe(true); + }); + + it('is overridable by env like any other category', () => { + process.env.ACCESS_POLICY_ROUTE_RAG_CHALLENGES_MODE = 'deny'; + _resetAccessPolicyCache(); + + expect(authorizeAccessPolicy(memberUser(['administrator']), authRequest(LIST))).toBe(false); + }); +}); diff --git a/src/utils/auth/access-control.ts b/src/utils/auth/access-control.ts index f4bfb09..71e8c71 100644 --- a/src/utils/auth/access-control.ts +++ b/src/utils/auth/access-control.ts @@ -15,6 +15,7 @@ */ import { getWebRequest, type MastraAuthRequest } from '@mastra/core/server'; import { + canonicalTargetId, DEFAULT_ACCESS_POLICIES, toEnvKey, type AccessCategory, @@ -128,13 +129,17 @@ function envPolicy(category: AccessCategory, targetId: string): AccessPolicy | u * invalid _MODE throws an actionable error on first resolution. */ export function resolveAccessPolicy(category: AccessCategory, targetId: string): AccessPolicy { - const cacheKey = `${category}:${targetId}`; + // A caller can address an agent/workflow by either its `.id` or its + // registry key; both must resolve to the same policy, or a restriction is + // bypassable by spelling the target the other way. + const canonicalId = canonicalTargetId(category, targetId); + const cacheKey = `${category}:${canonicalId}`; const cached = policyCache.get(cacheKey); if (cached) return cached; const policy = - envPolicy(category, targetId) ?? - DEFAULT_ACCESS_POLICIES[category][targetId] ?? + envPolicy(category, canonicalId) ?? + DEFAULT_ACCESS_POLICIES[category][canonicalId] ?? globalDefaultPolicy(); policyCache.set(cacheKey, policy); @@ -150,7 +155,17 @@ const WORKFLOW_PATH_RE = new RegExp(`^${API_PREFIX}/workflows/([^/]+)`); // chatRoute() is CHAT_ROUTE_BASE_PATH/:agentId — an agent by another path. const CHAT_PATH_RE = new RegExp(`^${CHAT_ROUTE_BASE_PATH}/([^/]+)`); -/** null when the path isn't an agent/workflow invocation (memory, threads, telemetry, ...). */ +/** + * This repo's own custom API routes, which are neither agents nor workflows and + * so match none of the patterns above. Each entry maps a path prefix to the + * DEFAULT_ACCESS_POLICIES.route slug that governs it; a custom route absent + * here falls through to "no target" and stays open to any authenticated caller. + */ +const ROUTE_PATH_TARGETS: { prefix: string; targetId: string }[] = [ + { prefix: `${API_PREFIX}/rag/challenges`, targetId: 'rag-challenges' }, +]; + +/** null when the path addresses none of the four categories (memory, threads, telemetry, ...). */ function parseTarget(pathname: string): { category: AccessCategory; targetId: string } | null { const agent = AGENT_PATH_RE.exec(pathname); if (agent) return { category: 'agent', targetId: decodeURIComponent(agent[1]) }; @@ -161,6 +176,11 @@ function parseTarget(pathname: string): { category: AccessCategory; targetId: st const chat = CHAT_PATH_RE.exec(pathname); if (chat) return { category: 'agent', targetId: decodeURIComponent(chat[1]) }; + const route = ROUTE_PATH_TARGETS.find( + r => pathname === r.prefix || pathname.startsWith(`${r.prefix}/`), + ); + if (route) return { category: 'route', targetId: route.targetId }; + return null; } diff --git a/src/utils/routes/rag-index.routes.ts b/src/utils/routes/rag-index.routes.ts new file mode 100644 index 0000000..b33e08b --- /dev/null +++ b/src/utils/routes/rag-index.routes.ts @@ -0,0 +1,112 @@ +/** + * RAG index admin API — list and delete what challenge_embeddings holds. + * + * Registered as custom apiRoutes under API_PREFIX (see src/mastra/index.ts). + * Auth: Mastra protects these automatically — the path matches apiAuthLayer's + * `${API_PREFIX}/*` and custom routes default to requiresAuth — and ADR 0004's + * `authorizeAccessPolicy` gates them on the `route`/`rag-challenges` policy, + * which ships restricted to administrators. `requiresAuth: true` is set + * explicitly rather than relying on that default, since the default is what + * decides whether these endpoints are reachable unauthenticated. + * + * Pagination is returned in X-Page/X-Per-Page/X-Total/X-Total-Pages headers + * with a bare array body — the Topcoder platform convention that + * platform-ui's xhrGetPaginatedAsync already reads, and which this server + * already lists in its CORS exposeHeaders. + */ + +import { registerApiRoute } from '@mastra/core/server'; +import { deleteIndexedChallenge, listIndexedChallenges } from '../../mastra/rag/index-admin'; +import { tcAILogger } from '../logger'; +import { API_PREFIX } from '../server-routes'; + +/** Empty/whitespace query params are treated as absent, not as a filter for "". */ +function queryValue(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +function queryNumber(raw: string | undefined): number | undefined { + const trimmed = queryValue(raw); + if (trimmed === undefined) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const listIndexedChallengesRoute = registerApiRoute( + `${API_PREFIX}/rag/challenges`, + { + method: 'GET', + requiresAuth: true, + openapi: { + summary: 'List challenges currently in the RAG vector index', + description: + 'Aggregates challenge_embeddings by challengeId. Paginated via ' + + 'X-Page/X-Per-Page/X-Total/X-Total-Pages response headers.', + tags: ['rag-admin'], + }, + handler: async c => { + try { + const result = await listIndexedChallenges({ + page: queryNumber(c.req.query('page')), + perPage: queryNumber(c.req.query('perPage')), + projectId: queryValue(c.req.query('projectId')), + track: queryValue(c.req.query('track')), + type: queryValue(c.req.query('type')), + search: queryValue(c.req.query('search')), + }); + + c.header('X-Page', String(result.page)); + c.header('X-Per-Page', String(result.perPage)); + c.header('X-Total', String(result.total)); + c.header('X-Total-Pages', String(result.totalPages)); + + return c.json(result.rows); + } catch (error) { + tcAILogger.error('[rag-index-admin] list failed', { error }); + return c.json({ error: errorMessage(error) }, 500); + } + }, + }, +); + +export const deleteIndexedChallengeRoute = registerApiRoute( + `${API_PREFIX}/rag/challenges/:challengeId`, + { + method: 'DELETE', + requiresAuth: true, + openapi: { + summary: "Remove one challenge's vectors from the RAG index", + description: + 'Deletes every chunk whose metadata.challengeId matches. ' + + 'Returns 404 when the challenge holds no vectors.', + tags: ['rag-admin'], + }, + handler: async c => { + const challengeId = c.req.param('challengeId')?.trim(); + if (!challengeId) { + return c.json({ error: 'challengeId is required' }, 400); + } + + try { + const result = await deleteIndexedChallenge(challengeId); + if (!result) { + return c.json( + { error: `Challenge "${challengeId}" is not in the index` }, + 404, + ); + } + return c.json(result); + } catch (error) { + tcAILogger.error('[rag-index-admin] delete failed', { challengeId, error }); + return c.json({ error: errorMessage(error) }, 500); + } + }, + }, +); + +export const ragIndexRoutes = [listIndexedChallengesRoute, deleteIndexedChallengeRoute]; From f3582f6af80b4f8a62d076facb4e840e636b7bd2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 4 Sep 2026 13:52:00 +0300 Subject: [PATCH 25/27] Deployment script --- .circleci/config.yml | 2 ++ package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 618691a..d37c3d9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,6 +65,8 @@ workflows: only: - develop - challenges-rag + tags: + only: /^dev-.*/ # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/package.json b/package.json index ef6fbc4..8e4be2e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "prettier . --check", "studio": "mastra studio", "test:watch": "vitest", + "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\"", "ingest": "tsx --env-file=.env src/scripts/ingest-challenges.ts", "sync": "tsx --env-file=.env src/scripts/sync-challenges.ts" }, From 0ac54402132daad61e0dda67f4cdfd7de1a3ea5b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 4 Sep 2026 14:14:36 +0300 Subject: [PATCH 26/27] PM-5999 - Update routes prefix --- README.md | 10 ++-- ...based-access-for-agents-workflows-tools.md | 5 +- src/utils/auth/access-control.test.ts | 10 ++-- src/utils/auth/access-control.ts | 4 +- src/utils/auth/index.ts | 8 ++- src/utils/routes/rag-index.routes.test.ts | 54 +++++++++++++++++++ src/utils/routes/rag-index.routes.ts | 22 ++++---- src/utils/server-routes.ts | 17 ++++++ 8 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 src/utils/routes/rag-index.routes.test.ts diff --git a/README.md b/README.md index e959cb3..2cc74ed 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Authentication is handled by `CompositeAuth` from `@mastra/core/server` (`src/ut 1. **Member tokens** — issued by `AUTH0_DOMAIN` with audience `AUTH0_AUDIENCE` 2. **M2M (machine-to-machine) tokens** — issued by `AUTH0_M2M_DOMAIN` with audience `AUTH0_M2M_AUDIENCE` -A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*', '/v6/ai-chat/*']` (the server's `apiPrefix` plus the `chatRoute()` base path — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. `/v6/ai-chat/*` has to be listed explicitly because `chatRoute()` is registered outside `apiPrefix` and never sets `requiresAuth`, so Mastra's `isProtectedPath` check would otherwise skip it — including its authorization step (see [Access control](#access-control)). +A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*', '/v6/ai-chat/*', '/v6/ai-rag/*']` (the server's `apiPrefix` plus each custom-route base path — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. `/v6/ai-chat/*` has to be listed explicitly because `chatRoute()` is registered outside `apiPrefix` and never sets `requiresAuth`, so Mastra's `isProtectedPath` check would otherwise skip it — including its authorization step (see [Access control](#access-control)). Both providers also set `mapUserToResourceId`, deriving the caller's Topcoder user id from the JWT claim `https:///userId` (member tokens) or `sub` (M2M tokens) — see `tcUserIdClaimKey()` / `mapUserToResourceId` in `src/utils/auth/index.ts`. Mastra's core auth flow stores that value under `MASTRA_RESOURCE_ID_KEY` in the request context automatically, and it takes precedence over any client-supplied `resourceId`/`memory.resource` — this is what actually enforces per-user memory/thread isolation; the `Resource ID Middleware` below is a belt-and-suspenders check on top of it, not the primary mechanism. @@ -298,7 +298,7 @@ Everything else is `public`, i.e. unchanged from pre-ADR-0004 behavior. Note tha **Three enforcement points:** - **Agents & workflows** — `authorizeAccessPolicy` is supplied as `authorizeUser` to both Auth0 providers. Mastra's own `coreAuthMiddleware` already invokes that hook on every protected request and returns **403** when it returns `false`. It parses the request path into `('agent', id)` / `('workflow', id)`, covering `/v6/ai/agents/:id/*`, `/v6/ai/workflows/:id/*` and `/v6/ai-chat/:agentId`. Non-invocation paths (memory, threads, telemetry, scorers) are out of scope and pass through. Mastra Studio uses these same paths, so it gets no bypass. -- **Custom admin routes** (`ROUTE`) — this repo's own `registerApiRoute` entries are neither agents nor workflows, so they match none of the patterns above and would otherwise stay open to any authenticated caller. `ROUTE_PATH_TARGETS` maps a path prefix to a route slug, which then resolves like any other target. Currently one entry: `/v6/ai/rag/challenges` → `rag-challenges`, restricted to `administrator` / `challengesRAG:admin` out of the box. +- **Custom admin routes** (`ROUTE`) — this repo's own `registerApiRoute` entries are neither agents nor workflows, so they match none of the patterns above and would otherwise stay open to any authenticated caller. `ROUTE_PATH_TARGETS` maps a path prefix to a route slug, which then resolves like any other target. Currently one entry: `/v6/ai-rag/challenges` → `rag-challenges`, restricted to `administrator` / `challengesRAG:admin` out of the box. - **Tools** — tools have no HTTP route of their own, so `withAccessPolicy()` wraps each tool's `execute` at its **export site** (e.g. the last line of `challenge-vector-query-tool.ts`). The guard travels with the exported tool object, so a future agent that adds the tool to its `tools:` map can't forget it. It reads the `user` already on `RequestContext` and throws `ToolAccessDeniedError` on denial — surfaced to the LLM as a failed tool call, or to a workflow step as a rejected `execute()`. Nested, in-process invocations (`challenge-bulk-ingestion` → `challenge-ingestion`, `challenge-context` → `challenge-parser-agent`) are **not** re-gated: they never re-enter the HTTP router, and you can't reach them without passing the outer check first. @@ -629,10 +629,12 @@ Both CLIs invoke the same workflows the API exposes (via `mastra.getWorkflowById Two custom routes for inspecting and pruning what the index currently holds — the backend for the **TopScout RAG** admin page. Both are **administrator-only** (`route`/`rag-challenges` policy, see [Access control](#access-control)). ``` -GET /v6/ai/rag/challenges list indexed challenges -DELETE /v6/ai/rag/challenges/:challengeId remove one challenge's vectors +GET /v6/ai-rag/challenges list indexed challenges +DELETE /v6/ai-rag/challenges/:challengeId remove one challenge's vectors ``` +> **Why `/v6/ai-rag` and not `/v6/ai/rag`?** Mastra reserves its `apiPrefix` exclusively for built-in routes and **refuses to start** if a custom `apiRoutes` entry is registered at or beneath it — `validateCustomRoutePaths()` throws during `createHonoServer`, so the container crash-loops rather than failing a request. Custom routes are therefore hyphenated siblings of the prefix (`/v6/ai-chat`, `/v6/ai-rag`), which is also why each needs its own entry in `apiAuthLayer`'s `protected` list. `src/utils/routes/rag-index.routes.test.ts` asserts this rule, so a bad path fails a test instead of a deploy. + `GET` aggregates `challenge_embeddings` by `metadata->>'challengeId'` — the ingestion path writes one row per *chunk*, while an operator thinks in *challenges*. Query params: `page` (1-based, default 1), `perPage` (default 25, max 100), `projectId`, `track`, `type`, `search` (case-insensitive substring on challenge name **or** id). Empty/whitespace params are treated as absent. The response body is a **bare JSON array**, with pagination in `X-Page` / `X-Per-Page` / `X-Total` / `X-Total-Pages` response headers — the Topcoder platform convention (already listed in this server's CORS `exposeHeaders`, so browsers can read them): diff --git a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md index 86a92b3..5f15209 100644 --- a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md +++ b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md @@ -179,8 +179,9 @@ export const DEFAULT_ACCESS_POLICIES: Record { // --------------------------------------------------------------------------- describe('route policies', () => { - const LIST = '/v6/ai/rag/challenges'; - const DELETE_ONE = '/v6/ai/rag/challenges/9f1c2e4a-7b3d'; + const LIST = '/v6/ai-rag/challenges'; + const DELETE_ONE = '/v6/ai-rag/challenges/9f1c2e4a-7b3d'; it('ships the RAG index admin API restricted, with no env vars set', () => { expect(resolveAccessPolicy('route', 'rag-challenges')).toEqual({ @@ -523,10 +523,10 @@ describe('route policies', () => { }); it('does not match a path that merely starts with the same prefix', () => { - // /rag/challenges-export is a different route and must not inherit the - // policy by accident. + // /challenges-export would be a different route and must not inherit + // the policy by accident. expect( - authorizeAccessPolicy(memberUser(['copilot']), authRequest('/v6/ai/rag/challenges-export')), + authorizeAccessPolicy(memberUser(['copilot']), authRequest('/v6/ai-rag/challenges-export')), ).toBe(true); }); diff --git a/src/utils/auth/access-control.ts b/src/utils/auth/access-control.ts index 71e8c71..74ff04d 100644 --- a/src/utils/auth/access-control.ts +++ b/src/utils/auth/access-control.ts @@ -22,7 +22,7 @@ import { type AccessPolicy, } from '../../config/access-control.config'; import { tcAILogger } from '../logger'; -import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; +import { API_PREFIX, CHAT_ROUTE_BASE_PATH, RAG_ADMIN_ROUTE_BASE_PATH } from '../server-routes'; import { resolveTcDomain, tcUserIdClaimKey } from './tc-domain'; // --------------------------------------------------------------------------- @@ -162,7 +162,7 @@ const CHAT_PATH_RE = new RegExp(`^${CHAT_ROUTE_BASE_PATH}/([^/]+)`); * here falls through to "no target" and stays open to any authenticated caller. */ const ROUTE_PATH_TARGETS: { prefix: string; targetId: string }[] = [ - { prefix: `${API_PREFIX}/rag/challenges`, targetId: 'rag-challenges' }, + { prefix: `${RAG_ADMIN_ROUTE_BASE_PATH}/challenges`, targetId: 'rag-challenges' }, ]; /** null when the path addresses none of the four categories (memory, threads, telemetry, ...). */ diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts index 6249777..a75dddc 100644 --- a/src/utils/auth/index.ts +++ b/src/utils/auth/index.ts @@ -1,6 +1,6 @@ import { MastraAuthAuth0 } from '@mastra/auth-auth0'; import { CompositeAuth } from '@mastra/core/server'; -import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; +import { API_PREFIX, CHAT_ROUTE_BASE_PATH, RAG_ADMIN_ROUTE_BASE_PATH } from '../server-routes'; import { authorizeAccessPolicy } from './access-control'; import { tcUserIdClaimKey } from './tc-domain'; @@ -15,7 +15,11 @@ const mapUserToResourceId = (user: Record): string | undefined // sets requiresAuth, so Mastra's coreAuthMiddleware treats it as unprotected and // returns before ever reaching authorizeUser. Listing it here is what brings it // under the same authenticate-then-authorize path as the native routes. -const PROTECTED_PATHS = [`${API_PREFIX}/*`, `${CHAT_ROUTE_BASE_PATH}/*`]; +const PROTECTED_PATHS = [ + `${API_PREFIX}/*`, + `${CHAT_ROUTE_BASE_PATH}/*`, + `${RAG_ADMIN_ROUTE_BASE_PATH}/*`, +]; export const apiAuthLayer = new CompositeAuth([ // TC Member Auth0 JWTs diff --git a/src/utils/routes/rag-index.routes.test.ts b/src/utils/routes/rag-index.routes.test.ts new file mode 100644 index 0000000..0f6aff3 --- /dev/null +++ b/src/utils/routes/rag-index.routes.test.ts @@ -0,0 +1,54 @@ +/** + * Boot-time contract tests for the custom API routes. + * + * These exist because a bad route PATH is not a type error and is not covered + * by the handler tests — it throws only when Mastra builds the Hono app, i.e. + * at container start. `/v6/ai/rag/challenges` shipped once and crash-looped the + * service on boot with: + * + * Custom API route "/v6/ai/rag/challenges" must not start with "/v6/ai" — + * that path is reserved for built-in Mastra routes. + * + * Asserting Mastra's own rule here turns that class of failure into a red test. + */ +import { describe, expect, it } from 'vitest'; +import { ragIndexRoutes } from './rag-index.routes'; +import { + API_PREFIX, + RAG_ADMIN_ROUTE_BASE_PATH, + RAG_CHALLENGE_ROUTE_PATH, + RAG_CHALLENGES_ROUTE_PATH, +} from '../server-routes'; + +describe('rag index routes', () => { + it('registers exactly the list and delete routes', () => { + expect(ragIndexRoutes.map(route => `${route.method} ${route.path}`)).toEqual([ + `GET ${RAG_CHALLENGES_ROUTE_PATH}`, + `DELETE ${RAG_CHALLENGE_ROUTE_PATH}`, + ]); + }); + + // Mastra's validateCustomRoutePaths() throws at boot for any custom route + // at or beneath apiPrefix. Nothing else in the build catches it. + it.each(['GET', 'DELETE'])('%s path does not collide with apiPrefix', method => { + const route = ragIndexRoutes.find(r => r.method === method); + expect(route).toBeDefined(); + expect(route!.path.startsWith(`${API_PREFIX}/`)).toBe(false); + expect(route!.path).not.toBe(API_PREFIX); + }); + + it('keeps every route under the base path the auth layer protects', () => { + // apiAuthLayer's `protected` list and access-control's ROUTE_PATH_TARGETS + // are both keyed off RAG_ADMIN_ROUTE_BASE_PATH; a route outside it would + // be silently unauthenticated and unauthorized. + for (const route of ragIndexRoutes) { + expect(route.path.startsWith(`${RAG_ADMIN_ROUTE_BASE_PATH}/`)).toBe(true); + } + }); + + it('requires auth explicitly rather than inheriting Mastra\'s default', () => { + for (const route of ragIndexRoutes) { + expect(route.requiresAuth).toBe(true); + } + }); +}); diff --git a/src/utils/routes/rag-index.routes.ts b/src/utils/routes/rag-index.routes.ts index b33e08b..1d6519d 100644 --- a/src/utils/routes/rag-index.routes.ts +++ b/src/utils/routes/rag-index.routes.ts @@ -1,13 +1,15 @@ /** * RAG index admin API — list and delete what challenge_embeddings holds. * - * Registered as custom apiRoutes under API_PREFIX (see src/mastra/index.ts). - * Auth: Mastra protects these automatically — the path matches apiAuthLayer's - * `${API_PREFIX}/*` and custom routes default to requiresAuth — and ADR 0004's - * `authorizeAccessPolicy` gates them on the `route`/`rag-challenges` policy, - * which ships restricted to administrators. `requiresAuth: true` is set - * explicitly rather than relying on that default, since the default is what - * decides whether these endpoints are reachable unauthenticated. + * Registered as custom apiRoutes (see src/mastra/index.ts). They live under + * `/v6/ai-rag`, NOT under API_PREFIX: Mastra reserves the prefix for its + * built-ins and throws at boot for any custom route beneath it (see + * server-routes.ts). Auth comes from two places: `requiresAuth: true` makes + * Mastra authenticate them (set explicitly rather than relying on its + * `requiresAuth !== false` default, since that default is what decides whether + * these are reachable unauthenticated), and ADR 0004's `authorizeAccessPolicy` + * gates them on the `route`/`rag-challenges` policy, which ships restricted to + * administrators. * * Pagination is returned in X-Page/X-Per-Page/X-Total/X-Total-Pages headers * with a bare array body — the Topcoder platform convention that @@ -18,7 +20,7 @@ import { registerApiRoute } from '@mastra/core/server'; import { deleteIndexedChallenge, listIndexedChallenges } from '../../mastra/rag/index-admin'; import { tcAILogger } from '../logger'; -import { API_PREFIX } from '../server-routes'; +import { RAG_CHALLENGE_ROUTE_PATH, RAG_CHALLENGES_ROUTE_PATH } from '../server-routes'; /** Empty/whitespace query params are treated as absent, not as a filter for "". */ function queryValue(raw: string | undefined): string | undefined { @@ -38,7 +40,7 @@ function errorMessage(error: unknown): string { } export const listIndexedChallengesRoute = registerApiRoute( - `${API_PREFIX}/rag/challenges`, + RAG_CHALLENGES_ROUTE_PATH, { method: 'GET', requiresAuth: true, @@ -75,7 +77,7 @@ export const listIndexedChallengesRoute = registerApiRoute( ); export const deleteIndexedChallengeRoute = registerApiRoute( - `${API_PREFIX}/rag/challenges/:challengeId`, + RAG_CHALLENGE_ROUTE_PATH, { method: 'DELETE', requiresAuth: true, diff --git a/src/utils/server-routes.ts b/src/utils/server-routes.ts index 2c906b9..39e26e6 100644 --- a/src/utils/server-routes.ts +++ b/src/utils/server-routes.ts @@ -1,5 +1,22 @@ // Single source of truth for the server's route surfaces, so auth/middleware // path patterns can't drift out of sync with how routes are actually mounted. export const API_PREFIX = '/v6/ai'; + +/** + * Custom apiRoutes CANNOT live under API_PREFIX. Mastra reserves it for its + * built-ins and throws at boot from `validateCustomRoutePaths()`: + * + * Custom API route "/v6/ai/rag/challenges" must not start with "/v6/ai" — + * that path is reserved for built-in Mastra routes. + * + * So every custom route is a hyphenated sibling of the prefix instead + * (`/v6/ai-chat`, `/v6/ai-rag`), which is also why both need their own entry in + * apiAuthLayer's `protected` list and in resourceIdMiddleware's registration. + */ export const CHAT_ROUTE_BASE_PATH = '/v6/ai-chat'; export const CHAT_ROUTE_PATH = `${CHAT_ROUTE_BASE_PATH}/:agentId`; + +/** RAG index administration API — see src/utils/routes/rag-index.routes.ts. */ +export const RAG_ADMIN_ROUTE_BASE_PATH = '/v6/ai-rag'; +export const RAG_CHALLENGES_ROUTE_PATH = `${RAG_ADMIN_ROUTE_BASE_PATH}/challenges`; +export const RAG_CHALLENGE_ROUTE_PATH = `${RAG_CHALLENGES_ROUTE_PATH}/:challengeId`; From 3ca8ce17d80c0ce833342f8551aeea8e0fedd7af Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 6 Sep 2026 16:32:21 +0300 Subject: [PATCH 27/27] PM-5999 - use /ai-api prefix --- README.md | 10 ++++---- ...based-access-for-agents-workflows-tools.md | 2 +- src/utils/auth/access-control.test.ts | 6 ++--- src/utils/auth/index.ts | 4 ++-- src/utils/routes/rag-index.routes.ts | 2 +- src/utils/server-routes.ts | 24 ++++++++++++++----- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2cc74ed..954a926 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Authentication is handled by `CompositeAuth` from `@mastra/core/server` (`src/ut 1. **Member tokens** — issued by `AUTH0_DOMAIN` with audience `AUTH0_AUDIENCE` 2. **M2M (machine-to-machine) tokens** — issued by `AUTH0_M2M_DOMAIN` with audience `AUTH0_M2M_AUDIENCE` -A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*', '/v6/ai-chat/*', '/v6/ai-rag/*']` (the server's `apiPrefix` plus each custom-route base path — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. `/v6/ai-chat/*` has to be listed explicitly because `chatRoute()` is registered outside `apiPrefix` and never sets `requiresAuth`, so Mastra's `isProtectedPath` check would otherwise skip it — including its authorization step (see [Access control](#access-control)). +A request is authorized if it passes validation against **either** tenant. Both providers declare `protected: ['/v6/ai/*', '/v6/ai-chat/*', '/v6/ai-api/*']` (the server's `apiPrefix` plus each custom-route base path — see [Framework Setup](#framework-setup--mastra)); Mastra's built-in `protected`/`public` defaults only cover `/api/*`, so without this override every built-in route would be silently unauthenticated once `apiPrefix` is changed from the default. `/v6/ai-chat/*` has to be listed explicitly because `chatRoute()` is registered outside `apiPrefix` and never sets `requiresAuth`, so Mastra's `isProtectedPath` check would otherwise skip it — including its authorization step (see [Access control](#access-control)). Both providers also set `mapUserToResourceId`, deriving the caller's Topcoder user id from the JWT claim `https:///userId` (member tokens) or `sub` (M2M tokens) — see `tcUserIdClaimKey()` / `mapUserToResourceId` in `src/utils/auth/index.ts`. Mastra's core auth flow stores that value under `MASTRA_RESOURCE_ID_KEY` in the request context automatically, and it takes precedence over any client-supplied `resourceId`/`memory.resource` — this is what actually enforces per-user memory/thread isolation; the `Resource ID Middleware` below is a belt-and-suspenders check on top of it, not the primary mechanism. @@ -298,7 +298,7 @@ Everything else is `public`, i.e. unchanged from pre-ADR-0004 behavior. Note tha **Three enforcement points:** - **Agents & workflows** — `authorizeAccessPolicy` is supplied as `authorizeUser` to both Auth0 providers. Mastra's own `coreAuthMiddleware` already invokes that hook on every protected request and returns **403** when it returns `false`. It parses the request path into `('agent', id)` / `('workflow', id)`, covering `/v6/ai/agents/:id/*`, `/v6/ai/workflows/:id/*` and `/v6/ai-chat/:agentId`. Non-invocation paths (memory, threads, telemetry, scorers) are out of scope and pass through. Mastra Studio uses these same paths, so it gets no bypass. -- **Custom admin routes** (`ROUTE`) — this repo's own `registerApiRoute` entries are neither agents nor workflows, so they match none of the patterns above and would otherwise stay open to any authenticated caller. `ROUTE_PATH_TARGETS` maps a path prefix to a route slug, which then resolves like any other target. Currently one entry: `/v6/ai-rag/challenges` → `rag-challenges`, restricted to `administrator` / `challengesRAG:admin` out of the box. +- **Custom admin routes** (`ROUTE`) — this repo's own `registerApiRoute` entries are neither agents nor workflows, so they match none of the patterns above and would otherwise stay open to any authenticated caller. `ROUTE_PATH_TARGETS` maps a path prefix to a route slug, which then resolves like any other target. Currently one entry: `/v6/ai-api/rag/challenges` → `rag-challenges`, restricted to `administrator` / `challengesRAG:admin` out of the box. - **Tools** — tools have no HTTP route of their own, so `withAccessPolicy()` wraps each tool's `execute` at its **export site** (e.g. the last line of `challenge-vector-query-tool.ts`). The guard travels with the exported tool object, so a future agent that adds the tool to its `tools:` map can't forget it. It reads the `user` already on `RequestContext` and throws `ToolAccessDeniedError` on denial — surfaced to the LLM as a failed tool call, or to a workflow step as a rejected `execute()`. Nested, in-process invocations (`challenge-bulk-ingestion` → `challenge-ingestion`, `challenge-context` → `challenge-parser-agent`) are **not** re-gated: they never re-enter the HTTP router, and you can't reach them without passing the outer check first. @@ -629,11 +629,11 @@ Both CLIs invoke the same workflows the API exposes (via `mastra.getWorkflowById Two custom routes for inspecting and pruning what the index currently holds — the backend for the **TopScout RAG** admin page. Both are **administrator-only** (`route`/`rag-challenges` policy, see [Access control](#access-control)). ``` -GET /v6/ai-rag/challenges list indexed challenges -DELETE /v6/ai-rag/challenges/:challengeId remove one challenge's vectors +GET /v6/ai-api/rag/challenges list indexed challenges +DELETE /v6/ai-api/rag/challenges/:challengeId remove one challenge's vectors ``` -> **Why `/v6/ai-rag` and not `/v6/ai/rag`?** Mastra reserves its `apiPrefix` exclusively for built-in routes and **refuses to start** if a custom `apiRoutes` entry is registered at or beneath it — `validateCustomRoutePaths()` throws during `createHonoServer`, so the container crash-loops rather than failing a request. Custom routes are therefore hyphenated siblings of the prefix (`/v6/ai-chat`, `/v6/ai-rag`), which is also why each needs its own entry in `apiAuthLayer`'s `protected` list. `src/utils/routes/rag-index.routes.test.ts` asserts this rule, so a bad path fails a test instead of a deploy. +> **Why `/v6/ai-api/rag` and not `/v6/ai/rag`?** Mastra reserves its `apiPrefix` exclusively for built-in routes and **refuses to start** if a custom `apiRoutes` entry is registered at or beneath it — `validateCustomRoutePaths()` throws during `createHonoServer`, so the container crash-loops rather than failing a request. Custom routes therefore sit beside the prefix (`/v6/ai-chat`, `/v6/ai-api/*`) rather than under it, which is also why each base path needs its own entry in `apiAuthLayer`'s `protected` list. Their paths are absolute — Mastra mounts an `apiRoutes` entry at its literal `path`, unlike built-ins which it registers with `{ prefix: apiPrefix }`. `src/utils/routes/rag-index.routes.test.ts` asserts the collision rule, so a bad path fails a test instead of a deploy. `GET` aggregates `challenge_embeddings` by `metadata->>'challengeId'` — the ingestion path writes one row per *chunk*, while an operator thinks in *challenges*. Query params: `page` (1-based, default 1), `perPage` (default 25, max 100), `projectId`, `track`, `type`, `search` (case-insensitive substring on challenge name **or** id). Empty/whitespace params are treated as absent. diff --git a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md index 5f15209..2703e0b 100644 --- a/docs/adr/0004-role-based-access-for-agents-workflows-tools.md +++ b/docs/adr/0004-role-based-access-for-agents-workflows-tools.md @@ -179,7 +179,7 @@ export const DEFAULT_ACCESS_POLICIES: Record { // --------------------------------------------------------------------------- describe('route policies', () => { - const LIST = '/v6/ai-rag/challenges'; - const DELETE_ONE = '/v6/ai-rag/challenges/9f1c2e4a-7b3d'; + const LIST = '/v6/ai-api/rag/challenges'; + const DELETE_ONE = '/v6/ai-api/rag/challenges/9f1c2e4a-7b3d'; it('ships the RAG index admin API restricted, with no env vars set', () => { expect(resolveAccessPolicy('route', 'rag-challenges')).toEqual({ @@ -526,7 +526,7 @@ describe('route policies', () => { // /challenges-export would be a different route and must not inherit // the policy by accident. expect( - authorizeAccessPolicy(memberUser(['copilot']), authRequest('/v6/ai-rag/challenges-export')), + authorizeAccessPolicy(memberUser(['copilot']), authRequest('/v6/ai-api/rag/challenges-export')), ).toBe(true); }); diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts index a75dddc..6e4762d 100644 --- a/src/utils/auth/index.ts +++ b/src/utils/auth/index.ts @@ -1,6 +1,6 @@ import { MastraAuthAuth0 } from '@mastra/auth-auth0'; import { CompositeAuth } from '@mastra/core/server'; -import { API_PREFIX, CHAT_ROUTE_BASE_PATH, RAG_ADMIN_ROUTE_BASE_PATH } from '../server-routes'; +import { API_PREFIX, CHAT_ROUTE_BASE_PATH, CUSTOM_API_BASE_PATH } from '../server-routes'; import { authorizeAccessPolicy } from './access-control'; import { tcUserIdClaimKey } from './tc-domain'; @@ -18,7 +18,7 @@ const mapUserToResourceId = (user: Record): string | undefined const PROTECTED_PATHS = [ `${API_PREFIX}/*`, `${CHAT_ROUTE_BASE_PATH}/*`, - `${RAG_ADMIN_ROUTE_BASE_PATH}/*`, + `${CUSTOM_API_BASE_PATH}/*`, ]; export const apiAuthLayer = new CompositeAuth([ diff --git a/src/utils/routes/rag-index.routes.ts b/src/utils/routes/rag-index.routes.ts index 1d6519d..265cf31 100644 --- a/src/utils/routes/rag-index.routes.ts +++ b/src/utils/routes/rag-index.routes.ts @@ -2,7 +2,7 @@ * RAG index admin API — list and delete what challenge_embeddings holds. * * Registered as custom apiRoutes (see src/mastra/index.ts). They live under - * `/v6/ai-rag`, NOT under API_PREFIX: Mastra reserves the prefix for its + * `/v6/ai-api/rag`, NOT under API_PREFIX: Mastra reserves the prefix for its * built-ins and throws at boot for any custom route beneath it (see * server-routes.ts). Auth comes from two places: `requiresAuth: true` makes * Mastra authenticate them (set explicitly rather than relying on its diff --git a/src/utils/server-routes.ts b/src/utils/server-routes.ts index 39e26e6..08c5bfc 100644 --- a/src/utils/server-routes.ts +++ b/src/utils/server-routes.ts @@ -3,20 +3,32 @@ export const API_PREFIX = '/v6/ai'; /** - * Custom apiRoutes CANNOT live under API_PREFIX. Mastra reserves it for its - * built-ins and throws at boot from `validateCustomRoutePaths()`: + * Custom-route paths are ABSOLUTE. Mastra mounts an apiRoutes entry on the root + * app at its literal `path` — unlike built-ins, which it registers with + * `{ prefix: apiPrefix }` — so each constant below must spell out the full + * URL path, not a fragment relative to anything. + * + * They also CANNOT live under API_PREFIX. Mastra reserves it for its built-ins + * and throws at boot from `validateCustomRoutePaths()`: * * Custom API route "/v6/ai/rag/challenges" must not start with "/v6/ai" — * that path is reserved for built-in Mastra routes. * - * So every custom route is a hyphenated sibling of the prefix instead - * (`/v6/ai-chat`, `/v6/ai-rag`), which is also why both need their own entry in - * apiAuthLayer's `protected` list and in resourceIdMiddleware's registration. + * So every custom route sits beside the prefix rather than under it, which is + * also why each base path needs its own entry in apiAuthLayer's `protected` + * list and in resourceIdMiddleware's registration. */ export const CHAT_ROUTE_BASE_PATH = '/v6/ai-chat'; export const CHAT_ROUTE_PATH = `${CHAT_ROUTE_BASE_PATH}/:agentId`; +/** + * Namespace for this repo's own (non-Mastra-built-in) API routes. Protecting + * the namespace rather than each route means a custom route added here later + * is covered by apiAuthLayer without a second edit. + */ +export const CUSTOM_API_BASE_PATH = '/v6/ai-api'; + /** RAG index administration API — see src/utils/routes/rag-index.routes.ts. */ -export const RAG_ADMIN_ROUTE_BASE_PATH = '/v6/ai-rag'; +export const RAG_ADMIN_ROUTE_BASE_PATH = `${CUSTOM_API_BASE_PATH}/rag`; export const RAG_CHALLENGES_ROUTE_PATH = `${RAG_ADMIN_ROUTE_BASE_PATH}/challenges`; export const RAG_CHALLENGE_ROUTE_PATH = `${RAG_CHALLENGES_ROUTE_PATH}/:challengeId`;