Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
ff45872
feat(user-api): scaffold user identity service with better-auth SIWE
brunod-e Jul 9, 2026
d9e042a
feat(user-api): user-scoped draft proposals behind SIWE sessions
brunod-e Jul 9, 2026
562c6c6
feat(user-api): resolve better-auth instance from x-forwarded-host
brunod-e Jul 9, 2026
52e20ab
chore: changeset for user-api service
brunod-e Jul 10, 2026
83e8429
feat(user-api): add magic-link and Google sign-in (env-gated)
brunod-e Jul 10, 2026
c457bb2
chore(infra): add Railway build config for the user-api service
brunod-e Jul 10, 2026
5aaa6ac
fix(user-api): resolve SIWE host from x-anticapture-host first
brunod-e Jul 10, 2026
6fb93cf
docs(user-api): correct BETTER_AUTH_URL guidance + track .env.example
brunod-e Jul 10, 2026
bc8bc23
refactor(user-api): derive better-auth baseURL per host, drop BETTER_…
brunod-e Jul 10, 2026
bffe89e
feat(user-api): expose enabled sign-in methods at GET /auth/methods
brunod-e Jul 10, 2026
b1c8e28
feat(api)!: absorb the drafts cutover, scoped Authful provisioning an…
brunod-e Jul 11, 2026
c96bf63
feat(dashboard): platform accounts, drafts on the User API & API Keys…
brunod-e Jul 11, 2026
9ca055d
fix(dashboard): gate the API Keys page behind sign-in instead of over…
brunod-e Jul 11, 2026
940838f
fix(dashboard): tie wallet-born sessions to the wallet and harden the…
brunod-e Jul 11, 2026
9e8168a
fix(dashboard): refresh the session store after SIWE verify and keep …
brunod-e Jul 11, 2026
5ae02d7
fix(dashboard): route better-auth's self-generated URLs through the p…
brunod-e Jul 12, 2026
27f94c9
fix(user-api): send magic links through an interstitial page, not the…
brunod-e Jul 12, 2026
bf0b0a6
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 12, 2026
9da635b
feat(dashboard): magic-link interstitial page at /auth/magic-link
brunod-e Jul 12, 2026
28f7348
feat(dashboard): account chip for wallet-less platform sessions
brunod-e Jul 12, 2026
0bb8e90
fix(user-api): address codex review — claim on every path, atomic quo…
brunod-e Jul 12, 2026
163ca41
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 12, 2026
4a4590a
fix(dashboard): address codex + UI review on the accounts/API-keys su…
brunod-e Jul 12, 2026
845d698
fix(user-api): require the JSON body on POST /me/api-keys
brunod-e Jul 13, 2026
6e79415
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 13, 2026
f8e6390
fix(dashboard): point agent snippets at the MCP server's real /mcp path
brunod-e Jul 13, 2026
d6a1cc3
fix(authful): accept absent optional secrets under zod v4
brunod-e Jul 13, 2026
a3e39ec
revert(api): keep the DAO-API draft routes until the dashboard cutove…
brunod-e Jul 13, 2026
a57ef16
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 13, 2026
fe815fe
feat(api)!: retire the DAO-API draft routes now that the dashboard is…
brunod-e Jul 13, 2026
851b35e
fix(dashboard): don't run SIWE sign-in when a signed-in user needs a …
brunod-e Jul 13, 2026
ffd990a
feat(user-api): sign-in from Vercel preview links (Railway PR envs only)
brunod-e Jul 13, 2026
7aff9cc
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 13, 2026
75bf04b
feat(dashboard): one-click preview login button (server-advertised)
brunod-e Jul 13, 2026
d9b3e25
refactor(user-api): drop the preview test-credential login, keep dyna…
brunod-e Jul 14, 2026
4c6a55e
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 14, 2026
d7fba83
refactor(dashboard): remove the preview-login button
brunod-e Jul 14, 2026
459e10a
perf(dashboard): prefetch auth methods at mount, not on modal open
brunod-e Jul 14, 2026
504f974
feat(user-api): copy provider profile onto the user on account link
brunod-e Jul 14, 2026
b8f794c
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 14, 2026
20f461b
refactor(user-api): apply /simplify review cleanups
brunod-e Jul 14, 2026
a86c302
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 14, 2026
2dbb9cc
refactor(dashboard): apply /simplify review cleanups
brunod-e Jul 14, 2026
4fe0739
feat(dashboard): open api-keys page to signed-out users & design revi…
brunod-e Jul 15, 2026
d8eee2f
fix(dashboard): copy stays a labeled button, pinned flush to the corner
brunod-e Jul 15, 2026
6a17ff9
fix(dashboard): overlap the copy button border with the code block's
brunod-e Jul 15, 2026
fb62a4b
fix(dashboard): use arbitrary-value negative inset for the copy button
brunod-e Jul 15, 2026
ae29065
refactor(dashboard): extract the CodeBlock design-system component
brunod-e Jul 15, 2026
698268d
style(dashboard): align action popovers with the combobox dropdown
brunod-e Jul 15, 2026
69cce1e
fix(dashboard): teach tailwind-merge the rounded-base token
brunod-e Jul 15, 2026
b9a3074
style(dashboard): square the connect-wallet chip (rounded-base)
brunod-e Jul 15, 2026
5b1c3c3
fix: address codex and reviewer feedback on the user-api PR
brunod-e Jul 16, 2026
f3e1767
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 16, 2026
0ef8b50
fix(dashboard): address codex review on the accounts PR
brunod-e Jul 16, 2026
d39173e
chore: group public token metrics by single tenant
pikonha Jul 17, 2026
dd6bfb4
chore: remove ts ingore any
pikonha Jul 17, 2026
244edbe
fix: address second codex round on the user-api PR
brunod-e Jul 20, 2026
22d16c6
Merge branch 'feat/user-api' into feat/user-dashboard
brunod-e Jul 20, 2026
476c726
fix(dashboard): revoking a key clears its session plaintext
brunod-e Jul 20, 2026
64c7e7d
Merge remote-tracking branch 'origin/dev' into feat/user-api
brunod-e Jul 20, 2026
ed9ac55
feat(dashboard): integrate whitelabel support across authentication c…
brunod-e Jul 20, 2026
bd335b8
feat(draft-storage): implement excludePersistedDrafts to filter local…
brunod-e Jul 20, 2026
2f9355d
Merge pull request #2051 from blockful/feat/user-dashboard
brunod-e Jul 20, 2026
1c3db82
Merge branch 'feat/user-api' of https://github.com/blockful/anticaptu…
brunod-e Jul 20, 2026
6b91739
fix: broken pnpm lock
brunod-e Jul 20, 2026
a6fdcb5
test(user-api): raise vitest hook/test timeouts for slow schema push
brunod-e Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/authful-user-key-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/authful": patch
---

