Skip to content

feat(user-api): self-service API keys via scoped Authful provisioning (DEV-950)#2047

Open
brunod-e wants to merge 10 commits into
feat/user-drafts-cutoverfrom
feat/user-api-keys
Open

feat(user-api): self-service API keys via scoped Authful provisioning (DEV-950)#2047
brunod-e wants to merge 10 commits into
feat/user-drafts-cutoverfrom
feat/user-api-keys

Conversation

@brunod-e

Copy link
Copy Markdown
Collaborator

Summary

Backend for self-service API keys (DEV-950), stacked on #2046. Signed-in users can mint their own Anticapture API keys (for the MCP server / public API) without an admin in the loop. Two coordinated changes:

Authful — scoped provisioning key

  • New optional PROVISIONING_API_KEY. A caller bearing it may only mint/revoke tokens under the user:* tenant prefix and cannot list tokens; the admin key stays unrestricted. Closes the gap where handing any admin-capable key to the User API would expose every tenant's token metadata.
  • Revoking a non-user: token with the provisioning scope returns 404, not 403 — no oracle for first-party token ids.
  • Timing-safe key comparison (sha256 + timingSafeEqual).

User API — key brokering

  • Session-authenticated POST/GET/DELETE /me/api-keys. Create mints in Authful under tenant user:<userId>, returns the plaintext exactly once, and stores only ownership in user_api_keys (id, userId, authfulTokenId, label, timestamps) — never the secret or its hash.
  • Per-user quota (default 10); ownership enforced in the query (foreign id → 404, no oracle); Authful-first revocation so a failure there aborts before the local row is marked revoked (never the reverse, which would strand a live key).
  • Both surfaces are env-gated (AUTHFUL_URL + AUTHFUL_PROVISIONING_API_KEY) — the User API boots without them and simply omits the routes.

Verification

  • Authful: 22 tests incl. new provisioning-scope suite (mint user:* ok; mint first-party → 403; list → 403; revoke own → 204; revoke first-party → 404 no-oracle; unknown bearer → 401). Typecheck + lint clean.
  • User API: 25 tests incl. 6 new api-keys ones (session required; mint under user:<id> + plaintext once; list excludes plaintext and other users; owner-only revoke calls Authful then drops locally, foreign → 404 without calling Authful; quota 403). Typecheck + lint clean.

Deploy prerequisites

Set PROVISIONING_API_KEY on Authful and the same value as AUTHFUL_PROVISIONING_API_KEY (+ AUTHFUL_URL, private network) on the User API.

Follow-up

Dashboard API Keys page (separate frontend PR).

Refs DEV-950.

🤖 Generated with Claude Code

Authful gains an optional scoped provisioning key (PROVISIONING_API_KEY):
it may only mint/revoke user:* tenants and cannot list all tenants, so
handing it to the User API never exposes first-party token metadata; the
admin key stays unrestricted. Timing-safe key comparison.

The User API brokers end-user keys: session-authenticated
POST/GET/DELETE /me/api-keys mint into Authful under tenant user:<userId>,
return the plaintext exactly once, and persist only ownership (user_api_keys
holds no secret) — with a per-user quota and Authful-first revocation so a
failure there never leaves a live token the user thinks is gone. Both
surfaces stay disabled until their env is configured.

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 7:57pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
anticapture Ignored Ignored Jul 10, 2026 7:57pm

Request Review

Copy link
Copy Markdown
Collaborator

🎨 UI Review

Automated review · scope check only

This PR is backend-only: it adds a scoped PROVISIONING_API_KEY to apps/authful (controller/service/repository/middleware/env) and session-authenticated /me/api-keys brokering to apps/user-api (controller/service/repository/mapper/client/schema + a Drizzle migration). All 22 changed files are under apps/authful and apps/user-api — no .tsx, no apps/dashboard, no packages/ui/design-system files, no rendered UI surface.

Per the PR description, the dashboard API Keys page is explicit follow-up work in a separate frontend PR — this PR has nothing for a UI review to evaluate yet.

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: 80ca7561b9

