Skip to content
Merged
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
44 changes: 26 additions & 18 deletions .claude/skills/build-fountain-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: build-fountain-app
description: Build an application on top of the Fountain API — a browser app, a bot, an internal tool, anything that hires agents, sends them prompts and renders what they do. Use whenever the user says "build an app on Fountain", "a client for Fountain", "another fountain-team / workbench / demo app", "Sign in with Fountain", or wants to ship something that talks to /api from its own origin. Covers the published TypeScript SDK (@managoat/fountain-sdk), the shape (static SPA on the SDK, or a small server in front), auth (OAuth code + PKCE, tokens are API keys), streaming, the server-side registration an app needs (API_CORS_ORIGINS, OAUTH_CLIENTS), a hosting recipe for any static host or container, and the traps every previous app hit.
description: Build an application on top of the Fountain API — a browser app, a bot, an internal tool, anything that hires agents, sends them prompts and renders what they do. Use whenever the user says "build an app on Fountain", "a client for Fountain", "another fountain-team / workbench / demo app", "Sign in with Fountain", or wants to ship something that talks to /api from its own origin. Covers the published TypeScript SDK (@managoat/fountain-sdk), the shape (static SPA on the SDK, or a small server in front), auth (OAuth code + PKCE, tokens are API keys), streaming, self-service OAuth client registration, a hosting recipe for any static host or container, and the traps every previous app hit.
---

# Build an app on Fountain
Expand Down Expand Up @@ -129,10 +129,10 @@ the token. ~80 lines, copied verbatim in
[references/sign-in.md](references/sign-in.md).

The client id and the **exact** redirect URI must be registered on the
server — a client nobody registered renders an error page and redirects
nowhere. Registration is the operator's, not something the app can do
(§5). Keep the pasted-key path: the CLI, sandboxes (`$FOUNTAIN_TOKEN`) and
an instance where your client is not registered all use it.
server. Register it yourself as described in section 5. A client nobody
registered renders an error page and redirects nowhere. Keep the pasted-key
path for the CLI, sandboxes (`$FOUNTAIN_TOKEN`) and an instance where your
client is not registered.

## 4. The four things people judge an app on

Expand All @@ -147,20 +147,28 @@ returns keys); say so in the UI when someone pastes a token.

## 5. Register the app with the Fountain it talks to

Two settings on the Fountain deployment, both exact-match lists
(`docs/configuration.md`, ADR 0021):
Register it yourself with a full-scope key. One registration enables both
sign-in and CORS.

| Setting | What |
|---|---|
| `API_CORS_ORIGINS` | one exact origin per app: `https://<host>` — scheme and host, no path. Off by default; it only ever admits a presented bearer key, since no cookie crosses an origin. |
| `OAUTH_CLIENTS` | JSON array: `{"id":"<app>","name":"<Title>","redirect_uris":["https://<host>/"]}`. Redirect URIs match exactly, trailing slash included. |
```bash
fountain oauth-client create "My App" \
--redirect-uri https://<host>/callback \
--redirect-uri http://localhost:5173/callback
```

You can also use `POST /api/oauth/clients` or Account, then OAuth apps. The
response prints the generated `client_id` your app sends.

Who sets them:
The client starts in development mode. It signs in only its owner. An
operator must publish a client before other accounts can use it. Redirect
URIs match exactly, except that loopback hosts match on any port. Use
`https` unless the host is `localhost` or `127.0.0.1`.

