Skip to content

feat(user-api): user identity service — SIWE, magic link & Google sessions + user-scoped drafts#2044

Open
brunod-e wants to merge 9 commits into
devfrom
feat/user-api
Open

feat(user-api): user identity service — SIWE, magic link & Google sessions + user-scoped drafts#2044
brunod-e wants to merge 9 commits into
devfrom
feat/user-api

Conversation

@brunod-e

Copy link
Copy Markdown
Collaborator

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/user proxy 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 userId with identity coming exclusively from the session — there is no address parameter left to spoof (DEV-978 hijack) and no per-address listing to enumerate (DEV-979).

Architecture

Browser ──/api/user/*──▶ User API (better-auth) ──▶ User DB (Postgres)
Browser ──/api/gateful/*──▶ Gateful ──▶ DAO APIs   (unchanged)
  • better-auth + SIWE: EOA and EIP-1271/ERC-6492 (Safe) signatures via viem. One better-auth instance per host in AUTH_SIWE_DOMAINS so main and whitelabel domains each sign against their real host (SIWE anti-phishing preserved); unlisted hosts fail closed. Instance resolved from x-forwarded-host behind the proxy.
  • Magic link (env-gated by RESEND_API_KEY): email via Resend, mirroring the dashboard's existing usage. Google OAuth (env-gated by GOOGLE_CLIENT_ID/SECRET). No account linking in v1 — wallet, Google and email logins are deliberately separate users.
  • Drafts, user-scoped: server-generated UUID ids (the id is the share capability — client-chosen ids would mint guessable links), session-scoped list with daoId filter, public share GET with server-derived isOwner, 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).
  • Own Postgres database; better-auth core schema generated via its CLI; drizzle migrations included. Hono app with /health (DB probe) and /metrics, mirroring authful's layout.

Verification

  • 14 integration tests over the real ceremony on in-memory PGlite: SIWE nonce → sign → verify → session cookie → drafts CRUD; unauthenticated 401; untrusted-host 400; foreign-row 404 indistinguishable from missing; share isOwner per session; quota 403; migration claim (case-insensitive); magic-link endpoint firing the injected sender and absent when unconfigured.
  • pnpm typecheck and pnpm lint clean.

Deploy prerequisites

  • New Railway service + dedicated Postgres. Env: DATABASE_URL, BETTER_AUTH_SECRET (≥32 chars), BETTER_AUTH_URL, AUTH_SIWE_DOMAINS, TRUSTED_ORIGINS, RPC_URL (+ optional RESEND_API_KEY, GOOGLE_CLIENT_ID/SECRET).

Follow-ups (separate PRs)

  • Remove draft routes from the DAO APIs + @anticapture/client regen (major).
  • Data migration script: DAO DBs → User DB (ids preserved; claim already implemented).
  • Authful user-key provisioning (DEV-950).

Refs DEV-978, DEV-979. Frontend counterpart: stacked PR from feat/user-accounts-dashboard.

🤖 Generated with Claude Code

brunod-e and others added 5 commits July 9, 2026 20:57
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>
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
anticapture-storybook Ready Ready Preview, Comment Jul 10, 2026 2:05pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
anticapture Ignored Ignored Jul 10, 2026 2:05pm

Request Review

@isadorable-png isadorable-png left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 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

Copy link
Copy Markdown
Collaborator

🎨 UI Review

Automated review · scope check only

This PR adds a new backend-only service, apps/user-api (SIWE, magic link & Google auth backend, session cookies, and a user-scoped drafts API). All 35 changed files are under apps/user-api/**, plus .changeset/user-api-service.md and pnpm-lock.yaml — no .tsx/component/style files, and no dashboard integration in this PR (the frontend counterpart is a stacked PR from feat/user-accounts-dashboard).

No rendered UI surface. Out of scope for a UI review; no findings to report.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +28 to +30
async listForUser(userId: string, daoId: string): Promise<DraftRow[]> {
const wallets = await this.repo.findWalletAddresses(userId);
await this.repo.claimByAddresses(userId, wallets);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🔍 Vercel preview: https://anticapture-10k7qufld-ful.vercel.app

@railway-app

railway-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

🚅 Deployed to the anticapture-pr-2044 environment in anticapture-infra

Service Status Web Updated (UTC)
authful ✅ Success (View Logs) Web Jul 10, 2026 at 2:05 pm
tempo ✅ Success (View Logs) Jul 10, 2026 at 2:04 pm
grafana ✅ Success (View Logs) Web Jul 10, 2026 at 2:04 pm
mcp-server ✅ Success (View Logs) Jul 10, 2026 at 2:04 pm
gateful ✅ Success (View Logs) Web Jul 10, 2026 at 2:04 pm
otelcol ✅ Success (View Logs) Jul 10, 2026 at 2:04 pm
loki ✅ Success (View Logs) Web Jul 10, 2026 at 2:04 pm
prometheus ✅ Success (View Logs) Web Jul 10, 2026 at 2:04 pm
alertmanager ✅ Success (View Logs) Web Jul 10, 2026 at 2:04 pm
compound-indexer-offchain ✅ Success (View Logs) Jul 10, 2026 at 9:52 am
ens-indexer-offchain ✅ Success (View Logs) Jul 10, 2026 at 9:52 am
gitcoin-indexer-offchain ✅ Success (View Logs) Jul 10, 2026 at 9:52 am
mcp-docs ✅ Success (View Logs) Jul 10, 2026 at 9:52 am
uniswap-indexer-offchain ✅ Success (View Logs) Jul 10, 2026 at 9:52 am
mcp-proxy ✅ Success (View Logs) Web Jul 10, 2026 at 9:52 am
nodeful 🚨 Crashed (View Logs) Jul 10, 2026 at 12:42 am
aave-api ✅ Success (View Logs) Jul 10, 2026 at 12:39 am
obol-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
nouns-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
address-enrichment ✅ Success (View Logs) Web Jul 10, 2026 at 12:38 am
lil-nouns-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
fluid-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
uniswap-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
gitcoin-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
lil-nouns-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
nouns-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
shutter-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
ens-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
scroll-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
aave-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
compound-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
tornado-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
ens-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
obol-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
gitcoin-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
uniswap-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
compound-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
shutter-api ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
fluid-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
tornado-indexer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
ens-relayer ✅ Success (View Logs) Jul 10, 2026 at 12:38 am
erpc ✅ Success (View Logs) Web Jul 10, 2026 at 12:38 am
scroll-api ✅ Success (View Logs) Jul 10, 2026 at 12:36 am

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/user-api/src/auth.ts Outdated
return betterAuth({
database: drizzleAdapter(config.db, { provider: "pg" }),
secret: config.secret,
baseURL: config.baseURL,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/user-api/README.md Outdated
Comment on lines +46 to +49
> `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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/user-api/README.md
Comment on lines +9 to +10
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants