Skip to content

fix(api): make GET and DELETE /api/posts/:postId/taxonomy-tags reachable - #247

Merged
rkm1 merged 1 commit into
mainfrom
fix/taxonomy-tags-route-prefix
Sep 8, 2026
Merged

fix(api): make GET and DELETE /api/posts/:postId/taxonomy-tags reachable#247
rkm1 merged 1 commit into
mainfrom
fix/taxonomy-tags-route-prefix

Conversation

@rkm1

@rkm1 rkm1 commented Sep 8, 2026

Copy link
Copy Markdown
Member

The defect

GET and DELETE /api/posts/:postId/taxonomy-tags have never worked over HTTP.

Both route patterns omitted the /api prefix that their POST sibling, the /api/posts/:postId/tags/suggestions route, and test/e2e/taxonomy-tagging.test.ts all use — while both handlers parse the id with pathname.split("/api/posts/")[1]. That is broken in two directions at once:

Request What happens
/api/posts/:id/taxonomy-tags matches no route → 404
/posts/:id/taxonomy-tags (the pattern's own path) reaches the handler; split("/api/posts/")[1] is undefined; .split() on it throws a TypeError into the catch → 500

So reading a post's taxonomy tags and removing them are both dead. POST (adding tags) works — it has the prefix.

Why nothing caught it

This is the part worth reading.

Two unit files already exercised these handlers, and they passed. They found the route by testing the pattern against /posts/post-123/taxonomy-tags — the prefix-less pattern — and then handed the handler pathname: "/api/posts/post-123/taxonomy-tags".

The two halves cancel out. The lookup succeeds because the pattern lacks /api; the handler's parse succeeds because the pathname has it. Neither half corresponds to a real request, and together they describe a configuration the router cannot produce.

routes/posts.test.ts even carried this at every call site:

pathname: "/api/posts/post-123/taxonomy-tags", // Router may normalize to include /api

The mismatch was noticed, guessed at, and never checked. It does not normalize — regexToHonoPath translates the pattern as written and toHono passes the real url.pathname.

The e2e suite does call the real paths, and would have failed. It needs a live API_URL and doesn't run in the PR gate.

And this reaches a security fix. The V4(a) change that made this GET authenticate before its existence check — closing the anonymous cross-tenant post-existence oracle — is verified by posts-taxonomy-authz.test.ts. That file was green against a route no request could reach, and green because of the same cancelling mismatch. The ordering in the handler is correct; nothing was holding it down.

The fix

Add \/api to both patterns. Minimal, and it makes the patterns agree with their POST sibling, with both handlers' own parsing, and with the e2e contract.

Then remove the cancellation: every lookup and request URL in the three test files now uses the /api path, so if one half drifts again the tests fail instead of agreeing with each other.

New cover

routes-posts-extended.test.ts had no cases for either method, which is what let this survive. Added four:

  • routabilityfindRoute tests each pattern against the path, so this asserts reachability rather than mere existence of a route object;
  • the postId reaches the authorizer as "post-1", not undefined, plus an explicit assertion that no 500 is produced;
  • GET answers 401 for an anonymous caller without consulting the post at all (mockDataRouterGetPost and mockCanReadPost both un-called) — pinning V4(a) with a test that can actually fail;
  • DELETE's id reaches both DataRouter.getPost and removePostTaxonomyTags.

All four fail red against the old patterns: findRoute returns undefined, which is the 404 half of the defect reproduced as a test.

Verification

  • vitest run in apps/api: 12264 passed, 0 failed (652 files, 6 skipped).
  • tsc --build in apps/api (the lint script): clean.
  • scripts/check-api-snapshot.mjs: both snapshots up to date. An /api prefix alone does not enter the OpenAPI snapshot — the POST sibling already has one and is absent from it.
  • Two files failed on first run purely from the local sandbox blocking TCP binds (email-provider-scaleway, worker-runtime/health-shutdown); both pass with it off. Not related to this change.

Found while here, not fixed

DELETE still distinguishes 404 (no such post) from 403 (post exists, not yours) after a bare DataRouter.getPost. For any authenticated caller that discriminates post existence across tenants — the same family as the V4 residuals, narrower than the anonymous oracle since it needs a session. The consistent shape would be GET's uniform taxonomyDenyResponse, but turning a 403 into a 404 is an API behaviour change and a judgement call about the authz surface, so it belongs in the V4 residual work rather than smuggled in here.

🤖 Generated with Claude Code

Both route patterns omitted the `/api` prefix that their POST sibling, the
`/api/posts/:postId/tags/suggestions` route and `test/e2e/
taxonomy-tagging.test.ts` all use, while both handlers parse the id with
`pathname.split("/api/posts/")[1]`. So the endpoints were unusable in two
different ways at once:

  - a request to `/api/posts/:id/taxonomy-tags` matched no route — 404;
  - a request to the pattern's own `/posts/:id/taxonomy-tags` reached the
    handler, where `split("/api/posts/")[1]` is `undefined`, `.split()` on it
    threw a TypeError into the catch, and the answer became 500.

Reading a post's taxonomy tags and removing them have therefore never worked
over HTTP. The e2e suite does call the real paths, but it needs a live
API_URL and does not run in the PR gate.

WHY NOTHING CAUGHT IT, which is the more useful half. The two existing unit
files exercised these handlers through a lookup that could not correspond to
any real request: they found the route by testing the pattern against
`/posts/post-123/taxonomy-tags` — the prefix-less pattern — and then handed
the handler `pathname: "/api/posts/post-123/taxonomy-tags"`. The halves
cancel. The lookup succeeds because the pattern lacks `/api`; the handler's
parse succeeds because the pathname has it. `routes/posts.test.ts` even
carried the comment "Router may normalize to include /api" at each call site
— the mismatch was noticed, guessed at, and never checked. It does not
normalize.

That matters beyond a broken read: the V4(a) fix which made this GET
authenticate before its existence check (the anonymous cross-tenant oracle)
is verified by `posts-taxonomy-authz.test.ts`, which was green against a
route no request could reach — and green *because* of the same mismatch.

Both lookups and every request URL in the three test files now use the `/api`
path, so a future drift of one half fails instead of cancelling. New cover in
`routes-posts-extended.test.ts`, which had none for either method: routability
via `findRoute` (the direct guard — it tests the pattern against the path), the
postId actually arriving at the authorizer rather than as `undefined`, no 500,
GET's 401-before-any-post-lookup, and DELETE's id reaching both
`DataRouter.getPost` and `removePostTaxonomyTags`.

All four fail red against the old patterns — `findRoute` returns undefined,
which is the 404 half of the defect reproduced as a test.

No snapshot change: `check-api-snapshot.mjs` reports both snapshots up to
date, and an `/api` prefix alone does not enter the OpenAPI snapshot (the POST
sibling already has one and is absent from it). `tsc --build` in apps/api is
clean; the api suite is 12264 passed / 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rkm1
rkm1 added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit e692707 Sep 8, 2026
15 checks passed
@rkm1
rkm1 deleted the fix/taxonomy-tags-route-prefix branch September 8, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant