Skip to content

Design: Fullsend admin installation SPA (static, GitHub App–centric)

Date: 2026-04-06 Status: Draft (brainstorm consolidated)

Context

Today, fullsend organization installation and analysis are delivered through the Go CLI (fullsend admin install|uninstall|analyze <org>), which uses a layer stack (config repo, workflows, secrets, enrollment) and GitHub App setup for agent roles (internal/cli/admin.go, internal/layers/*, internal/appsetup/*). Org-level configuration is conventionally stored in an org-owned .fullsend repository (see ADR 0003 in docs/ADRs/0003-org-config-repo-convention.md).

This document specifies a single-page application that provides a guided, friendly path through the same responsibilities as the CLI, without a Fullsend-hosted backend.

Goals

  • Static SPA only: no Fullsend server or generic OAuth backend; all GitHub API calls from the browser with tokens obtained via GitHub’s documented flows.
  • Sign-in: use a GitHub App for the admin UI (not a separate OAuth App for login). Additional GitHub Apps for fullsend agent roles are created or wired during onboarding, consistent with today’s CLI.
  • Org dashboard: list all org memberships (alphabetical, search-as-you-type); per org, async checking then show permission sufficiency, onboarding status (not / partial / healthy, aligned with CLI analyze / layer semantics), and appropriate actions or disabled state with short reasons.
  • Org drill-down: show union of API-visible repos and config.yaml repo names—surface repos not in config and orphaned config entries (repo missing) to support cleanup.
  • Full CLI parity over time: install, repair, uninstall, and the insights of analyze; analyze and dry-run are implicit via continuous status and a final review step before mutating changes.
  • Hosting: official deployment + self-hosted static deploy + per-PR previews on unique hostnames.
  • Implementation approach: TypeScript in the SPA reimplements layer behavior and GitHub integration (Approach 1); automated CLI↔SPA parity tests are not required in the initial phase (see Parity section).

Non-goals (initial phase)

  • Automated golden/fixture parity tests or CI enforcement between CLI and SPA (future milestone).
  • Defining exact GitHub App permission scopes in this document—those must be derived from code and recorded in the permission matrix appendix as implementation proceeds.

Architectural approach

Chosen: Approach 1 — TypeScript implementation in the SPA

Mirror the existing layer model and the GitHub REST/GraphQL usage of internal/forge/github in TypeScript. Mitigate drift through manual and review-time discipline until automated checks exist (see Parity).

Deferred: compiling Go to WASM for shared logic (revisit only if dual maintenance becomes unacceptable).

UI stack (tentative): Svelte with TypeScript—subject to team preference; the design is otherwise stack-agnostic.

Section 1 — Product shape and constraints

  • The SPA is the primary guided experience for admins who prefer a browser over the CLI.
  • Hard constraints: static hosting only; short-lived tokens; localStorage on the SPA origin for session persistence; explicit handling of token expiry and long-lived tabs (refresh, re-auth, clear UX on 401).
  • Delivery: scope is full parity with CLI admin capabilities, but shipping may be split across multiple tasks and PRs.

Section 2 — Authentication, tokens, production, self-hosted, previews

Production admin SPA

  • Production GitHub App registered for the official admin origin (homepage + SPA entry as callback, e.g. https://<origin>/admin/ with redirect_uri matching Vite base; GitHub appends ?code=&state= on the document URL, which the SPA reads once then clears via history.replaceState to #/).
  • User completes user authorization for that app; the SPA exchanges the code for tokens per current GitHub documentation for GitHub App user access tokens.
  • Verification gate before implementation: Task 1 in 2026-04-12-fullsend-admin-spa.md records hands-on outcomes; GitHub’s docs already require client_secret for the web-application exchange (see Open items and Appendix A). If maintainer Part B/C notes differ, update those sections after the experiment.
  • Tokens: short TTL; store in localStorage; sign-out clears storage; handle refresh if GitHub provides it, otherwise re-auth.

Self-hosted

  • Operators use their own GitHub App configuration with homepage and callback matching their admin origin.
  • Redirect allowlist stays small and stable for that origin.
  • Documentation: checklist for app settings, required GitHub permissions (see matrix), and preview behavior if they use ephemeral preview URLs.

Preview deployments (unique per-PR hostnames)

  • Separate “preview” GitHub App from production: lower trust, easy to disable; do not register many preview URLs on the production app.
  • No CI-driven editing of redirect URL allowlists (too fragile).
  • Full-page flow (no popups): user on https://<preview-host>/ navigates to production to start OAuth for the preview app; PKCE and state (including validated return_to) live on the production origin (e.g. sessionStorage during the round trip). GitHub redirects to production callback only; production exchanges code for tokens, then redirects to the preview URL with credentials in the URL fragment (hash) (not query string). Preview reads hash once, persists to localStorage, strips hash via history.replaceState.
  • Risks: fragment in history, XSS on preview, shoulder surfing; mitigate with short-lived tokens, minimal preview scopes, clear “preview only” labeling, and tight CSP where hosts allow.
  • Open redirect: return_to must be allowlisted or cryptographically bound in state.
  • Dedicated production routes for preview OAuth (e.g. /oauth/preview-start, /oauth/preview-callback) are recommended vs overloading the production callback.

Security (cross-cutting)

  • localStorage + XSS is the main browser-side risk; avoid logging tokens; keep dependencies pinned.

Section 3 — Org and repo dashboard, permission checks, status model

Org list

  • All org memberships, alphabetical sort, search-as-you-type.
  • Per row: checking → resolved permission outcome + onboarding status (from TS layer engine).
  • Actions: Start onboarding / Continue / Open org; disabled with specific reason when permissions are insufficient.
  • Caching within the session to limit API churn; manual refresh available.

Org detail

  • Rollup status, links to repair, uninstall, repo onboarding.

Repo list

  • Union of org API repos and config.yaml names.
  • Rows: normal repos with not / partial / full enrollment-style status; not in config; orphan (in config, repo gone).

Status model

  • Align with LayerReport semantics (not installed, degraded, installed) and CLI analyze wording where possible.
  • No separate “analyze mode”: dashboard always reflects current state; wizards end with review of pending mutations before Confirm.

Errors

  • Rate limits: backoff and retry UX.
  • Token expiry: re-auth without silently losing wizard progress where sessionStorage on the same origin can help.

Section 4 — Wizards (onboard, repair, uninstall), agent apps, secrets

General wizard rules

  • Linear steps, Back / Next, final review before mutating fullsend installation (config repo content, secrets API writes, workflow files, enrollment).
  • Implicit analyze: re-check relevant layers when entering a step or on Refresh.

Org onboarding

Steps follow CLI install ordering: .fullsend / configagent GitHub Apps (per role) → secrets (LLM and app keys via GitHub APIs) → workflows in config repo → enrollment; then final review → Apply in stack install order with idempotent operations and per-step retry.

Exception — agent GitHub Apps

  • App creation / user confirmation on github.com may interrupt the wizard; this is expected and not subject to “review before any GitHub interaction.”
  • Staging: after GitHub steps, the SPA may persist intermediate credentials (e.g. client id, slug, PEM) in localStorage on the current origin to resume the wizard.
  • Policy: clear staging on success, cancel, sign-out, or documented abandon behavior; store only necessary fields; never put secrets in URLs or logs.
  • Final review still applies to bulk apply to the org’s fullsend installation (secrets to GitHub, config/workflow/enrollment changes).

Repair / partial

  • Enter at the first failing layer in stack order.

Repo-scoped onboarding

  • Enrollment-focused wizard plus config.yaml updates as needed; same review → confirm pattern.

Uninstall

  • Match CLI uninstall behavior and ordering; strong confirmation; surface manual GitHub App deletion instructions where automation cannot remove apps.

Section 5 — Permission matrix, parity guidance, phased delivery

Permission matrix

  • Maintain a table in this spec (appendix) or linked doc: each SPA capabilityGitHub API operations → required permissions / roles (derived from internal/layers, internal/appsetup, internal/forge/github). Update when either CLI or SPA behavior changes.

Parity with CLI (initial phase)

  • Do not add or modify automated tests solely for CLI↔SPA parity in this phase.
  • Contributor guidance: use CLI analyze/install/uninstall and Go layers as source of truth; cross-reference in PR descriptions when touching one surface; align user-visible status language with CLI; manually verify critical scenarios when both surfaces exist.
  • Future: automated parity (fixtures, goldens, CI)—explicitly out of scope for initial delivery.

Phased delivery (priorities)

Early

  1. Minimal SPA + CI/CD that deploys per-PR previews on unique hostnames, with preview GitHub App and production hash handoff so PRs are browser-reviewable immediately.
  2. Limited self-hosted for local dev: documented local static serve / dev server, localhost callback, dev GitHub App checklist—before full operator-facing self-hosted docs.

Then

  1. Production sign-in and org dashboard (checking, permissions, status).
  2. Read-only org/repo views and TS status engine.
  3. Wizards: onboard → repair → repo-scoped → uninstall.
  4. Full self-hosted operator documentation, hardening (CSP, rate limits, a11y).

Appendix A — Permission matrix

During implementation, add a row per GitHub capability the SPA uses (REST paths or GraphQL operations), with documented permission or role expectations and notes for enterprise edge cases. Derive rows from internal/forge/github and from each layer’s Install / Analyze / Uninstall path. The first PR that introduces a new API surface adds the corresponding row(s) here.

CapabilityHTTPNotes
User access token exchange (GitHub App web application flow)POST https://github.com/login/oauth/access_tokenForm body / query parameters per GitHub: client_id, client_secret (required in official docs), code, optional redirect_uri (must match a registered callback URL), optional PKCE code_verifier when code_challenge was used at authorize time. JSON response includes access_token (user tokens use the ghu_ prefix), token_type (bearer), and optionally expires_in / refresh_token (ghr_) when expiring tokens are enabled. See Generating a user access token for a GitHub App.
OAuth authorize start (no client id in static bundle)GET /api/oauth/authorize302 to GET https://github.com/login/oauth/authorizeQuery: redirect_uri, state, code_challenge, code_challenge_method=S256. Client sends state = high-entropy nonce only (no Turnstile material in the static build — PR CI cannot inject keys at bake time). Worker reads TURNSTILE_SITE_KEY from env, combines nonce + site key into a single opaque state sent to GitHub; GitHub echoes it on callback. Client decodes and verifies the nonce, then uses the embedded site key for the Turnstile widget. No extra /api/.../public-config round-trip. Do not infer browser identity from Referer. When Origin is present, it must align with redirect_uri; full-page navigations may omit Origin — document the Worker + admin README. Then redirects to GitHub with client_id from Worker env only.
SPA to Worker token exchange (same origin)POST /api/oauth/token (site Worker)JSON body: code, redirect_uri, code_verifier, turnstile_token (required). Worker-expanded state from authorize is mandatory; missing TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY in the Worker yields 503 missing_turnstile_keys (no silent disable). Worker enforces Wrangler native rate limits (e.g. 30 / 60s on token exchange and 120 / 60s on GET /api/github/user, per path + IP key in each Cloudflare location), verifies Turnstile with siteverify, requires Origin (only) to match redirect_uri origin (no Referer fallback), then calls GitHub POST https://github.com/login/oauth/access_token with client_secret from Worker secrets only.
Current user profile (browser, no GitHub CORS)GET https://api.github.com/user via same-origin GET /api/github/userBearer user token; site Worker proxies to GitHub. GitHub App user token defaults are usually sufficient for login.
Organizations for the admin picker (step toward repos)GET https://api.github.com/user/repos (browser / Octokit paginate) with affiliation=owner,collaborator,organization_member; unique owner.login where owner.type is OrganizationBearer user token. Org names are not from membership or GET /user/orgs; they are inferred from repositories the token can access, matching a repo-first workflow. Classic OAuth: private repos typically need repo (or public_repo for public only) on GET /user/repos. GitHub App user-to-server tokens: follow repository (and installation) permissions for each repo returned.
  • internal/cli/admin.go — install, uninstall, analyze entrypoints
  • internal/layers/*ConfigRepoLayer, WorkflowsLayer, SecretsLayer, EnrollmentLayer
  • internal/appsetup/* — GitHub App setup per agent role

Open items

  • 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.
    • 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 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:<PORT> 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 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).