Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,31 @@ enabled_tools = ["watch_pr_lifecycle"]
GH_MONITOR_HARNESS = "codex"
GH_MONITOR_MODEL = "gpt-5.3-codex-spark"
GH_MONITOR_WAKES = "1"

[mcp_servers.pinpoint]
url = "https://pinpoint.austinpinballcollective.org/api/mcp/mcp"
oauth_resource = "https://pinpoint.austinpinballcollective.org/api/mcp/mcp"
required = false
default_tools_approval_mode = "prompt"
startup_timeout_sec = 30
tool_timeout_sec = 60

# Read-only tools are safe to call without interrupting the task. Every other
# tool inherits the server-level prompt requirement.
[mcp_servers.pinpoint.tools.whoami]
approval_mode = "auto"

[mcp_servers.pinpoint.tools.list_machines]
approval_mode = "auto"

[mcp_servers.pinpoint.tools.get_machine]
approval_mode = "auto"

[mcp_servers.pinpoint.tools.list_issues]
approval_mode = "auto"

[mcp_servers.pinpoint.tools.get_issue]
approval_mode = "auto"

[mcp_servers.pinpoint.tools.search_pinballmap_catalog]
approval_mode = "auto"
15 changes: 9 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,15 @@ DISCORD_CLIENT_SECRET=
PINBALLMAP_API_TOKEN=

# MCP remote-admin server (/api/mcp/mcp)
# Static bearer token an MCP client (Claude Code) presents as
# `Authorization: Bearer <token>`, plus the Supabase user UUID every MCP tool
# call acts as. The mapped user must hold the `admin` access level — re-checked
# on every request. Auth fails closed (401) when either is unset, which leaves
# the rest of PinPoint working — so both are optional everywhere, including
# production. Set them only when you actually want the MCP server reachable.
# MCP_ADMIN_USER_ID is the sole user UUID allowed through either auth path; the
# user must hold the `admin` access level, which is re-checked on every request.
# MCP_BEARER_TOKEN is the legacy credential retained for the existing Claude
# connection. OAuth clients use Supabase instead and do not require it. The MCP
# surface fails closed independently when its chosen auth path is unconfigured,
# so all three settings remain optional to the rest of PinPoint.
# Generate the token with: openssl rand -hex 32 (minimum 32 chars)
MCP_BEARER_TOKEN=
MCP_ADMIN_USER_ID=
# Temporary OAuth activation switch. Leave false/unset except during the short
# DCR canary in docs/runbooks/mcp-oauth-codex.md; never leave true afterward.
MCP_OAUTH_DCR_CANARY=false
8 changes: 8 additions & 0 deletions docs/ENV_VARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ the degradation is a known, documented choice — not an oversight.
| `NEXT_PUBLIC_SENTRY_DSN` | 🟢 | ⭕ | `src/components/SentryInitializer.tsx` | Sentry not initialized |
| `MCP_BEARER_TOKEN` | 🔴 | ⚪ | `src/lib/mcp/verify-token.ts` | MCP auth fails closed — `/api/mcp/mcp` 401s, warns `reason: "not_configured"`. Rest of PinPoint unaffected. |
| `MCP_ADMIN_USER_ID` | 🔴 | ⚪ | `src/lib/mcp/verify-token.ts` | as above |
| `MCP_OAUTH_DCR_CANARY` | 🟢 | ⚪ | `src/lib/mcp/verify-token.ts` | unset/false keeps the final client-allowlist + resource-audience gate; `true` temporarily accepts Tim's DCR client with Supabase's default `authenticated` audience during the documented OAuth canary. Never leave enabled after pinning the client. |
| `PINBALLMAP_OUTBOUND_EMAIL` | 🟢 | ⚪ | `supabase/seed-pinballmap-creds.mjs` | **Seed-time only, never read at runtime.** Absent → outbound list/unlist stays unprovisioned and both actions return `NOT_PROVISIONED`. |
| `PINBALLMAP_OUTBOUND_TOKEN` | 🔴 | ⚪ | `supabase/seed-pinballmap-creds.mjs` | as above; the value lands in Supabase Vault, not in a column. |
| `DISCORD_PBM_ALERT_CHANNEL_ID` | 🟢 | ⚪ | `src/lib/pinballmap/region-alerts.ts` | region new-machine alert is off: the hourly cron makes **no** PBM call and records nothing, so nothing is queued and nothing floods when it is later set. Not a secret (a channel snowflake), but never `NEXT_PUBLIC_`. |
Expand Down Expand Up @@ -154,6 +155,13 @@ the degradation is a known, documented choice — not an oversight.
> is `NEXT_PUBLIC_`; neither is reused as another var's fallback. Rotate by
> changing `MCP_BEARER_TOKEN`.
>
> OAuth uses the same `MCP_ADMIN_USER_ID` as a subject allowlist and re-checks
> that user's live admin role. `MCP_OAUTH_DCR_CANARY=true` is a temporary
> activation flag only; see `docs/runbooks/mcp-oauth-codex.md`. OAuth otherwise
> requires an enabled `mcp_oauth_clients` row whose audience exactly matches the
> MCP endpoint. The legacy bearer stays available only for the existing Claude
> connection.
>
> **Deliberately not build-gated.** These are required for **MCP** to work, not
> for **PinPoint** to work. Both are unset → the MCP endpoint 401s and every
> other surface is untouched, which is the correct degraded state for an
Expand Down
57 changes: 57 additions & 0 deletions docs/runbooks/mcp-oauth-codex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Codex MCP OAuth activation

