I've been running Spliit Cloud for a while and wanted to point an agent at my own data. Nothing exotic, mostly adding expenses from a script and reading balances back out. Turns out there's no way to do it: sessions are cookie-bound, and the OAuth path is wired specifically for MCP.
ROADMAP.md already flags this in Phase 4, "Stable per-user API tokens remain planned". CONTRIBUTING.md asks for an issue before larger changes, so this is a design proposal rather than a surprise PR. I'd rather agree on the shape before writing any code.
Where things stand
Three credentials exist today. None of them work for a script.
Session cookies reach everything behind protectedProcedure, but they're browser-bound. There's no issue/scope/revoke story for a headless client.
OAuth through the oauth-provider plugin gets you four procedures on the assistant router. It's gated behind ENABLE_MCP, the audience is pinned to the MCP endpoint, and it wants a public tunnel plus an interactive consent flow. That's the right design for ChatGPT and Claude connectors. It's the wrong one for a cron job.
Group bearer tokens are read-only and scoped to a group rather than an account.
So the OpenAPI document advertises 163 operations and nothing outside a browser can authenticate against a single one.
Design
Resolution. AuthContext.auth in trpc/init.ts is already a union discriminated on credentialKind, and the context factory already chains two resolvers. I'd follow that seam rather than widen it: add an ApiKeyResolvedAuth variant mirroring OAuthResolvedAuth, add a resolver reading x-api-key, chain it.
Authorization. protectedProcedure keeps rejecting non-session credentials, the same way it already rejects OAuth ones. That's what makes the first step safe: no existing procedure changes behaviour. Access comes instead from a new apiProcedure(scope), taking a session or a key carrying that scope, reusing the spliit:* namespace the OAuth provider already established (spliit:groups:read, spliit:expenses:write, and so on).
Storage. The @better-auth/api-key plugin, which matches the better-auth version already pinned here. It covers hashing, prefixes, a short fragment for rendering keys safely in a UI, expiry, per-key rate limiting and metadata. Its table needs the same kind of modelName override the other better-auth models already use, plus a migration.
I'd skip enableSessionForAPIKeys, since it promotes a key to a full session and throws away the scoping above.
Surface. Create/list/revoke procedures under account, plaintext returned once at creation. A settings section next to the existing account ones. A securitySchemes.apiKey entry so /docs documents the header.
Splitting it up
- Plugin, schema mapping, migration, the new auth variant and
apiProcedure. Nothing exposed yet.
- Management procedures and settings UI.
apiProcedure applied to groups and expenses.
- OpenAPI security scheme and docs.
Stopping after any of them leaves the tree coherent.
Where I'm unsure
Scope shape. Domain and verb is the obvious axis, but per-group scoping (a key that only ever sees one ledger) might be worth having from the start instead of retrofitted. I genuinely don't know which way you'd want this.
Rate limiting. The plugin ships a per-key limiter and the repo already has FixedWindowLimiter. Layering both is wasteful, picking one feels arbitrary from outside. Preference?
No env flag. I'd rather not gate this one. ENABLE_MCP earns its flag because it has real config dependencies and the API won't boot without them. API keys have nothing to provision, so a flag would only add an ops decision on top of the merge, Spliit Cloud included. Push back if you see it differently.
Anonymous accounts presumably can't issue keys, consistent with the checks elsewhere. Assuming that's right.
Once those are answered and the direction looks right to you, I'm happy to take the implementation from there and open the PRs.
I've been running Spliit Cloud for a while and wanted to point an agent at my own data. Nothing exotic, mostly adding expenses from a script and reading balances back out. Turns out there's no way to do it: sessions are cookie-bound, and the OAuth path is wired specifically for MCP.
ROADMAP.mdalready flags this in Phase 4, "Stable per-user API tokens remain planned".CONTRIBUTING.mdasks for an issue before larger changes, so this is a design proposal rather than a surprise PR. I'd rather agree on the shape before writing any code.Where things stand
Three credentials exist today. None of them work for a script.
Session cookies reach everything behind
protectedProcedure, but they're browser-bound. There's no issue/scope/revoke story for a headless client.OAuth through the oauth-provider plugin gets you four procedures on the
assistantrouter. It's gated behindENABLE_MCP, the audience is pinned to the MCP endpoint, and it wants a public tunnel plus an interactive consent flow. That's the right design for ChatGPT and Claude connectors. It's the wrong one for a cron job.Group bearer tokens are read-only and scoped to a group rather than an account.
So the OpenAPI document advertises 163 operations and nothing outside a browser can authenticate against a single one.
Design
Resolution.
AuthContext.authintrpc/init.tsis already a union discriminated oncredentialKind, and the context factory already chains two resolvers. I'd follow that seam rather than widen it: add anApiKeyResolvedAuthvariant mirroringOAuthResolvedAuth, add a resolver readingx-api-key, chain it.Authorization.
protectedProcedurekeeps rejecting non-session credentials, the same way it already rejects OAuth ones. That's what makes the first step safe: no existing procedure changes behaviour. Access comes instead from a newapiProcedure(scope), taking a session or a key carrying that scope, reusing thespliit:*namespace the OAuth provider already established (spliit:groups:read,spliit:expenses:write, and so on).Storage. The
@better-auth/api-keyplugin, which matches the better-auth version already pinned here. It covers hashing, prefixes, a short fragment for rendering keys safely in a UI, expiry, per-key rate limiting and metadata. Its table needs the same kind ofmodelNameoverride the other better-auth models already use, plus a migration.I'd skip
enableSessionForAPIKeys, since it promotes a key to a full session and throws away the scoping above.Surface. Create/list/revoke procedures under
account, plaintext returned once at creation. A settings section next to the existing account ones. AsecuritySchemes.apiKeyentry so/docsdocuments the header.Splitting it up
apiProcedure. Nothing exposed yet.apiProcedureapplied togroupsandexpenses.Stopping after any of them leaves the tree coherent.
Where I'm unsure
Scope shape. Domain and verb is the obvious axis, but per-group scoping (a key that only ever sees one ledger) might be worth having from the start instead of retrofitted. I genuinely don't know which way you'd want this.
Rate limiting. The plugin ships a per-key limiter and the repo already has
FixedWindowLimiter. Layering both is wasteful, picking one feels arbitrary from outside. Preference?No env flag. I'd rather not gate this one.
ENABLE_MCPearns its flag because it has real config dependencies and the API won't boot without them. API keys have nothing to provision, so a flag would only add an ops decision on top of the merge, Spliit Cloud included. Push back if you see it differently.Anonymous accounts presumably can't issue keys, consistent with the checks elsewhere. Assuming that's right.
Once those are answered and the direction looks right to you, I'm happy to take the implementation from there and open the PRs.