feat(user-api): user identity service — SIWE, magic link & Google sessions + user-scoped drafts#2044
feat(user-api): user identity service — SIWE, magic link & Google sessions + user-scoped drafts#2044brunod-e wants to merge 9 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
🎨 UI Review
This PR adds a new backend-only service, No rendered UI surface. Out of scope for a UI review; no findings to report. Generated by Claude Code |
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".
| async listForUser(userId: string, daoId: string): Promise<DraftRow[]> { | ||
| const wallets = await this.repo.findWalletAddresses(userId); | ||
| await this.repo.claimByAddresses(userId, wallets); |
There was a problem hiding this comment.
Claim migrated drafts before owner checks
This is currently the only path that calls claimByAddresses, so a migrated draft (user_id NULL, author_address matching the signed-in wallet) remains unowned whenever the user lands directly on /drafts/{id} or tries to update/delete by id before listing their drafts. In that scenario the share response reports isOwner: false and owner mutations return 404 even though the session wallet is the author; run the claim step at sign-in or before direct read/mutation ownership checks.
Useful? React with 👍 / 👎.
|
🔍 Vercel preview: https://anticapture-10k7qufld-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".
| return betterAuth({ | ||
| database: drizzleAdapter(config.db, { provider: "pg" }), | ||
| secret: config.secret, | ||
| baseURL: config.baseURL, |
There was a problem hiding this comment.
Use a request-specific Better Auth base URL
In the multi-domain/whitelabel case (multiple AUTH_SIWE_DOMAINS entries with Google or magic-link enabled), every per-host auth instance still uses the single BETTER_AUTH_URL here. Better Auth builds magic-link verification URLs and OAuth callback URLs from baseURL, so sign-in started on a whitelabel is completed on the canonical host and leaves the whitelabel without its host-only session cookie. Derive the base URL from the resolved request host, or create each per-domain instance with its matching base URL.
Useful? React with 👍 / 👎.
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: 6fb93cf75a
ℹ️ 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".
| > `BETTER_AUTH_URL` is the dashboard **origin** (e.g. `https://dev.anticapture.com`), | ||
| > not the `/api/user` proxy path. better-auth mounts at `baseURL` + `basePath` | ||
| > (`/api/auth`), and the proxy strips `/api/user` before forwarding — so a path | ||
| > in `BETTER_AUTH_URL` makes better-auth 404 every route. |
There was a problem hiding this comment.
Route auth callbacks through the user proxy
When RESEND_API_KEY or Google OAuth is enabled, Better Auth uses this browser-facing base URL plus its /api/auth basePath for verification/callback URLs. This service is documented as reachable from the browser only under /api/user/*, and the proxy strips that prefix before forwarding; with the origin-only value shown here, emails/OAuth redirects land on https://host/api/auth/... instead of the proxied /api/user/api/auth/..., so the dashboard won't forward them and no session can be established. Keep the internal handler path separate from the URL sent to browsers, or rewrite these callback URLs before sending/redirecting.
Useful? React with 👍 / 👎.
| "lint": "eslint src", | ||
| "lint:fix": "eslint src --fix", | ||
| "clean": "rm -rf node_modules *.tsbuildinfo dist", | ||
| "auth:generate": "better-auth generate --config ./src/auth.ts --output ./src/database/auth-schema.ts --yes", |
There was a problem hiding this comment.
Point auth schema generation at the auth export
The configured Better Auth instance exported for CLI introspection is auth in src/auth-instance.ts, but this script points the CLI at src/auth.ts, which only defines the resolver factory/types. When someone follows the README to regenerate the auth schema after changing Better Auth plugins, the CLI won't load the actual instance, so schema generation fails or produces stale tables. Point the script at the file that exports auth, or re-export it from this config.
Useful? React with 👍 / 👎.
…AUTH_URL A single BETTER_AUTH_URL can't be right for a service that serves many frontend origins (localhost, main domain, whitelabel hosts) — it pinned the session cookie/CSRF to one domain, breaking local-frontend and every whitelabel. Each per-host better-auth instance now derives its own baseURL and trusted origins from AUTH_SIWE_DOMAINS (https://<host>, http for localhost), so cookies scope to the origin that made the request. Removes the BETTER_AUTH_URL and TRUSTED_ORIGINS env vars. 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: bc8bc23e42
ℹ️ 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".
| Reached only through the dashboard's `/api/user/*` Next proxy (HTTP-only session | ||
| cookies). It does **not** sit behind Gateful — Gateful stays the API-key data |
There was a problem hiding this comment.
Add the advertised dashboard user proxy
This service is documented as browser-reachable only through /api/user/*, but this commit does not add that proxy; I checked apps/dashboard/app/api and only the existing gateful proxy is present. In the advertised same-origin flow, /api/user/api/auth/... and /api/user/drafts will therefore 404 in the dashboard, while direct calls to the User API do not provide the required same-origin HTTP-only cookie/forwarded-host setup. Add the Next proxy route before relying on these auth and draft endpoints.
Useful? React with 👍 / 👎.
Summary
New User API service (
apps/user-api) — the platform's user identity & session layer, per the team's auth architecture decision. It owns authentication (SIWE now; magic link and Google env-gated) and off-chain user data (draft proposals), reached only through the dashboard's/api/userproxy with HTTP-only session cookies. It does not sit behind Gateful — Gateful stays the API-key data plane; identity is control plane.Closes the draft-proposals vulnerabilities structurally: drafts are keyed by
userIdwith identity coming exclusively from the session — there is noaddressparameter left to spoof (DEV-978 hijack) and no per-address listing to enumerate (DEV-979).Architecture
AUTH_SIWE_DOMAINSso main and whitelabel domains each sign against their real host (SIWE anti-phishing preserved); unlisted hosts fail closed. Instance resolved fromx-forwarded-hostbehind the proxy.RESEND_API_KEY): email via Resend, mirroring the dashboard's existing usage. Google OAuth (env-gated byGOOGLE_CLIENT_ID/SECRET). No account linking in v1 — wallet, Google and email logins are deliberately separate users.daoIdfilter, public share GET with server-derivedisOwner, owner-only update/delete via compound WHERE, identical 404s for foreign and missing rows (no existence oracle), 100 drafts/user quota, and claim-on-first-login for rows migrated from the DAO DBs (userId NULL+authorAddress)./health(DB probe) and/metrics, mirroring authful's layout.Verification
isOwnerper session; quota 403; migration claim (case-insensitive); magic-link endpoint firing the injected sender and absent when unconfigured.pnpm typecheckandpnpm lintclean.Deploy prerequisites
DATABASE_URL,BETTER_AUTH_SECRET(≥32 chars),BETTER_AUTH_URL,AUTH_SIWE_DOMAINS,TRUSTED_ORIGINS,RPC_URL(+ optionalRESEND_API_KEY,GOOGLE_CLIENT_ID/SECRET).Follow-ups (separate PRs)
@anticapture/clientregen (major).Refs DEV-978, DEV-979. Frontend counterpart: stacked PR from
feat/user-accounts-dashboard.🤖 Generated with Claude Code