This runbook activates PinPoint's Supabase OAuth 2.1 path for Codex after the
code and migration land. The MCP endpoint is
`https://pinpoint.austinpinballcollective.org/api/mcp/mcp`; that exact URL is
also its RFC 9728 protected-resource identifier and JWT audience.

The committed Codex project configuration defaults every MCP tool to a prompt
and opts only the six read-only tools into automatic approval. The existing
static bearer remains solely for the already-configured Claude client.

## Safety boundary

Enabling the Supabase custom access-token hook affects every future access
token. Perform the steps in order, keep the canary short, and do not enable the
hook until the DCR-created client is present in `mcp_oauth_clients`.

## One-time canary and pinning

1. Deploy the migration and application code. Confirm an unauthenticated MCP
request returns `401` with a `resource_metadata` link, and GET that link to
confirm it returns JSON naming the exact MCP resource and the Supabase Auth
issuer.
2. In Supabase Authentication > OAuth Server, confirm the authorization path is
`/oauth/consent`, enable the OAuth server and Dynamic Client Registration,
and leave the custom access-token hook disabled.
3. Set `MCP_OAUTH_DCR_CANARY=true` in production and redeploy. The canary still
admits only the UUID in `MCP_ADMIN_USER_ID`, with a live `admin` role and a
Supabase-verified ES256 token; it temporarily permits the default
`authenticated` audience and an unregistered client id.
4. Restart Codex so it loads the project `.codex/config.toml`, then run
`codex mcp login pinpoint`. Complete login as Tim, approve the consent page,
and call `whoami`. Record its `clientId`; `authMode` must be `oauth`.
5. Insert that exact client id into `public.mcp_oauth_clients`, with name
`Codex Desktop`, audience
`https://pinpoint.austinpinballcollective.org/api/mcp/mcp`, and `enabled =
true`. Verify the redirect URI registered by DCR is the exact callback Codex
used. Keep that client registration and disable Dynamic Client Registration.
6. In Supabase Authentication > Hooks, enable the Postgres custom access-token
hook `public.mcp_custom_access_token_hook`.
7. Remove `MCP_OAUTH_DCR_CANARY` and redeploy. Add the recorded client id under
`[mcp_servers.pinpoint.oauth]` as `client_id = "..."` in project config so a
fresh Codex install does not depend on DCR.
8. Run `codex mcp logout pinpoint`, then `codex mcp login pinpoint` and call
`whoami` again. Confirm the access token has the exact MCP audience, reads do
not prompt, and each mutation prompts before execution.

## Refresh and revocation proof

After the final login, let the access token expire (or temporarily use a short
JWT lifetime in a controlled window) and confirm Codex refreshes without a new
browser login. Then revoke the OAuth grant/client session in Supabase and
confirm the next refresh or MCP call fails closed. Restore the normal JWT
lifetime immediately if it was changed.

If any final-mode check fails, disable the OAuth client row (`enabled = false`)
before investigating. This leaves Claude's unrelated static bearer path intact.
1 change: 1 addition & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default defineConfig({
//Prevent Drizzle Kit from parsing Supabase system tables
tablesFilter: [
"user_profiles",
"mcp_oauth_clients",
"machines",
"issues",
"issue_comments",
Expand Down
61 changes: 61 additions & 0 deletions drizzle/0072_powerful_nehzno.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
CREATE TABLE "mcp_oauth_clients" (
"client_id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"audience" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mcp_oauth_clients" ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
REVOKE ALL ON TABLE "mcp_oauth_clients" FROM anon, authenticated, public;
--> statement-breakpoint
GRANT SELECT ON TABLE "mcp_oauth_clients" TO supabase_auth_admin;
--> statement-breakpoint
CREATE POLICY "Supabase Auth reads enabled MCP OAuth clients"
ON "mcp_oauth_clients"
FOR SELECT
TO supabase_auth_admin
USING (true);
--> statement-breakpoint
CREATE OR REPLACE FUNCTION public.mcp_custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SET search_path = ''
AS $$
DECLARE
client_audience text;
BEGIN
-- Supabase may re-grant EXECUTE on public functions to API roles. The hook
-- changes unsigned JSON only, but keeping the execution boundary explicit
-- prevents it from becoming an accidental public RPC.
IF current_user <> 'supabase_auth_admin' THEN
RAISE EXCEPTION 'permission denied'
USING ERRCODE = '42501';
END IF;

SELECT client.audience
INTO client_audience
FROM public.mcp_oauth_clients AS client
WHERE client.client_id = event->'claims'->>'client_id'
AND client.enabled;

IF client_audience IS NOT NULL THEN
event := jsonb_set(
event,
'{claims,aud}',
to_jsonb(client_audience)
);
END IF;

RETURN jsonb_build_object('claims', event->'claims');
END;
$$;
--> statement-breakpoint
GRANT EXECUTE ON FUNCTION public.mcp_custom_access_token_hook(jsonb)
TO supabase_auth_admin;
--> statement-breakpoint
REVOKE EXECUTE ON FUNCTION public.mcp_custom_access_token_hook(jsonb)
FROM anon, authenticated, public;
Loading
Loading