Foundry Foundry

E12: MCP Authorization (OAuth 2.0 + GitHub IdP) — Design Doc

Status: Step 0 — Draft for Refinement Created: April 9, 2026 Authors: Clay (draft), Dan (review) Refinement: open — see ❓ markers throughout, please annotate inline


Overview

E12 makes Foundry a real multi-tenant, multi-client system. Today every write to Foundry happens under a single hardcoded identity (FOUNDRY_MCP_USER='clay') authenticated by a single shared static Bearer token (FOUNDRY_WRITE_TOKEN). That worked when Foundry had one user (Dan) and one agent (Clay over the stdio bridge). It does not work now.

After E12:

  1. Foundry is an OAuth 2.0 Authorization Server that implements the MCP Authorization spec — protected resource metadata (RFC 9728), authorization server metadata (RFC 8414), dynamic client registration (RFC 7591), authorization code flow with PKCE (RFC 7636).
  2. GitHub is the upstream identity provider. Foundry doesn't store passwords. The /oauth/authorize flow bounces the user's browser through GitHub OAuth and mints a Foundry token tied to a real GitHub identity.
  3. Every API request carries two identitiesreq.client (the OAuth client, e.g. "Claude.ai", "Cowork-Claude", "stdio-bridge") and req.user (the human, e.g. danhannah94, claymore-clay-i). Annotation/review attribution becomes accurate. The audit trail becomes real.
  4. Private docs respect per-user authorization. Search and pages routes check req.user.scopes instead of "is there any Bearer token at all." This is the actual fix for #99.
  5. The Claude.ai hosted MCP connector works natively with no env-flag escape hatches. We can flip FOUNDRY_MCP_REQUIRE_AUTH back to default-true and forget it exists.

Why Now

Three drivers, all of them load-bearing:

  1. The Claude.ai hosted MCP connector is broken without OAuth. Per the MCP Authorization spec, hosted connectors only support OAuth 2.0 + dynamic client registration. Static Bearer tokens are a dead end for them. PR #108 shipped FOUNDRY_MCP_REQUIRE_AUTH=false as a one-day escape hatch — that flag is currently disabling auth on the entire MCP transport in prod. This is a security debt clock and it is ticking.

  2. The hardcoded FOUNDRY_MCP_USER='clay' is wrong in three ways. It attributes Cowork-Claude's annotations to Clay. It attributes Dan's annotations to Clay (when Dan uses MCP tools). It bakes into the schema a fiction that there is one user. We have the reviews.user_id and annotations.user_id columns wired up since E5 — they're just being populated with a lie.

  3. #99 — private docs leak in search — is a real bug, not a hypothetical. The current isRequestAuthenticated() check in routes/search.ts:28-32 is binary: any valid Bearer token gets full access; no token gets public-only. With E12, "authenticated" becomes "this specific user has scope X" and the fix lands naturally.

Scope

In scope:

  • Foundry-as-OAuth-AS: discovery, registration, authorize, token endpoints
  • GitHub OAuth integration as upstream IdP
  • New tables: oauth_clients, oauth_authorization_codes, oauth_tokens, users
  • New requireOAuth-style middleware that sets req.user + req.client
  • Identity propagation through write paths (kill FOUNDRY_MCP_USER)
  • Per-user authorization on private doc reads/search (fix #99)
  • Migration path: dual-auth (OAuth OR legacy Bearer) until stdio bridge + Cowork-Claude are migrated
  • Minimal HTML consent page served from the API
  • Removal of FOUNDRY_MCP_REQUIRE_AUTH escape hatch as a final step

Out of scope (deliberately):

  • Email/password auth (we don't store passwords, period)
  • SSO with anything other than GitHub (Auth0, Clerk, WorkOS — overkill)
  • Per-doc ACLs (still binary public/private at the doc-meta level)
  • GitHub org/team-membership-driven scope mapping (v2 — for v1 we use a simple env allowlist)
  • Token rotation policies beyond standard expiry/refresh
  • Admin UI for managing OAuth clients (use SQL until it hurts)
  • Frontend changes to login UI (browser users still hit the consent page directly during the OAuth flow; no separate "Sign in with GitHub" button on the doc viewer)

Architecture: How OAuth Plugs In

The whole flow, end to end

┌─ Hosted Agent (Claude.ai connector) ─────────────────────────────────┐
│                                                                       │
│  1. Agent discovers Foundry as MCP server                            │
│     GET https://foundry-claymore.fly.dev/.well-known/                │
│         oauth-protected-resource                                     │
│     → { authorization_servers: ["https://foundry-claymore..."] }     │
│                                                                       │
│  2. Agent fetches AS metadata                                        │
│     GET /.well-known/oauth-authorization-server                      │
│     → { registration_endpoint, authorization_endpoint,               │
│         token_endpoint, scopes_supported, ... }                      │
│                                                                       │
│  3. Agent registers itself dynamically (RFC 7591)                    │
│     POST /oauth/register                                             │
│     { client_name: "Claude.ai", redirect_uris: [...] }               │
│     → { client_id, client_secret }                                   │
│                                                                       │
│  4. Agent redirects user's browser to authorize endpoint             │
│     GET /oauth/authorize?client_id=...&code_challenge=...&scope=...  │
└──────────┬────────────────────────────────────────────────────────────┘
           │
           ▼
┌─ Foundry API (Express + SQLite) ─────────────────────────────────────┐
│                                                                       │
│  5. /oauth/authorize handler:                                        │
│     - Validates client_id, redirect_uri, PKCE challenge              │
│     - Stores PKCE state in session cookie or temp table              │
│     - Redirects browser to GitHub OAuth:                             │
│       https://github.com/login/oauth/authorize?client_id=GH_ID...    │
│                                                                       │
│  6. User logs in to GitHub, approves                                 │
│                                                                       │
│  7. GitHub redirects back: /oauth/github/callback?code=...           │
│     - Foundry exchanges code for GitHub access token                 │
│     - Fetches GitHub user profile (login, id)                       │
│     - Upserts into `users` table                                    │
│     - Resolves scopes from FOUNDRY_PRIVATE_DOC_USERS allowlist      │
│     - Renders consent page: "Claude.ai wants to access Foundry      │
│        as <github-login> with scopes [docs:read, docs:write]"       │
│                                                                       │
│  8. User clicks Approve:                                             │
│     - Foundry mints an authorization code (random opaque)           │
│     - Stores in oauth_authorization_codes (with PKCE challenge)     │
│     - Redirects browser back to client redirect_uri with code        │
│                                                                       │
│  9. POST /oauth/token (from agent backend, not browser)              │
│     { grant_type: "authorization_code", code, code_verifier,         │
│       client_id, client_secret }                                     │
│     - Validates PKCE                                                 │
│     - Validates client credentials                                   │
│     - Issues access_token + refresh_token                            │
│     - Stores in oauth_tokens                                         │
│                                                                       │
│  10. Agent now calls /mcp/sse with                                   │
│      Authorization: Bearer <access_token>                            │
│      - requireOAuth middleware introspects token                    │
│      - Sets req.user = { id, github_login, scopes }                 │
│      - Sets req.client = { id, name }                               │
│      - Routes use req.user.id for write attribution                 │
│      - Search/pages routes use req.user.scopes for private filter   │
└───────────────────────────────────────────────────────────────────────┘

Trust boundaries

  • Foundry trusts GitHub to identify humans. We never see passwords. We cache github_login, github_id, and a refreshable scope set.
  • Foundry trusts itself to identify clients. Clients register dynamically and get a client_id + client_secret. The secret is bcrypt-hashed at rest.
  • MCP transport trusts the access token introspected by requireOAuth. No more env-var-driven user identity.

Why Foundry as the AS, not GitHub directly?

Per the MCP Authorization spec, the resource server (Foundry) MUST be able to mint its own tokens for hosted clients. We can't hand a GitHub access token to Claude.ai and call it done — Claude.ai needs to register dynamically against an OAuth AS that speaks RFC 7591, and GitHub doesn't support dynamic client registration. So Foundry has to be the AS. GitHub is just the upstream identity bootstrap.

D-arch-1 locked in — Foundry owns the AS. Security validation is codified as S12 acceptance criteria (PKCE required, exact redirect_uri match, state CSRF defense, refresh-token single-use rotation, no token material in logs, bcrypt'd secrets, DCR gated by initial access token) and a pre-deploy pen-test pass. Alternatives (Auth0, WorkOS) rejected as vendor lock-in + monthly cost for a few hundred lines of well-tested protocol code.


The MCP Authorization Spec — What We Owe

The MCP Authorization spec (2025-06-18 revision) requires resource servers that need auth to:

  1. Expose protected resource metadata at /.well-known/oauth-protected-resource (RFC 9728) — points to the authorization server(s) the resource trusts.
  2. Expose authorization server metadata at /.well-known/oauth-authorization-server (RFC 8414) — declares supported endpoints, grant types, scopes, code challenge methods.
  3. Support dynamic client registration at the declared registration_endpoint (RFC 7591) — hosted clients self-register, no human-in-the-loop. (We gate registration with an initial access token per RFC 7591 §3 — see S4 and D-S4-1.)
  4. Implement authorization code flow with mandatory PKCE (RFC 7636 with S256) — no implicit flow, no plain code challenge.
  5. Validate and introspect tokens on every protected request — return WWW-Authenticate with the resource metadata URL on 401 so clients can discover the AS.

We will implement all five. None are optional.

D-spec-1 locked in — canonical issuer/resource URL is env-driven. Both the issuer field in /.well-known/oauth-authorization-server and the resource field in /.well-known/oauth-protected-resource are read from process.env.FOUNDRY_OAUTH_ISSUER at startup. Fail loudly if unset. Setting it via Fly secrets is part of S12's pre-deploy checklist. Host-header-derivation rejected as a footgun — a proxy or custom domain in front of Foundry would cause issuer values to vary by caller, breaking token validation and enabling issuer impersonation.


Current Auth Inventory

Middleware

FilePurposeToday
packages/api/src/middleware/auth.tsrequireAuth(req, res, next)Compares Authorization: Bearer <token> to process.env.FOUNDRY_WRITE_TOKEN. Sets nothing on req. Dev mode if env unset.

Routes that use it

RouteAuthFile:Line
POST /api/annotationsrequireAuthindex.ts:240
PATCH /api/annotations/:idrequireAuthindex.ts:240
DELETE /api/annotations/:idrequireAuthindex.ts:240
POST /api/reviewsrequireAuthindex.ts:245
PATCH /api/reviews/:idrequireAuthindex.ts:245
GET /api/searchbinary isRequestAuthenticated() (filters private results)routes/search.ts:28-32, 94-97
GET /api/pagesconditional (requires auth only when include_private=true)routes/pages.ts:8-16, 21-45
GET /api/docs/*noneindex.ts:234
GET /mcp/ssemcpAuthMiddleware (= requireAuth unless FOUNDRY_MCP_REQUIRE_AUTH=false)index.ts:177-185
POST /mcp/messagemcpAuthMiddleware (same)index.ts:177-206

MCP-side identity (the lie we are killing)

packages/api/src/mcp/http-client.ts:

  • Line 4: const WRITE_TOKEN = () => process.env.FOUNDRY_WRITE_TOKEN || ''
  • Lines 6-10: authHeaders() injects Bearer token into every API call
  • Line 99 (createAnnotation): user_id: process.env.FOUNDRY_MCP_USER || 'clay'
  • Line 141 (submitReview): user_id: process.env.FOUNDRY_MCP_USER || 'clay'

Schema columns that are already wired and just need real data

packages/api/src/db.ts:

  • reviews.user_id TEXT DEFAULT "anonymous" (line 39)
  • annotations.user_id TEXT DEFAULT "anonymous" (line 70)
  • annotations.author_type TEXT NOT NULL DEFAULT "human" (line 71)
  • docs_meta.access TEXT DEFAULT 'public' (line 54) — drives the private-doc filter

Env vars in play

VariableUsed atPurposeE12 fate
FOUNDRY_WRITE_TOKENauth.ts:40, http-client.ts:4Static Bearer secretKept as legacy fallback during dual-auth, removed at end of E12
FOUNDRY_MCP_USERhttp-client.ts:99,141Hardcoded user_idRemoved. Identity comes from the OAuth token.
FOUNDRY_MCP_REQUIRE_AUTHindex.ts:177Disables /mcp/* auth (escape hatch from PR #108)Removed in S10 cutover.
FOUNDRY_API_URLhttp-client.ts:3MCP client target URLUnchanged
(new) GITHUB_OAUTH_CLIENT_IDoauth/github.tsFoundry's GitHub OAuth app client IDNew
(new) GITHUB_OAUTH_CLIENT_SECREToauth/github.tsFoundry's GitHub OAuth app secretNew
(new) FOUNDRY_OAUTH_ISSUERdiscovery endpointsCanonical AS URL (e.g. https://foundry-claymore.fly.dev)New
(new) FOUNDRY_PRIVATE_DOC_USERSscope resolution at GitHub callbackComma-separated GitHub logins that get docs:read:privateNew (v1)
(new) FOUNDRY_OAUTH_SESSION_SECRETsigned cookies for the consent page flow32+ random bytesNew

New Schema

Four new tables. All additive — no migrations to existing tables.

CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY,                  -- internal ID (uuid)
  github_login TEXT NOT NULL UNIQUE,    -- e.g. "danhannah94"
  github_id INTEGER NOT NULL UNIQUE,    -- numeric GitHub user id (immutable across renames)
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS oauth_clients (
  id TEXT PRIMARY KEY,                  -- client_id (random opaque, 32 chars)
  secret_hash TEXT NOT NULL,            -- bcrypt(client_secret)
  name TEXT NOT NULL,                   -- e.g. "Claude.ai", "Cowork-Claude"
  redirect_uris TEXT NOT NULL,          -- JSON array
  client_type TEXT NOT NULL,            -- 'interactive' | 'autonomous'. Drives author_type on writes (S8).
  created_at TEXT NOT NULL,
  last_used_at TEXT
);

CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
  code TEXT PRIMARY KEY,                -- random opaque, single-use, 10-min TTL
  client_id TEXT NOT NULL,
  user_id TEXT NOT NULL,
  scope TEXT NOT NULL,                  -- space-separated
  redirect_uri TEXT NOT NULL,
  pkce_challenge TEXT NOT NULL,         -- S256 challenge from /authorize
  pkce_method TEXT NOT NULL DEFAULT 'S256',
  expires_at TEXT NOT NULL,
  consumed_at TEXT,                     -- set when exchanged at /token
  FOREIGN KEY (client_id) REFERENCES oauth_clients(id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE IF NOT EXISTS oauth_tokens (
  access_token_hash TEXT PRIMARY KEY,   -- sha256(access_token); never store plaintext
  refresh_token_hash TEXT UNIQUE,       -- sha256(refresh_token); nullable
  client_id TEXT NOT NULL,
  user_id TEXT NOT NULL,
  scope TEXT NOT NULL,
  expires_at TEXT NOT NULL,             -- access token expiry
  refresh_expires_at TEXT,              -- refresh token expiry
  revoked_at TEXT,                      -- soft delete for explicit revocation
  created_at TEXT NOT NULL,
  FOREIGN KEY (client_id) REFERENCES oauth_clients(id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_authorization_codes(expires_at);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expires ON oauth_tokens(expires_at);

Decisions locked in (from refinement):

  • D-schema-1: Opaque tokens, hashed at rest. Access tokens and refresh tokens are opaque random strings; only sha256 hashes are stored in oauth_tokens. Revocation is a row delete. Chosen over JWT to sidestep signing-key management and alg-confusion footguns; per-request SQLite lookup is sub-millisecond at our scale.
  • D-schema-2: Scopes computed fresh at every token mint. Not stored on the user row. On each mint (initial grant + every refresh), re-read FOUNDRY_PRIVATE_DOC_USERS from env and compute the scope set. Lets allowlist changes take effect within one token lifetime without requiring re-login.

Stories

Twelve stories. Several are tiny (S3 is a static JSON handler). Bundled into ~5 PRs across three batches.

FND-E12-S1: Schema + DAOs

Why: Everything else needs the tables. This is the foundation story; nothing parallelizes against it.

Scope:

  • Add 4 CREATE TABLE statements to db.ts initDb() (idempotent — guarded by IF NOT EXISTS)
  • New file packages/api/src/oauth/dao.ts with typed wrappers:
    • usersDao: findByGithubId(id), upsert({github_login, github_id})
    • clientsDao: register({name, redirect_uris, client_type}), findById(id), verifySecret(id, secret)
    • codesDao: mint({client_id, user_id, scope, redirect_uri, pkce_challenge}), consume(code) (atomic check-and-set on consumed_at)
    • tokensDao: mint({client_id, user_id, scope, ttl}), introspect(access_token), refresh(refresh_token) (rotates — old row revoked, new access+refresh minted atomically), revoke(access_token)

Acceptance Criteria:

  • All 4 tables exist after initDb()
  • Existing tests still pass (no regressions on reviews/annotations/docs_meta)
  • DAO unit tests for every method, covering: success, not-found, expired, already-consumed, revoked
  • Inserted tokens are stored as sha256 hashes; introspection takes a plaintext token, hashes it internally, queries by hash
  • tokensDao.refresh() atomically revokes the presented refresh token and mints a fresh access+refresh pair; reusing an already-revoked refresh token returns an error

Boundaries: No HTTP routes. No middleware. Just storage. client_type validation ('interactive' | 'autonomous') happens at the DCR route (S4); the DAO accepts whatever the route passes.


FND-E12-S2: GitHub OAuth client + callback

Why: Foundry needs to actually talk to GitHub. This is the upstream IdP integration that S5 (/oauth/authorize) will redirect into.

Prerequisites (manual, done once by Dan before S2 starts):

  • Register GitHub OAuth app at https://github.com/settings/applications/new under danhannah94
  • Callback URL: https://foundry-claymore.fly.dev/oauth/github/callback (and http://localhost:3000/oauth/github/callback for local dev)
  • Set Fly secrets: GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET

Scope:

  • New file packages/api/src/oauth/github.ts:
    • buildAuthorizeUrl(state) → GitHub authorize URL with our client_id, scope=read:user, state
    • exchangeCode(code) → GitHub access token
    • fetchUser(token){ login, id }
  • New route GET /oauth/github/callback in packages/api/src/routes/oauth.ts:
    • Validates state against signed session cookie
    • Exchanges code for GitHub token
    • Fetches user
    • Upserts via usersDao
    • Resolves scopes from FOUNDRY_PRIVATE_DOC_USERS allowlist
    • Stores user_id + resolved scopes in session, redirects to consent page (S5)

Acceptance Criteria:

  • GET /oauth/github/callback?code=...&state=... round-trips a real GitHub user
  • Unknown / invalid state returns 400
  • Allowlisted users get docs:read docs:write docs:read:private; non-allowlisted get docs:read docs:write
  • Tests use a mocked GitHub fetch (do NOT call real GitHub in CI)

Boundaries: Doesn't touch the /oauth/authorize or /oauth/token endpoints — those are S5 and S6. This story ends with "user is upserted, session cookie is set, redirect to consent page placeholder."


FND-E12-S3: .well-known discovery endpoints

Why: RFC 8414 + RFC 9728 metadata. Three lines of JSON each but mandatory for spec compliance.

Scope:

  • New routes in packages/api/src/routes/oauth.ts:
    • GET /.well-known/oauth-protected-resource{ resource: ISSUER, authorization_servers: [ISSUER], bearer_methods_supported: ["header"] }
    • GET /.well-known/oauth-authorization-server → full RFC 8414 metadata document
  • Mounted at the app root, NOT under /api, because the spec requires .well-known paths at the host root

Acceptance Criteria:

  • Both endpoints return valid JSON matching the RFC schemas
  • issuer field equals process.env.FOUNDRY_OAUTH_ISSUER (fail loud at startup if unset)
  • code_challenge_methods_supported: ["S256"] (PKCE mandatory, no plain)
  • grant_types_supported: ["authorization_code", "refresh_token"]
  • scopes_supported: ["docs:read", "docs:write", "docs:read:private"]
  • Tests verify both endpoints return a 200 with the expected schema

Boundaries: Static metadata only. No DB access.


FND-E12-S4: Dynamic Client Registration

Why: RFC 7591. Without this, Claude.ai's hosted connector cannot bootstrap. This is the unblock-the-thing story.

Scope:

  • New route POST /oauth/register in packages/api/src/routes/oauth.ts
  • Gated by initial access token (D-S4-1 locked in): require Authorization: Bearer $FOUNDRY_DCR_TOKEN. Rotate by regenerating the secret if leaked. RFC 7591 §3 explicitly supports this pattern.
  • Request body: { client_name: string, redirect_uris: string[], client_type: 'interactive' | 'autonomous', grant_types?, response_types?, token_endpoint_auth_method? }
  • Validation:
    • client_name required, max 100 chars
    • redirect_uris required, at least 1, all must be HTTPS or http://localhost (for local dev)
    • client_type required, must be 'interactive' or 'autonomous' (drives author_type on writes — see S8)
    • Reject if grant_types present and doesn't include authorization_code
  • Generate client_id (32-char random), client_secret (48-char random), bcrypt the secret
  • Insert into oauth_clients via clientsDao.register
  • Return { client_id, client_secret, client_name, redirect_uris, client_type, registration_access_token: null } per RFC 7591

Acceptance Criteria:

  • POST /oauth/register with valid Bearer + body returns 201 + client credentials
  • Missing or invalid Bearer → 401 invalid_token
  • Invalid redirect_uris → 400 with invalid_redirect_uri
  • Missing required fields → 400 with invalid_client_metadata
  • Missing or invalid client_type → 400 with invalid_client_metadata
  • client_secret is never returned again after the initial registration (no GET endpoint)
  • Tests cover: valid registration, missing name, missing URIs, missing Bearer, wrong Bearer, http://non-localhost rejected, invalid client_type

Boundaries: No rate-limiting on top of the bearer gate in v1. Bearer rotation is a manual op — document in S12 runbook.

Env var: FOUNDRY_DCR_TOKEN — 48-char random, set as Fly secret. Added to S12's pre-deploy checklist.


Why: This is the user-facing entry point of the OAuth flow. Where the GitHub redirect originates and where consent is granted.

Scope:

  • New route GET /oauth/authorize in packages/api/src/routes/oauth.ts
  • Query params: client_id, redirect_uri, response_type=code, scope, state, code_challenge, code_challenge_method=S256
  • Flow:
    1. Validate client_id exists, redirect_uri matches a registered URI
    2. Validate code_challenge_method=S256 and code_challenge is a valid S256 challenge
    3. Validate scope is a subset of [docs:read, docs:write, docs:read:private]
    4. Stash the request params in a signed session cookie (oauth_pending)
    5. If no user_id in session → redirect to GitHub via oauth/github.buildAuthorizeUrl
    6. If user_id in session → render the consent page directly
  • New route POST /oauth/consent (called from the consent page form):
    • Validates session has user_id and oauth_pending
    • On approve: mints an authorization code via codesDao.mint, redirects to redirect_uri?code=...&state=...
    • On deny: redirects to redirect_uri?error=access_denied&state=...
  • New file packages/api/src/oauth/consent.html — minimal hand-rolled HTML, no Astro:
    • "<client_name> wants to access Foundry as <github_login>"
    • Lists requested scopes in plain language ("Read public docs", "Write annotations", "Read private docs")
    • Two buttons: Approve / Deny
    • Form POSTs to /oauth/consent

Acceptance Criteria:

  • GET /oauth/authorize with valid params + no session → 302 to GitHub
  • After GitHub callback (S2), user lands on consent page rendered server-side
  • POST /oauth/consent approve → 302 to client redirect_uri with code + state
  • POST /oauth/consent deny → 302 to client redirect_uri with error=access_denied
  • Mismatched redirect_uri → 400, NOT a redirect (per spec, never redirect to an unregistered URI)
  • Scope outside the supported set → invalid_scope error per spec
  • Tests cover: full happy path with mocked GitHub, mismatched redirect, denied consent, expired session

Boundaries (D-S5-1 locked in): Consent is shown on every authorization flow — no "remember this client" skip. Deferred to v2. Hitting Approve once per ~30 days per client is low friction; an oauth_consents table isn't worth it at our client count.


FND-E12-S6: Token endpoint (code + refresh grants)

Why: Closes the loop. Exchanges authorization codes for access tokens, refreshes expired tokens.

Scope:

  • New route POST /oauth/token in packages/api/src/routes/oauth.ts
  • Content-Type: application/x-www-form-urlencoded (per spec)
  • Two grant types:
    • authorization_code: requires code, code_verifier, client_id, client_secret, redirect_uri
      • Validate client credentials via clientsDao.verifySecret
      • Atomically consume code via codesDao.consume (rejects already-consumed)
      • Verify PKCE: sha256(code_verifier) base64url == code.pkce_challenge
      • Verify redirect_uri matches the one stored at code mint
      • Compute scopes fresh from FOUNDRY_PRIVATE_DOC_USERS for this user (per D-schema-2)
      • Mint access token (1h) + refresh token (30d) via tokensDao.mint
    • refresh_token (with rotation — D-S6-1 locked in): requires refresh_token, client_id, client_secret
      • Validate client, look up token by sha256(refresh_token)
      • Verify not revoked, not expired
      • Atomically revoke the presented refresh token (revoked_at = now)
      • Recompute scopes fresh for this user
      • Mint a new access token (1h) AND a new refresh token (30d)
      • Return both
      • Reuse of an already-revoked refresh token → invalid_grant (signal of possible token theft)
  • Response: { access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope }
  • Errors per spec: invalid_grant, invalid_client, invalid_request

Acceptance Criteria:

  • authorization_code grant happy path returns valid tokens
  • PKCE mismatch → invalid_grant
  • Already-consumed code → invalid_grant
  • Wrong client_secret → invalid_client
  • refresh_token grant returns new access token AND new refresh token
  • Second use of the same refresh token → invalid_grant (rotation reuse-detection)
  • Expired refresh token → invalid_grant
  • Tests cover all error paths + happy paths, including rotation reuse detection

Boundaries: No client_credentials grant (no machine-only flows in v1). No password grant (we don't have passwords).

Deferred to v2: on rotation reuse detection, revoke ALL tokens for the user (full-chain revocation). Current implementation only rejects the reused token; a malicious party could keep a stolen refresh token alive if they race the legitimate client. Cheap to add later.


FND-E12-S7: requireOAuth middleware (dual-auth)

Why: This is the seam where every protected route changes. Replaces requireAuth internally; external API stays the same.

Scope:

  • Update packages/api/src/middleware/auth.ts:
    • requireAuth (name unchanged, internals replaced) accepts EITHER:
      • A valid OAuth Bearer token (introspected via tokensDao.introspect)
      • The legacy FOUNDRY_WRITE_TOKEN (kept as fallback during dual-auth window)
    • Sets on success: req.user = { id, github_login, scopes }, req.client = { id, name }
    • For legacy token path: req.user = { id: 'legacy', github_login: 'legacy', scopes: ['docs:read', 'docs:write', 'docs:read:private'] }, req.client = { id: 'legacy', name: 'legacy-bearer' }
    • On 401: returns WWW-Authenticate: Bearer realm="foundry", resource_metadata="<ISSUER>/.well-known/oauth-protected-resource" per spec
  • New optional middleware requireScope(scope: string):
    • Use after requireAuth
    • Returns 403 insufficient_scope if req.user.scopes doesn't include the required scope
  • Type augmentation in packages/api/src/types/express.d.ts (or wherever existing augmentations live) for Request.user and Request.client

Acceptance Criteria:

  • Valid OAuth token → 200 with req.user populated
  • Valid legacy token → 200 with req.user.id = 'legacy'
  • Expired OAuth token → 401 with WWW-Authenticate header
  • Revoked OAuth token → 401
  • No token → 401 with WWW-Authenticate resource_metadata pointer
  • requireScope('docs:read:private') returns 403 for users without the scope
  • Existing auth.test.ts tests pass with no changes (legacy path still works)
  • New tests cover OAuth introspection paths

Boundaries: This story does NOT update any routes to use req.user.id. That's S8. Only the middleware itself changes.


FND-E12-S8: Identity propagation through write paths

Why: Replace the 'clay' lie. After this story, reviews.user_id and annotations.user_id are accurate, and author_type reflects whether the write came from an interactive (human-driven) or autonomous (agent-driven) client.

Scope:

  • packages/api/src/routes/annotations.ts: replace any process.env.FOUNDRY_MCP_USER or default 'anonymous' with req.user.id. Set author_type from req.oauth.client.client_type'interactive''human', 'autonomous''ai'. Legacy Bearer path defaults author_type to 'ai' (preserving today's behavior, since Cowork-Claude is the only thing still hitting the legacy path during dual-auth).
  • packages/api/src/routes/reviews.ts: same pattern — and remove the FOUNDRY_DEFAULT_USER env var fallback (routes/reviews.ts:102).
  • packages/api/src/mcp/http-client.ts: delete the user_id field from the body of createAnnotation (line 99) and submitReview (line 141). The HTTP API now reads it from req.user.id, not the request body. Also delete any hardcoded author_type — server is authoritative.
  • The HTTP API should ignore (or warn-and-ignore) any user_id or author_type field in the request body.

Acceptance Criteria:

  • Annotations created via an interactive client get author_type='human', user_id=req.user.id
  • Annotations created via an autonomous client get author_type='ai', user_id=req.user.id
  • Annotations created via legacy Bearer get author_type='ai', user_id='legacy' (or whatever S7 sets)
  • Reviews follow the same pattern
  • FOUNDRY_MCP_USER env var is no longer referenced anywhere (grep is clean)
  • FOUNDRY_AI_USER_LOGINS env var is not introduced (bot-account allowlist idea dropped — see D-S8-1)
  • FOUNDRY_DEFAULT_USER env var removed from routes/reviews.ts
  • Tests assert author_type matches the client's client_type, and user_id matches authenticated identity

Boundaries: No schema changes on reviews/annotationsuser_id and author_type columns already exist. The new column is oauth_clients.client_type (added in S1's schema).

D-S8-1 locked in — author_type is client-driven, not user-driven. author_type is about auditability of the act: was this written by a human sitting at a UI, or by an agent acting autonomously? That's a property of the client, not the GitHub account. A human using Claude.ai (an interactive client) writes as 'human'; a Cowork-Claude autonomous run uses the same GitHub login but a different client, so it writes as 'ai'. No bot-account allowlist needed.


FND-E12-S9: Per-user authorization on private docs (fix #99)

Why: This is the issue #99 fix. Today the search filter is "any Bearer token = full access." After this story, it's "this specific user has docs:read:private scope."

Scope:

  • packages/api/src/routes/search.ts:
    • Do not mount requireAuth on /api/search — it stays auth-optional so anonymous browser search keeps working
    • Inside the handler, soft-introspect: if Authorization: Bearer ... is present, attach req.user the way requireAuth would; if not, req.user stays undefined
    • Filter results by req.user?.scopes?.includes('docs:read:private') — true → include private docs the user can see; false/undefined → public docs only
    • No 401 is ever returned from /api/search
  • packages/api/src/routes/pages.ts:
    • pages?include_private=true requires auth (this is an explicit private-data query, unlike search's lenient fallback). Without docs:read:private scope → 403.
  • All other read routes that surface docs_meta.access: confirm they consult req.user.scopes

Acceptance Criteria:

  • OAuth user with docs:read:private scope sees private docs in search results
  • OAuth user without scope sees only public docs
  • Unauthenticated browser request sees only public docs (no 401)
  • pages?include_private=true without scope → 403
  • Tests cover all four matrix combinations

Boundaries: No frontend changes. The browser fetches search anonymously and gets public results — that's fine for v1. Search is the only route that's auth-optional in this design; document this with an inline comment at the handler.


FND-E12-S10a: Extract service layer

Split from the original S10 on 2026-04-20. Pre-implementation investigation discovered mcp/http-client.ts is imported by mcp/server.ts which is also used by the /mcp/sse HTTP transport handler — deleting http-client.ts would break HTTP MCP, not just delete stdio. Correct fix is a service-layer extraction so both route handlers AND MCP tool handlers can share business logic without the HTTP-loopback hack.

Why: Lay the foundation for a clean MCP cutover. Today mcp/server.ts implements tool handlers by calling mcp/http-client.ts, which fetches back into the same Foundry process over HTTP — a loopback that injects its own bearer token and (pre-S8) hardcoded user_id='clay'. After S10a, both transports (REST route handlers and MCP tool handlers) share the same service-layer functions and can cleanly consume req.user / req.client from the auth middleware.

Scope:

  • Create packages/api/src/services/ with one file per domain:
    • annotations.ts, reviews.ts, search.ts, pages.ts, docs.ts, mgmt.ts (reindex, status, import, sync)
  • Each service exports pure functions of shape (ctx: AuthContext, params) → Promise<Result> where AuthContext = { user?: AuthUser, client?: AuthClient }
  • Refactor every existing route handler in packages/api/src/routes/*.ts to be thin: destructure req.body/req.query, call the service with context, respond with the result
  • Add service-layer unit tests in packages/api/src/services/__tests__/

Acceptance Criteria:

  • All existing HTTP integration tests pass unchanged — HTTP contract is identical
  • packages/api/src/services/ directory exists with all six service modules
  • Each service exposes pure (ctx, params) functions — no Express types, no HTTP concerns
  • Route handlers are thin (rule of thumb: ≤15 lines per handler after refactor)
  • New service-layer unit tests cover domain logic independent of transport
  • No change to mcp/server.ts or mcp/http-client.ts (those land in S10b)

Boundaries / non-goals:

  • MCP server still calls http-client.ts after this story — S10b decouples it.
  • No transport changes — SSE stays mounted until S10b.
  • No deletions of stdio bridge — S10c handles that.

Dependencies: S9 (final route shapes + identity middleware from W6 must be settled before extracting services).


FND-E12-S10b: MCP cutover + Streamable HTTP transport

Why: Complete the cutover. With services in place from S10a, MCP tool handlers can call services directly and receive {user, client} context from the transport layer — the same way route handlers do. Simultaneously swap the deprecated SSEServerTransport for the current StreamableHTTPServerTransport. SDK deprecation notice: @modelcontextprotocol/sdk/dist/esm/server/sse.d.ts line 35. Investigation on 2026-04-20 confirmed both Claude Code and Claude.ai Connectors already speak Streamable HTTP and in fact prefer it — safe to swap without breaking current clients.

Scope:

  • Delete packages/api/src/mcp/http-client.ts (entire file)
  • Delete packages/api/src/mcp/stdio.ts (see note on stdio below)
  • Rewrite packages/api/src/mcp/server.ts:
    • MCP tool handlers dispatch directly to the S10a services with {user, client} context threaded from the transport
    • No more HTTP loopback; no hardcoded tokens or user_ids
  • Replace SSEServerTransport with StreamableHTTPServerTransport in packages/api/src/index.ts:
    • Single /mcp endpoint (Streamable handles request/response and streaming in-band; no more separate /mcp/sse + /mcp/message pair)
    • Mount requireAuth directly on /mcp
    • Drop the per-session transports Map (Streamable handles lifecycle in-band)
    • Remove the FOUNDRY_MCP_REQUIRE_AUTH escape-hatch conditional (the env var itself is cleaned up from Fly in S10c)
  • Update packages/api/src/__tests__/mcp-transport-auth.test.ts for the new surface:
    • 401 + WWW-Authenticate on unauthed /mcp
    • 200 on authed tool call
    • FOUNDRY_MCP_REQUIRE_AUTH=false has no effect (regression guard — the escape hatch is gone with the conditional)

Acceptance Criteria:

  • packages/api/src/mcp/http-client.ts no longer exists; grep -r "http-client" packages/api/src returns zero hits
  • packages/api/src/mcp/stdio.ts no longer exists
  • mcp/server.ts has no fetch() calls; tool handlers call services directly
  • StreamableHTTPServerTransport mounted on /mcp
  • SSEServerTransport no longer imported anywhere in packages/api/src
  • /mcp rejects unauthenticated requests with 401 + WWW-Authenticate
  • Authenticated MCP tool call successfully creates an annotation with user_id = req.user.id (verifies identity propagation through MCP path — S8's gain now reaches MCP)
  • Manual smoke: Claude Code (v2.1.64+) completes DCR → authorize → consent → token → MCP call end-to-end against prod
  • Manual smoke: Claude.ai hosted connector completes the same flow via DCR

Note on stdio deletion moving to S10b (adjusted 2026-04-20): Originally planned for S10c. Architectural reality: stdio.ts imports server.ts which imports http-client.ts. S10b deletes http-client.ts and rewrites server.ts to call services directly (which need DB access and only work server-side). After S10b, stdio.ts would be orphaned dead code — compiles, but can't run from a client process. Cleaner to delete it in the same PR that breaks its dependency chain. S10c becomes pure docs + Fly ops.

Boundaries / non-goals:

  • FOUNDRY_MCP_REQUIRE_AUTH env var + Fly secret cleanup → S10c (this story removes the code conditional by replacing the mount).
  • No legacy Bearer (FOUNDRY_WRITE_TOKEN) removal — stays for the 30-day break-glass window per S10-era boundary.
  • README / DEPLOY / CICD doc updates → S10c (the runbook for client migration lives there).

Dependencies: S10a (services must exist before MCP server consumes them).


FND-E12-S10c: Stdio deletion + config cleanup

Why: Final cleanup. With S10a+S10b landed, stdio.ts and http-client.ts are gone and the auth escape hatch is unneeded. Tidy up config, docs, and Fly secrets. Pure docs + ops — no code changes in this PR.

Scope:

  • Drop remaining FOUNDRY_MCP_REQUIRE_AUTH references — S10b removed the conditional when it replaced the mount; S10c does the final grep-clean (config files, comments) and Fly secret unset
  • Update docs:
    • README.md — MCP section reflects HTTP-only + OAuth flow; drop dist/mcp/stdio.js references
    • DEPLOY.md — no more dist/mcp/stdio.js references
    • CICD.md — same
    • foundry.config.example.yaml, docker-compose.yml — drop MCP stdio env vars
  • Runbook addition — short "migrating from stdio to HTTP MCP" doc (placement TBD — probably docs/ or expanded README section), covering:
    • ~/.claude/mcp.json config swap from stdio to HTTP+OAuth
    • Claude.ai connector registration flow
    • Cowork-Claude migration notes (pointer to connector-side change)
  • Fly ops:
    • fly secrets unset FOUNDRY_MCP_REQUIRE_AUTH
    • Leave FOUNDRY_WRITE_TOKEN in place (30-day break-glass window from S7/S10 scope)

Acceptance Criteria:

  • grep -r "FOUNDRY_MCP_REQUIRE_AUTH" packages/api/src returns zero hits (S10b covered most; final sweep here)
  • grep -r "dist/mcp/stdio" . returns zero hits (docs + config files clean)
  • README / DEPLOY / CICD docs have no references to dist/mcp/stdio.js
  • foundry.config.example.yaml + docker-compose.yml drop MCP stdio env vars
  • fly secrets list on foundry-claymore no longer shows FOUNDRY_MCP_REQUIRE_AUTH
  • Runbook entry exists for client migration

Boundaries / non-goals:

  • No code changes in packages/api/src/ — S10a + S10b did all the code work. If you find yourself editing a .ts file, stop and reassess.
  • FOUNDRY_WRITE_TOKEN stays for 30 days post-S10 ship (break-glass window). Removal is a separate follow-up PR outside of E12.

Dependencies: S10b (Streamable must be live before runbook references the new URL shape).


FND-E12-S12: Tests, docs, deploy

Why: Capstone. Don't ship until this is green.

Scope:

  • Two testing layers (D-arch-1 integration-test follow-up locked in):
    1. In-repo E2E suite at packages/api/test/oauth.e2e.test.ts — Vitest against in-memory SQLite with mocked GitHub. Walks: DCR → authorize → consent → token exchange → authenticated MCP call → refresh with rotation → rotation reuse detection. Runs on every CI push, <10s. Guards against regressions.
    2. Conformance test against staging, pre-deploy only. Uses a third-party OAuth client (e.g. node-openid-client) pointed at the deployed staging instance. Validates our server behaves the way a real client expects, not just how our own code expects. Run via scripts/oauth-conformance.mjs as part of S12's pre-deploy checklist.
  • Update DEPLOY.md with new env vars and Fly secrets
  • Update skills/foundry/SKILL.md if it references auth (point at OAuth flow)
  • Reindex prod Anvil (templates may have been updated by csdlc-docs#5 churn during E12)
  • Verify all 12 stories' acceptance criteria pass against prod
  • Update NEXT.md with E12 ship log
  • File any tech debt items discovered during implementation
  • Document DCR bearer-token rotation runbook

Security acceptance criteria (D-arch-1 locked in): none of these are optional. Each gets a dedicated test in the in-repo E2E suite.

  • PKCE required — /authorize without code_challenge → 400; non-S256 method → 400
  • state parameter opaque, echoed back verbatim to the client
  • redirect_uri exact string match — no prefix/suffix matching, no query-param stripping
  • Authorization codes are one-time-consumable via atomic DB update (consumed_at check-and-set)
  • Refresh tokens rotate on every use; reuse of a consumed refresh token → invalid_grant
  • No token material in logs: access tokens, refresh tokens, and authorization codes never appear in plaintext in any log output, even at debug level
  • Client secrets bcrypt'd at rest (verified by reading schema + DAO)
  • DCR gated by FOUNDRY_DCR_TOKEN bearer
  • Issuer URL sourced from FOUNDRY_OAUTH_ISSUER env var; no Host-header-derived fallback exists

Pre-deploy pen-test pass: walk the MCP Authorization spec's threat model and OAuth 2.1 threat model (RFC 9700) end-to-end against a deployed staging instance. Document findings in a one-off security-review.md. Run open-source RFC 8414 / 9728 metadata validators.

Fly secrets checklist (all must be set before deploy):

  • FOUNDRY_OAUTH_ISSUER
  • FOUNDRY_OAUTH_SESSION_SECRET
  • GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET
  • FOUNDRY_DCR_TOKEN
  • FOUNDRY_PRIVATE_DOC_USERS

Acceptance Criteria:

  • In-repo E2E suite green on CI
  • Conformance test green against staging
  • DEPLOY.md updated
  • All stories deployed and verified in prod
  • FOUNDRY_MCP_REQUIRE_AUTH no longer set in Fly secrets
  • FOUNDRY_MCP_USER no longer set in Fly secrets
  • All security acceptance criteria (list above) green
  • Pre-deploy pen-test sign-off recorded in security-review.md

Boundaries: No new code beyond test fixtures, security review doc, and deploy docs.


Code Organization

New directory: packages/api/src/oauth/

packages/api/src/oauth/
├── dao.ts           # usersDao, clientsDao, codesDao, tokensDao
├── github.ts        # GitHub OAuth integration (buildAuthorizeUrl, exchangeCode, fetchUser)
├── pkce.ts          # S256 challenge verification
├── tokens.ts        # mint/hash/introspect helpers
├── consent.html     # static HTML for consent page
└── __tests__/
    ├── dao.test.ts
    ├── github.test.ts
    ├── pkce.test.ts
    └── tokens.test.ts

New routes file: packages/api/src/routes/oauth.ts — registers all /oauth/* and /.well-known/* endpoints.

Updated:

  • packages/api/src/middleware/auth.ts — internals of requireAuth swapped for dual-auth, requireScope added
  • packages/api/src/db.ts — 4 new CREATE TABLE statements in initDb()
  • packages/api/src/index.ts — mount oauth router, remove mcpAuthMiddleware conditional in S10
  • packages/api/src/routes/annotations.ts — read req.user.id (S8)
  • packages/api/src/routes/reviews.ts — read req.user.id (S8)
  • packages/api/src/routes/search.ts — soft introspection (S9)
  • packages/api/src/routes/pages.tsrequireScope('docs:read:private') for include_private (S9)
  • packages/api/src/mcp/http-client.ts — drop user_id from request bodies (S8), use OAuth token (S11)

New file: packages/api/src/mcp/oauth-client.ts — stdio bridge token caching (S11)


Migration Plan

Three phases. Each phase is shippable independently and doesn't break existing clients.

Phase 1: Build the OAuth surface (S1-S6)

Deploy the entire OAuth machinery without touching existing auth. The new /oauth/* and /.well-known/* endpoints exist; nothing uses them yet. Existing Bearer auth continues to work unchanged. Risk: zero.

Phase 2: Dual-auth + identity propagation (S7-S9)

  • S7 swaps requireAuth internals to dual-auth (OAuth or legacy Bearer)
  • S8 starts using req.user.id for write attribution
  • S9 fixes #99 with per-user scopes At this point, both OAuth clients and the legacy stdio bridge work. Risk: medium — req.user shape changes touch every protected route. Mitigated by exhaustive tests and the legacy fallback.

Phase 3: Cutover (S10-S12)

  • S10 (absorbs former S11 per D-S11-1): switch all MCP clients from the stdio bridge to Foundry's HTTP MCP transport with OAuth. Mount requireAuth on /mcp/sse and /mcp/message. Delete the bridge code. Remove FOUNDRY_MCP_REQUIRE_AUTH.
  • Cowork-Claude migrates as part of S10 rollout (Dan handles client-side config).
  • Legacy FOUNDRY_WRITE_TOKEN path stays in requireAuth as a break-glass fallback for 30 days after cutover, then gets deleted in a follow-up PR (not coupled to E12).

Risk: low — Claude Code v2.1.64+ supports the full OAuth HTTP MCP flow natively (DCR + PKCE + refresh), verified 2026-04-17.


Story Summary

StoryTitleScopeDepsPhase
FND-E12-S1Schema + DAOs4 tables, typed wrappersNone1
FND-E12-S2GitHub OAuth client + callbackUpstream IdP integrationS11
FND-E12-S3.well-known discovery endpointsRFC 8414 + 9728 metadataNone1 (parallel to S2)
FND-E12-S4Dynamic Client RegistrationRFC 7591 endpointS11 (parallel to S2)
FND-E12-S5Authorization endpoint + consent page/oauth/authorize + /oauth/consent + HTMLS1, S21
FND-E12-S6Token endpoint/oauth/token (code + refresh w/ rotation)S1, S51
FND-E12-S7requireOAuth middleware (dual-auth)Replace internals of requireAuthS62
FND-E12-S8Identity propagationRead req.user.id in write routes; author_type from client_typeS72
FND-E12-S9Per-user private doc authorizationFix #99S72
FND-E12-S10aExtract service layerpackages/api/src/services/* with (ctx, params) → result fns; route handlers go thinS93
FND-E12-S10bMCP cutover + Streamable HTTP transportDelete http-client.ts loopback; MCP calls services directly; swap SSE → StreamableHTTPServerTransportS10a3
FND-E12-S10cStdio deletion + config cleanupDelete stdio.ts, drop FOUNDRY_MCP_REQUIRE_AUTH, update docs, Fly secret unsetS10b3
FND-E12-S12Tests, docs, deployCapstoneAll3

Note (2026-04-20): Original S10 was a single story scoped as "delete stdio bridge + mount requireAuth on /mcp/sse." Pre-implementation grep discovered mcp/http-client.ts is imported by mcp/server.ts which is also used by the /mcp/sse HTTP transport handler — deleting http-client.ts as a single story would break HTTP MCP. Same aggregate scope, split into three reviewable PRs: S10a extracts a service layer so both route handlers and MCP tool handlers can share business logic; S10b deletes the loopback, swaps SSE → Streamable HTTP (SSE deprecated in the MCP SDK; both Claude Code and Claude.ai Connectors prefer Streamable today per investigation 2026-04-20); S10c deletes stdio.ts and cleans up config. Documented as D-S10-1 in "Resolved from Refinement."

Note: S11 (stdio bridge OAuth migration) was absorbed into S10 per D-S11-1 — Claude Code natively supports HTTP MCP with OAuth, so the bridge is deleted rather than migrated. Story numbering preserved (S12 remains S12) to keep stable IDs in commit messages and runbooks.

Execution plan:

  • Phase 1 batch: S1 first (foundation), then S2/S3/S4 in parallel (3 interns), then S5, then S6. ~3 PRs.
  • Phase 2 batch: S7 → S8 + S9 in parallel. ~2 PRs.
  • Phase 3 batch: S10a → S10b → S10c → S12. ~4 PRs (all sequential).

Total: 13 stories across ~9-11 PRs.


Design Decisions

#DecisionRationaleStatus
D1Foundry IS the OAuth Authorization ServerMCP spec requires RFC 7591 dynamic client registration; GitHub doesn't support DCR; Foundry has to be the AS or we're stuck with vendor lock-in.Decided
D2GitHub is the upstream IdP (not Auth0/Clerk/WorkOS)Already how we think about identity in the project; free; no vendor bill; no password storage.Decided
D3Opaque tokens hashed at rest, not JWTsEasy revocation, no signing-key management, sub-millisecond SQLite lookups at our scale.❓ Open (D-schema-1)
D4Three scopes: docs:read, docs:write, docs:read:privateMaps cleanly to existing routes; can split later.❓ Open
D5Allowlist via FOUNDRY_PRIVATE_DOC_USERS env (v1); GitHub org membership (v2)Allowlist ships in 5 lines; org membership is a roundtrip per request.❓ Open
D6PKCE S256 mandatory, no plainSpec requires it. No reason to support insecure flow.Decided
D7Dual-auth migration: requireAuth accepts OAuth OR legacy BearerAvoids big-bang cutover. Legacy path stays as break-glass after migration.Decided
D8Access token TTL 1h, refresh token TTL 30d, no rotation in v1Standard. Add rotation in tech debt if compromise risk feels real.❓ Open (D-S6-1)
D9Minimal hand-rolled HTML consent page served from APIOne file, no Astro round trip, no frontend changes.Decided
D10bcrypt client secrets at restStandard.Decided
D11Keep middleware name requireAuth (replace internals)Less churn across existing routes; the rename doesn't carry information.❓ Open (lean: keep)
D12Browser anonymous search stays anonymous via soft introspectionDon't break browser UX during E12.❓ Open (D-S9-1)
D13Stdio bridge bootstraps with one-time browser flow → cached refresh tokenPreserves identity correctness; one-time setup pain is worth it.❓ Open (D-S11-1)
D14GitHub OAuth app owned by danhannah94, not claymore-clay-iHuman owns infra, bot owns commits.❓ Open (D-S2-1)
D15No skip-consent for re-authorizing clients in v1Defer the oauth_consents table to v2; for our 3 clients, consent-per-month is fine.❓ Open (D-S5-1)
D16No DCR rate limit in v1Defer to tech debt unless abuse appears.❓ Open (D-S4-1)
D17author_type derived from req.user.github_login matched against bot allowlistCheap, gives real human/AI audit distinction.❓ Open (D-S8-1)
D18OAuth issuer URL config-driven via FOUNDRY_OAUTH_ISSUER, fail loud if unsetAvoids host-header spoofing of issuer field.❓ Open (D-spec-1)

Open Questions Index (for refinement)

A flat list of every open question so you can address them in any order:

  • D-arch-1 — Foundry-as-AS vs third-party AS (Auth0, etc.)
  • D-spec-1 — Issuer URL: env config vs Host header
  • D-schema-1 — Opaque tokens vs JWT
  • D-schema-2 — User scopes: stored on user record vs computed on every token mint
  • D-S2-1 — GitHub OAuth app under danhannah94 vs claymore-clay-i
  • D-S4-1 — DCR rate limit in v1 or defer
  • D-S5-1 — Skip-consent for already-approved clients
  • D-S6-1 — Refresh token rotation in v1
  • D-S8-1 — Auto-derive author_type='ai' from GitHub login allowlist
  • D-S9-1 — Browser anonymous search: soft introspection vs require auth
  • D-S11-1 — Stdio bridge OAuth bootstrap mechanism

Resolved from Refinement

This section will fill in as questions get answered. Today: nothing yet — the doc is fresh.


Draft for refinement. Annotate the open questions inline via the Foundry review loop, and we move to Step 1 (story prompts for the interns).

Review

🔒

Enter your access token to view annotations