Commit 61faeb1
fix(api): P1 bundle 2026-05-29 — 7 agent-UX hygiene fixes (#178)
* fix(middleware): add request_id + agent_action to idempotency 409 envelope (BUG-API-013)
Pre-fix the 409 returned when an agent reused an Idempotency-Key with a
different body carried only {ok, error, message} — the agent had no
request_id to quote to support and no agent_action sentence to render
to the user. Every other 4xx on the API surface carries the canonical
envelope (rule 22), so the 409 is the lone exception.
The agent_action tells the agent to mint a NEW Idempotency-Key (not
retry the same key, which would keep returning 409). retry_after_seconds
is null: the conflict is permanent for this (key, body) pair — only
re-keying resolves it.
Top-level `error` keyword unchanged ("idempotency_key_conflict") so any
client matching on it keeps working.
Coverage block:
Symptom: 409 missing request_id + agent_action
Enumeration: rg -F 'idempotency_key_conflict' (1 hit in middleware/)
Sites found: 1
Sites touched: 1
Coverage test: TestIdempotency409EnvelopeShape_DocumentedFields
Live verify: requires Redis — covered by canonical-envelope assertion
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(middleware): differentiate 401 cases via error_code sub-field (BUG-API-051)
RequireAuth's 401 envelope now carries an `error_code` sub-field that
names which sub-case fired:
- missing_credentials : no Authorization header / non-Bearer scheme
- malformed_token : header present but JWT/PAT won't parse
- expired_token : JWT parsed cleanly but exp is in the past
- invalid_claims : signature valid, uid/tid missing
- revoked_session : jti in the session-revocation set
Pre-fix every 401 from this middleware carried `error=unauthorized`
with no sub-classification, so an agent inspecting a 401 from /auth/me,
/api/v1/whoami, or any protected route had no way to distinguish "I
never sent credentials" from "my JWT expired 30s ago" from "the
signature is wrong" — all rendered the same envelope (BUG-API-035/051).
Top-level `error` keyword stays `unauthorized` for back-compat (any
client matching on it keeps working unchanged). The sub-code lands in
the new `error_code` field. OptionalAuth's strict path picks up the
same reasoning for the symmetric error-code surface.
Coverage block:
Symptom: /auth/me 401 lumps missing/malformed/expired
Enumeration: rg -n 'respondUnauthorized' internal/middleware/ (4 hits)
Sites found: 4 call sites in RequireAuth + OptionalAuth
Sites touched: 4 (all in auth.go); rbac.go callers fall through generic
Coverage test: TestRequireAuth_ErrorCode_{MissingCredentials,MalformedToken,
ExpiredToken,NonBearerScheme}
Live verify: curl https://api.instanode.dev/auth/me → 401 +
jq .error_code must be "missing_credentials"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(router,middleware): reject non-allowlisted CORS preflights with 403 (BUG-API-066/067)
Fiber's CORS middleware sets Access-Control-Allow-* response headers
from the static Config but does NOT cross-check the inbound
Access-Control-Request-Method / Access-Control-Request-Headers against
the allowlist. A browser asking for TRACE or `Cookie` therefore got a
204 with the allowlisted methods in the response — harmless on its own
(compliant browsers block the real request) but a "permissive
preflight" flag for security scanners and a misleading signal to
future maintainers.
PreflightAllowlist is a tiny pre-CORS gate. For OPTIONS requests
carrying an Access-Control-Request-Method header, it rejects (403)
when:
- the requested method is not in the allowed-methods list
- any requested header is not in the allowed-headers list
The allowlist strings are extracted into named constants
(`corsAllowMethods` / `corsAllowHeaders`) and passed to both the new
middleware and the existing fiberCORS.New(...) — single source of truth,
no drift risk.
Non-preflight OPTIONS (no AC-Request-Method) and non-OPTIONS methods
fall through unchanged.
Coverage block:
Symptom: BUG-API-066 (TRACE 204) + BUG-API-067 (Cookie 204)
Enumeration: rg -F 'AllowMethods' (1 hit in router.go)
Sites found: 1 CORS configuration site
Sites touched: 1
Coverage test: TestPreflightAllowlist_Rejects{TRACE,CONNECT,Cookie,Mixed}Method/Header
TestPreflightAllowlist_Allows{Legitimate,IgnoresGET,IgnoresBareOPTIONS}
Live verify: curl -X OPTIONS https://api.instanode.dev/whoami
-H "Origin: https://instanode.dev"
-H "Access-Control-Request-Method: TRACE" → 403
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth,router): /auth/logout idempotent on missing/expired creds (BUG-AUTH-005)
OpenAPI says POST /auth/logout is "Idempotent; safe to call without a
valid token." Pre-fix the route was gated by RequireAuth, so:
- no Authorization header → 401
- non-Bearer scheme → 401
- expired JWT → 401
- wrong-secret JWT → 401
That broke the dashboard's logout-on-expiry path (the local token is
already useless; the dashboard wants the server to clear its row and
move on, not flag a confusing 401).
Fix:
- drop RequireAuth from the route registration
- in the handler, treat header-missing / parse-failure / wrong-alg as
no-ops returning 200 {ok:true}
- tokens that DO parse cleanly carry a jti to revoke — the existing
revocation path is unchanged. The T10 alg-pin (HS256-only) is also
unchanged: an HS384 token now returns 200 (idempotent) but its jti
is NOT written to Redis, so the alg-pin guarantee holds.
Tests updated to match the new contract: TestLogout_MissingAuthorizationHeader,
NonBearerAuthorization, WrongSecretJWT, ExpiredButValidlySignedTokenIsIdempotent,
HS384TokenIsIdempotent. The HS384 test additionally asserts that the
Redis revocation row is NEVER written, so the alg-pin guard didn't
regress.
Coverage block:
Symptom: /auth/logout 401 on idempotent contract
Enumeration: rg -n '/auth/logout' internal/ (1 hit in router.go)
Sites found: 1 route + 1 handler
Sites touched: 2 (router.go drops RequireAuth; auth_logout.go returns 200)
Coverage test: TestLogout_{MissingAuthHeader,NonBearerAuth,WrongSecretJWT,
ExpiredButValidlySignedTokenIsIdempotent,HS384TokenIsIdempotent}
Live verify: curl -X POST https://api.instanode.dev/auth/logout (no auth) → 200
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(handlers): drop hardcoded 64-char cap from name_too_long agent_action (BUG-AUTH-006)
Pre-fix agent_action said "Tell the user the 'name' field exceeds 64
characters. Shorten it to a short human label (1-64 chars)..." but the
actual cap varies per endpoint:
- resource names : 1-64 chars
- PAT (API key) names : 1-120 chars (api_keys.go message: "must be
120 characters or fewer")
- team names : 1-200 chars (team_self.go message: "must be
200 characters or fewer")
A user POSTing a 100-char PAT name therefore saw "Field 'name' must be
120 characters or fewer" in `message` AND "exceeds 64 characters" in
agent_action — agent renders contradiction, user opens support ticket.
Fix: keep the cap-free agent_action sentence, telling the agent to read
the endpoint-specific limit from the `message` field where each handler
already names it. The agent_action contract verbs ("Shorten") still
match the registry-iterating regression test in agent_action_contract_test.go.
Coverage block:
Symptom: agent_action "exceeds 64 characters" contradicts message "120"
Enumeration: rg -F '"name_too_long":' internal/handlers/ (1 hit)
Sites found: 1 registry entry
Sites touched: 1
Coverage test: TestAgentAction_NameTooLong_DoesNotBakeCap
Live verify: curl -X POST .../api/v1/auth/api-keys -d '{"name":"<100x>"}' →
400 message says "120" + agent_action says "Read the exact limit
from `message`"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(handlers): accept ?token= as fallback for magic-link ?t= (BUG-API-011)
GET /auth/email/callback?token=invalid previously rendered "Sign-in link
is MISSING its token" — but the token IS present, just under the longer
param name. Canonical magic-link URLs we emit always use `?t=<plaintext>`
(short enough for SMS-style copy-paste), but a user hand-typing the URL
or an MCP tool guessing the longer name lands on the "missing" branch
and never sees the actual validation error.
Fix: read `?token=` as a fallback only when `?t=` is absent. `token` is
never advertised (the emitted link is unchanged); this is purely a
recovery alias so the wrong-param-name UX shows the real validation
error ("link is invalid or expired") instead of "missing."
Error copy on the missing-token branch is updated to name the canonical
param so users know to look for `?t=...` in their email link.
Coverage block:
Symptom: ?token= → "missing its token" even though token is present
Enumeration: rg -F 'c.Query("t")' internal/handlers/magic_link.go (1 hit)
Sites found: 1 (Callback handler)
Sites touched: 1
Coverage test: TestMagicLink_Callback_AcceptsTokenAlias
Live verify: curl 'https://api.instanode.dev/auth/email/callback?token=bad'
→ "link is invalid or expired" (validation branch, not "missing")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cors): cover empty-token continue branches (patch coverage)
cors_preflight_allowlist.go lines 70+88 are defensive 'skip empty token'
branches that the BUG-API-066/067 regression tests don't hit. Add two
tiny tests that pass comma-separated input with stray double-commas so
diff-cover lands at 100% on the new file.
auth.go lines 519-520 (the AuthErrorExpiredToken assignment for
optionalAuth strict mode) are already covered by the existing
TestOptionalAuthStrict_ExpiredToken_Returns401 — verified locally:
'go tool cover' shows hit count 1 on both.
Verified locally: middleware suite passes, line-coverage on the two
new branches confirmed via 'go tool cover -func'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(middleware): cover AuthErrorInvalidClaims branch (patch coverage)
auth.go lines 528-529 are reached when a JWT with valid signature has
empty uid OR tid claims — the InvalidClaims arm added in PR #178 for
BUG-API-051 sub-codes. Existing strict tests cover Garbage/Expired/
WrongSecret/NonBearer but never construct a valid-sig + empty-claim
combination.
Three subcases added: empty tid, empty uid, both empty. All three
must 401 in OptionalAuthStrict mode.
Verified locally: all 3 tests PASS; coverprofile shows lines 528-529
hit count = 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): use jwt/v4 (matches existing imports), satisfies go.sum
The previous push imported jwt/v5 which broke the build. The whole
middleware package uses v4 per go.mod. This commit fixes the import
path; locally verified TestOptionalAuthStrict_*_InvalidClaims all PASS
and coverage shows lines 528-529 hit count = 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): drop unused fiber import
Three failed pushes in a row reminds the discipline: when modifying
the file, re-run go build before each push. Locally verified green
this time before push.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 339b269 commit 61faeb1
15 files changed
Lines changed: 853 additions & 29 deletions
File tree
- internal
- handlers
- middleware
- router
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
99 | | - | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
100 | 104 | | |
101 | 105 | | |
102 | | - | |
| 106 | + | |
103 | 107 | | |
104 | 108 | | |
105 | 109 | | |
| |||
117 | 121 | | |
118 | 122 | | |
119 | 123 | | |
120 | | - | |
| 124 | + | |
| 125 | + | |
121 | 126 | | |
122 | 127 | | |
123 | 128 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
75 | 80 | | |
76 | 81 | | |
77 | 82 | | |
| |||
81 | 86 | | |
82 | 87 | | |
83 | 88 | | |
84 | | - | |
| 89 | + | |
85 | 90 | | |
86 | 91 | | |
87 | 92 | | |
| |||
94 | 99 | | |
95 | 100 | | |
96 | 101 | | |
97 | | - | |
| 102 | + | |
98 | 103 | | |
99 | 104 | | |
100 | 105 | | |
| |||
117 | 122 | | |
118 | 123 | | |
119 | 124 | | |
120 | | - | |
| 125 | + | |
121 | 126 | | |
122 | 127 | | |
123 | 128 | | |
| |||
165 | 170 | | |
166 | 171 | | |
167 | 172 | | |
168 | | - | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
169 | 179 | | |
170 | 180 | | |
171 | 181 | | |
172 | 182 | | |
173 | | - | |
174 | 183 | | |
175 | 184 | | |
176 | 185 | | |
177 | 186 | | |
178 | 187 | | |
179 | 188 | | |
180 | | - | |
| 189 | + | |
| 190 | + | |
181 | 191 | | |
182 | 192 | | |
183 | 193 | | |
| |||
235 | 245 | | |
236 | 246 | | |
237 | 247 | | |
238 | | - | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
239 | 254 | | |
240 | 255 | | |
241 | 256 | | |
| |||
255 | 270 | | |
256 | 271 | | |
257 | 272 | | |
258 | | - | |
259 | | - | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
260 | 282 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
441 | 441 | | |
442 | 442 | | |
443 | 443 | | |
444 | | - | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
445 | 450 | | |
446 | 451 | | |
447 | 452 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
393 | 393 | | |
394 | 394 | | |
395 | 395 | | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
396 | 402 | | |
397 | 403 | | |
398 | | - | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
399 | 408 | | |
400 | 409 | | |
401 | 410 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
99 | 139 | | |
100 | 140 | | |
101 | 141 | | |
| |||
108 | 148 | | |
109 | 149 | | |
110 | 150 | | |
| 151 | + | |
111 | 152 | | |
112 | 153 | | |
113 | 154 | | |
| |||
266 | 307 | | |
267 | 308 | | |
268 | 309 | | |
269 | | - | |
| 310 | + | |
270 | 311 | | |
271 | 312 | | |
272 | 313 | | |
| |||
276 | 317 | | |
277 | 318 | | |
278 | 319 | | |
279 | | - | |
| 320 | + | |
280 | 321 | | |
281 | 322 | | |
282 | 323 | | |
| |||
296 | 337 | | |
297 | 338 | | |
298 | 339 | | |
299 | | - | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
300 | 351 | | |
301 | 352 | | |
302 | 353 | | |
303 | | - | |
| 354 | + | |
304 | 355 | | |
305 | 356 | | |
306 | 357 | | |
| |||
323 | 374 | | |
324 | 375 | | |
325 | 376 | | |
326 | | - | |
| 377 | + | |
327 | 378 | | |
328 | 379 | | |
329 | 380 | | |
| |||
443 | 494 | | |
444 | 495 | | |
445 | 496 | | |
446 | | - | |
| 497 | + | |
447 | 498 | | |
448 | 499 | | |
449 | 500 | | |
| |||
470 | 521 | | |
471 | 522 | | |
472 | 523 | | |
473 | | - | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
474 | 531 | | |
475 | 532 | | |
476 | 533 | | |
| |||
0 commit comments