feat(user-api): User API — platform accounts & self-service API keys (backend)#2044
feat(user-api): User API — platform accounts & self-service API keys (backend)#2044brunod-e wants to merge 23 commits into
Conversation
New apps/user-api service — the user identity & session layer decided in the auth architecture discussion. Reached only via the dashboard /api/user proxy (HTTP-only cookies), outside Gateful. This first increment is the foundation: - better-auth wired with the SIWE plugin; EOA + EIP-1271/ERC-6492 (smart-contract wallets) verified via viem. - One better-auth instance per host in AUTH_SIWE_DOMAINS so main and whitelabel domains each sign SIWE against their real host; unlisted hosts fail closed. - Own Postgres database (default public schema) — better-auth core + SIWE walletAddress tables generated via the better-auth CLI, drizzle migration emitted. - Hono app with health (DB probe) and Prometheus metrics, mirroring the authful service layout. Follow-ups (separate increments): drafts moved to user scope, the /api/user Next proxy, dashboard login modal + drafts rewire, Authful provisioning for user API keys, Google OAuth + magic link. Refs DEV-978, DEV-979, DEV-950. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drafts move off the DAO APIs into the User API, keyed by userId with a daoId filter column — this structurally kills the DEV-978 hijack and DEV-979 enumeration (identity comes only from the session; there is no address parameter to spoof). - drafts table: server-generated UUID (the id is the share capability, so a client-chosen id would mint guessable links), nullable userId + authorAddress for migrated rows, jsonb actions, bigint ms timestamps. - Endpoints (Hono + zod-openapi): list (session-scoped, daoId filter), create (author = session, id server-side, 100/user quota), public share GET (isOwner derived from the session — replaces the dashboard's author-vs-wallet comparison), owner-only update/delete via a compound WHERE, identical 404s for foreign and missing rows (no existence oracle). - claim-on-first-login: migrated rows (userId NULL) are adopted by matching the user's SIWE wallet address, case-insensitively. - auth refactored to a dependency-injected createAuthResolver so the whole ceremony is testable offline. - 12 integration tests over the real SIWE nonce->verify->cookie->CRUD chain on in-memory PGlite: unauth 401, untrusted-host 400, ownership 404s, share isOwner per session, quota 403, migration claim. Refs DEV-978, DEV-979. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behind the dashboard's /api/user proxy the Host header is the internal service host, never a configured SIWE domain. Resolve the per-host better-auth instance (and fail-closed check) from x-forwarded-host, falling back to Host for direct calls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both methods are wired but activate only when configured, so the service stays SIWE-only out of the box: - magic link: enabled when RESEND_API_KEY is set; email delivered via Resend (mirrors the dashboard's existing Resend usage), sender from RESEND_FROM_EMAIL. Uses better-auth's verification table — no new schema. - Google OAuth: enabled when GOOGLE_CLIENT_ID/SECRET are set. - No account linking (product decision): wallet, Google and email logins are separate users. Integration tests cover the magic-link endpoint firing the injected sender and being absent when unconfigured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
isadorable-png
left a comment
There was a problem hiding this comment.
🎨 UI Review
Automated review · scope check only
This PR adds a new backend-only service, apps/user-api (better-auth SIWE/magic-link/Google sessions, user-scoped drafts — controllers, repositories, services, database schema, integration tests). No .tsx/component/style files, no dashboard integration in this PR — the dashboard counterpart ships separately in #2045. No rendered UI surface. Out of scope for a UI review; no findings to report.
Generated by Claude Code
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83e8429765
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
🔍 Vercel preview: https://anticapture-l33swexbl-ful.vercel.app |
|
🚅 Deployed to the anticapture-pr-2044 environment in anticapture-infra
|
Mirrors the authful service (same Hono + drizzle stack): multi-stage turbo-pruned build, /health healthcheck, and an entrypoint that runs db:migrate before serving so the schema is applied on each deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c457bb2008
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
A managed edge (Railway) in front of this service overwrites x-forwarded-* with its own host, so the dashboard proxy's forwarded host was lost and every request 400'd as untrusted_host. Prefer the custom x-anticapture-host header the proxy now sets; keep x-forwarded-host as the edge-less fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BETTER_AUTH_URL is the dashboard origin, not the /api/user proxy path — a path there makes better-auth mount at /api/user/api/auth/* and 404 everything (the proxy strips /api/user; the service serves /api/auth via basePath). Documents the x-anticapture-host + PORT=4003 deploy notes, drops the stale Identful name, and force-adds .env.example to match the other services (all tracked despite the .env* ignore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9b3e25c22
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
updateUserInfoOnLink: a Google sign-in linking onto a magic-link-born user (same email) now carries the Google name/photo onto the local profile, so the account chip shows the picture regardless of which method came first. Local email/emailVerified are never overwritten. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- drop the never-recorded auth ceremony counter (dead metric surface) - share ErrorResponseSchema + unauthorizedResponses in mappers/errors instead of duplicating the block and cross-importing from drafts - build all auth instances in one uniform pass (no primary special case) - run the api-keys DB read and Authful usage lookup concurrently - skip the drafts claim UPDATE once a user is settled (process-local memo; the claimable set is fixed pre-migration rows only) - validate migration addresses with viem's isAddress (non-strict) - csv env helper: plain schema, not a zero-arg factory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20f461b806
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
pikonha
left a comment
There was a problem hiding this comment.
isn't this supposed to remove the drafts from the APIs?
There was a problem hiding this comment.
I wouldn't commit this given that it will be used only once
There was a problem hiding this comment.
It can't run before the prod cutover happens, so deleting it now would orphan the migration plan: this is the documented, tested path that moves general.proposal_drafts from each DAO DB into the user-api when the accounts release ships. Plan: run it once per DAO at cutover, then delete script + test in the cleanup PR. If you'd rather keep the tree clean now and run it from a branch at cutover time, happy to remove — your call.
| const { key, plaintext } = await service.create(userId, label); | ||
| return c.json( | ||
| CreatedApiKeyResponseSchema.parse({ | ||
| ...toResponse(key), |
There was a problem hiding this comment.
again, why do you need this instead of relying on the zod schema
There was a problem hiding this comment.
Same seam as above: the schema validates the wire shape; the mapper converts driver types (Date→ISO). Without it, parse() rejects the rows.
codex P2s: - authful client trims trailing slashes off AUTHFUL_URL (hono 404s doubled paths) - blank AUTHFUL_URL / AUTHFUL_PROVISIONING_API_KEY count as unset so a SIWE-only boot can't fail on an empty env template value - drop the drafts claim memo: the one-shot migration can insert claimable rows after a user was first seen, so claim-on-touch must stay idempotent per request - authful token name is a bounded constant (not the user label) and gateful buckets user:* tenants in usage metrics — no unbounded Prometheus series from self-service keys - gateful caches user:* positive verdicts for 30s (was 300s) so a dashboard revoke propagates in seconds; changeset added - preview deploys receive USER_API_URL via --env (branch-scoped upserts don't apply to CLI deployments) review feedback (pikonha): - rename DraftsService -> ProposalDraftsService; clearer claim docs - listProposalDrafts operationId + proposal-drafts description - runtime db pool gets connection/statement timeouts - drop the duplicate test:unit script; .env.example keygen hint and deduped optional-var blocks; simpler request log lines; skill table Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b1c3c33eb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d39173e50f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| daoId: z.string().min(1), | ||
| title: z.string().default(""), | ||
| discussionUrl: z.string().default(""), | ||
| body: z.string().default(""), | ||
| actions: z.array(ActionSchema).default([]), |
There was a problem hiding this comment.
Bound persisted draft payload sizes
Any signed-in user can submit arbitrarily large title, discussionUrl, body, and actions values, and these values are persisted directly while the quota only limits the number of rows. Because SIWE and optional email accounts are self-service, a user can consume excessive request memory and database storage despite the 100-draft cap; add field/array limits and enforce an overall request-body limit.
Useful? React with 👍 / 👎.
| if (safeEqual(token, opts.adminApiKey)) { | ||
| c.set("authScope", "admin"); | ||
| return next(); |
There was a problem hiding this comment.
Reject collisions between admin and provisioning keys
When PROVISIONING_API_KEY is accidentally configured to the same value as ADMIN_API_KEY, this first branch classifies every User API provisioning request as admin, bypassing the controller's user:* restrictions and allowing that credential to mint, list, or revoke first-party tokens. Since the separate credential is intended to be a security boundary, reject equal values during environment validation.
Useful? React with 👍 / 👎.
| @@ -0,0 +1,364 @@ | |||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | |||
There was a problem hiding this comment.
Restore type safety in integration tests
This file-wide suppression enables numerous as any casts for decoded responses and database setup, allowing API-contract mistakes to compile unnoticed. This directly violates the root AGENTS.md boundary against casting to any/unknown and .claude/skills/testing/SKILL.md, which says test code must never use unsafe casts; replace the blanket suppression with typed response schemas and properly typed test adapters.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd6bfb47e5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @@ -0,0 +1,259 @@ | |||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | |||
There was a problem hiding this comment.
Replace blanket any suppression in API-key tests
This file-wide suppression permits the response and database as any casts throughout the suite, so API-contract changes can compile without the tests noticing them. This violates both the root AGENTS.md boundary, “Cast types to any/unknown unless explicitly asked,” and .claude/skills/testing/SKILL.md Rule 9, which requires test code to avoid unsafe casts; validate responses with the route schemas and use correctly typed test adapters instead.
Useful? React with 👍 / 👎.
| return this.db | ||
| .select() | ||
| .from(tokens) | ||
| .where(eq(tokens.tenant, tenant)) |
There was a problem hiding this comment.
Exclude revoked tokens from usage enrichment
When a user repeatedly creates and revokes keys, every revoked Authful row remains stored and this tenant query returns the entire history, while ApiKeysService.list downloads it on every GET /me/api-keys even though only the at-most-ten locally active keys need usage data. The response and map therefore grow without bound under normal key rotation or deliberate churn; use a bounded active-token query or pagination for this enrichment path.
Useful? React with 👍 / 👎.
| const dynamic = createAuth(config, host); | ||
| if (instances.size < MAX_DYNAMIC_INSTANCES) instances.set(host, dynamic); | ||
| return dynamic; |
There was a problem hiding this comment.
Reject new preview hosts after reaching the cache limit
When preview mode is enabled and the instance map reaches MAX_DYNAMIC_INSTANCES, an unseen valid *.vercel.app host still causes createAuth to run, but the result is not cached. Repeated requests for such a host therefore rebuild a complete Better Auth instance on every request, defeating the stated scanner-junk guard and allowing preview traffic to consume excessive CPU; check the limit before construction or evict an existing cached entry.
Useful? React with 👍 / 👎.
What
The entire backend for platform accounts, consolidated into one PR (was #2044 + #2046 + #2047):
New service:
apps/user-api(:4003)publicClient.verifyMessage), magic link (Resend, env-gated) and Google OAuth (env-gated). No account linking in v1 — wallet and email/Google logins are separate users by design.AUTH_SIWE_DOMAINS(one list drives SIWE domain, baseURL, cookies/CSRF and trusted origins) — noBETTER_AUTH_URL; whitelabel domains just get added to the list. The dashboard proxy forwards the browser host viax-anticapture-host(Railway's edge overwritesx-forwarded-*).GET /auth/methods: public capability discovery so the frontend only offers sign-in methods this deployment actually serves.daoIdcolumn filter, server-generated ids, public share GET with server-derivedisOwner, claim-on-first-SIWE-login for migrated rows, 100 drafts/user cap.user:<id>tenant, unbounded rate (rateLimitPerMin: 0— free & unlimited for now), plaintext shown once, quota of 10 keys/user,lastUsedAtenrichment best-effort.Breaking:
apps/apigeneral-schemaremoved — drafts belong to the User API now. One-shot migration script (apps/user-api/scripts/migrate-drafts.ts,--dry-runsupported) preserves ids and lowercases author addresses.apps/authfuluser:*tenants and list a singleuser:*tenant via?tenant=; admin key unrestricted. Timing-safe key compare; foreign tenants answer 404 (no oracle).Infra
infra/user-apiDockerfile + railway.json (migrate-then-serve entrypoint,/healthhealthcheck);scripts/dev.shlane sopnpm devboots the user-api locally.Architecture note
The User API is reached only via the dashboard's same-origin
/api/userproxy (cookie sessions) — it does not sit behind Gateful. Bearer/API-key traffic keeps flowing through Gateful; user-api → Authful runs over the Railway private network.Testing
Frontend counterpart: the stacked dashboard PR (base = this branch).
Replaces #2046 and #2047, which are closed in favor of this consolidation.
🤖 Generated with Claude Code