- **Your own Fountain** (self-hosted, `docker compose`, a dev server): you do. For a local server, add the client to `config/dev.exs` `:oauth_clients` with `http://localhost:5173/` and `:5174/` — that is a PR to this repo and benefits every contributor — and run `API_CORS_ORIGINS=http://localhost:5173 mix phx.server`. A dev instance has no sandbox provider token, so a real turn needs a real one.
- **The hosted instance** (the SDK's default base URL): the operator. Open an issue on this repo with the app's origin and redirect URI; the registration is an env change on their deployment, not a code change.
A sprite-scoped `FOUNTAIN_TOKEN` cannot register a client because a client
is a standing path to a full-scope key. Register it with your own key, then
run the flow from the app.

Convention: `client_id` = repo name. An app that *replaces* a first-party
An app that *replaces* a first-party
surface (conversations or team) is also wired through `Fountain.Apps`
(`CONVERSATIONS_APP_URL` / `TEAM_APP_URL`) — the only place the server knows
where an app lives.
Expand All @@ -171,13 +179,13 @@ A static SPA needs a static host and nothing else: GitHub Pages
(`VITE_BASE=/<repo>/`, the redirect URI then carries that path), any CDN, or
an nginx container. An app with a server ships as one container. The recipe
the reference apps use, file by file, is
[references/ship.md](references/ship.md); the only server-side facts are the
two settings in §5.
[references/ship.md](references/ship.md); the only server-side fact is the
registration in section 5.

## 7. Before calling it done

- Sign-in tested in **Firefox**, not only headless Chrome (the User-Agent preflight trap).
- One real turn against a real instance: hire, message, see blocks render, see `turn/done` on the stream.
- `bun test` + `tsc --noEmit` green in CI.
- The CORS + OAuth registration is **live on the instance**, not merely agreed: an `OPTIONS` preflight from your origin returns `access-control-allow-origin` before you test sign-in.
- The OAuth registration is **live on the instance**: an `OPTIONS` preflight from your origin returns `access-control-allow-origin` before you test sign-in.
- Read [references/traps.md](references/traps.md) once; every entry cost a previous app real hours.
6 changes: 3 additions & 3 deletions .claude/skills/build-fountain-app/references/ship.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Ship an app

The only server-side facts an app depends on are `API_CORS_ORIGINS` and
`OAUTH_CLIENTS` on the Fountain it talks to (SKILL.md §5). Everything below
is about hosting the app itself, which needs nothing from Fountain.
The only server-side fact an app depends on is its OAuth client registration
on the Fountain it talks to (SKILL.md section 5). Everything below is about
hosting the app itself.

## Static SPA

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/build-fountain-app/references/sign-in.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Facts that shape it (ADR 0021, `docs/api.md`):
```ts
import { normalizeBaseUrl } from "./settings";

const CLIENT_ID = "my-app"; // = the repo name, registered in OAUTH_CLIENTS
const CLIENT_ID = "app_xxx"; // from fountain oauth-client create
const STASH = "my-app.oauth";

function base64url(bytes: ArrayBuffer): string {
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/build-fountain-app/references/traps.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ noted.

## Transport

- **`ConnectionError` / "Failed to fetch" in a browser is CORS**, not your code. The origin is missing from `API_CORS_ORIGINS`. Show it as such (fountain-team: "Check the URL, and that API_CORS_ORIGINS on the server includes this site").
- **`ConnectionError` / "Failed to fetch" in a browser is usually CORS**, not your code. Register the app's redirect origin or add it to `API_CORS_ORIGINS`.
- **Firefox sends a page-set `User-Agent`; Chrome drops it.** Any custom header turns every call into a preflight, and the server's allow-list is static. SDK ≥ 0.1.5 no longer stamps UA in a browser and the plug admits `user-agent` (#1062), but **do not add custom headers of your own** and smoke in Firefox, not only headless Chrome. The workbench server uses plain `fetch` rather than the SDK on proxied calls precisely so it stamps nothing.
- **`EventSource` cannot send `Authorization`.** Streams are read with `fetch` and parsed by hand (SSE spec: blank-line records, `id:`/`event:`/`data:`, `:` heartbeats). The SDK's `stream()`/`team.stream()` do this and reconnect from their own last id; if you hand-roll, send `Last-Event-ID`, back off 1s→×2→15s cap, and `refresh()` on every open because a reconnect can miss a turn-end.
- **`/api/events/stream` follows unfinished conversations only** (#1060): one that fails or finishes before the debounced re-follow leaves a gap the stream never fills. Workaround: on any change to `turn_count` / `status` / `sandbox.status`, re-read `history({ after: lastSeenId })` and merge by event id — and merge live events into an in-flight history fetch, or the live ones get overwritten.
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ upgrade, is in

### Added

- **An account registers its own OAuth clients** (#1125, ADR 0021 amended).
"Sign in with Fountain" no longer needs an operator to edit `OAUTH_CLIENTS`
and redeploy. Register an app in the console under Account, then OAuth apps,
with `fountain oauth-client create`, or over `/api/oauth/clients`, and the
response carries the generated `client_id` the app sends. The registration
also admits the app's redirect origins to `/api`, so one registration covers
both the sign-in and the calls that follow it and `API_CORS_ORIGINS` needs
no entry.

A new client is in **development mode**: it signs in only the account that
registered it, and every other account gets an error page rather than a
redirect. That is what makes a self-chosen redirect URI safe, and it is why
an owner may name a sandbox's HTTPS URL or an `http://localhost` one. A
loopback URI matches on any port (RFC 8252). Only an operator publishes a
client for other accounts to use, and only an operator changes or removes it
afterwards. One account holds at most 25. Registration needs a full-scope
key, because a client is a standing route to a full-scope key after consent.

The consent page's `form-action` header now names the one redirect origin
this request asked for rather than every registered client's.

- **An `acp` runtime launches a named command, so a deterministic program can
run as an agent** (#1634). `agents.runtime` accepts `"acp"`, and a new
`runtime_command` field carries the command it runs. The field is required
Expand Down
102 changes: 99 additions & 3 deletions decisions/0021-oauth-for-first-party-apps.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
type: ADR
title: "Fountain is the OAuth 2.0 authorization server for its own browser apps (code + PKCE, public clients, keys as tokens)"
description: "The standalone team and conversations apps on other origins sign in with a Fountain session instead of a pasted API key: authorization code + PKCE (S256), public clients registered in config, redirect URIs exact, tokens are ordinary expiring API keys. No client secrets, no refresh tokens yet, no third-party clients yet."
description: "The standalone team and conversations apps on other origins sign in with a Fountain session instead of a pasted API key: authorization code + PKCE (S256), public clients, redirect URIs exact, tokens are ordinary expiring API keys. Amended 2026-09-10 (#1125): a tenant may register its own client, unpublished, which authorizes only its owner. No client secrets, no refresh tokens yet."
tags: [auth, oauth, api, spa]
status: stable
adr: "0021"
adr_status: "Accepted"
date: 2026-08-18
generated: { by: human:jhgaylor, at: 2026-08-18T14:00:00-04:00 }
verified: { by: human:jhgaylor, at: 2026-08-18T14:00:00-04:00 }
stale_after: 2026-11-18
verified: { by: claude-code/opus-5, at: 2026-09-10T23:40:00-04:00 }
stale_after: 2026-12-10
---

# 0021 — Fountain is the OAuth 2.0 authorization server for its own browser apps
Expand All @@ -18,6 +18,11 @@ stale_after: 2026-11-18
`Fountain.OAuth`, `GET/POST /oauth/authorize`, `POST /api/oauth/token`,
`POST /api/oauth/revoke`, `OAUTH_CLIENTS`.

**Amended 2026-09-10 ([#1125](https://github.com/managoat/fountain/issues/1125)):**
the config registry is no longer the only one. See
[Amendment: tenant-registered clients](#amendment-tenant-registered-clients-1125)
at the end; the two consequences it changes are marked below.

## Context

Two of Fountain's user surfaces now live outside the Phoenix app, on another
Expand All @@ -44,6 +49,8 @@ Fountain acts as the **OAuth 2.0 authorization server** for its own apps.
:oauth_clients` (runtime.exs reads `OAUTH_CLIENTS` as JSON) lists
`{id, name, redirect_uris}`. Redirect URIs match **exactly**. Two clients
are ours; a table and an admin UI come when a third party asks.
*(Amended: the table exists — see below. Config clients remain, and are
read as published.)*
- **Consent page** at `GET /oauth/authorize` behind the browser session:
names the client, Allow / Deny. Signed out → the request is stashed
(`FountainWeb.ReturnTo`) and the normal login — password or GitHub —
Expand Down Expand Up @@ -72,8 +79,97 @@ Fountain acts as the **OAuth 2.0 authorization server** for its own apps.
- Every login method Fountain has now, or grows later, works for the apps for
free — the OAuth layer sits on the session, not on a provider.
- Adding an app = one entry in `OAUTH_CLIENTS` (and `API_CORS_ORIGINS`).
*(Amended: only for an app the operator vouches for. Anyone else registers
their own, and one registration covers both.)*
- What this is **not**: a way for third parties to act as Fountain users. The
registry is ours, consent is coarse (full scope), and there are no scopes
narrower than what API keys already have. Doing that properly means a
clients table, per-scope consent and refresh rotation — the shape is
compatible, the work is not done.
*(Amended: the clients table is done and a tenant may register into it, but
only for itself. Per-scope consent and refresh rotation are still not
built, and there is still no way for a stranger's app to act as a Fountain
user without an operator publishing it.)*

## Amendment: tenant-registered clients (#1125)

**2026-09-10.** The consequence above said a third-party clients table would
come "when a third party asks". The ask arrived in the shape the product is
about: people build apps *inside* Fountain sandboxes, and pointing one at a
production Fountain meant asking an operator to edit `OAUTH_CLIENTS` and
`API_CORS_ORIGINS` and redeploy. That is not a first minute anybody finishes.

### The wrong answer, on the record

Wildcard the sandbox domain in both lists. Fine for CORS, a phishing kit for
OAuth: anyone with any sandbox could start a flow with their own PKCE
challenge and their own box as `redirect_uri`, and a consenting user's 30-day
full-scope key would land there. **PKCE does not help when the attacker
initiates the flow.**

### What was built

- **`oauth_clients`**, tenant-owned: `client_id` (generated, never supplied),
`name`, `redirect_uris`, a derived `origin_keys` lookup column, `published`.
`Fountain.OAuth.get_client/1` reads config first, so a row can never shadow
a first-party client.
- **Development mode is the security boundary, not the redirect allowlist.**
An unpublished client authorizes **only its owner**; every other account
gets a rendered error page, never a redirect, and the identity check runs
*before* the redirect check so a stranger's error page discloses no
registration. Because the only account such a client can capture belongs to
the person who registered it, its owner may name any HTTPS redirect URI or
an HTTP loopback URI.
`published` is an operator flip with no self-serve path. Once published, the
owner can neither change nor delete the operator-approved registration
through self-service. Deleting matters as much as editing: publication moved
the trust boundary to every account, and the row's `client_id` is random, so
a deletion would break sign-in for all of them with an id nobody can
recreate.
- **RFC 8252 loopback matching**, for unpublished clients only: a
`http://localhost` or `http://127.0.0.1` redirect matches on any port,
because the port a local dev server lands on is not a fact anybody
registered. Published clients get no such latitude.
- **CORS reads the same table.** `API_CORS_ORIGINS` first, then any registered
client's redirect origin. A preflight carries no authentication, so the
origin is the only key there is — which is exactly why "an origin some
client registered" is the right predicate: admitting an origin admits
nobody who does not already hold a bearer key, and no cookie crosses an
origin either way. Loopback matches on any port here too, so the sign-in and
the first API call cannot disagree.
- **`form-action` narrowed** from every client's origins to this request's
validated redirect origin. With tenant-registered apps the old header would
grow without bound and hand the whole registry to any visitor. The source is
the requested URI rather than the registration, because a loopback port
varies legally and a header naming the registered port has the browser block
a redirect the server approved.
- **Full scope to manage clients.** `/api/oauth/clients` sits behind
`:require_full_scope`, not the sprite scope #1125 originally proposed. A
registered client is a standing way to obtain a full-scope 30-day key with
one consent, which is the escalation the sprite scope exists to prevent
(`Fountain.Accounts.ApiKey`); a sandbox token must not be able to leave one
behind. Registering from the console, the CLI or the API with the owner's
own key still removes the operator, which was the point.
- **Twenty-five clients per account**, an abuse ceiling rather than an
allowance, because every row widens the deployment's CORS allowlist and
registration is self-serve. An operator raises it by editing the constant;
nobody has asked yet.
- **Audit**: `oauth_client.created`, `.updated`, `.deleted`, and
`oauth.authorized` now carries the client's `resource_id`.

### Still not done

Per-scope consent, client secrets, refresh tokens, and a publish or review
workflow. Publishing is SQL or a `mix` task until somebody needs more.

**There is no operator switch that turns self-registration off.** Any verified
account on any deployment may register, which is deliberate: it is the whole
point of the amendment, and the CORS consequence admits nobody who does not
already hold a bearer key. A self-hoster who wants the old behaviour has no
setting to reach for, so if one is ever asked for, it is a new flag rather
than a change of mind here. Nothing
here auto-registers a sandbox's redirect at spawn: explicit registration is
one call, and auto-registration would leave stale origins admitted after the
sandbox is gone. The SDKs do not model the registration endpoints
(`sdk/contract/omissions.json`), because registering is a once-per-app setup
step rather than something a client does per run.
Loading
Loading