ℹ️ 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 revoke(tokenId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/tokens/${tokenId}`, {

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 Normalize Authful URL before revoking keys

When AUTHFUL_URL is configured with a trailing slash, this builds //tokens/${tokenId}; the repo already normalizes Authful URLs in apps/gateful/src/config.ts:11-13 because Hono serves double-slash paths as 404. Since revoke() treats 404 as idempotent success, deleting an existing key in that configuration will mark the local row revoked while the Authful token remains active.

Useful? React with 👍 / 👎.

Comment on lines +29 to +30
const current = await this.repo.countActiveByUser(userId);
if (current >= this.maxKeysPerUser) {

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 Enforce the API-key quota atomically

With concurrent POST /me/api-keys requests for the same user, each request can read the same active count here before any insert commits, then all proceed to mint and insert, allowing the active-key count to exceed maxKeysPerUser. The quota needs a transaction/lock or a database-enforced slot/constraint so the check and insert cannot race.

Useful? React with 👍 / 👎.

Comment on lines +79 to +80
body: {
content: { "application/json": { schema: CreateApiKeyBodySchema } },

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 Mark the create body as required

Because this body is optional to @hono/zod-openapi without required: true, a request to POST /me/api-keys with no JSON content type can skip schema validation and leave label undefined, turning a client error into an Authful/DB failure instead of a 400. The Authful token route in this same change sets required: true for this reason, so add it here as well.

Useful? React with 👍 / 👎.

@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: 0ec86b8925

ℹ️ 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/env.ts
Comment on lines +54 to +55
AUTHFUL_URL: z.string().url().optional(),
AUTHFUL_PROVISIONING_API_KEY: z.string().min(16).optional(),

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 Treat blank Authful provisioning env as disabled

When this feature is meant to stay disabled but the deployment/.env defines placeholders like AUTHFUL_URL= or AUTHFUL_PROVISIONING_API_KEY=, these schemas reject the empty strings before the pair-check can treat them as absent, so the User API fails startup instead of simply omitting /me/api-keys. The Authful side already preprocesses blank optional secrets to undefined; applying the same normalization here keeps the advertised env-gated rollout safe for blank variables.

Useful? React with 👍 / 👎.

@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: 8bd8442dc3

ℹ️ 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 +35 to +37
const minted = await this.authful.mint(
`${USER_TENANT_PREFIX}${userId}`,
label,

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 Bound self-service Authful labels before minting

When these self-service keys are used against Gateful, Authful returns the minted tenant and name, and Gateful emits both as Prometheus labels (apps/gateful/src/auth/usage.ts:42-45). This mints them from an unbounded user ID and caller-controlled label, so signups or key-label churn create a new time series per user/key/route and can blow up Prometheus cardinality. Use bounded labels for user-issued keys and store per-user attribution outside Prometheus.

Useful? React with 👍 / 👎.

@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: 564170dde5

ℹ️ 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 +48 to +50
if (safeEqual(token, opts.adminApiKey)) {
c.set("authScope", "admin");
return next();

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 Reject identical admin and provisioning keys

When Authful is deployed with PROVISIONING_API_KEY accidentally set to the same value as ADMIN_API_KEY, this branch classifies that credential as admin before the provisioning check runs, so the User API’s supposedly scoped key can list tokens and mint/revoke first-party tenants. Since the new rollout depends on the provisioning key being restricted to user:*, reject equal admin/provisioning secrets at startup rather than silently granting admin scope.

Useful? React with 👍 / 👎.

brunod-e and others added 2 commits July 10, 2026 11:05
createAuthResolver no longer takes baseURL/trustedOrigins (derived per host
from AUTH_SIWE_DOMAINS); use localhost:3000 as the test host so the derived
origin matches.

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: 8fb3612ee7

ℹ️ 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".

id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),

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 Preserve API-key rows until Authful revocation

When a user row is deleted (for example by account-deletion or admin cleanup), this FK cascades and removes the only local mapping from the user to the Authful token IDs. Authful is a separate store, so the bearer tokens minted under user:<id> are not revoked by that cascade; they remain valid while disappearing from /me/api-keys, leaving the user/API with no ownership record to revoke. Use a deletion path that revokes Authful tokens before removing these rows, or avoid cascading until cleanup succeeds.

Useful? React with 👍 / 👎.

Authful's list gains a ?tenant filter the provisioning scope may use for a
single user:* tenant (still no unfiltered listing). The User API enriches
GET /me/api-keys with each key's lastUsedAt from Authful — best-effort, so
the list still returns (lastUsedAt null) when Authful is unreachable.

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: bfd6fe5391

ℹ️ 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 +61 to +64
const res = await fetch(
`${this.baseUrl}/tokens?tenant=${encodeURIComponent(tenant)}`,
{ headers: this.headers() },
);

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 a timeout to best-effort Authful lookups

When Authful accepts the connection but stalls or the private network blackholes responses, GET /me/api-keys waits on this fetch without an abort signal, so the best-effort usage enrichment never falls back to lastUsedAt: null and the whole user request can hang until the platform's long default timeout. Add a short AbortSignal.timeout here, as the existing Gateful Authful client does, so listing keys remains available when usage metadata is unavailable.

Useful? React with 👍 / 👎.

Product decision: API access is free and unlimited for everyone until
monetization ships. User keys are minted with rateLimitPerMin 0, which
gateful treats as no rate limit. Also fixes a latent test-only type error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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