Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
27f94c9
fix(user-api): send magic links through an interstitial page, not the…
brunod-e Jul 12, 2026
0bb8e90
fix(user-api): address codex review — claim on every path, atomic quo…
brunod-e Jul 12, 2026
845d698
fix(user-api): require the JSON body on POST /me/api-keys
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
ffd990a
feat(user-api): sign-in from Vercel preview links (Railway PR envs only)
brunod-e Jul 13, 2026
d9b3e25
refactor(user-api): drop the preview test-credential login, keep dyna…
brunod-e Jul 14, 2026
504f974
feat(user-api): copy provider profile onto the user on account link
brunod-e Jul 14, 2026
20f461b
refactor(user-api): apply /simplify review cleanups
brunod-e Jul 14, 2026
5b1c3c3
fix: address codex and reviewer feedback on the user-api 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
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