11.1.2 The shared `/api/v1` route wrapper — bearer-PAT auth + scope gate + `{ code, error }` mapping + request id, proven by `GET /api/v1/me`
Repo: motir-core. One PR. The first /api/v1 code: the wrapper every later endpoint composes, plus the smallest real endpoint that proves it works end to end.
blocked_by 11.1.1 — the wrapper implements that ADR's decisions; building it first would mean deciding them in code.
What to build
1. The wrapper — one helper in lib/api/v1/ that every /api/v1/**/route.ts composes. It:
- Authenticates the bearer PAT by calling the shipped
authenticateApiToken(req, scope)(lib/apiTokens/routeAuth.ts). Do NOT re-implement token parsing, hashing, revocation or expiry checks — that function already returns{ ok, userId, workspaceId }or{ ok: false, reason: 'unauthenticated' | 'forbidden' }, which is exactly this wrapper's 401/403 split. - Maps the result to status:
unauthenticated→ 401 (undifferentiated),forbidden→ 403. - Builds the
ServiceContextthe services expect from the resolved{ userId, workspaceId }, so the route's single service call runs with the token owner's identity and the product's own access checks apply unchanged. - Translates a thrown typed service error into
{ code, error }+ its status; an UNRECOGNISED error propagates as a 500 with nocodeleaked and no Prisma / stack text in the body. - Stamps a request id on the response for support correlation.
2. GET /api/v1/me — the token owner's identity + the workspace the token is bound to + the token's granted scopes. Requires read. Chosen as the proving endpoint because it exercises the whole wrapper with no collection and no resource modelling, so a failure here is unambiguously the wrapper's.
Returning the granted scopes is deliberate and load-bearing: it is how a client discovers what its own token may do without probing endpoints and collecting 403s — the same reason motir doctor exists on the CLI side.
Architecture
Follow the 4-layer contract exactly: the route parses, calls the wrapper, calls ONE service method, returns. No db.*, no $transaction, no business logic in the route. Nothing about this story needs a new service, repository, mapper or migration — if it appears to, stop: that is a card in the owning feature's epic, not here.
Scope BOUNDARY
Ends at the wrapper + /me. It does NOT add pagination (11.1.3), rate limiting (11.1.4) or any resource endpoint (11.2 / 11.3) — but it MUST leave clean seams for the first two, since they compose into the same wrapper rather than wrapping it again. It does NOT modify authenticateApiToken, apiTokensService, lib/mcp/**, or anything under app/api/** outside the new app/api/v1/ tree. It writes no OpenAPI spec (11.4).
Acceptance criteria
- A valid PAT carrying
readgets 200 fromGET /api/v1/mewith the owner's identity, the bound workspace, and the token's granted scopes. - Missing, malformed, unknown, revoked and expired tokens ALL return 401, and the response body does not distinguish which — asserted as five separate cases against one shared expectation, so a future "helpful" error message cannot silently turn the endpoint into a token oracle.
- A valid token WITHOUT the required scope returns 403 — distinct from 401, and never a 200 with an empty body.
- A typed service error becomes
{ code, error }with its mapped status; an unexpected error becomes a 500 whose body contains no Prisma message, no stack and nocode— asserted by throwing a raw error through the wrapper. - Every response carries the request id header.
- The route contains no
db.*call and no$transaction— the 4-layer contract, asserted by the architecture guard, not just by review. - The wrapper composes: a second route can adopt it without copying auth or error-mapping logic (demonstrated by the tests exercising a fixture route, not only
/me). - Unit + integration tests ship in the same PR, against real Postgres (the repo convention — no mocked DB), covering every branch above.
- The per-file coverage floor (≥90% branch/fn/line) holds on every new file.
Context refs
lib/apiTokens/routeAuth.ts—authenticateApiToken(req, requiredScope)and itsApiTokenAuthResultdiscriminated union; the 401/403 split to map, NOT to re-implement.lib/mcp/scopes.ts—TokenScope, the scope type the wrapper takes.lib/mcp/auth.ts—verifyMcpToken, the MCP-side sibling; read it for the non-disclosure behaviour this must match.lib/mcp/context.ts+lib/workItems/serviceContext.ts— how a resolved token becomes theServiceContextservices expect.app/api/work-items/[id]/route.ts— the shipped{ code, error }+ status envelope to mirror.app/api/work-items/[id]/acceptance-evidence/upload-token/route.ts— the existing REST route that already authenticates a PAT; the closest working example.- Decision: 11.1.1. Parent story: 11.1.