Self-service (`user:*`) API keys: the internal token validation metric buckets the per-user tenant label as `user:*` so the Prometheus series set stays bounded, matching Gateful's usage metric.
5 changes: 5 additions & 0 deletions .changeset/gateful-user-key-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/gateful": patch
---

Self-service (`user:*`) API keys: usage metrics bucket the per-user tenant label as `user:*` so the Prometheus series set stays bounded, and cached positive token verdicts use a 30s TTL (instead of 300s) so a key revoked from the dashboard stops authenticating within seconds.
4 changes: 4 additions & 0 deletions .changeset/user-api-dev-stack-lane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

chore: add a local dev-stack lane for the User API (`scripts/dev.sh`) + `pnpm user-api` root alias. Optional, mirroring Address Enrichment — runs via `railway run` when the service is available and exports `USER_API_URL` for the dashboard `/api/user` proxy; skipped otherwise so existing flows are unaffected.
6 changes: 6 additions & 0 deletions .changeset/user-api-keys-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@anticapture/authful": minor
"@anticapture/user-api": minor
---

Add self-service API keys (DEV-950). Authful gains an optional scoped provisioning key that may only mint/revoke `user:*` tenants and cannot list all tenants (the admin key stays unrestricted). The User API brokers end-user keys through it: `POST/GET/DELETE /me/api-keys` (session-authenticated) mint into Authful under tenant `user:<userId>`, return the plaintext exactly once, and store only ownership (never the secret) — with a per-user quota and Authful-first revocation. Both surfaces stay disabled until their env is configured.
11 changes: 11 additions & 0 deletions .changeset/user-api-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@anticapture/user-api": minor
---

New user identity & session service (`apps/user-api`). Provides SIWE
authentication via better-auth (EOA + EIP-1271, per-host domain resolution for
whitelabel) and session-scoped draft proposals: identity comes from the
session cookie, drafts are keyed by user with a `daoId` filter, the share
endpoint is public with a server-derived `isOwner`, and migrated rows are
claimed on first sign-in. Reached only through the dashboard's `/api/user`
proxy, independent of Gateful.
1 change: 1 addition & 0 deletions .claude/skills/local-dev-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ the first.
| Dashboard | 3000 | http://localhost:3000 | always |
| Address Enrichment | 3001 | http://localhost:3001 | optional, skipped if no Railway svc |
| Relayer (ENS) | 3002 | http://localhost:3002 | ENS only |
| User API | 4003 | http://localhost:4003 | optional |

Client SDK codegen runs in watch mode (no port) and regenerates on API/gateful changes.

Expand Down
50 changes: 32 additions & 18 deletions .github/workflows/tests.yaml
Comment thread
pikonha marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
gateful_url: ${{ steps.vercel-env.outputs.gateful_url }}
user_api_url: ${{ steps.vercel-env.outputs.user_api_url }}
trusted: ${{ steps.trust.outputs.trusted }}
configured: ${{ steps.vercel-upsert.outputs.configured }}

Expand Down Expand Up @@ -50,7 +51,7 @@ jobs:
echo "trusted=${trusted}" >> "${GITHUB_OUTPUT}"
echo "PR author '${PR_AUTHOR}' (association=${AUTHOR_ASSOCIATION}) trusted=${trusted}."

- name: Compute branch-scoped Gateful URL
- name: Compute branch-scoped preview service URLs
id: vercel-env
shell: bash
env:
Expand All @@ -61,16 +62,19 @@ jobs:

# Trusted PRs get their own ephemeral Railway preview; untrusted PRs
# (no Vercel/Railway preview is provisioned for them) fall back to the
# shared dev Gateful so the downstream wait/typecheck/test jobs have a
# live target instead of a preview host that never comes up.
# shared dev services so the downstream wait/typecheck/test jobs have
# a live target instead of a preview host that never comes up.
if [ "${IS_TRUSTED}" = "true" ]; then
gateful_url="https://gateful-anticapture-pr-${PR_NUMBER}.up.railway.app"
user_api_url="https://user-api-anticapture-pr-${PR_NUMBER}.up.railway.app"
else
gateful_url="https://dev-gateful.up.railway.app"
user_api_url="https://user-api-dev-9002.up.railway.app"
fi
echo "gateful_url=${gateful_url}" >> "${GITHUB_OUTPUT}"
echo "user_api_url=${user_api_url}" >> "${GITHUB_OUTPUT}"

- name: Upsert branch-scoped Gateful URL into Vercel
- name: Upsert branch-scoped service URLs into Vercel
id: vercel-upsert
if: steps.trust.outputs.trusted == 'true'
shell: bash
Expand All @@ -80,6 +84,7 @@ jobs:
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
GATEFUL_URL: ${{ steps.vercel-env.outputs.gateful_url }}
USER_API_URL: ${{ steps.vercel-env.outputs.user_api_url }}
run: |
set -euo pipefail

Expand All @@ -89,20 +94,27 @@ jobs:
fi

api_url="https://api.vercel.com/v10/projects/${VERCEL_PROJECT_ID}/env?upsert=true&teamId=${VERCEL_TEAM_ID}"
payload=$(jq -n \
--arg key "ANTICAPTURE_API_URL" \
--arg value "${GATEFUL_URL}" \
--arg gitBranch "${PR_BRANCH}" \
'{ key: $key, value: $value, type: "plain", target: ["preview"], gitBranch: $gitBranch }')

curl --fail-with-body --silent --show-error \
--request POST \
--url "${api_url}" \
--header "Authorization: Bearer ${VERCEL_TOKEN}" \
--header "Content-Type: application/json" \
--data "${payload}"

echo "Configured ANTICAPTURE_API_URL=${GATEFUL_URL} for Vercel preview branch ${PR_BRANCH}."
upsert() {
payload=$(jq -n \
--arg key "$1" \
--arg value "$2" \
--arg gitBranch "${PR_BRANCH}" \
'{ key: $key, value: $value, type: "plain", target: ["preview"], gitBranch: $gitBranch }')

curl --fail-with-body --silent --show-error \
--request POST \
--url "${api_url}" \
--header "Authorization: Bearer ${VERCEL_TOKEN}" \
--header "Content-Type: application/json" \
--data "${payload}"

echo "Configured $1=$2 for Vercel preview branch ${PR_BRANCH}."
}

# ANTICAPTURE_API_URL feeds the data proxy (/api/gateful);
# USER_API_URL feeds the auth/session proxy (/api/user).
upsert "ANTICAPTURE_API_URL" "${GATEFUL_URL}"
upsert "USER_API_URL" "${USER_API_URL}"
Comment thread
brunod-e marked this conversation as resolved.
echo "configured=true" >> "${GITHUB_OUTPUT}"

wait-for-gateful:
Expand Down Expand Up @@ -166,6 +178,7 @@ jobs:
VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
GATEFUL_URL: ${{ needs.configure-vercel-preview.outputs.gateful_url }}
USER_API_URL: ${{ needs.configure-vercel-preview.outputs.user_api_url }}
# Git metadata so Vercel attributes the deploy to the PR author (avatar +
# "Created by"); a bare `vercel deploy` shows "No attribution data available".
GIT_SHA: ${{ github.event.pull_request.head.sha }}
Expand All @@ -192,6 +205,7 @@ jobs:
url=$(npx --yes vercel@latest deploy --yes --token="${VERCEL_TOKEN}" \
--build-env ANTICAPTURE_API_URL="${GATEFUL_URL}" \
--env ANTICAPTURE_API_URL="${GATEFUL_URL}" \
--env USER_API_URL="${USER_API_URL}" \
--meta githubCommitSha="${GIT_SHA}" \
--meta githubCommitRef="${GIT_REF}" \
--meta githubCommitMessage="${GIT_MSG}" \
Expand Down
11 changes: 8 additions & 3 deletions apps/authful/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { validateController } from "@/controllers/validate";
import { exporter } from "@/instrumentation";
import { metricsMiddleware } from "@/middlewares/metrics";
import { requestLogger } from "@/middlewares/logger";
import { scopedTokenAuth } from "@/middlewares/token-auth";
import { logger } from "@/logger";
import type { TokensService } from "@/services/tokens";

Expand All @@ -17,13 +18,15 @@ export type AppConfig = {
db: AuthfulDrizzle;
adminApiKey: string;
internalApiKey: string;
provisioningApiKey?: string;
};

export function createApp({
service,
db,
adminApiKey,
internalApiKey,
provisioningApiKey,
}: AppConfig): Hono {
const app = new Hono();

Expand All @@ -48,9 +51,11 @@ export function createApp({
return c.body(body, 200, { "Content-Type": contentType });
});

// Admin surface: humans minting/listing/revoking tokens.
app.use("/tokens", bearerAuth({ token: adminApiKey }));
app.use("/tokens/*", bearerAuth({ token: adminApiKey }));
// Token-management surface: the admin key (unrestricted) or the optional
// provisioning key (scoped to `user:*`, enforced in the controller).
const tokenAuth = scopedTokenAuth({ adminApiKey, provisioningApiKey });
app.use("/tokens", tokenAuth);
app.use("/tokens/*", tokenAuth);

// Internal surface: Gateful validating tokens.
app.use("/validate", bearerAuth({ token: internalApiKey }));
Expand Down
117 changes: 117 additions & 0 deletions apps/authful/src/controllers/authful.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TokensService, hashToken } from "@/services/tokens";

const ADMIN_KEY = "test-admin-key-0123456789";
const INTERNAL_KEY = "test-internal-key-0123456789";
const PROVISIONING_KEY = "test-provisioning-key-0123456789";

const adminHeaders = {
Authorization: `Bearer ${ADMIN_KEY}`,
Expand All @@ -32,6 +33,10 @@ const internalHeaders = {
Authorization: `Bearer ${INTERNAL_KEY}`,
"Content-Type": "application/json",
};
const provisioningHeaders = {
Authorization: `Bearer ${PROVISIONING_KEY}`,
"Content-Type": "application/json",
};

describe("authful app", () => {
let client: PGlite;
Expand All @@ -50,6 +55,7 @@ describe("authful app", () => {
db,
adminApiKey: ADMIN_KEY,
internalApiKey: INTERNAL_KEY,
provisioningApiKey: PROVISIONING_KEY,
});
});

Expand Down Expand Up @@ -105,6 +111,18 @@ describe("authful app", () => {
expect(res.status).toBe(200);
});

it("rejects an unknown bearer token", async () => {
const res = await app.request("/tokens", {
method: "POST",
headers: {
Authorization: "Bearer not-a-real-key-000000000000",
"Content-Type": "application/json",
},
body: JSON.stringify({ tenant: "user:1", name: "x" }),
});
expect(res.status).toBe(401);
});

it("returns 503 when the DB probe fails", async () => {
const failingDb = {
execute: () => Promise.reject(new Error("db down")),
Expand All @@ -126,6 +144,88 @@ describe("authful app", () => {
});
});

describe("provisioning scope", () => {
const provMint = (body: Record<string, unknown>) =>
app.request("/tokens", {
method: "POST",
headers: provisioningHeaders,
body: JSON.stringify(body),
});

it("mints a user:* token", async () => {
const res = await provMint({ tenant: "user:abc", name: "my-agent" });
expect(res.status).toBe(201);
const body = (await res.json()) as MintResponse;
expect(body.tenant).toBe("user:abc");
expect(body.token).toMatch(/^act_/);
});

it("cannot mint a first-party (non-user) tenant", async () => {
const res = await provMint({ tenant: "uniswap", name: "sneaky" });
expect(res.status).toBe(403);
const before = await app.request("/tokens", { headers: adminHeaders });
const { items } = (await before.json()) as { items: unknown[] };
expect(items).toHaveLength(0);
});

it("cannot list all tokens (no tenant filter)", async () => {
const res = await app.request("/tokens", {
headers: provisioningHeaders,
});
expect(res.status).toBe(403);
});

it("cannot list a non-user tenant", async () => {
const res = await app.request("/tokens?tenant=uniswap", {
headers: provisioningHeaders,
});
expect(res.status).toBe(403);
});

it("lists only its own user:* tenant", async () => {
await provMint({ tenant: "user:abc", name: "one" });
await provMint({ tenant: "user:abc", name: "two" });
await mint({ tenant: "uniswap" }); // must not leak into the result

const res = await app.request("/tokens?tenant=user:abc", {
headers: provisioningHeaders,
});
expect(res.status).toBe(200);
const { items } = (await res.json()) as {
items: { tenant: string; lastUsedAt: string | null }[];
};
expect(items).toHaveLength(2);
expect(items.every((t) => t.tenant === "user:abc")).toBe(true);
});

it("revokes its own user:* token", async () => {
const minted = (await (
await provMint({ tenant: "user:abc", name: "temp" })
).json()) as MintResponse;
const res = await app.request(`/tokens/${minted.id}`, {
method: "DELETE",
headers: provisioningHeaders,
});
expect(res.status).toBe(204);
});

it("cannot revoke a first-party token — 404, not 403 (no oracle)", async () => {
const firstParty = await mint({ tenant: "uniswap" });
const res = await app.request(`/tokens/${firstParty.id}`, {
method: "DELETE",
headers: provisioningHeaders,
});
expect(res.status).toBe(404);

// The token is untouched — admin can still see it active.
const list = await app.request("/tokens", { headers: adminHeaders });
const { items } = (await list.json()) as {
items: { id: string; revokedAt: string | null }[];
};
expect(items.find((t) => t.id === firstParty.id)?.revokedAt).toBeNull();
});
});

describe("POST /tokens", () => {
it("mints a token and returns the plaintext exactly once", async () => {
const body = await mint({});
Expand Down Expand Up @@ -299,6 +399,23 @@ describe("authful app", () => {
});
});

it("buckets user tenants to user:* to bound the metric's cardinality", async () => {
const add = vi.spyOn(tokenValidationRequestTotal, "add");
const minted = await mint({ tenant: "user:1", name: "my-agent" });

await app.request("/validate", {
method: "POST",
headers: internalHeaders,
body: JSON.stringify({ tokenHash: hashToken(minted.token) }),
});

expect(add).toHaveBeenCalledWith(1, {
tenant: "user:*",
name: "my-agent",
result: "valid",
});
});

it("rejects an unknown hash", async () => {
const add = vi.spyOn(tokenValidationRequestTotal, "add");
const res = await app.request("/validate", {
Expand Down
Loading
Loading