Skip to content

fix(web): declare the three remaining implicit return types (#1381 review residue) - #1402

Closed
groupthinking wants to merge 1 commit into
groupthinking-fix-upstream-error-leakagefrom
claude/clever-heisenberg-eleejm
Closed

fix(web): declare the three remaining implicit return types (#1381 review residue)#1402
groupthinking wants to merge 1 commit into
groupthinking-fix-upstream-error-leakagefrom
claude/clever-heisenberg-eleejm

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1383 (shares the canonical issue of its base PR #1381 — stacked follow-up, not a competing implementation).

Base is groupthinking-fix-upstream-error-leakage, not main.

This PR was rewritten. It originally carried the unguarded-fetch fix and the error-code contract. While it was open, ae2be83 and 5e69b7e landed both directly on #1381's branch from another session. Rather than merge a conflicting duplicate, this branch was reset onto their head and reduced to the only thing still missing. The original commit f9f964e is gone; see the note below for why that is the right outcome.

Outcome

Three helpers still had implicit return types after ae2be83 and 5e69b7e. Those commits typed upstreamFailure, upstreamUnreachable, and the activateRequest / webhookRequest / transcribeRequest helpers; these were missed:

Helper Declared type
getOpenAiHeaders (realtime/session/route.ts) Record<string, string> | null
sdpRequest (realtime-session-route.test.ts) Request
upstreamResponse (realtime-session-route.test.ts) Response

This closes out CodeRabbit's strict-mode path instruction ("flag implicit any, missing return types") for the routes #1381 touches. getOpenAiHeaders already returned exactly that union on both of its paths, so the annotation is descriptive, not a narrowing.

Scope

  • Included: types only, in two files.
  • Explicitly excluded: everything already landed in ae2be83 / 5e69b7e, and the "add the copilot-rabbit label and obtain an explicit Copilot approval" thread — a repository process gate, not a code change, and not something an agent should self-grant.

Why the original commit was dropped rather than merged

Both implementations were compared line by line before discarding mine. Theirs is better on every overlapping point:

  • Transport guard. upstreamUnreachable wraps the fetch and await upstream.text(), so a body read that rejects mid-stream is caught too. My fetchUpstream only wrapped the call itself and would have let that case escape. Their version also carries a third regression test for exactly that path.
  • Turnstile code. Theirs returns a stable code: 'turnstile_rejected' while keeping error verbatim. Mine made code mirror the dynamic turnstile.error, which is a weaker contract — a machine-readable key that varies isn't a stable key.

Merging the original would have regressed both. Dropping it was the correct call, not a concession.

Risk

  • Risk level: low — type annotations only.
  • Failure mode: none at runtime; annotations are erased at compile time. No response shape, status code, or test expectation changes.
  • Rollback: revert this single commit.

Verification

Run against head 855dcec.

$ cd apps/web && npm run type-check   # tsc --noEmit
  (clean, exit 0)

$ cd apps/web && npm run lint         # eslint src middleware.ts
  (clean, exit 0)

$ cd apps/web && npm test             # vitest run
  Test Files  1 failed | 48 passed (49)
       Tests  1 failed | 271 passed (272)

271 passed / 1 failed matches 5e69b7e exactly — this PR adds no tests and changes no counts. The single failure is billing-chat-gating.test.ts:35 "blocks free tier after daily quota", 5000 ms timeout: pre-existing, unrelated, tracked in #1116.

  • Focused tests — full web suite, counts identical to base
  • Required CI — agent-completion/truth-gate is structurally unsatisfiable here; see below
  • Review threads resolved — the substantive findings landed in ae2be83 / 5e69b7e; this is the residue

Production evidence

Not applicable: type-only change stacked on an unmerged PR, so there is no independent deployment surface and no runtime behavior to observe. Production evidence belongs to #1381.

Known blocker — agent-completion/truth-gate

Cannot pass on this PR and is not worth retrying. agentTaskApplicable (.github/workflows/pr-checks.yml:646-649) engages the agent-task policy on any claude/* head branch; the gate then requires a GitHub-native closing-issue reference, which GitHub only creates for PRs targeting the default branch. This PR targets groupthinking-fix-upstream-error-leakage, so no issue resolves and policy.agent_login / policy.run_id — read from the linked issue body at pr-checks.yml:2162-2165 — come back empty. Full analysis in this comment.

Merging this into groupthinking-fix-upstream-error-leakage moots it: the change is then covered by #1381's own gate, which is green.

Agent handoff

  • One canonical issue is linked
  • No competing PR implements the same issue — reduced specifically to avoid overlapping ae2be83 / 5e69b7e
  • Acceptance criteria are satisfied
  • Required checks pass on the current head — truth-gate blocked structurally, as above
  • Human decision is requested only for product, security, irreversible infrastructure, or production approval — merge is left to a human; nothing was auto-merged

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8dc749c7-03c1-4256-8847-9190c0bad8e6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: BLOCKED

Reasons: invalid_payload

Machine-readable verdict
{
  "details": {
    "collection_errors": [
      "incomplete_linked_issue_contract",
      "missing_closing_issue_reference",
      "linked_issue_not_agent_task",
      "missing_intent_snapshot",
      "missing_agent_run_id",
      "missing_agent_login"
    ],
    "invalid_fields": [
      "policy.agent_login",
      "policy.run_id"
    ]
  },
  "reasons": [
    "invalid_payload"
  ],
  "verdict": "blocked"
}

Workflow evidence

@groupthinking groupthinking added javascript Pull requests that update javascript code security tests labels Aug 5, 2026 — with Claude
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 5, 2026 10:01am

Copy link
Copy Markdown
Owner Author

agent-completion/truth-gate — blocked, and not fixable from this PR

Traced it rather than retrying. The gate is structurally unsatisfiable while this PR is stacked, and the cause is worth recording because it is systemic, not specific to this branch.

Why the gate applies here but not to #1381. agentTaskApplicable in .github/workflows/pr-checks.yml:646-649 sets applicable from the head branch name:

const agentBranch =
  /^(?:agent|claude|codex|copilot|jules)[/-]/i.test(pull.head.ref);

This branch is claude/clever-heisenberg-eleejm, so the agent-task policy engages. #1381's branch is groupthinking-fix-upstream-error-leakage, which does not match, so its gate resolves not_applicable and passes.

Why it cannot pass. Once applicable, the collector needs exactly one GitHub-native closing-issue reference (pr-checks.yml:591), and then reads expectedRunId and expectedAgentLogin out of that issue's body, from ## Agent Run ID and ## Agent Login sections (pr-checks.yml:2162-2165):

const expectedRunId = section(issueBody, ['agent run id', 'run id'])
const expectedAgentLogin = section(issueBody, ['agent login'])

GitHub only creates closing-issue references for pull requests targeting the default branch. This PR targets groupthinking-fix-upstream-error-leakage, so linkedIssues is empty regardless of the Closes #1383 line in the body — which is exactly what the verdict reports as missing_closing_issue_reference. Every other collection error follows from it: with no linked issue there is no issue body, hence missing_agent_run_id, missing_agent_login, linked_issue_not_agent_task, incomplete_linked_issue_contract. policy.agent_login and policy.run_id are then empty, which is the invalid_payload verdict.

Adding an agent-lock-manifest block to this body does not fix it. The manifest is only ever compared against the issue-derived values (pr-checks.yml:2168, 2173); it cannot supply them.

The two ways out are both outside what this PR should decide:

  1. Re-target this PR at main. The gate would then resolve a linked issue — but the diff would duplicate all of fix(web): stop leaking upstream and Stripe error details to clients #1381, which trips the competing-PR gate and defeats the point of stacking.
  2. Open an agent-task issue carrying ## Agent Run ID and ## Agent Login sections plus a dispatched intent snapshot, and target main against it. That requires an issue-dispatched agent run, which this session is not.

Recommendation: merge this into groupthinking-fix-upstream-error-leakage (base of this PR), so the fixes land inside #1381 and are covered by #1381's own gate, which is green. This PR then closes as merged and the question is moot.

Worth flagging that this also explains #1356's agent-completion/truth-gate failure — same claude/-prefixed branch, no resolvable agent-task issue. Any PR pushed to a claude/* branch without an issue-dispatched agent run will fail this gate.

Everything else on this head is green or passing: type-check clean, eslint clean, npm test 272 passed / 1 failed (the pre-existing billing-chat-gating timeout, #1116), Vercel preview Ready.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@coderabbitai review

(Auto-review was skipped for lack of a qualifying label; javascript, security, and tests have since been applied.)


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@groupthinking I will review the pull request.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

groupthinking pushed a commit that referenced this pull request Aug 5, 2026
…ile fallback

Absorbs the three items #1402 carried that this branch did not, so the two
lines of work converge here rather than diverging:

- `getOpenAiHeaders` declares `: Record<string, string> | null`
- `sdpRequest` declares `: Request`, `upstreamResponse` declares `: Response`
- a test for `verifyTurnstileToken` returning `{ ok: false }` with no `error`,
  which the optional-field fallback added in 5e69b7e handles but nothing
  exercised

#1402's `fetchUpstream` rewrite is deliberately not taken. It guards only the
`fetch` call, leaving `await upstream.text()` outside the try — so a connection
reset mid-body still escapes the route. `upstreamUnreachable` (ae2be83) wraps
both, which is the stricter guarantee.

`npm test` 272 passed / 1 failed — the pre-existing `billing-chat-gating`
timeout (#1116), identical on the base commit. `npm run lint` and
`npm run type-check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy
Residue from CodeRabbit's review of #1381 after ae2be83 and 5e69b7e landed
the substantive findings. Those two commits typed `upstreamFailure`,
`upstreamUnreachable`, and the `activateRequest` / `webhookRequest` /
`transcribeRequest` helpers; these three were missed:

- `getOpenAiHeaders` -> `Record<string, string> | null`
- `sdpRequest`       -> `Request`
- `upstreamResponse` -> `Response`

Types only — no behavior, no response shape, no test expectation changes.
`getOpenAiHeaders` already returned exactly this union on both paths, so the
annotation is descriptive rather than a narrowing.

Verified at this head: tsc clean, eslint clean, web suite 271 passed /
1 failed — the pre-existing billing-chat-gating timeout (#1116), matching
5e69b7e exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138bgDsN77ZFnktuYw26Pqa
@groupthinking
groupthinking force-pushed the claude/clever-heisenberg-eleejm branch from f9f964e to 855dcec Compare August 5, 2026 09:59

Copy link
Copy Markdown
Owner Author

Closing as superseded — the work landed directly on this PR's own base branch instead.

Two sessions picked up the same five CodeRabbit threads on #1381 within minutes of each other. This PR branched from 672bac3; groupthinking-fix-upstream-error-leakage has since advanced to f5cb509, which now contains everything here. That is also why this PR reads mergeable_state: dirty — its diff and its base's diff touch the same lines because they are the same change.

Absorbed from this PR (in f5cb509, credited to the work here):

  • getOpenAiHeaders: Record<string, string> | null
  • sdpRequest: Request, upstreamResponse: Response
  • the { ok: false }-with-no-error Turnstile test — the branch had the optional-field fallback but nothing exercised it

Already on the branch, so not re-applied: the full code contract across all eleven branches, the NextRequest/Request test-helper annotations, and upstreamFailure: Response (5e69b7e), plus transport-failure handling (ae2be83).

One difference, deliberate. fetchUpstream here guards only the fetch call:

try { return await fetch(url, init); } catch {  }
// …then, outside the guard:
const body = await upstream.text();

upstream.text() can itself reject — a connection reset after headers arrive but before the body completes — and that rejection escapes the route to the framework's own error page, which is the disclosure this module exists to prevent. upstreamUnreachable on the branch wraps fetch and the body read in one try, so both degrade to the same fixed 502. Keeping the stricter guarantee.

Two small contract choices also differ, both in the branch's favour:

  • realtime_not_configured rather than openai_api_key_missing — the branch's code names the condition the caller can act on, not the specific env var, which is server-internal.
  • checkout returns a fixed code: 'turnstile_rejected' while error stays verbatim. This PR set code: turnstile.error ?? 'turnstile_rejected', which makes the code vary with the reason — a client branching on code would then need to enumerate all four Turnstile outcomes to detect one condition.

Nothing is lost by closing; reopen if you disagree with any of the above. Verified on f5cb509: npm test 272 passed / 1 failed (the pre-existing billing-chat-gating timeout, #1116), lint and type-check clean.


Generated by Claude Code

@groupthinking groupthinking changed the title fix(web): handle rejected upstream fetch and complete the error-code contract (#1381 review) fix(web): declare the three remaining implicit return types (#1381 review residue) Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Closing as superseded by #1381 — the two branches independently implemented the same CodeRabbit remediation on top of ae2be83, and #1381's version is the more complete one.

Why this PR is redundant. Both heads address the same three findings. Comparing 5e69b7e (#1381 head) against f9f964e (this head):

Finding #1381 5e69b7e This PR f9f964e
Rejected upstream fetch → fixed 502 try/catch wraps fetch and await upstream.text() ⚠️ wraps fetch only
code on missing-key / invalid-SDP branches realtime_not_configured, invalid_sdp_offer openai_api_key_missing, invalid_sdp_offer
upstreamFailure return type : Response : Response
code across billing routes ✅ activate, checkout, renew, webhook ✅ same
Test helper return types activateRequest/webhookRequest: NextRequest, transcribeRequest: Request ✅ same

The one behavioural difference is the first row, and it favours #1381. This PR's fetchUpstream helper returns the Response and the caller then does await upstream.text() outside the try — so a connection reset mid-body still throws past the handler and renders the framework's own error page, which is the uncontrolled disclosure the change exists to prevent. #1381 keeps the body read inside the guarded block and has a regression test for it (returns the static 502 when the upstream body cannot be read) that this PR does not.

Test coverage is otherwise a superset on #1381: it carries the rejected-GET and rejected-POST cases this PR adds, plus the body-read case, and folds the missing-key/invalid-SDP code assertions into the existing branch tests rather than adding three separate ones.

Concrete reason to close rather than leave open. The Canonical issue and evidence and PR Governance checks on #1381 are both red for exactly one reason:

Issue #1383 already has another open implementation PR: #1402

Two open PRs claim Closes #1383, so the canonical-delivery gate blocks the one that should ship. Closing this one clears that gate.

One thing worth keeping from this branch. getOpenAiHeaders gains an explicit : Record<string, string> | null return type here, and #1381 leaves it inferred. That is a genuine (minor) gap against the repo's TypeScript-strict path instruction. It was never raised as a review comment on #1381, and opening a separate PR for it would recreate the competing-PR block this close is resolving — so it is recorded here as a follow-up rather than filed now.

Nothing is lost by closing: reopen if the comparison above is wrong on any row.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Correction to my closing comment above — it was written against f9f964e and this PR was force-pushed to 855dcec while I was posting. The close still stands, but the accurate reason is stronger than the one I gave.

My comment compared the original head (the fetchUpstream rewrite) and argued it was superseded. By the time it landed, this branch had already been reset onto f5cb509 and reduced to three return-type annotations. So disregard that table — it describes a commit that no longer exists here.

Here is the state as of now. f5cb509 on groupthinking-fix-upstream-error-leakage already contains all three annotations this PR was reduced to:

$ git show f5cb509:apps/web/src/app/api/realtime/session/route.ts | grep -n 'function getOpenAiHeaders'
88:function getOpenAiHeaders(contentType?: string): Record<string, string> | null {

$ git show f5cb509:apps/web/src/app/api/__tests__/realtime-session-route.test.ts | grep -n 'function sdpRequest\|function upstreamResponse'
7:function sdpRequest(body: string): Request {
15:function upstreamResponse(

f5cb509's commit message says as much — it deliberately absorbed those three items, and independently reached the same conclusion this PR's own description reached about the fetchUpstream rewrite (guards fetch but not await upstream.text()). The two lines of work converged.

That leaves this PR's diff against its current base as a net removal:

$ git diff f5cb509 855dcec
 apps/web/src/app/api/__tests__/billing-checkout-route.test.ts | 18 ------------------

The only change remaining is deleting falls back to a stable error and code when Turnstile reports no reason — the test covering TurnstileVerifyResult.error being optional, so an { ok: false } result with no reason still yields { error: 'turnstile_rejected', code: 'turnstile_rejected' } rather than { error: undefined }. Merging this would drop that coverage and gain nothing.

The structurally-unsatisfiable agent-completion/truth-gate documented in the PR body also stops being a problem, since there is nothing left to land.

Separately, this unblocks #1381: its Canonical issue and evidence and PR Governance checks were failing only on Issue #1383 already has another open implementation PR: #1402.

Reopen if the git diff above is wrong — that single command is the whole case for closing.


Generated by Claude Code

groupthinking added a commit that referenced this pull request Aug 7, 2026
…1381)

* fix(web): stop leaking upstream and Stripe error details to clients

Two information-disclosure issues were returning third-party error text
verbatim to callers.

1. /api/realtime/session (GET + POST) returned the raw OpenAI response
   body as `details` while mirroring `upstream.status`. OpenAI bodies
   echo org/project IDs, quota state, and on 401 a partial key
   (`Incorrect API key provided: sk-proj-****ABCD`). Both handlers now
   log the body server-side and return a static message with a fixed 502.

2. Five billing routes returned raw Stripe SDK `err.message`. All five
   are on the unauthenticated PUBLIC_API_EXACT allowlist, so any internet
   caller could trigger and read them. Each now returns a static string
   plus a machine-readable code; the raw message stays in console.error
   and kaizenObserve.

/api/transcribe had the same raw-upstream pattern in its JSON-parse and
catch-all branches and is fixed the same way.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs(web): correct two inaccurate security comments flagged in review

Applies the two fixes agreed in the Copilot review threads on #1381. Both
are comment-only; no behavior, status code, or response body changes.

transcribe/route.ts — the docblock claimed all error responses are static
and machine-readable. The four `!result.success` branches return
`fetchTranscript`'s message verbatim and carry no `code`, and the billing
branch still returns a static `details` hint. Those messages are
app-authored (every `error` in transcription-service.ts is a literal, a
numeric status, or our own SSRF-guard text), so the CWE-209 objective
holds — but the docblock overstated it. Scoped it to the two sanitized
paths and described the rest accurately.

billing/webhook/route.ts — the rationale was wrong on two counts.
`constructEvent` verifies Stripe-Signature against the webhook signing
secret, not the API key, so an API-key fragment cannot forge a signature;
and StripeSignatureVerificationError describes tolerance/scheme/digests
without echoing key material. Rewrote it to state the real risk: on an
unauthenticated route, verification internals are an oracle for tuning
replay/timestamp attacks.

Verified at this head: type-check clean, eslint clean, the six route test
files pass (17 tests), full web suite 261 passed / 1 failed — the failure
being the pre-existing billing-chat-gating timeout tracked in #1116, which
fails identically on the base commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jsfy3ts3U1ggGsJqFB2KSC

* fix(web): return the static 502 when the Realtime upstream is unreachable

CodeRabbit flagged this on #1381 and it reproduces: neither GET nor POST
wrapped its `fetch`, so a transport-level failure — DNS, TLS, connection
reset, timeout — rejected before `upstreamFailure` could run. The throw
escaped to Next.js's own unstructured 500 instead of the `{ error, code }`
JSON the route promises, `use-realtime-voice.ts` got a payload it cannot
parse, and no `[realtime]` line was logged for the operator.

Both handlers now wrap the fetch and the body read in try/catch and route
transport failures through a new `upstreamUnreachable` helper, which logs
the cause server-side and returns the same fixed 502 as a non-OK upstream
response. The cause is an Error from our own transport rather than upstream
text, so logging it introduces no disclosure. `await upstream.text()` is
inside the try as well — a truncated body rejects there for the same reason.

The gap predates this PR, but this PR is what establishes the contract that
these routes always answer with a static structured error, so it belongs
here.

Three regression tests, each verified non-vacuous — they fail against the
unwrapped route and pass against the fix: GET with a rejected fetch
(ENOTFOUND), POST with a rejected fetch (socket hang up), and GET with a
response whose body read rejects mid-stream.

Verified at this head: tsc clean, eslint clean, full web suite 264 passed /
1 failed — the pre-existing billing-chat-gating timeout tracked in #1116,
identical on the base commit. Up from 261 passed by exactly these 3 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jsfy3ts3U1ggGsJqFB2KSC

* fix(web): complete the error-code contract across the patched routes

CodeRabbit's review of 1adbed6 found the `code` field was added only to the
branches that were sanitized for CWE-209, leaving the rest of each route's
error surface without a machine-readable key. A client cannot branch on a
contract that only half the branches honour.

Every remaining error branch on the six patched routes now returns `code`
alongside its existing `error` value:

- realtime/session — `realtime_not_configured` (GET and POST missing-key),
  `invalid_sdp_offer`
- billing/activate — `invalid_json`, `session_id_required`, `not_eligible`
- billing/checkout — `invalid_json`, `turnstile_rejected`
- billing/renew   — `invalid_json`
- billing/webhook — `webhook_not_configured`, `missing_signature`

`error` values are unchanged, so no client contract breaks. The checkout
Turnstile branch additionally gains a fallback: `turnstile.error` is optional
on `TurnstileVerifyResult`, so the previous `{ error: turnstile.error }`
could serialize to `{}`. Its values are all our own literals, never Cloudflare
response text, so returning it verbatim remains correct.

Also from the same review:

- `upstreamFailure` and `upstreamUnreachable` declare `: Response`.
- `activateRequest`/`webhookRequest` declare `: NextRequest`, `transcribeRequest`
  declares `: Request`.

The review's remaining finding — that a rejected `fetch()` skips `upstreamFailure`
entirely — was already fixed in ae2be83, which landed after the review ran.

+9 tests covering each newly-coded branch. `npm test` 271 passed / 1 failed;
the single failure is the pre-existing `billing-chat-gating` 5000 ms timeout
(#1116), identical on the base commit. `npm run lint` and `npm run type-check`
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): annotate remaining helper return types and cover the Turnstile fallback

Absorbs the three items #1402 carried that this branch did not, so the two
lines of work converge here rather than diverging:

- `getOpenAiHeaders` declares `: Record<string, string> | null`
- `sdpRequest` declares `: Request`, `upstreamResponse` declares `: Response`
- a test for `verifyTurnstileToken` returning `{ ok: false }` with no `error`,
  which the optional-field fallback added in 5e69b7e handles but nothing
  exercised

#1402's `fetchUpstream` rewrite is deliberately not taken. It guards only the
`fetch` call, leaving `await upstream.text()` outside the try — so a connection
reset mid-body still escapes the route. `upstreamUnreachable` (ae2be83) wraps
both, which is the stricter guarantee.

`npm test` 272 passed / 1 failed — the pre-existing `billing-chat-gating`
timeout (#1116), identical on the base commit. `npm run lint` and
`npm run type-check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): stop verifyTurnstileToken rejecting into an unstructured 500

CodeRabbit's review of the previous head found this and it reproduces.
verifyTurnstileToken returns a TurnstileVerifyResult on every other exit, so
its sole caller — the unauthenticated /api/billing/checkout route — awaits it
outside any try/catch. Two awaits inside it break that contract: the
siteverify fetch rejects on a transport failure, and res.json() rejects when
Cloudflare answers 2xx with a truncated or non-JSON body. Either escaped POST
as an unstructured framework 500 with no kaizenObserve trace and no `code` —
the same disclosure-shaped hole ae2be83 closed on the realtime route.

Fixed at the source rather than at the call site: the function now honours its
own Promise<TurnstileVerifyResult> signature, so every present and future
caller is covered. The reject reason is logged server-side — undici puts the
resolved host and port in it (`connect ECONNREFUSED 10.0.3.14:443`) — and
collapsed into the app-authored literal `turnstile_verification_unavailable`.

The route's existing 403 branch handles it unchanged, so no status code or
response shape moves. Note the 403 is now also reachable when Cloudflare is
merely unreachable rather than rejecting; keeping the existing status was the
narrower choice, and the distinction is visible to operators in the log and to
clients in the `error` literal.

Five tests, verified non-vacuous — reverting the guard fails three of them:
rejected fetch, rejected res.json(), and a non-rejection contract check, plus
the accept and non-2xx paths. They assert the resolved host/port never rides
out on the result while the reason still reaches console.error.

Verified at this head: tsc clean, eslint clean, full web suite 277 passed /
1 failed — the pre-existing billing-chat-gating 5000 ms timeout (#1116),
which is also intermittently joined by a second timeout under load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jsfy3ts3U1ggGsJqFB2KSC

* fix(web): complete the error-code contract on the transcribe route

CodeRabbit flagged the `!result.success` branches of /api/transcribe as a
CWE-209 leak. Traced every value `fetchTranscript` can put in `error` before
changing anything: each one is an app-authored literal (transcription-service
75, 296, 303, 314), a numeric HTTP status (287), our own SSRF-guard message
(278, and every throw in ssrf-guard interpolates only the caller's own URL),
or a conditional literal (363). Upstream throws are swallowed at console.warn
and never reach `error`. So the leak half of that finding does not hold.

The contract half does. This PR established `error` + a stable machine-readable
`code` on every error branch of the five billing routes and realtime/session;
transcribe was the last surface still missing it. The docblock documented the
gap instead of closing it.

All five error branches now carry a code — input_required, rate_limited,
billing_not_configured, transcription_unavailable, alongside the existing
invalid_json and transcription_failed. The `error` strings are untouched, so
the human-facing contract is unchanged, matching the precedent already accepted
for the Turnstile branch: dynamic message, pinned code.

The `details` hint on the billing branch is kept. It is an app-authored
operator string, not upstream text; the docblock no longer claims otherwise.

Tests: +5. One asserting the missing-input branch short-circuits before
fetchTranscript, and four table-driven cases pinning each resolved-failure
branch's status and code while asserting no provider-shaped token reaches the
client. Also added vi.clearAllMocks() to the afterEach — restoreAllMocks only
restores spies, so the module-factory vi.fn() was leaking call history between
tests in this file.

apps/web: type-check clean, eslint clean, 282 passed / 1 failed — the
pre-existing billing-chat-gating timeout tracked in #1116.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1xcdTagvCU4K7ZwTgGQF6

* fix(web): stop the SSRF guard leaking DNS resolver text to the caller

CodeRabbit found the last CWE-209 path on this PR's surface, and it is a
real one. `assertPublicHttpUrl` throws app-authored literals at every exit
except `dns.lookup`, which was awaited bare:

  ssrf-guard.ts:121      await dns.lookup(host, { all: true })
    -> rejects `getaddrinfo ENOTFOUND <submitted-hostname>`
  transcription-service.ts:273-280
    -> `Rejected audioUrl: ${guardErr.message}`
  transcribe/route.ts:104-113
    -> returns that verbatim as `error` in a 503

So a caller could name any host and read back the server's resolver verdict
on it. That is system error text reaching a client, which is this PR's whole
objective — but the sharper problem is that it is a DNS oracle. ENOTFOUND vs
EAI_AGAIN vs the existing 'Host resolves to a private address' distinguishes
"no such name" from "exists but private" from "exists and public", letting a
caller map internal names from the server's network position. That is exactly
the reconnaissance this guard exists to block, leaking out of the guard.

Fixed at the source rather than at either call site, so present and future
callers of `assertPublicHttpUrl` are covered: the lookup is wrapped, the
reject reason is logged server-side, and every resolver failure collapses
into 'Host does not resolve' — the literal a zero-result lookup already
threw, so the oracle closes in both directions.

This also corrects a claim made earlier on this PR. The transcribe docblock
thread asserted every `result.error` value was app-authored and listed
line 278 as "our own SSRF-guard message". That held for the five explicit
`throw new Error(...)` sites but not for the `dns.lookup` rejection, which is
Node's. The docblock scoping stands; the reasoning behind it was incomplete.

Severity is bounded: `/api/transcribe` is not on `PUBLIC_API_EXACT`, so this
needs an authenticated caller, unlike the billing routes.

Four tests, verified non-vacuous — reverting the try/catch fails exactly the
two leakage tests (resolver text suppressed but still logged; ENOTFOUND and
EAI_AGAIN indistinguishable from an empty result) while the two behaviour
tests still pass, since private-address blocking and the public-host allow
path do not depend on the catch.

Verified at this head: tsc clean, eslint clean, web suite 286 passed /
1 failed — the pre-existing billing-chat-gating 5000 ms timeout (#1116).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): close the last DNS oracle in the SSRF guard

Vercel's review bot found this on 8628b5e and it is correct — 36645ad closed
only half the hole it described. That commit collapsed the resolver-failure
cases (ENOTFOUND, EAI_AGAIN, zero results) into one literal, but left the
private-address branch throwing a distinct one:

    Host does not resolve            <- the name does not exist
    Host resolves to a private address  <- the name EXISTS, and is internal

That difference is the sharpest oracle of the set. `fetchTranscript`
interpolates the message into `Rejected audioUrl: ${guardErr.message}`
(transcription-service.ts:278) and the transcribe route returns it verbatim on
the `!result.success` path, so an unauthenticated caller could guess internal
hostnames and read back whether each one exists and points at internal
infrastructure — the reconnaissance this guard exists to prevent. 36645ad's own
comment names "Host resolves to a private address" as part of the oracle and
then does not unify it.

All four non-public outcomes now throw one literal, `Host does not resolve to a
public address`, which is true of every one of them. The resolved address is
logged for operators instead.

The IP-literal branch keeps its own distinct message deliberately: the caller
supplied that address, so naming it private reveals nothing they did not
already know, and no hostname is confirmed or denied.

Two tests, verified non-vacuous — restoring the old literal fails both:
one asserts the private address never rides out on the rejection while still
reaching console.error, the other feeds the same hostname down the
does-not-exist and exists-but-internal paths and asserts the caller cannot
tell them apart.

npm test 287 passed / 1 failed — the pre-existing billing-chat-gating timeout
(#1116). lint and type-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSkV9zjdhpkfUSMeLhrFGy

* fix(web): stop /api/transcribe leaking probe status and key config

#1381 sanitized the thrown and JSON-parse paths of /api/transcribe but left
the `!result.success` branches returning `fetchTranscript`'s message verbatim,
on the stated grounds that every such value is an app-authored literal. Two
were not, and both are reachable by a caller who controls the request body:

- `Failed to fetch audio: ${audioResponse.status}` echoed the HTTP status of
  the caller-supplied `audioUrl`. The SSRF guard admits public hosts, so this
  returned a cross-origin read (401 vs 403 vs 404 vs 500) that the browser's
  same-origin policy would otherwise deny — a probe oracle, not upstream text,
  which is why it survived the original pass.
- The all-strategies-failed message branched on whether provider keys were
  set, disclosing server configuration and naming both the variables and the
  hosting platform.

Both are fixed at the source in transcription-service.ts rather than masked at
the route, so any future caller of fetchTranscript inherits the fix. The real
status and the real key state are logged for operators, where they were the
only thing actionable. Also drops the residual `details` field from the
billing branch, which named this deployment's cloud and model vendors — #1381
stated `details` was gone from this route, and this is the one that was left.

The route docblock carried the claim these values disproved; it now states the
invariant and what enforces it.

Verification (head e9bf62d, apps/web):
- 3 new tests in transcription-error-disclosure.test.ts; all 3 fail against
  the unfixed service and pass with it — the status test asserts four distinct
  upstream statuses collapse to one message, so a partial fix cannot pass it.
- vitest run: 1 failed | 290 passed (291). The single failure,
  billing-chat-gating "blocks free tier after daily quota", is pre-existing and
  unrelated — confirmed failing identically on the untouched e9bf62d with this
  change stashed. Tracked in #1116; #1230 makes the suite hermetic against it.
- npm run type-check: clean. npm run lint: clean.
- package-lock.json drift from npm install reverted, per #1381's convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

* test: use the repo's standard fixture video ID

The new disclosure tests used dQw4w9WgXcQ. `.github/copilot-instructions.md`
requires auJzb1D-fag for all test data, and 60 files follow it against 6
stragglers — this file was one I had just added to that tail. Flagged by
Vercel VADE review on #1440.

The ID is inert here: fetch is stubbed, so the URL is never dereferenced and
only reaches the strategy dispatch. 3/3 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

* fix(web): stop forwarding the SSRF guard's rejection reason

Raised by CodeRabbit on #1440 (P2), and correct. The previous commit's docblock
asserted that every `fetchTranscript` error value reaching the client is a fixed
app-authored literal. That was false: the guard branch returned
`Rejected audioUrl: ${guardErr.message}` verbatim.

`assertPublicHttpUrl` throws five distinguishable messages — `Invalid URL`,
`Blocked URL scheme: <protocol>`, `Blocked host`, `Blocked private IP literal`,
and `Host does not resolve to a public address`. Forwarding them told the caller
WHICH rule fired. `Blocked host` confirms a hostname-blocklist match; the
resolution message confirms only that DNS returned nothing public. That
difference is a policy oracle, and it sharpens as BLOCKED_HOSTNAMES grows.

All five rejections now return one fixed `Rejected audioUrl`; the real reason is
logged. With this, all seven of the service's error values are fixed literals —
verified by enumeration, not assumed.

The docblock overstated in the same way #1381's did, which is what this PR was
opened to correct, so it has been rewritten to state what is actually true and
to name what it does NOT claim: the choice among literals still tells a caller
whether their host cleared the guard. That residue is inherent to a guard that
refuses some inputs and attempts others, is far coarser than naming the rule,
and the route is session-gated — accepted, and now written down rather than
glossed.

Complementary to #1428, which fixes this at the guard level with SsrfGuardError
(public message + private reason). This is the call-site boundary and does not
touch ssrf-guard.ts, so the two do not conflict.

Verification (head 8d756b6 + this, apps/web):
- New test drives all five real guard messages through fetchTranscript and
  asserts the caller-visible error is identical. Fails without the fix
  (`expected 5 to be 1`), passes with it.
- vitest run: 1 failed | 291 passed (292). The failure,
  billing-chat-gating "blocks free tier after daily quota", is pre-existing and
  unrelated — confirmed earlier on the untouched base. Tracked in #1116.
- npm run type-check: clean. npm run lint: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EX3tLZJJfbDUM6XXi6Uefm

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update javascript code security tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants