diff --git a/.github/workflows/site-build.yml b/.github/workflows/site-build.yml index 0fc95cf8d..ead0f8bff 100644 --- a/.github/workflows/site-build.yml +++ b/.github/workflows/site-build.yml @@ -19,6 +19,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + submodules: true - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/docs/admin-oauth-worker.md b/docs/admin-oauth-worker.md index 4e8bec14a..8c2767036 100644 --- a/docs/admin-oauth-worker.md +++ b/docs/admin-oauth-worker.md @@ -2,7 +2,7 @@ This document describes intentional behavior of the **Cloudflare site Worker** that backs the admin SPA (`cloudflare_site/worker/`), especially CORS for `GET /api/github/user` and why there is **no** separate “admin OAuth enabled” boolean in configuration. -For local setup and env vars, see [`web/admin/README.md`](../web/admin/README.md). For CI and deploy layout, see [`docs/site-deployment.md`](site-deployment.md). +For local setup and env vars, see [`web/admin/README.md`](../web/admin/README.md). For CI and deploy layout, see [`docs/web-admin-deployment.md`](web-admin-deployment.md). ## `GET /api/github/user` CORS: missing `Origin` diff --git a/docs/doc-site.md b/docs/doc-site.md new file mode 100644 index 000000000..31fb7fd27 --- /dev/null +++ b/docs/doc-site.md @@ -0,0 +1,46 @@ +# Documentation site + +The documentation site is built with **[VitePress](https://vitepress.dev/)**. Markdown source lives in `docs/`, site configuration in `website/.vitepress/config.ts`, and build output in `website/dist/`. + +## Local development + +```bash +cd website +npm ci +npm run dev +``` + +The dev server starts on `http://localhost:5173/docs/`. Submodules (e.g. `experiments/`) are initialized automatically via a `predev` hook — no manual `git submodule` step needed. + +## Building + +```bash +cd website +npm run build +``` + +A `prebuild` hook runs `git submodule update --init` before the VitePress build, matching CI behavior. + +## How it works + +- `docs/` contains all markdown content, organized by section (agents, guides, ADRs, etc.) +- `website/.vitepress/config.ts` defines the sidebar navigation and markdown processing +- `getMarkdownFiles()` auto-discovers markdown files and subdirectory READMEs for dynamic sidebar sections (ADRs, experiments, design docs, specs, plans) +- Symlinks connect submodule content into `docs/` (e.g. `docs/experiments` → `../experiments`) + +## Submodules + +Some doc content lives in separate repositories linked as git submodules: + +| Submodule | Path | Docs symlink | +|-----------|------|-------------| +| [fullsend-ai/experiments](https://github.com/fullsend-ai/experiments) | `experiments/` | `docs/experiments` → `../experiments` | + +The `predev` and `prebuild` hooks in `website/package.json` handle initialization automatically for local dev. CI uses `submodules: true` on `actions/checkout` in `.github/workflows/site-build.yml`. + +## CI/CD + +- **`.github/workflows/site-build.yml`** — builds the VitePress site on PRs and pushes to `main`, uploads the artifact +- **`.github/workflows/site-deploy.yml`** — deploys the built artifact to Cloudflare Workers on `main` pushes, uploads preview versions on PRs + +For Cloudflare Worker setup, secrets, and troubleshooting, see [`web-admin-deployment.md`](web-admin-deployment.md). diff --git a/docs/experiments b/docs/experiments new file mode 120000 index 000000000..f3dfc08d5 --- /dev/null +++ b/docs/experiments @@ -0,0 +1 @@ +../experiments \ No newline at end of file diff --git a/docs/superpowers/experiments/oauth-localhost-part-b/.gitignore b/docs/superpowers/experiments/oauth-localhost-part-b/.gitignore deleted file mode 100644 index 665da4550..000000000 --- a/docs/superpowers/experiments/oauth-localhost-part-b/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.env -.env.* diff --git a/docs/superpowers/experiments/oauth-localhost-part-b/README.md b/docs/superpowers/experiments/oauth-localhost-part-b/README.md deleted file mode 100644 index 6c79e8136..000000000 --- a/docs/superpowers/experiments/oauth-localhost-part-b/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Task 1 Part B — real `http://localhost` origin - -OAuth **Part A** (authorize) and **Part B** (same-origin `POST` to this helper, which exchanges the `code` **server-side** with GitHub) against a real **`http://localhost:`** origin, matching the GitHub App callback URL. - -The in-repo **admin** SPA under `web/admin/` uses a **different** callback: register `http://localhost:/admin/` (see `web/admin/README.md`), not this folder’s `/oauth/callback.html` path. - -Dynamic pages are served by **`serve.py`**, which reads your GitHub App’s public **OAuth client id** from the environment (`CLIENT_ID`). Optionally it reads **`CLIENT_SECRET`** for a **full** server-side token exchange (same shape as a production Worker/BFF). The secret is **never** read from the browser or from JSON bodies—only from your shell environment on the machine running `serve.py`. **Never commit** secrets; this folder’s `.gitignore` ignores `.env*`. - -## 1. Register the callback on your test GitHub App - -For the default port **5173**, the **Callback URL** must be exactly: - -`http://localhost:5173/oauth/callback.html` - -If you use `PORT=…` when starting the server, register **`http://localhost:/oauth/callback.html`** instead. - -## 2. Start the server - -From **this directory**: - -```bash -export CLIENT_ID='Iv23…' # GitHub App → OAuth credentials → Client ID -export CLIENT_SECRET='…' # optional: full token exchange (generate once in GitHub UI) -python3 serve.py -``` - -Optional environment variables: - -| Variable | Default | Meaning | -|----------|---------|---------| -| `CLIENT_ID` | _(required)_ | GitHub App OAuth client ID | -| `CLIENT_SECRET` | _(unset)_ | If set, server-side `POST` to GitHub includes `client_secret` (full flow; **do not commit**) | -| `PORT` | `5173` | local HTTP port | -| `BIND` | `127.0.0.1` | address the socket listens on | - -Example with a non-default port: - -```bash -export CLIENT_ID='Iv23…' -PORT=8765 python3 serve.py -``` - -Then register `http://localhost:8765/oauth/callback.html` on the app and open **`http://localhost:8765/`** in the browser (same host as the callback). - -## 3. Use a normal browser tab - -Open **Chrome** or **Firefox** (not an embedded IDE preview). Visit: - -`http://localhost:/` - -Use the **Sign in with GitHub** link (Part A). After approval, GitHub redirects to **`/oauth/callback.html`**, which: - -1. Shows the query parameters (`code`, `state`, or OAuth errors). -2. Calls **`POST /_experiment/github-access-token`** once; `serve.py` forwards to GitHub **server-side** so the one-time `code` is not consumed by a prior browser `fetch` to `github.com`. Without `CLIENT_SECRET` you can still inspect GitHub’s error JSON; **with** `CLIENT_SECRET` in the environment you get the full exchange (expect `access_token` / `ghu_…` when the `code` is valid). Requests that include `client_secret` in JSON are **rejected** — use env only. - -### Note on CORS - -A direct browser `fetch` to `https://github.com/login/oauth/access_token` is not used here: GitHub’s response is not meant to be read cross-origin from JS, and an attempt could still **consume** the authorization `code` before this helper runs. This flow uses **same-origin** `fetch` to localhost only. - -## Fallback: static files only - -If you run `python3 -m http.server` against this folder, `index.html` and `oauth/callback.html` are only stubs pointing you back to **`serve.py`** — they do **not** inject `CLIENT_ID` or auto-run Part B. - -## Files - -| Path | Role | -|------|------| -| `serve.py` | HTTP server: `/` index, `/oauth/callback.html` (proxy exchange only), `POST /_experiment/github-access-token` | -| `index.html` | Stub when not using `serve.py` | -| `oauth/callback.html` | Stub when not using `serve.py` | diff --git a/docs/superpowers/experiments/oauth-localhost-part-b/index.html b/docs/superpowers/experiments/oauth-localhost-part-b/index.html deleted file mode 100644 index 49964b56d..000000000 --- a/docs/superpowers/experiments/oauth-localhost-part-b/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - Use serve.py - - -

- This file is only used if you run a dumb static server (e.g. - python3 -m http.server). For Task 1 with a configured - CLIENT_ID and the correct authorize link + automatic Part B on the - callback page, run: -

-
export CLIENT_ID='Iv23…'
-python3 serve.py
-

Then open http://localhost:5173/ (or your PORT). See README.md.

- - diff --git a/docs/superpowers/experiments/oauth-localhost-part-b/oauth/callback.html b/docs/superpowers/experiments/oauth-localhost-part-b/oauth/callback.html deleted file mode 100644 index 0db82fcfd..000000000 --- a/docs/superpowers/experiments/oauth-localhost-part-b/oauth/callback.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Use serve.py - - -

- OAuth redirects must hit the callback served by serve.py so - CLIENT_ID is injected and Part B runs automatically. Start from the repo - copy: -

-
export CLIENT_ID='Iv23…'
-python3 serve.py
-

Register http://localhost:5173/oauth/callback.html on your GitHub App (adjust port if you set PORT). See README.md.

- - diff --git a/docs/superpowers/experiments/oauth-localhost-part-b/serve.py b/docs/superpowers/experiments/oauth-localhost-part-b/serve.py deleted file mode 100644 index fe908491e..000000000 --- a/docs/superpowers/experiments/oauth-localhost-part-b/serve.py +++ /dev/null @@ -1,384 +0,0 @@ -#!/usr/bin/env python3 -"""Local OAuth Task 1 helper: inject CLIENT_ID and exercise the GitHub token exchange. - -Requires CLIENT_ID in the environment (GitHub App OAuth client id — public). - - export CLIENT_ID='Iv23…' - python3 serve.py - -Optional CLIENT_SECRET (same machine only — never commit, never send from the browser): -when set, POST /_experiment/github-access-token performs the full documented web-app -exchange (includes client_secret server-side only). - -Optional: PORT (default 5173), BIND (default 127.0.0.1). If PORT is not 5173, register the -matching callback URL on your GitHub App (http://localhost:/oauth/callback.html). -""" - -from __future__ import annotations - -import json -import os -import sys -import urllib.error -import urllib.request -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlencode - - -def _require_client_id() -> str: - cid = os.environ.get("CLIENT_ID", "").strip() - if not cid: - print("error: set CLIENT_ID to your GitHub App OAuth client ID, e.g.", file=sys.stderr) - print(" export CLIENT_ID='Iv23…'", file=sys.stderr) - sys.exit(1) - return cid - - -def _port() -> int: - raw = os.environ.get("PORT", "5173").strip() - try: - p = int(raw, 10) - except ValueError as e: - print(f"error: PORT must be an integer, got {raw!r}", file=sys.stderr) - raise SystemExit(1) from e - if not (1 <= p <= 65535): - print("error: PORT out of range", file=sys.stderr) - raise SystemExit(1) - return p - - -def _bind() -> str: - return os.environ.get("BIND", "127.0.0.1").strip() or "127.0.0.1" - - -def _redirect_uri(port: int) -> str: - return f"http://localhost:{port}/oauth/callback.html" - - -def _authorize_url(client_id: str, redirect_uri: str) -> str: - q = urlencode( - { - "client_id": client_id, - "redirect_uri": redirect_uri, - "state": "oauth-localhost-part-b", - } - ) - return f"https://github.com/login/oauth/authorize?{q}" - - -def _optional_client_secret() -> str | None: - raw = os.environ.get("CLIENT_SECRET", "").strip() - return raw or None - - -def _github_oauth_access_token( - client_id: str, - code: str, - redirect_uri: str, - *, - client_secret: str | None, -) -> tuple[int, str]: - """POST to GitHub from this process. Returns (status, response text).""" - params: dict[str, str] = { - "client_id": client_id, - "code": code, - "redirect_uri": redirect_uri, - } - if client_secret is not None: - params["client_secret"] = client_secret - body = urlencode(params).encode("utf-8") - req = urllib.request.Request( - "https://github.com/login/oauth/access_token", - data=body, - method="POST", - headers={ - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - raw = resp.read().decode("utf-8", errors="replace") - return resp.status, raw - except urllib.error.HTTPError as e: - raw = e.read().decode("utf-8", errors="replace") - return e.code, raw - except urllib.error.URLError as e: - reason = e.reason if isinstance(e.reason, str) else repr(e.reason) - raise OSError(f"GitHub token request failed: {reason}") from e - - -def _html_page(title: str, body: str) -> bytes: - doc = f""" - - - - -{title} - - -{body} - - -""" - return doc.encode("utf-8") - - -def _index_html(client_id: str, authorize_url: str, redirect_uri: str) -> bytes: - from html import escape - - cid_display = escape(client_id, quote=True) - auth_escaped = escape(authorize_url, quote=True) - redir_escaped = escape(redirect_uri, quote=True) - body = f"""

OAuth Task 1 — localhost Part B

-

Callback URL registered on the GitHub App must match exactly:

-

{redir_escaped}

-

Using CLIENT_ID (public): {cid_display}

-

-If you started the server with CLIENT_SECRET in the environment (only on this -machine — never in git, never in the browser), the same-origin proxy performs the -full token exchange and you should see an access_token in the -JSON when the code is valid. -

-

Part A — authorize

-

Open this link in this same browser (new tab is fine):

-

Sign in with GitHub (authorize)

-

Or copy URL:

-
{auth_escaped}
-

Part B

-

After GitHub redirects to /oauth/callback.html, the page calls -same-origin POST /_experiment/github-access-token; this helper -forwards to GitHub server-side so the one-time code is not spent -on a browser fetch to github.com first. With CLIENT_SECRET -set in the server environment, the POST includes the secret only on the -Python → GitHub hop (models a Worker/BFF). Still dev-only.

-

Never put client_secret in the browser or in JSON to this -server — use export CLIENT_SECRET=… before python3 serve.py only.

-""" - return _html_page("OAuth Task 1 — localhost Part B", body) - - -def _callback_html(client_id: str, redirect_uri: str) -> bytes: - cid_js = json.dumps(client_id) - redir_js = json.dumps(redirect_uri) - script = f""" - -""" - body = f"""

OAuth callback

-

← Back to index

-
Loading…
-

Token exchange (proxy)

-
(waiting)
-{script} -""" - return _html_page("OAuth callback (localhost)", body) - - -class Handler(BaseHTTPRequestHandler): - server_version = "OAuthLocalhostPartB/1.0" - - def _send( - self, - status: int, - body: bytes, - content_type: str = "text/html; charset=utf-8", - ) -> None: - self.send_response(status) - self.send_header("Content-Type", content_type) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def _send_json(self, status: int, obj: object) -> None: - raw = json.dumps(obj, indent=2).encode("utf-8") - self._send(status, raw, "application/json; charset=utf-8") - - def do_POST(self) -> None: # noqa: N802 - path = self.path.split("?", 1)[0] - if path != "/_experiment/github-access-token": - self.send_error(404, "unknown POST path") - return - - client_id = self.server.client_id # type: ignore[attr-defined] - port = self.server.port # type: ignore[attr-defined] - allowed_redirect = _redirect_uri(port) - - length_raw = self.headers.get("Content-Length", "0") - try: - length = int(length_raw, 10) - except ValueError: - self._send_json(400, {"error": "bad Content-Length"}) - return - if length <= 0 or length > 8192: - self._send_json(400, {"error": "body must be 1..8192 bytes"}) - return - - raw_body = self.rfile.read(length) - try: - payload = json.loads(raw_body.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - self._send_json(400, {"error": "body must be JSON"}) - return - - if "client_secret" in payload: - self._send_json( - 400, - { - "error": ( - "do not send client_secret in JSON; set CLIENT_SECRET in the " - "server environment only" - ), - }, - ) - return - - cid = payload.get("client_id") - code = payload.get("code") - redirect_uri = payload.get("redirect_uri") - if ( - not isinstance(cid, str) - or not isinstance(code, str) - or not isinstance(redirect_uri, str) - ): - self._send_json( - 400, - {"error": "JSON must have string client_id, code, redirect_uri"}, - ) - return - if cid != client_id: - self._send_json(403, {"error": "client_id must match this server’s CLIENT_ID"}) - return - if redirect_uri != allowed_redirect: - self._send_json( - 403, - {"error": "redirect_uri must match callback URL", "expected": allowed_redirect}, - ) - return - - secret = self.server.client_secret # type: ignore[attr-defined] - try: - gh_status, gh_text = _github_oauth_access_token( - cid, - code, - redirect_uri, - client_secret=secret, - ) - except OSError as e: - self._send_json(502, {"error": "upstream GitHub request failed", "detail": str(e)}) - return - try: - gh_json = json.loads(gh_text) - except json.JSONDecodeError: - gh_json = gh_text - if secret: - note = ( - "Dev-only: full web-app exchange (client_secret taken from this process " - "environment only, never from the browser). Treat access_token in the response " - "as sensitive." - ) - else: - note = ( - "Dev-only: exchange without client_secret (GitHub may return an error JSON). " - "Set CLIENT_SECRET in the environment and restart to test the full flow." - ) - self._send_json( - 200, - { - "note": note, - "github_http_status": gh_status, - "github_body": gh_json, - }, - ) - - def do_GET(self) -> None: # noqa: N802 - path = self.path.split("?", 1)[0] - client_id = self.server.client_id # type: ignore[attr-defined] - port = self.server.port # type: ignore[attr-defined] - redirect_uri = _redirect_uri(port) - - if path in ("/", "/index.html"): - page = _index_html( - client_id, - _authorize_url(client_id, redirect_uri), - redirect_uri, - ) - self._send(200, page) - return - if path == "/oauth/callback.html": - page = _callback_html(client_id, redirect_uri) - self._send(200, page) - return - - self.send_error(404, "use / or /oauth/callback.html") - - def log_message(self, format: str, *args: object) -> None: # noqa: A003 - message = format % args if args else format - sys.stderr.write(f"{self.address_string()} - - [{self.log_date_time_string()}] {message}\n") - - -def main() -> None: - client_id = _require_client_id() - port = _port() - bind = _bind() - client_secret = _optional_client_secret() - httpd = ThreadingHTTPServer((bind, port), Handler) - httpd.client_id = client_id # type: ignore[attr-defined] - httpd.port = port # type: ignore[attr-defined] - httpd.client_secret = client_secret # type: ignore[attr-defined] - redir = _redirect_uri(port) - print(f"Serving http://localhost:{port}/ (bind {bind}:{port})") - print(f"Callback URL must be: {redir}") - if client_secret: - print("CLIENT_SECRET is set: proxy will send full token exchange to GitHub.") - else: - print("CLIENT_SECRET not set: proxy exchange omits client_secret (partial test).") - print("Press Ctrl+C to stop.") - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\nStopped.") - finally: - httpd.server_close() - - -if __name__ == "__main__": - main() diff --git a/docs/superpowers/plans/2026-04-09-site-cloudflare-pages.md b/docs/superpowers/plans/2026-04-09-site-cloudflare-pages.md index e74c11b09..be5b4f5cc 100644 --- a/docs/superpowers/plans/2026-04-09-site-cloudflare-pages.md +++ b/docs/superpowers/plans/2026-04-09-site-cloudflare-pages.md @@ -21,7 +21,7 @@ | `cloudflare_site/wrangler.toml` | Worker name placeholder, `assets.directory = public`, SPA `not_found_handling`, `preview_urls` | | `cloudflare_site/public/.gitkeep` | Keeps `public/` in git; CI overwrites with artifact contents | | `.github/workflows/mindmap.yml` | **Removed** (replaced by `site-build.yml` / `site-deploy.yml`) | -| `docs/site-deployment.md` | Operator runbook: Worker, token scopes (Workers Edit), secrets/variables, fork policy, troubleshooting | +| `docs/web-admin-deployment.md` | Operator runbook: Worker, token scopes (Workers Edit), secrets/variables, fork policy, troubleshooting | --- @@ -136,11 +136,11 @@ git commit -m "ci: drop GitHub Pages workflow for documentation site" **Files:** -- Create: `docs/site-deployment.md` +- Create: `docs/web-admin-deployment.md` - [ ] **Step 1: Add the runbook** -Create `docs/site-deployment.md` with the following sections (adjust org/repo names when copying for upstream): +Create `docs/web-admin-deployment.md` with the following sections (adjust org/repo names when copying for upstream): 1. **Overview** — Link to the design spec `docs/superpowers/specs/2026-04-09-site-cloudflare-pages-design.md` and summarize **`Build Site`** / **`Deploy Site`** (Workers + static assets). 2. **Cloudflare setup** @@ -168,7 +168,7 @@ Create `docs/site-deployment.md` with the following sections (adjust org/repo na - [ ] **Step 2: Commit** ```bash -git add docs/site-deployment.md +git add docs/web-admin-deployment.md git commit -m "docs: add documentation site Cloudflare operator runbook" ``` @@ -178,7 +178,7 @@ git commit -m "docs: add documentation site Cloudflare operator runbook" **Files:** none (manual) -- [ ] **Step 1: Configure Cloudflare + GitHub** per `docs/site-deployment.md` on your fork. +- [ ] **Step 1: Configure Cloudflare + GitHub** per `docs/web-admin-deployment.md` on your fork. - [ ] **Step 2: Push a commit on `main` that touches `web/public/index.html`** (document graph; formerly `docs/mindmap.html`) diff --git a/docs/superpowers/plans/2026-04-12-fullsend-admin-spa.md b/docs/superpowers/plans/2026-04-12-fullsend-admin-spa.md index 395b6c606..3b2cd532e 100644 --- a/docs/superpowers/plans/2026-04-12-fullsend-admin-spa.md +++ b/docs/superpowers/plans/2026-04-12-fullsend-admin-spa.md @@ -106,7 +106,7 @@ You only need a **personal test GitHub App** (under your user **or** a throwaway Cross-origin `fetch` from `http://localhost:5173` to `https://github.com/login/oauth/access_token` is usually **blocked by CORS** (no `Access-Control-Allow-Origin`). If the request fails with a **network / CORS** error, record that: the endpoint is **not** intended for browser direct access. If you somehow get a JSON body, check for `incorrect_client_credentials` or missing `access_token`. -**Where to run Part B:** use a real **`http://localhost:5173`…** document. The repo includes [`docs/superpowers/experiments/oauth-localhost-part-b/serve.py`](../experiments/oauth-localhost-part-b/serve.py): `cd` that directory, `export CLIENT_ID='…'` (and optionally `CLIENT_SECRET` for a full exchange), run `python3 serve.py`, open **`http://localhost:5173/`** in Chrome or Firefox (not an embedded IDE preview). After redirect, `/oauth/callback.html` calls **`POST /_experiment/github-access-token`** on the same origin only (no prior cross-origin `fetch` to GitHub), so the one-time `code` is not consumed before the server-side exchange. **Do not** mix `http://127.0.0.1:5173` and `http://localhost:5173` in the address bar vs the registered callback URL (different origins). Task 2’s Vite dev server on the same port is also fine once it exists. **Do not** rely on `file://` or `chrome://`-style pages with strict CSP for the authorize/callback flow. For a **manual** CORS/CSP experiment, you can still paste the Part B `fetch` snippet in DevTools yourself (see below)—know that a successful reach to GitHub may **invalidate** the `code` before `curl` Part C or the proxy run. +**Where to run Part B:** use a real **`http://localhost:5173`…** document. The repo includes [`docs/superpowers/experiments/oauth-localhost-part-b/serve.py`](https://github.com/fullsend-ai/fullsend/blob/14b0b41d/docs/superpowers/experiments/oauth-localhost-part-b/serve.py): `cd` that directory, `export CLIENT_ID='…'` (and optionally `CLIENT_SECRET` for a full exchange), run `python3 serve.py`, open **`http://localhost:5173/`** in Chrome or Firefox (not an embedded IDE preview). After redirect, `/oauth/callback.html` calls **`POST /_experiment/github-access-token`** on the same origin only (no prior cross-origin `fetch` to GitHub), so the one-time `code` is not consumed before the server-side exchange. **Do not** mix `http://127.0.0.1:5173` and `http://localhost:5173` in the address bar vs the registered callback URL (different origins). Task 2’s Vite dev server on the same port is also fine once it exists. **Do not** rely on `file://` or `chrome://`-style pages with strict CSP for the authorize/callback flow. For a **manual** CORS/CSP experiment, you can still paste the Part B `fetch` snippet in DevTools yourself (see below)—know that a successful reach to GitHub may **invalidate** the `code` before `curl` Part C or the proxy run. Example (DevTools console on **localhost** after Task 2; fill placeholders): diff --git a/docs/superpowers/specs/2026-04-06-fullsend-admin-spa-design.md b/docs/superpowers/specs/2026-04-06-fullsend-admin-spa-design.md index 3143092c5..c70d33b9b 100644 --- a/docs/superpowers/specs/2026-04-06-fullsend-admin-spa-design.md +++ b/docs/superpowers/specs/2026-04-06-fullsend-admin-spa-design.md @@ -174,8 +174,8 @@ During implementation, add a **row per GitHub capability** the SPA uses (REST pa - **GitHub App user access token exchange vs pure static SPA (Task 1, 2026-04-12):** - **Documented contract:** GitHub’s **web application flow** for GitHub Apps lists **`client_secret`** as **required** on `POST https://github.com/login/oauth/access_token` together with `client_id` and the authorization `code` (optional `redirect_uri`, optional PKCE `code_verifier`). A successful JSON body includes `access_token` (user tokens use the `ghu_` prefix) and `token_type` (`bearer`). Source: [Using the web application flow to generate a user access token](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token). - - **Part B (browser `fetch` without `client_secret`, real `code`):** In an embedded / restricted browser context, `fetch` to `https://github.com/login/oauth/access_token` was blocked by the **page’s Content-Security-Policy** (`connect-src` / `default-src chrome:`), so the request never reached a stage where GitHub’s **CORS** policy could be observed. The [`oauth-localhost-part-b/serve.py`](../experiments/oauth-localhost-part-b/serve.py) helper uses a **same-origin** callback → local `POST` → server-side GitHub exchange only (so the one-time `code` is not spent on a cross-origin browser attempt first). For a **manual** CORS check, use DevTools on a normal **`http://localhost:`** tab and the plan’s `fetch` snippet—not `file://` or locked-down embedded previews. Use **`localhost`** consistently (not `127.0.0.1`) so the tab origin matches the GitHub callback URL. Task 2’s Vite dev server is also fine once it exists. - - **Part C (terminal `curl` with `client_secret`, secret never in repo or browser bundle):** Optional; same outcome as a server-side `POST` exchange. **2026-04-12:** full exchange validated via [`oauth-localhost-part-b/serve.py`](../experiments/oauth-localhost-part-b/README.md) with `CLIENT_SECRET` in the process environment only (proxy → GitHub), yielding a usable `ghu_` token when the authorization `code` was valid. `refresh_token` / `expires_in` appear when expiring user tokens are enabled on the app. + - **Part B (browser `fetch` without `client_secret`, real `code`):** In an embedded / restricted browser context, `fetch` to `https://github.com/login/oauth/access_token` was blocked by the **page’s Content-Security-Policy** (`connect-src` / `default-src chrome:`), so the request never reached a stage where GitHub’s **CORS** policy could be observed. The [`oauth-localhost-part-b/serve.py`](https://github.com/fullsend-ai/fullsend/blob/14b0b41d/docs/superpowers/experiments/oauth-localhost-part-b/serve.py) helper uses a **same-origin** callback → local `POST` → server-side GitHub exchange only (so the one-time `code` is not spent on a cross-origin browser attempt first). For a **manual** CORS check, use DevTools on a normal **`http://localhost:`** tab and the plan’s `fetch` snippet—not `file://` or locked-down embedded previews. Use **`localhost`** consistently (not `127.0.0.1`) so the tab origin matches the GitHub callback URL. Task 2’s Vite dev server is also fine once it exists. + - **Part C (terminal `curl` with `client_secret`, secret never in repo or browser bundle):** Optional; same outcome as a server-side `POST` exchange. **2026-04-12:** full exchange validated via [`oauth-localhost-part-b/serve.py`](https://github.com/fullsend-ai/fullsend/blob/14b0b41d/docs/superpowers/experiments/oauth-localhost-part-b/README.md) with `CLIENT_SECRET` in the process environment only (proxy → GitHub), yielding a usable `ghu_` token when the authorization `code` was valid. `refresh_token` / `expires_in` appear when expiring user tokens are enabled on the app. - **Smallest production-shaped adjustment:** keep `client_secret` **only** on a confidential path (for example a **Cloudflare Worker** handler with `GITHUB_APP_CLIENT_SECRET` in Wrangler secrets) that performs the `POST` exchange and returns tokens to the SPA over the **same admin origin**; do **not** embed the secret in static assets. **Device flow** is for headless clients and is **not** a substitute for the browser admin experience. - **Frontend stack (decided 2026-04-12):** **Svelte 5** + **TypeScript** + **Vite** for the admin SPA (see implementation plan). - Expand **Appendix A** from code during first implementation PRs (beyond the OAuth exchange row above). diff --git a/docs/superpowers/specs/2026-04-09-site-cloudflare-pages-design.md b/docs/superpowers/specs/2026-04-09-site-cloudflare-pages-design.md index 38ba8b9fd..27196b682 100644 --- a/docs/superpowers/specs/2026-04-09-site-cloudflare-pages-design.md +++ b/docs/superpowers/specs/2026-04-09-site-cloudflare-pages-design.md @@ -11,7 +11,7 @@ The repository publishes a **static documentation site**. Today the primary surf **Implemented:** [`.github/workflows/site-build.yml`](../../../.github/workflows/site-build.yml) and [`.github/workflows/site-deploy.yml`](../../../.github/workflows/site-deploy.yml) use the build → artifact → `workflow_run` deploy split. **Production** uses **`wrangler deploy`** (Worker + static assets). **Pull requests** use **`wrangler versions upload --preview-alias …`** so previews get a stable **`*.workers.dev`** URL without promoting a new production version. The previous GitHub Pages workflow has been **removed**. -**Operator setup:** Cloudflare **Worker**, API token with **Workers** permissions, and GitHub Actions secrets/variables are required; see [`docs/site-deployment.md`](../../site-deployment.md). +**Operator setup:** Cloudflare **Worker**, API token with **Workers** permissions, and GitHub Actions secrets/variables are required; see [`docs/web-admin-deployment.md`](../../web-admin-deployment.md). ## Goals @@ -90,7 +90,7 @@ Same as before: minimal auditable build; deploy trusts artifacts from the known ## Operator documentation -See [`docs/site-deployment.md`](../../site-deployment.md). +See [`docs/web-admin-deployment.md`](../../web-admin-deployment.md). ## Testing and acceptance diff --git a/docs/site-deployment.md b/docs/web-admin-deployment.md similarity index 57% rename from docs/site-deployment.md rename to docs/web-admin-deployment.md index e249d7707..b5f080db1 100644 --- a/docs/site-deployment.md +++ b/docs/web-admin-deployment.md @@ -1,25 +1,18 @@ -# Documentation site deployment (Cloudflare Workers) +# Web admin deployment (on hold) -## Overview - -This repository publishes a static documentation site. The root landing page is [`web/public/index.html`](../web/public/index.html); the interactive document graph is [`web/public/graph.html`](../web/public/graph.html) (served at `/graph.html`). **Vite** builds the **admin** SPA under **`web/dist/admin/`** (see [`web/admin/README.md`](../web/admin/README.md)). The **docs site** is built by **VitePress** from the **`website/`** directory, reading markdown from **`docs/`** and producing static HTML in **`website/dist/`**. CI copies **`assets/`**, **`admin/`**, and **`docs/`** into **`_bundle/public/`** so the Worker serves **`/admin/`** and **`/docs/`** from the same static asset tree. OAuth/CORS hardening for that Worker is summarized in [`docs/admin-oauth-worker.md`](admin-oauth-worker.md) (path-specific CORS for `/api/github/user`, no separate “OAuth enabled” env flag). +> **Status: On hold.** The web admin SPA (`web/admin/`) and Cloudflare Worker deployment are paused. This document is preserved for reference when work resumes. For the **documentation site** (VitePress), see [`doc-site.md`](doc-site.md). -**Build Site** runs **`npm ci`** and **`npm run build`** at the repository root, then packs **`public/`** (static files, including `assets/` and `admin/` from `web/dist/`, plus `docs/` from `website/dist/`) and **`worker/`** (TypeScript Worker from the same checkout—PR head on PR builds) under **`_bundle/`** in one artifact. **Deploy Site** checks out **only the default branch** (trusted [`cloudflare_site/wrangler.toml`](../cloudflare_site/wrangler.toml); never PR-controlled config on the secret-bearing runner), downloads the artifact to **`_bundle/`**, then **copies only** **`_bundle/public/`** and **`_bundle/worker/`** into **`cloudflare_site/`** (so a malicious artifact cannot overwrite `wrangler.toml` or other repo files), then runs Wrangler. Deployment uses **Cloudflare Workers with [static assets](https://developers.cloudflare.com/workers/static-assets/)** (not the legacy **Pages direct-upload** / `wrangler pages deploy` flow). - -Two GitHub Actions workflows: +## Overview -- **Build Site** — on `pull_request` and `push` to `main`, checks out the PR head when relevant, installs Node dependencies, builds the admin SPA (Vite) and docs site (VitePress), assembles **`_bundle/public/`** and **`_bundle/worker/`**, uploads artifact **`site`** (`_bundle/` contents). -- **Deploy Site** — on successful **Build Site** via `workflow_run`, checks out the repo default ref (trusted Wrangler project files), downloads artifact **`site`** into **`_bundle/`**, copies **`public/`** and **`worker/`** into **`cloudflare_site/`**, then: - - **push to `main`:** `wrangler deploy` → production Worker traffic. - - **pull_request:** `wrangler versions upload --preview-alias pr-` → preview URL on `*.workers.dev` without changing production (alias falls back to `pr-` only when the same fork branch matches more than one open PR). +The **admin installation UI** is a **Svelte 5 + Vite** single-page app under `web/admin/`, served at the `/admin/` base path. The root landing page is [`web/public/index.html`](../web/public/index.html); the interactive document graph is [`web/public/graph.html`](../web/public/graph.html) (served at `/graph.html`). **Vite** builds the admin SPA under **`web/dist/admin/`** (see [`web/admin/README.md`](../web/admin/README.md)). GitHub OAuth token exchange runs in the **Cloudflare Worker** under [`cloudflare_site/worker/`](../cloudflare_site/worker/). OAuth/CORS hardening is summarized in [`docs/admin-oauth-worker.md`](admin-oauth-worker.md). -GitHub **Deployments** use environments **`site-preview`** and **`site-production`**; PRs also get a single upserted comment with the preview link. +Deployment uses **Cloudflare Workers with [static assets](https://developers.cloudflare.com/workers/static-assets/)** (not the legacy **Pages direct-upload** / `wrangler pages deploy` flow). **Deploy Site** checks out **only the default branch** (trusted [`cloudflare_site/wrangler.toml`](../cloudflare_site/wrangler.toml); never PR-controlled config on the secret-bearing runner), downloads the build artifact, then **copies only** **`_bundle/public/`** and **`_bundle/worker/`** into **`cloudflare_site/`** (so a malicious artifact cannot overwrite `wrangler.toml` or other repo files), then runs Wrangler. -For architecture and naming, see [2026-04-09-site-cloudflare-pages-design.md](superpowers/specs/2026-04-09-site-cloudflare-pages-design.md) (document filename still says “pages” for history; content describes Workers). Repository layout for `web/` vs `cloudflare_site/` is decided in [ADR 0019](ADRs/0019-web-source-and-cloudflare-site-layout.md). +For architecture and naming, see [2026-04-09-site-cloudflare-pages-design.md](superpowers/specs/2026-04-09-site-cloudflare-pages-design.md) (document filename still says "pages" for history; content describes Workers). Repository layout for `web/` vs `cloudflare_site/` is decided in [ADR 0019](ADRs/0019-web-source-and-cloudflare-site-layout.md). ## Cloudflare setup -### Worker (not a Pages “project”) +### Worker (not a Pages "project") 1. In the Cloudflare dashboard, use **Workers & Pages** → **Create** → **Create Worker** (or let the first `wrangler deploy` create it). The Worker name must match the GitHub variable below. 2. Configure **[preview URLs](https://developers.cloudflare.com/workers/configuration/previews/)** (default on when `workers_dev` is enabled). PR builds rely on **`wrangler versions upload`** with `--preview-alias`. @@ -29,10 +22,10 @@ For architecture and naming, see [2026-04-09-site-cloudflare-pages-design.md](su Create an API token that can deploy Workers for your account, for example: -- **Account** → **Cloudflare Workers** → **Edit** (or the “Edit Cloudflare Workers” template), and +- **Account** → **Cloudflare Workers** → **Edit** (or the "Edit Cloudflare Workers" template), and - **Account** → **Account Settings** → **Read** if Wrangler requires it. -Store it as GitHub secret **`CLOUDFLARE_API_TOKEN`**. A token scoped **only** to “Cloudflare Pages — Edit” is **not** enough for `wrangler deploy` / `versions upload` on a Worker. +Store it as GitHub secret **`CLOUDFLARE_API_TOKEN`**. A token scoped **only** to "Cloudflare Pages — Edit" is **not** enough for `wrangler deploy` / `versions upload` on a Worker. ### Account ID and Worker name @@ -65,20 +58,17 @@ Disable **GitHub Pages** under **Settings → Pages** if it was only used for th **Full stack (recommended for admin OAuth):** from the repository root, run **`npm run dev`** so Vite serves the SPA and Wrangler runs the site Worker with shared process env — see [`web/admin/README.md`](../web/admin/README.md). -**Static tree + Worker (closer to production asset layout):** install dependencies, build admin SPA and VitePress docs site, copy the same layout CI uses under `cloudflare_site/public/`, then run Wrangler: +**Static tree + Worker (closer to production asset layout):** install dependencies, build the admin SPA, assemble the layout CI uses under `cloudflare_site/public/`, then run Wrangler: ```bash npm ci npm run build -cd website && npm ci && npm run build && cd .. mkdir -p cloudflare_site/public/assets mkdir -p cloudflare_site/public/admin -mkdir -p cloudflare_site/public/docs cp web/public/index.html cloudflare_site/public/index.html cp web/public/graph.html cloudflare_site/public/graph.html cp -a web/dist/assets/. cloudflare_site/public/assets/ cp -a web/dist/admin/. cloudflare_site/public/admin/ -cp -a website/dist/. cloudflare_site/public/docs/ cd cloudflare_site && npx wrangler@4 dev ``` diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 9f75b85ff..9e6a4b900 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -236,7 +236,6 @@ export default defineConfig({ }, { text: 'Roadmap', link: '/roadmap' }, { text: 'Landscape', link: '/landscape' }, - { text: 'Site Deployment', link: '/site-deployment' }, { text: 'Architecture Decisions', collapsed: true, @@ -247,6 +246,13 @@ export default defineConfig({ collapsed: true, items: getMarkdownFiles('problems', 'problems'), }, + { + text: 'Experiments (Exploratory)', + collapsed: true, + items: getMarkdownFiles('experiments', 'experiments'), + }, + { text: 'Doc Site', link: '/doc-site' }, + { text: 'Web Admin (On Hold)', link: '/web-admin-deployment' }, ], }, { @@ -267,11 +273,6 @@ export default defineConfig({ ...getMarkdownFiles('plans', 'plans'), ], }, - { - text: 'Experiments', - collapsed: true, - items: getMarkdownFiles('superpowers/experiments', 'superpowers/experiments'), - }, ], }, ], diff --git a/website/package.json b/website/package.json index ddd824d40..211d1a600 100644 --- a/website/package.json +++ b/website/package.json @@ -4,6 +4,8 @@ "private": true, "type": "module", "scripts": { + "predev": "git -C .. submodule update --init", + "prebuild": "git -C .. submodule update --init", "dev": "vitepress dev", "build": "vitepress build", "preview": "vitepress preview"