⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
src/auth/security.ts has two token authenticators side by side. authenticatePrivateToken early-returns null for a falsy token, then compares against nonBlank(env.LOOPOVER_API_TOKEN), nonBlank(env.LOOPOVER_MCP_ADMIN_TOKEN) and nonBlank(env.LOOPOVER_MCP_TOKEN). authenticateInternalToken compares against the raw env.INTERNAL_JOB_TOKEN with no nonBlank() and no early exit.
nonBlank (:12-15) returns the trimmed value, or undefined when the trimmed value is empty. The internal path is the only one of the four that does not use it, and it is also the only one missing the if (!token) return null early exit its sibling has at :110.
The caller side is already trimmed: extractBearerToken (:26-29) trims the captured group. So a configured INTERNAL_JOB_TOKEN carrying leading or trailing whitespace — the exact shape a secret file or a wrangler secret put piped from a file produces when it ends in a newline — can never be matched by any caller, while the same whitespace on LOOPOVER_API_TOKEN is tolerated. The failure mode is a blanket 401 unauthorized from the /v1/internal/* middleware (src/api/routes.ts:1216-1220) for every internal job route and every internal read endpoint, with nothing pointing at whitespace as the cause.
INTERNAL_JOB_TOKEN is typed non-optional (src/env.d.ts:341), so this is purely a normalization gap, not a fail-open one: timingSafeEqual already returns false for an empty or undefined expected value (:91), and nonBlank(" ") returns undefined, so a whitespace-only secret keeps denying everything.
Requirements
authenticateInternalToken must apply nonBlank() to env.INTERNAL_JOB_TOKEN, exactly as authenticatePrivateToken does for its three secrets.
authenticateInternalToken must early-return null for an absent/empty token argument, matching authenticatePrivateToken's if (!token) return null at src/auth/security.ts:110.
- A whitespace-only
INTERNAL_JOB_TOKEN must still authenticate nobody.
- No other authenticator changes.
⚠️ Required pattern: reuse the existing module-local nonBlank helper at src/auth/security.ts:12-15 and the existing timingSafeEqual call shape. It does NOT satisfy this issue to add a new trimming helper; to trim inside timingSafeEqual (which would change the semantics of every caller, including the OAuth state HMAC comparison at src/auth/github-oauth.ts:403); to trim at the middleware call site in src/api/routes.ts; or to normalize the incoming bearer instead of the configured secret.
Deliverables
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example adding nonBlank() without the whitespace-only denial test — does not resolve this issue.
Test Coverage Requirements
src/auth/** is inside Codecov's src/** include; the 99% branch-counted patch gate applies. Both arms of the new if (!token) guard and both arms of the nonBlank result (a real trimmed secret, and undefined from a blank one) must be covered by the cases above. The " secret " case is the named regression test for this fix.
Expected Outcome
INTERNAL_JOB_TOKEN tolerates the same surrounding whitespace LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, and LOOPOVER_MCP_ADMIN_TOKEN already do, so a secret file that ends in a newline does not silently 401 every /v1/internal/* route while the other three credentials keep working.
Links & Resources
src/auth/security.ts:12-15, :26-29, :90-97, :109-124; src/api/routes.ts:1216-1220; src/env.d.ts:341; test/unit/auth-security-helpers.test.ts.
Context
src/auth/security.tshas two token authenticators side by side.authenticatePrivateTokenearly-returnsnullfor a falsy token, then compares againstnonBlank(env.LOOPOVER_API_TOKEN),nonBlank(env.LOOPOVER_MCP_ADMIN_TOKEN)andnonBlank(env.LOOPOVER_MCP_TOKEN).authenticateInternalTokencompares against the rawenv.INTERNAL_JOB_TOKENwith nononBlank()and no early exit.nonBlank(:12-15) returns the trimmed value, orundefinedwhen the trimmed value is empty. The internal path is the only one of the four that does not use it, and it is also the only one missing theif (!token) return nullearly exit its sibling has at:110.The caller side is already trimmed:
extractBearerToken(:26-29) trims the captured group. So a configuredINTERNAL_JOB_TOKENcarrying leading or trailing whitespace — the exact shape a secret file or awrangler secret putpiped from a file produces when it ends in a newline — can never be matched by any caller, while the same whitespace onLOOPOVER_API_TOKENis tolerated. The failure mode is a blanket401 unauthorizedfrom the/v1/internal/*middleware (src/api/routes.ts:1216-1220) for every internal job route and every internal read endpoint, with nothing pointing at whitespace as the cause.INTERNAL_JOB_TOKENis typed non-optional (src/env.d.ts:341), so this is purely a normalization gap, not a fail-open one:timingSafeEqualalready returnsfalsefor an empty or undefined expected value (:91), andnonBlank(" ")returnsundefined, so a whitespace-only secret keeps denying everything.Requirements
authenticateInternalTokenmust applynonBlank()toenv.INTERNAL_JOB_TOKEN, exactly asauthenticatePrivateTokendoes for its three secrets.authenticateInternalTokenmust early-returnnullfor an absent/emptytokenargument, matchingauthenticatePrivateToken'sif (!token) return nullatsrc/auth/security.ts:110.INTERNAL_JOB_TOKENmust still authenticate nobody.Deliverables
authenticateInternalTokenwrapsenv.INTERNAL_JOB_TOKENinnonBlank()and early-returnsnullwhentokenis falsy.test/unit/auth-security-helpers.test.tsasserts that withINTERNAL_JOB_TOKEN = " secret ",authenticateInternalToken(env, "secret")returns{ kind: "static", actor: "internal" }. This case fails on currentmain.INTERNAL_JOB_TOKEN = " "(whitespace only),authenticateInternalToken(env, " ")andauthenticateInternalToken(env, "")both returnnull.authenticateInternalToken(env, undefined)returnsnullwithout consulting the secret.INTERNAL_JOB_TOKEN = "secret", bearer"secret"-> internal identity).All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example adding
nonBlank()without the whitespace-only denial test — does not resolve this issue.Test Coverage Requirements
src/auth/**is inside Codecov'ssrc/**include; the 99% branch-counted patch gate applies. Both arms of the newif (!token)guard and both arms of thenonBlankresult (a real trimmed secret, andundefinedfrom a blank one) must be covered by the cases above. The" secret "case is the named regression test for this fix.Expected Outcome
INTERNAL_JOB_TOKENtolerates the same surrounding whitespaceLOOPOVER_API_TOKEN,LOOPOVER_MCP_TOKEN, andLOOPOVER_MCP_ADMIN_TOKENalready do, so a secret file that ends in a newline does not silently 401 every/v1/internal/*route while the other three credentials keep working.Links & Resources
src/auth/security.ts:12-15,:26-29,:90-97,:109-124;src/api/routes.ts:1216-1220;src/env.d.ts:341;test/unit/auth-security-helpers.test.ts.