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:
- 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).
- GitHub is the upstream identity provider. Foundry doesn't store passwords. The
/oauth/authorizeflow bounces the user's browser through GitHub OAuth and mints a Foundry token tied to a real GitHub identity. - Every API request carries two identities —
req.client(the OAuth client, e.g. "Claude.ai", "Cowork-Claude", "stdio-bridge") andreq.user(the human, e.g.danhannah94,claymore-clay-i). Annotation/review attribution becomes accurate. The audit trail becomes real. - Private docs respect per-user authorization. Search and pages routes check
req.user.scopesinstead of "is there any Bearer token at all." This is the actual fix for #99. - The Claude.ai hosted MCP connector works natively with no env-flag escape hatches. We can flip
FOUNDRY_MCP_REQUIRE_AUTHback to default-true and forget it exists.
Why Now
Three drivers, all of them load-bearing:
-
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=falseas 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. -
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 thereviews.user_idandannotations.user_idcolumns wired up since E5 — they're just being populated with a lie. -
#99 — private docs leak in search — is a real bug, not a hypothetical. The current
isRequestAuthenticated()check inroutes/search.ts:28-32is 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 setsreq.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_AUTHescape 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:
- Expose protected resource metadata at
/.well-known/oauth-protected-resource(RFC 9728) — points to the authorization server(s) the resource trusts. - Expose authorization server metadata at
/.well-known/oauth-authorization-server(RFC 8414) — declares supported endpoints, grant types, scopes, code challenge methods. - 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.) - Implement authorization code flow with mandatory PKCE (RFC 7636 with S256) — no implicit flow, no plain code challenge.
- 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
| File | Purpose | Today |
|---|---|---|
packages/api/src/middleware/auth.ts | requireAuth(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
| Route | Auth | File:Line |
|---|---|---|
POST /api/annotations | requireAuth | index.ts:240 |
PATCH /api/annotations/:id | requireAuth | index.ts:240 |
DELETE /api/annotations/:id | requireAuth | index.ts:240 |
POST /api/reviews | requireAuth | index.ts:245 |
PATCH /api/reviews/:id | requireAuth | index.ts:245 |
GET /api/search | binary isRequestAuthenticated() (filters private results) | routes/search.ts:28-32, 94-97 |
GET /api/pages | conditional (requires auth only when include_private=true) | routes/pages.ts:8-16, 21-45 |
GET /api/docs/* | none | index.ts:234 |
GET /mcp/sse | mcpAuthMiddleware (= requireAuth unless FOUNDRY_MCP_REQUIRE_AUTH=false) | index.ts:177-185 |
POST /mcp/message | mcpAuthMiddleware (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
| Variable | Used at | Purpose | E12 fate |
|---|---|---|---|
FOUNDRY_WRITE_TOKEN | auth.ts:40, http-client.ts:4 | Static Bearer secret | Kept as legacy fallback during dual-auth, removed at end of E12 |
FOUNDRY_MCP_USER | http-client.ts:99,141 | Hardcoded user_id | Removed. Identity comes from the OAuth token. |
FOUNDRY_MCP_REQUIRE_AUTH | index.ts:177 | Disables /mcp/* auth (escape hatch from PR #108) | Removed in S10 cutover. |
FOUNDRY_API_URL | http-client.ts:3 | MCP client target URL | Unchanged |
(new) GITHUB_OAUTH_CLIENT_ID | oauth/github.ts | Foundry's GitHub OAuth app client ID | New |
(new) GITHUB_OAUTH_CLIENT_SECRET | oauth/github.ts | Foundry's GitHub OAuth app secret | New |
(new) FOUNDRY_OAUTH_ISSUER | discovery endpoints | Canonical AS URL (e.g. https://foundry-claymore.fly.dev) | New |
(new) FOUNDRY_PRIVATE_DOC_USERS | scope resolution at GitHub callback | Comma-separated GitHub logins that get docs:read:private | New (v1) |
(new) FOUNDRY_OAUTH_SESSION_SECRET | signed cookies for the consent page flow | 32+ random bytes | New |
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_USERSfrom 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 TABLEstatements todb.tsinitDb()(idempotent — guarded byIF NOT EXISTS) - New file
packages/api/src/oauth/dao.tswith 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 onconsumed_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(andhttp://localhost:3000/oauth/github/callbackfor 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, stateexchangeCode(code)→ GitHub access tokenfetchUser(token)→{ login, id }
- New route
GET /oauth/github/callbackinpackages/api/src/routes/oauth.ts:- Validates
stateagainst signed session cookie - Exchanges code for GitHub token
- Fetches user
- Upserts via
usersDao - Resolves scopes from
FOUNDRY_PRIVATE_DOC_USERSallowlist - Stores user_id + resolved scopes in session, redirects to consent page (S5)
- Validates
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 getdocs: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-knownpaths at the host root
Acceptance Criteria:
- Both endpoints return valid JSON matching the RFC schemas
-
issuerfield equalsprocess.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/registerinpackages/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_namerequired, max 100 charsredirect_urisrequired, at least 1, all must be HTTPS orhttp://localhost(for local dev)client_typerequired, must be'interactive'or'autonomous'(drivesauthor_typeon writes — see S8)- Reject if
grant_typespresent and doesn't includeauthorization_code
- Generate
client_id(32-char random),client_secret(48-char random), bcrypt the secret - Insert into
oauth_clientsviaclientsDao.register - Return
{ client_id, client_secret, client_name, redirect_uris, client_type, registration_access_token: null }per RFC 7591
Acceptance Criteria:
-
POST /oauth/registerwith valid Bearer + body returns 201 + client credentials - Missing or invalid Bearer → 401
invalid_token - Invalid
redirect_uris→ 400 withinvalid_redirect_uri - Missing required fields → 400 with
invalid_client_metadata - Missing or invalid
client_type→ 400 withinvalid_client_metadata -
client_secretis never returned again after the initial registration (no GET endpoint) - Tests cover: valid registration, missing name, missing URIs, missing Bearer, wrong Bearer,
http://non-localhostrejected, invalidclient_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.
FND-E12-S5: Authorization endpoint + consent page
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/authorizeinpackages/api/src/routes/oauth.ts - Query params:
client_id,redirect_uri,response_type=code,scope,state,code_challenge,code_challenge_method=S256 - Flow:
- Validate
client_idexists,redirect_urimatches a registered URI - Validate
code_challenge_method=S256andcode_challengeis a valid S256 challenge - Validate
scopeis a subset of[docs:read, docs:write, docs:read:private] - Stash the request params in a signed session cookie (
oauth_pending) - If no
user_idin session → redirect to GitHub viaoauth/github.buildAuthorizeUrl - If
user_idin session → render the consent page directly
- Validate
- New route
POST /oauth/consent(called from the consent page form):- Validates session has
user_idandoauth_pending - On approve: mints an authorization code via
codesDao.mint, redirects toredirect_uri?code=...&state=... - On deny: redirects to
redirect_uri?error=access_denied&state=...
- Validates session has
- 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/authorizewith valid params + no session → 302 to GitHub - After GitHub callback (S2), user lands on consent page rendered server-side
-
POST /oauth/consentapprove → 302 to clientredirect_uriwithcode+state -
POST /oauth/consentdeny → 302 to clientredirect_uriwitherror=access_denied - Mismatched
redirect_uri→ 400, NOT a redirect (per spec, never redirect to an unregistered URI) - Scope outside the supported set →
invalid_scopeerror 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/tokeninpackages/api/src/routes/oauth.ts - Content-Type:
application/x-www-form-urlencoded(per spec) - Two grant types:
authorization_code: requirescode,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_urimatches the one stored at code mint - Compute scopes fresh from
FOUNDRY_PRIVATE_DOC_USERSfor this user (per D-schema-2) - Mint access token (1h) + refresh token (30d) via
tokensDao.mint
- Validate client credentials via
refresh_token(with rotation — D-S6-1 locked in): requiresrefresh_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)
- Validate client, look up token by
- Response:
{ access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope } - Errors per spec:
invalid_grant,invalid_client,invalid_request
Acceptance Criteria:
-
authorization_codegrant happy path returns valid tokens - PKCE mismatch →
invalid_grant - Already-consumed code →
invalid_grant - Wrong client_secret →
invalid_client -
refresh_tokengrant 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)
- A valid OAuth Bearer token (introspected via
- 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_scopeifreq.user.scopesdoesn't include the required scope
- Use after
- Type augmentation in
packages/api/src/types/express.d.ts(or wherever existing augmentations live) forRequest.userandRequest.client
Acceptance Criteria:
- Valid OAuth token → 200 with
req.userpopulated - Valid legacy token → 200 with
req.user.id = 'legacy' - Expired OAuth token → 401 with
WWW-Authenticateheader - Revoked OAuth token → 401
- No token → 401 with
WWW-Authenticateresource_metadata pointer -
requireScope('docs:read:private')returns 403 for users without the scope - Existing
auth.test.tstests 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 anyprocess.env.FOUNDRY_MCP_USERor default'anonymous'withreq.user.id. Setauthor_typefromreq.oauth.client.client_type—'interactive'→'human','autonomous'→'ai'. Legacy Bearer path defaultsauthor_typeto'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 theFOUNDRY_DEFAULT_USERenv var fallback (routes/reviews.ts:102).packages/api/src/mcp/http-client.ts: delete theuser_idfield from the body ofcreateAnnotation(line 99) andsubmitReview(line 141). The HTTP API now reads it fromreq.user.id, not the request body. Also delete any hardcodedauthor_type— server is authoritative.- The HTTP API should ignore (or warn-and-ignore) any
user_idorauthor_typefield in the request body.
Acceptance Criteria:
- Annotations created via an
interactiveclient getauthor_type='human',user_id=req.user.id - Annotations created via an
autonomousclient getauthor_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_USERenv var is no longer referenced anywhere (grepis clean) -
FOUNDRY_AI_USER_LOGINSenv var is not introduced (bot-account allowlist idea dropped — see D-S8-1) -
FOUNDRY_DEFAULT_USERenv var removed fromroutes/reviews.ts - Tests assert
author_typematches the client'sclient_type, anduser_idmatches authenticated identity
Boundaries: No schema changes on reviews/annotations — user_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
requireAuthon/api/search— it stays auth-optional so anonymous browser search keeps working - Inside the handler, soft-introspect: if
Authorization: Bearer ...is present, attachreq.userthe wayrequireAuthwould; if not,req.userstaysundefined - 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
- Do not mount
packages/api/src/routes/pages.ts:pages?include_private=truerequires auth (this is an explicit private-data query, unlike search's lenient fallback). Withoutdocs:read:privatescope → 403.
- All other read routes that surface
docs_meta.access: confirm they consultreq.user.scopes
Acceptance Criteria:
- OAuth user with
docs:read:privatescope 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=truewithout 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>whereAuthContext = { user?: AuthUser, client?: AuthClient } - Refactor every existing route handler in
packages/api/src/routes/*.tsto be thin: destructurereq.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.tsormcp/http-client.ts(those land in S10b)
Boundaries / non-goals:
- MCP server still calls
http-client.tsafter 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
- MCP tool handlers dispatch directly to the S10a services with
- Replace
SSEServerTransportwithStreamableHTTPServerTransportinpackages/api/src/index.ts:- Single
/mcpendpoint (Streamable handles request/response and streaming in-band; no more separate/mcp/sse+/mcp/messagepair) - Mount
requireAuthdirectly on/mcp - Drop the per-session
transportsMap (Streamable handles lifecycle in-band) - Remove the
FOUNDRY_MCP_REQUIRE_AUTHescape-hatch conditional (the env var itself is cleaned up from Fly in S10c)
- Single
- Update
packages/api/src/__tests__/mcp-transport-auth.test.tsfor the new surface:- 401 + WWW-Authenticate on unauthed
/mcp - 200 on authed tool call
FOUNDRY_MCP_REQUIRE_AUTH=falsehas no effect (regression guard — the escape hatch is gone with the conditional)
- 401 + WWW-Authenticate on unauthed
Acceptance Criteria:
-
packages/api/src/mcp/http-client.tsno longer exists;grep -r "http-client" packages/api/srcreturns zero hits -
packages/api/src/mcp/stdio.tsno longer exists -
mcp/server.tshas nofetch()calls; tool handlers call services directly -
StreamableHTTPServerTransportmounted on/mcp -
SSEServerTransportno longer imported anywhere inpackages/api/src -
/mcprejects 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_AUTHenv 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_AUTHreferences — 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; dropdist/mcp/stdio.jsreferencesDEPLOY.md— no moredist/mcp/stdio.jsreferencesCICD.md— samefoundry.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.jsonconfig 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_TOKENin place (30-day break-glass window from S7/S10 scope)
Acceptance Criteria:
-
grep -r "FOUNDRY_MCP_REQUIRE_AUTH" packages/api/srcreturns 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.ymldrop MCP stdio env vars -
fly secrets liston foundry-claymore no longer showsFOUNDRY_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.tsfile, stop and reassess. FOUNDRY_WRITE_TOKENstays 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):
- 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. - 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.mjsas part of S12's pre-deploy checklist.
- In-repo E2E suite at
- Update
DEPLOY.mdwith new env vars and Fly secrets - Update
skills/foundry/SKILL.mdif 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.mdwith 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 —
/authorizewithoutcode_challenge→ 400; non-S256 method → 400 -
stateparameter opaque, echoed back verbatim to the client -
redirect_uriexact string match — no prefix/suffix matching, no query-param stripping - Authorization codes are one-time-consumable via atomic DB update (
consumed_atcheck-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_TOKENbearer - Issuer URL sourced from
FOUNDRY_OAUTH_ISSUERenv 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_AUTHno longer set in Fly secrets -
FOUNDRY_MCP_USERno 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 ofrequireAuthswapped for dual-auth,requireScopeaddedpackages/api/src/db.ts— 4 newCREATE TABLEstatements ininitDb()packages/api/src/index.ts— mountoauthrouter, removemcpAuthMiddlewareconditional in S10packages/api/src/routes/annotations.ts— readreq.user.id(S8)packages/api/src/routes/reviews.ts— readreq.user.id(S8)packages/api/src/routes/search.ts— soft introspection (S9)packages/api/src/routes/pages.ts—requireScope('docs:read:private')for include_private (S9)packages/api/src/mcp/http-client.ts— dropuser_idfrom 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
requireAuthinternals to dual-auth (OAuth or legacy Bearer) - S8 starts using
req.user.idfor write attribution - S9 fixes #99 with per-user scopes
At this point, both OAuth clients and the legacy stdio bridge work. Risk: medium —
req.usershape 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
requireAuthon/mcp/sseand/mcp/message. Delete the bridge code. RemoveFOUNDRY_MCP_REQUIRE_AUTH. - Cowork-Claude migrates as part of S10 rollout (Dan handles client-side config).
- Legacy
FOUNDRY_WRITE_TOKENpath stays inrequireAuthas 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
| Story | Title | Scope | Deps | Phase |
|---|---|---|---|---|
| FND-E12-S1 | Schema + DAOs | 4 tables, typed wrappers | None | 1 |
| FND-E12-S2 | GitHub OAuth client + callback | Upstream IdP integration | S1 | 1 |
| FND-E12-S3 | .well-known discovery endpoints | RFC 8414 + 9728 metadata | None | 1 (parallel to S2) |
| FND-E12-S4 | Dynamic Client Registration | RFC 7591 endpoint | S1 | 1 (parallel to S2) |
| FND-E12-S5 | Authorization endpoint + consent page | /oauth/authorize + /oauth/consent + HTML | S1, S2 | 1 |
| FND-E12-S6 | Token endpoint | /oauth/token (code + refresh w/ rotation) | S1, S5 | 1 |
| FND-E12-S7 | requireOAuth middleware (dual-auth) | Replace internals of requireAuth | S6 | 2 |
| FND-E12-S8 | Identity propagation | Read req.user.id in write routes; author_type from client_type | S7 | 2 |
| FND-E12-S9 | Per-user private doc authorization | Fix #99 | S7 | 2 |
| FND-E12-S10a | Extract service layer | packages/api/src/services/* with (ctx, params) → result fns; route handlers go thin | S9 | 3 |
| FND-E12-S10b | MCP cutover + Streamable HTTP transport | Delete http-client.ts loopback; MCP calls services directly; swap SSE → StreamableHTTPServerTransport | S10a | 3 |
| FND-E12-S10c | Stdio deletion + config cleanup | Delete stdio.ts, drop FOUNDRY_MCP_REQUIRE_AUTH, update docs, Fly secret unset | S10b | 3 |
| FND-E12-S12 | Tests, docs, deploy | Capstone | All | 3 |
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
| # | Decision | Rationale | Status |
|---|---|---|---|
| D1 | Foundry IS the OAuth Authorization Server | MCP 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 |
| D2 | GitHub 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 |
| D3 | Opaque tokens hashed at rest, not JWTs | Easy revocation, no signing-key management, sub-millisecond SQLite lookups at our scale. | ❓ Open (D-schema-1) |
| D4 | Three scopes: docs:read, docs:write, docs:read:private | Maps cleanly to existing routes; can split later. | ❓ Open |
| D5 | Allowlist via FOUNDRY_PRIVATE_DOC_USERS env (v1); GitHub org membership (v2) | Allowlist ships in 5 lines; org membership is a roundtrip per request. | ❓ Open |
| D6 | PKCE S256 mandatory, no plain | Spec requires it. No reason to support insecure flow. | Decided |
| D7 | Dual-auth migration: requireAuth accepts OAuth OR legacy Bearer | Avoids big-bang cutover. Legacy path stays as break-glass after migration. | Decided |
| D8 | Access token TTL 1h, refresh token TTL 30d, no rotation in v1 | Standard. Add rotation in tech debt if compromise risk feels real. | ❓ Open (D-S6-1) |
| D9 | Minimal hand-rolled HTML consent page served from API | One file, no Astro round trip, no frontend changes. | Decided |
| D10 | bcrypt client secrets at rest | Standard. | Decided |
| D11 | Keep middleware name requireAuth (replace internals) | Less churn across existing routes; the rename doesn't carry information. | ❓ Open (lean: keep) |
| D12 | Browser anonymous search stays anonymous via soft introspection | Don't break browser UX during E12. | ❓ Open (D-S9-1) |
| D13 | Stdio bridge bootstraps with one-time browser flow → cached refresh token | Preserves identity correctness; one-time setup pain is worth it. | ❓ Open (D-S11-1) |
| D14 | GitHub OAuth app owned by danhannah94, not claymore-clay-i | Human owns infra, bot owns commits. | ❓ Open (D-S2-1) |
| D15 | No skip-consent for re-authorizing clients in v1 | Defer the oauth_consents table to v2; for our 3 clients, consent-per-month is fine. | ❓ Open (D-S5-1) |
| D16 | No DCR rate limit in v1 | Defer to tech debt unless abuse appears. | ❓ Open (D-S4-1) |
| D17 | author_type derived from req.user.github_login matched against bot allowlist | Cheap, gives real human/AI audit distinction. | ❓ Open (D-S8-1) |
| D18 | OAuth issuer URL config-driven via FOUNDRY_OAUTH_ISSUER, fail loud if unset | Avoids 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
danhannah94vsclaymore-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).