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; the
mapUserToResourceId/resourceIdMiddlewarefix 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.
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":
- Hardcoded service M2M token, always.
fetch-challenge-by-id,search-challenges, andfetch-project-by-idunconditionally callM2MService.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. - No Authorization header at all.
standardized-skills-semantic-search,standardized-skills-fuzzy-match, and the internalfetchScorecard()helper inchallenge-context-workflow.tscallTC_API_BASEanonymously. 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.
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" and exercised byapiAuthLayer/CompositeAuthin this repo) setsMASTRA_AUTH_TOKEN_KEYon the request'sRequestContextimmediately after a token is successfully authenticated, on every protected route — both the built-in/v6/ai/*routes and the customchatRoute()at/chat/:agentId.- That exact
RequestContextinstance is whatchatRoute()(@mastra/ai-sdk) forwards intoagent.stream()/agent.generate()asparams.requestContext. - Mastra threads that same
RequestContextautomatically into every tool invocation triggered during that run, exposed ascontext.requestContexton a tool'sexecute(inputData, context)— confirmed in the installed@mastra/core@1.61.0types (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.
| 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 |
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 inchallenge-context-workflow.ts— also anonymous today, left unmodified. src/scripts/ingest-challenges.tsandsrc/scripts/sync-challenges.ts— these are offline CLI jobs with no HTTP request, no requestor, and noRequestContext; they don't callTC_API_BASEtoday (confirmed by search) and aren't affected.- Changing what
M2MServiceis or how it obtains/caches tokens. - Changing
apiAuthLayer,resourceIdMiddleware, or anything upstream of tool execution — those are already fixed to correctly populateMASTRA_AUTH_TOKEN_KEYandMASTRA_RESOURCE_ID_KEY. - Any change to which Topcoder platform endpoints exist or what they return.
| # | 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.
- Add a shared TC API client (
src/utils/tc-api-client.ts) that every call site above uses instead of hand-rollingfetch(). Its contract: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<Response>
- Requestor-token-first. The client reads
requestContext?.get(MASTRA_AUTH_TOKEN_KEY)and, if present, makes the request withAuthorization: Bearer <requestor token>. This is the default and only path for anytoolIdnot opted into fallback. - Fallback trigger. If the requestor-token attempt returns HTTP
401or403(or there was no requestor token to try), the client checksTOOL_M2M_FALLBACK_CONFIG[toolId]. - 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 asfalse. Only when the entry istruedoes the client retry once withM2MService.getM2MToken(). - 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. - Exactly one fallback retry, no further backoff/retry loop — if the M2M attempt also fails, the error propagates to the tool/agent as-is.
- Every fallback event is logged (
tcAILogger.warn) with thetoolIdand the HTTP status that triggered it — for the same auditability reason the auth-resolution log was added toresourceIdMiddleware. Never log the token itself (neither requestor nor M2M). - The three currently-M2M tools ship requestor-token-only (confirmed at review — see Resolution of open questions, below):
TOOL_M2M_FALLBACK_CONFIGhas 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.
// src/config/tool-auth-fallback.config.ts
export const TOOL_M2M_FALLBACK_CONFIG: Record<string, boolean> = {
// 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.
};src/utils/tc-api-client.ts:callTcApi()per the contract above, plus default headers (Content-Type,app-version); timeout stays caller-supplied viainit.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 (mockedfetch, mockedM2MService, 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.
fetch-challenge-tool.ts,search-challenges-tool.ts,fetch-project-tool.ts: replaced the directM2MService+fetch()calls withcallTcApi({ toolId: <tool's own id>, requestContext: context.requestContext, ... }). Dropped the module-levelconst m2mService = new M2MService()from each tool file (onlytc-api-client.tsinstantiates it now, used solely on the fallback path these three don't exercise).- Updated each tool's existing
*.test.tsto supply a stubcontext.requestContextreturning a fake requestor token (instead of relying on the mockedM2MService), and updated the "Authorization" assertions accordingly.
standardized-skills-semantic-tool.ts,standardized-skills-fuzzy-tool.ts, andchallenge-context-workflow.ts'sfetchScorecard()are confirmed public/no-auth endpoints and are left exactly as they were — no code change.
npx tsc --noEmit,npx eslint, fullvitest run— all green (359 tests, up from 348: 49 updated across the three migrated tools' existing suites + 11 new intc-api-client.test.ts).- Manual verification still recommended before merge: hit
/chat/:agentIdas a real TC member and confirm (via thetcAILogger.info('Auth resolved for request', ...)log) that the same request's tool calls reach the Topcoder platform with that member's own token.
| 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 |
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
resourceIdfix 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/challengesor/v6/projectsfor 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 toTOOL_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
trueshould say why).
The three open questions blocking implementation were resolved at review, confirmed by the requester:
- 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.
- 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_CONFIGfor 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 fallbacktrueas a safety net) — see the Negative/risk note above for the trade-off this accepts. - 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, orchallenge-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.warnis the right level for fallback events — same caveat, not yet exercised outside unit tests.
Answers to Open Questions 1–3— resolved above.MASTRA_AUTH_TOKEN_KEY(@mastra/core/request-context) usage — implemented and covered bysrc/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 inM2MServiceitself, confirming the original assumption.