The REST API is a second OAuth resource, which a client asks for when it registers¶
Context and Problem Statement¶
The REST API (/api/v1, #5) follows MCP (V5). Scripts need tokens for it, and ADR 0004 and the engineering conventions (MCP first, one domain layer) say a token is bound to one surface: an MCP token fails on REST and the other way round. ADR 0006 wires Better Auth 1.7's mcp() plugin with one resource, /mcp. How does a script get a token for /api/v1, and which clients may?
Decision Drivers¶
- One token, one surface: the audience decides, checked on every request.
- No sign-in code of questlog's own beyond configuration (ADR 0006).
- claude.ai registers itself (RFC 7591) and only ever needs
/mcp; a client that only reads the log through MCP shouldn't hold a key to the API without asking.
Considered Options¶
- Every registered client may ask for either resource (
/api/v1among the defaults every client is linked to). Simple, but every MCP client could mint API tokens. - A client asks for
/api/v1when it registers (resourcesin its registration), allowed byclientRegistrationAllowedResources; every client is still linked to/mcpby default. (chosen) - Personal API keys made on a settings page. A second credential type to store, show once, revoke and test; OAuth with PKCE already does it, and a script can run the flow once and keep the refresh token.
Decision Outcome¶
Chosen: a second resource, asked for at registration.
mcp()getsresources: [${PUBLIC_URL}/api/v1]andclientRegistrationAllowedResourceswith the same. Better Auth enforces per-client resources by default, so a client registered without asking (claude.ai) getsinvalid_targetfor/api/v1.- A script registers as a native client (
application_type: "native", a loopback redirect URI) withresources: ["<PUBLIC_URL>/api/v1"], signs the player in with PKCE, and asks the token endpoint withresource=<PUBLIC_URL>/api/v1. /api/v1checks the token withverifyAccessTokenRequestfrombetter-auth/oauth2: signature (keys over loopback, as/mcpfetches them), issuer, audience/api/v1, expiry, DPoP when bound; then that the player still exists. A refused request gets a 401 withWWW-Authenticatepointing at/.well-known/oauth-protected-resource/api/v1, which questlog serves itself (themcp()plugin serves only/mcp's).- The session cookie doesn't count on
/api/v1, as it doesn't on/mcp.
Consequences¶
- Good, because one token can't cross surfaces, and MCP clients get nothing they didn't ask for.
- Good, because there is no credential type of questlog's own.
- Bad, because a script has to run an OAuth flow once instead of pasting a key; the reference page shows it.