Skip to content

feat(admin): Scaffold Vite/Svelte admin SPA with oAuth - #240

Merged
ifireball merged 28 commits into
fullsend-ai:mainfrom
ifireball:feat/admin-spa-vite-docs-pr
May 3, 2026
Merged

feat(admin): Scaffold Vite/Svelte admin SPA with oAuth#240
ifireball merged 28 commits into
fullsend-ai:mainfrom
ifireball:feat/admin-spa-vite-docs-pr

Conversation

@ifireball

@ifireball ifireball commented Apr 15, 2026

Copy link
Copy Markdown
Member

Summary

scaffolds a minimal admin front-end (Vite + OAuth callback wiring for local dev only). There is no real admin functionality yet—no production-ready screens, workflows, or server-backed admin features beyond what is needed to exercise OAuth locally.

Note: PR is huge because of the packages.lock file which nobody really needs to read...

What changed

  • Toolchain: mise.toml for Node/Go versions used for the admin app and Go work.
  • Design: Updated admin SPA spec plus the dated implementation plan.
  • Experiment: OAuth “localhost Part B” static callback experiment; small agent_runner CLI guard asserts.
  • Product: Admin SPA sources, root npm lockfile, Vite config, repo ignores for node_modules, web/admin/dist, Wrangler state, and .env.local; Worker + Wrangler updates and site-build.yml wiring.
  • Docs: web/admin/README.md and web/README.md for local dev, GitHub App setup, and the single web/.env.local convention.

How to verify

Follow web/admin/README.md for local OAuth; run npm ci at repo root and then npm run dev to launch the site.

@github-actions

github-actions Bot commented Apr 15, 2026

Copy link
Copy Markdown

Site preview

Preview: https://0d9c90f3-site.fullsend-ai.workers.dev

Commit: bce3fefc87d0e7ada17373d02c00a2ad56ff77f8

@ifireball
ifireball requested review from maruiz93 and ralphbean and removed request for maruiz93 April 15, 2026 08:37
@ifireball ifireball self-assigned this Apr 15, 2026
@ifireball ifireball changed the title feat(admin): Vite admin SPA, local OAuth, and dev documentation feat(admin): scaffold bare-bones Vite admin SPA (no real admin UI yet) Apr 15, 2026
@ifireball
ifireball marked this pull request as draft April 15, 2026 10:34
@ifireball
ifireball force-pushed the feat/admin-spa-vite-docs-pr branch from 7a333b4 to c0242ff Compare April 16, 2026 08:59
@ifireball
ifireball force-pushed the feat/admin-spa-vite-docs-pr branch from c0242ff to faf10b7 Compare April 16, 2026 09:48
@ifireball
ifireball force-pushed the feat/admin-spa-vite-docs-pr branch from faf10b7 to 561c200 Compare April 16, 2026 10:32
@ifireball ifireball changed the title feat(admin): scaffold bare-bones Vite admin SPA (no real admin UI yet) feat(admin): Scaffold Vite/Svelte admin SPA with oAuth Apr 16, 2026
@ifireball
ifireball requested a review from rh-hemartin April 16, 2026 10:40
@ifireball
ifireball marked this pull request as ready for review April 16, 2026 10:40
@ifireball
ifireball requested a review from waynesun09 April 16, 2026 11:48

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: 3 High-Severity Issues

Reviewed the full diff across all 33 changed files (excluding package-lock.json). The BFF pattern, PKCE implementation, and overall scaffold are architecturally sound — nice work. However, there are three high-severity issues that should be addressed before merge.


High 1: Worker OAuth BFF reachable in production with client_secret in env

File: cloudflare_site/worker/src/index.ts

The /api/oauth/token and /api/github/user routes are always active — there is no env flag to disable them. The origin check (isLocalhostDevOrigin) only allows localhost, which means:

  1. Legitimate production browser requests will get 403 (the admin SPA won't work on deployed previews or production).
  2. Non-browser clients (curl) can bypass the Origin check entirely. Worse, getEffectiveLoopbackOrigin() falls back to the Referer header when Origin is absent — Referer is trivially forgeable:
    curl -H "Referer: http://localhost:5173/" \
      https://prod-worker.example/api/oauth/token -d '...'
    This passes the origin check and reaches handleOAuthToken, which has GITHUB_APP_CLIENT_SECRET available in env.

Recommendation: Either:

  • Gate API routes behind an env var (ADMIN_OAUTH_ENABLED=1) so they're explicitly dead in production until configured, or
  • Expand the origin allowlist to include real production/preview origins and remove the Referer fallbackReferer should never be used for origin validation in production.
  • Add rate limiting on the token exchange endpoint.

High 2: Token expiry stored but never checked — dead code creating false confidence

Files: web/admin/src/lib/auth/tokenStore.ts, session.ts, oauth.ts

expiresAt is saved in localStorage via saveToken() but loadToken() returns the token regardless of whether it's expired. No consumer checks expiresAt anywhere in the codebase — the field is dead code.

Additionally, expiresAt: 0 is used as a sentinel for "GitHub didn't tell us when it expires" (oauth.ts), but 0 is a valid epoch timestamp. This creates ambiguity if an expiry check is added later.

Recommendation:

  • Add expiry check in loadToken():
    if (t.expiresAt && t.expiresAt > 0 && Date.now() > t.expiresAt) {
      clearSession();
      return null;
    }
  • Change type to expiresAt: number | null and use null instead of 0 for unknown expiry.

High 3: Minimal test coverage for security-critical OAuth code paths

Files: Only web/admin/src/lib/auth/pkce.test.ts exists (2 test cases)

The following security-critical paths have zero test coverage:

Module Untested functions
oauth.ts consumeOAuthParamsFromDocumentUrl(), completeGithubOAuthFromHandoff(), startGithubSignIn() — contains the state validation logic where a bypass exists when sessionStorage is cleared
tokenStore.ts saveToken/loadToken/clearSession round-trip (the plan document has these tests written out but they weren't implemented)
session.ts refreshSession(), signOut()
user.ts fetchGitHubUser()
Worker index.ts Origin validation, CORS, token exchange handler — no tests at all

The existing pkce.test.ts also doesn't validate against the RFC 7636 Appendix B known test vector, which would prove correctness rather than just stability.

Recommendation: At minimum, add tests for oauth.ts state validation and tokenStore.ts round-trip before merge. Worker tests can follow up but should be tracked.

ifireball added a commit to ifireball/fullsend that referenced this pull request Apr 19, 2026
…High 3

Add RFC 7636 Appendix B PKCE vector, completeGithubOAuthFromHandoff
state and token-exchange scenarios with mocked Turnstile, session, and
fetch, session refresh/sign-out tests with mocked GitHub user fetch,
fetchGitHubUser BFF tests, and invalid JSON handling for tokenStore.

Made-with: Cursor
@ifireball
ifireball requested a review from waynesun09 April 20, 2026 05:42
@ifireball

Copy link
Copy Markdown
Member Author

@waynesun09 can you please re-review or drop the req0changes flag?

Declare the runtime versions used for local admin SPA and Go CLI work without adding JavaScript packaging yet.

Made-with: Cursor

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: #240

Head SHA: fc70542bc15ec5390847eaffcc5c264826f6bee4
Method: 4 independent review agents (security, quality, cursor, gemini) — consolidated
Outcome: comment-only

Previous High-Severity Issues: All 3 Resolved

Issue Status How
Worker OAuth BFF / Referer bypass Resolved getBrowserOrigin() reads only Origin; fetchTabBindingOk requires it for token exchange; Turnstile env gate returns 503 if keys missing; rate limiting added
Token expiry stored but never checked Resolved loadToken() validates expiry + auto-clears; parseExpiresAt normalizes 0null; tested
Minimal test coverage for OAuth paths Resolved 12 test files added covering Worker endpoints, CORS, OAuth flow, token store, session, PKCE (RFC 7636 vectors), preview handoff

New Findings

Severity Count Summary
High 1 Dead Octokit client module + unused event listener (see inline)
Medium 2 Broad HTTPS redirect_uri allowlist (4/4 agents flagged); no Turnstile timeout
Low 1 No-op test for dead module

Overall

Strong security hardening since the prior review. OAuth BFF has proper layered defenses: Origin-only validation, Turnstile, rate limiting, PKCE, tab-binding, and field stripping on /user proxy. The high finding is dead code cleanup, not a security issue. Medium findings are documented design trade-offs worth tracking.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Dead code — createUserOctokit is never imported or used

Nothing in the SPA imports createUserOctokit. The actual GitHub user fetch goes through fetchGitHubUser() in user.ts (plain fetch via same-origin BFF), called by session.ts:refreshSession().

The 401 auto-signout this module enables via window.dispatchEvent(new CustomEvent("fullsend:github-unauthorized")) is also dead — App.svelte listens for this event, but 401 handling actually works through session.ts catching GitHubUserRequestError with status 401 directly.

The test in client.test.ts only asserts expect(o).toBeDefined() — it doesn't verify the auth header or the 401 hook, so it provides no value.

Recommendation: Either remove client.ts, client.test.ts, and the event listener in App.svelte:28, or if this is intentional scaffolding for future endpoints, add a code comment explaining that and exclude it from the bundle until needed.

Comment thread web/admin/src/App.svelte
window.addEventListener("fullsend:github-unauthorized", onGithub401);

void (async () => {
try {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Dead event listener — fullsend:github-unauthorized is never dispatched

This listener calls signOut() on the fullsend:github-unauthorized event, but the only code that dispatches this event is createUserOctokit in client.ts, which is never imported or instantiated anywhere in the SPA.

The 401→signOut path actually works through session.ts:refreshSession() catching GitHubUserRequestError with status === 401 — this listener is redundant dead code.

Consider removing this listener along with the cleanup of client.ts.

u.hostname === "localhost" ||
u.hostname === "127.0.0.1" ||
u.hostname === "[::1]"
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: isAllowedOAuthRedirectUri accepts any HTTPS host at /admin* paths

This allows https://evil.com/admin/ to pass validation. The defense-in-depth is documented and sound (GitHub App callback registration + PKCE + tab-binding + Turnstile + rate limits), but it means the Worker will process OAuth flows for any HTTPS host.

All 4 independent reviewers flagged this as the most notable design trade-off.

Suggestion: Consider adding an env-driven ALLOWED_REDIRECT_HOSTS allowlist for production deployments, so this broad acceptance is limited to dev/preview environments where preview URLs are not enumerable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: No timeout on Turnstile token acquisition

obtainTurnstileToken() creates a promise that resolves on callback or rejects on error-callback, but if the Turnstile widget hangs (network issue, CDN latency, script loaded but no callback), the user sees an indefinite loading state with no error. The hidden DOM element also leaks if the promise never settles.

Suggestion: Add a setTimeout that rejects with a user-friendly error after ~15 seconds (e.g., "Turnstile verification timed out — please try again").

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: No-op test — asserts only that a constructor returns a value

The test name says "sets auth header from token" but only checks expect(o).toBeDefined(). It doesn't verify the auth header is set or that the 401 hook dispatches the custom event. Since the module under test (client.ts) appears to be dead code (see file-level comment there), this test provides false confidence.

If client.ts is kept, this test should verify the auth header and 401 event dispatch. If client.ts is removed, delete this test too.

- turnstile: align with feat/admin-spa-org-list (AbortSignal, teardown on abort)
  plus 120s acquisition deadline (future branch had signal only, no wall-clock cap)
- oauth: optional AbortSignal + readJsonBodyWithSignal; SIGNING_IN_CANCELLED_MESSAGE;
  clearSession if aborted after token save (org-list parity)
- App: AbortController for OAuth boot; suppress error UI on cancel message
- github/client: Octokit ignores request.hook in constructor — use hook.wrap so
  fullsend:github-unauthorized fires on 401 (response or thrown RequestError)
- Tests: turnstile timeout + abort; oauth abort during JSON read; client 401 dispatch

Made-with: Cursor
@ifireball

Copy link
Copy Markdown
Member Author

Pushed `ef495068` addressing the latest consolidated review:

Turnstile timeout — `feat/admin-spa-org-list` already had `AbortSignal` plumbing for `obtainTurnstileToken` + OAuth handoff; there was still no wall-clock cap. This branch merges that signal behavior and adds a 120s deadline so a stuck widget cannot block OAuth boot forever (`turnstile.ts` + `turnstile.test.ts`).

OAuth boot — `App.svelte` now uses an `AbortController` (same pattern as org-list) and passes `signal` into `completeGithubOAuthFromHandoff`; `oauth.ts` gains `readJsonBodyWithSignal`, cancel handling, and `SIGNING_IN_CANCELLED_MESSAGE` for unmount.

Octokit / “dead module” — `createUserOctokit` is kept: `feat/admin-spa-org-list` uses it from `fetchOrgs.ts`. The real bug was that `new Octokit({ request: { hook } })` never ran our hook (`@octokit/core` overwrites `request.hook`). The hook is now registered with `octokit.hook.wrap("request", …)`, so 401 dispatches `fullsend:github-unauthorized` (response or thrown error). `client.test.ts` asserts that with a mocked `fetch`.

@fullsend-ai-review

fullsend-ai-review Bot commented Apr 29, 2026

Copy link
Copy Markdown

Review: #240

Head SHA: 3c95c5b
Timestamp: 2026-04-30T00:00:00Z
Outcome: comment-only

Summary

This PR scaffolds a Vite/Svelte admin SPA with GitHub OAuth via a Cloudflare Worker BFF. The implementation is well-structured: PKCE S256 is correctly implemented per RFC 7636, the OAuth state parameter provides CSRF protection, Turnstile bot protection is wired in, and the Worker strips GitHub /user responses down to three fields (data minimization). Svelte components use text interpolation exclusively (no {@html}), eliminating XSS vectors. The CI/CD pipeline maintains a strong trust boundary between the untrusted build step and the secret-bearing deploy step. No critical or high findings — the items below are worth discussing but none block merge for a local-dev scaffold.

Findings

Medium

  • [platform-security] web/admin/src/lib/auth/tokenStore.ts:28 — GitHub OAuth access tokens are stored in localStorage under a predictable key (fullsend_admin_github_token). localStorage persists across tabs and browser restarts, making tokens accessible to any JavaScript on the same origin including XSS payloads from future features or third-party scripts. The ephemeral PKCE/state values correctly use sessionStorage.
    Remediation: Consider sessionStorage instead (tokens already have server-side expiry), or move to HttpOnly cookies set by the Worker when the product moves beyond local dev. If localStorage is intentional for multi-tab UX, document the trade-off and ensure CSP headers are strict.

  • [platform-security] web/admin/index.html — No Content-Security-Policy meta tag or response header is configured. The SPA dynamically loads the Turnstile script from challenges.cloudflare.com; without a CSP, any injected <script> would execute unconstrained. A CSP provides defense-in-depth against future XSS vectors.
    Remediation: Add a CSP header via the Worker or a <meta> tag. Minimum: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://api.github.com; style-src 'self' 'unsafe-inline'.

  • [platform-security] cloudflare_site/worker/src/oauthCors.ts:69-85isAllowedOAuthRedirectUri accepts any HTTPS host with the path /admin/. This is intentional (Cloudflare preview URLs are not enumerable), and safety relies on GitHub App callback registration + PKCE + Origin binding + Turnstile. However, a misconfigured GitHub App callback list could enable OAuth authorization code interception on attacker-controlled hosts.
    Remediation: Document this design decision explicitly. Consider adding an ALLOWED_REDIRECT_HOSTS env var for production deployments to narrow the allowlist beyond what the GitHub App enforces.

  • [correctness] web/admin/src/lib/auth/oauth.ts:110-117consumeOAuthParamsFromDocumentUrl stashes an empty code into sessionStorage and calls history.replaceState to clean the URL. The downstream takeDocHandoff rejects empty codes, but the user sees a clean URL with no visible error until completeGithubOAuthFromHandoff runs, creating a confusing UX.
    Remediation: Reject empty code in consumeOAuthParamsFromDocumentUrl (return false, leave URL intact) or surface the error immediately.

  • [correctness] web/admin/src/lib/auth/oauth.test.ts — No test covers the case where fetch to /api/oauth/token returns a non-2xx status. The function has explicit handling for !res.ok (extracting error_description or error from the response body), but this path is untested.
    Remediation: Add a test mocking fetch to return a non-200 status with an error body and verify error propagation.

  • [correctness] web/admin/src/lib/auth/turnstile.test.ts — No test covers the Turnstile success path (script loads, widget renders, callback fires with token). Only timeout and pre-aborted signal are tested.
    Remediation: Add a test simulating the Turnstile API calling the callback parameter and verify resolution.

Low

  • [platform-security] sample.env.local:31-32 — Contains Cloudflare's official always-pass dummy Turnstile keys. These are publicly documented test keys, not secrets, but accidental production deployment would disable bot protection entirely.
    Remediation: Consider a startup check in the Worker that rejects known dummy keys outside dev environments.

  • [correctness] web/admin/src/lib/auth/previewHandoff.tsassertAllowedReturnTo has only two tests (one accept, one reject). Missing coverage for: empty allowedOrigins array, returnTo with a port, http:// scheme rejection, and malformed URL strings.
    Remediation: Add edge-case tests for port-based origins, empty allowlist, and non-HTTPS URLs.

  • [style] cloudflare_site/worker/src/index.ts — The GitHub OAuth authorize redirect does not set a scope parameter. GitHub defaults to zero scopes (public profile only), appropriate for the current PoC. The PR description mentions "org listing via fetchOrgs" as a follow-on, which will require read:org and re-authorization.
    Remediation: No action needed now; note for future scope additions.

Info

  • [correctness] PKCE implementation (pkce.ts) correctly uses 32 random bytes, base64url without padding per RFC 7636, and the S256 challenge is validated against the RFC test vector.
  • [platform-security] Worker strips GitHub /user responses to only login, name, and avatar_url — good data minimization.
  • [platform-security] CI/CD deploy workflow validates artifact contents (only public/ and worker/ allowed), uses trusted checkout (not PR head), and guards against fork workflow runs.
  • [injection-defense] No prompt injection patterns, non-rendering Unicode, bidirectional overrides, or suspicious instruction-like content detected in PR body, commit messages, code comments, or string literals.
  • [platform-security] .env.local and .dev.vars are properly gitignored. No real secrets found in any committed file.

Footer

Outcome: comment-only
This review applies to SHA 3c95c5bd747ef89bbe1a6304882323c6194ea21f. Any push to the PR head clears this review and requires a new evaluation.

Previous run

Review: #240

Head SHA: ef49506
Timestamp: 2026-04-29T00:00:00Z
Outcome: approve

Summary

This PR scaffolds a Vite + Svelte admin SPA with a Cloudflare Worker OAuth BFF (Backend-for-Frontend), including PKCE, Turnstile verification, CORS hardening, and rate limiting. The architecture is sound: client_secret never leaves the Worker, the SPA bundle contains no secrets, and the OAuth flow follows best practices with S256 PKCE, state nonce validation, and Origin-based tab binding. The code is well-documented across multiple markdown files, well-tested with comprehensive edge-case coverage, and follows repository conventions (ADR 0019 layout, root package.json, cloudflare_site/ for Worker). No critical or high findings.

Findings

Medium

  • [Platform security] cloudflare_site/worker/src/oauthCors.tsisAllowedOAuthRedirectUri accepts any HTTPS host at /admin/ paths (e.g. https://evil.com/admin/ returns true). This is documented and intentional — preview URLs are not enumerable, and actual redirect safety relies on GitHub App callback URL registration, PKCE, Origin tab-binding, Turnstile, and rate limits. The layered defense is adequate, but the permissive allowlist is worth tracking as scope widens.
    Remediation: No immediate change needed. If the GitHub App is ever configured with a wildcard callback or the PKCE requirement changes, revisit this allowlist to add explicit host restrictions.

  • [Platform security] cloudflare_site/worker/src/oauthCors.ts:164-185GET /api/github/user CORS inference uses Sec-Fetch-Site + Referer when Origin is absent. This is explicitly documented in docs/admin-oauth-worker.md as an intentional trade-off limited to the Bearer-only user-profile proxy. It does not apply to token exchange or authorize binding. Acceptable for this route but should not be extended to other endpoints.
    Remediation: Ensure future /api/* routes do not inherit this fallback. The current path-specific gating is correct.

Low

  • [Style/conventions] cloudflare_site/worker/src/index.ts — The file has grown to ~530 lines with multiple route handlers inline. As more admin API routes are added, consider extracting handlers into separate modules (e.g. oauthHandlers.ts, githubProxy.ts) to keep each file focused.
    Remediation: Track as technical debt for the next admin API expansion.

Info

  • [Correctness] package.json test script runs two sequential vitest run commands. If the first fails, the second is not executed. This is acceptable for CI (where any failure should halt), but consider vitest --config ... --reporter ... workspace mode for unified reporting in the future.

  • [Injection defense] docs/superpowers/plans/2026-04-12-fullsend-admin-spa.md — Contains instruction-like text ("REQUIRED SUB-SKILL", "use superpowers:subagent-driven-development"). This is legitimate plan documentation for agentic workflows, not prompt injection.

  • [Content security] The Worker at GET /api/github/user correctly strips the GitHub profile response to only login, name, and avatar_url — email, 2FA status, and other sensitive fields are never returned to the browser. Good data minimization.

  • [Platform security] Turnstile keys are required (503 on missing) with no silent disable mode. The site key reaches the browser only via Worker-expanded OAuth state, not baked into the SPA build. This is a solid design that avoids leaking keys through CI build args or VITE_* env vars.

Footer

Outcome: approve
This review applies to SHA ef495068c10d8637eb79d761c02777bd1d9c8dc3. Any push to the PR head clears this review and requires a new evaluation.


Protected paths detected — this PR modifies files under one or more
protected paths. The review agent cannot approve PRs that touch these paths.
A human reviewer must approve this PR.

Protected files in this PR:

  • .github/workflows/lint.yml
  • .github/workflows/site-build.yml
  • .github/workflows/site-deploy.yml

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment above for full details.

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified review: 2 Medium + 1 Low confirmed out of 8 High/Medium findings from 4-agent review

Ran 4 independent review agents (security, architecture, code quality, CI/infra) which surfaced 3 High and 5 Medium findings. Verified each against source code — most were either edge cases, documented design decisions, or not applicable to the current scope.

Confirmed:

  • Medium: refreshSession silently swallows non-401 errors — user can't distinguish "not authenticated" from "network down"
  • Medium (design): Token in localStorage gives persistent XSS exposure — sessionStorage would limit blast radius for an admin SPA
  • Low: Unreachable response.status === 401 check on Octokit success path (dead code)

Dismissed after verification:

  • Empty OAuth codetakeDocHandoff() catches it, GitHub never sends empty code
  • mergeAbortSignals listener leak — at most 1 dead ref per mount, cleaned on unmount
  • isAllowedOAuthRedirectUri any HTTPS host — documented intentional design with layered defenses (PKCE + tab-binding + Turnstile)
  • No proactive token refresh — no ongoing API calls in current scope
  • Turnstile site key in OAuth state — site keys are public by design

if (e instanceof GitHubUserRequestError && e.status === 401) {
signOut();
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: refreshSession silently swallows non-401 errors

When fetchGitHubUser throws a non-401 error (network failure, 500, timeout), this catch block sets githubUser to null without surfacing the error. The user sees a logged-out state with no indication that a transient network issue occurred — indistinguishable from "not authenticated."

Suggestion: Expose an error store (e.g. export const sessionError = writable<string | null>(null)) or return a result type so the UI can distinguish auth failures from network errors and show an appropriate message like "Could not reach server — retrying."

expiresAt: number | null;
};

const KEY = "fullsend_admin_github_token";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (design): GitHub token stored in localStorage — persistent XSS exposure

localStorage persists across tabs and browser restarts, so any XSS on this origin gives an attacker persistent access to the GitHub token. Since this is an admin SPA (elevated privilege surface), consider using sessionStorage instead — it limits exposure to the current tab and is cleared when the tab closes. The trade-off is that users would need to re-authenticate in each new tab.

Alternatively, if cross-tab auth is needed later, an HttpOnly cookie set by the Worker BFF would prevent client-side JS access entirely.

Comment thread web/admin/src/lib/github/client.ts Outdated
octokit.hook.wrap("request", async (request, options) => {
try {
const response = await request(options as never);
if (response.status === 401) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Unreachable 401 check on success path

@octokit/request throws RequestError for non-2xx responses, so request(options) never resolves with status === 401 — this branch is dead code. The 401 dispatch is correctly handled in the catch block below (and verified by the test).

Consider removing this check to avoid misleading future readers into thinking Octokit can resolve with 4xx status codes.

Suggested change
if (response.status === 401) {
const response = await request(options as never);
return response as OctokitResponse<unknown>;

Octokit rejects non-2xx before returning; 401 is handled in the catch path.
Confirmed in waynesun09 verified review (2026-04-29).

Made-with: Cursor
@ifireball

Copy link
Copy Markdown
Member Author

Round-up for waynesun09’s latest verified review (2026-04-29) and follow-ups:

Confirmed items from that review

  • Low — unreachable response.status === 401 in Octokit success path: removed in 3c95c5bd (web/admin/src/lib/github/client.ts); 401 handling stays in the catch path only (Octokit throws on non-2xx before returning).

localStorage token (medium / design)

Tracked for discussion and options (not a PR-blocking change): #541Admin SPA: decide token-at-rest strategy (localStorage vs mitigations).

Still open (not changed in this push)

  • Medium — refreshSession swallows non-401 errors: users still cannot distinguish “no network” from “signed out”; worth a small follow-up (UX / logging / lastSessionError store) when you want to prioritize it.

Earlier items (Turnstile timeout, hook.wrap, etc.) remain on ef495068 and subsequent commits unless noted otherwise.

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment above for full details.

@ifireball

Copy link
Copy Markdown
Member Author

Follow-up for reviewers: loose ends and tracked issues

This comment ties review threads to tracked follow-ups under the delivery umbrella #509 (sub-issues). Use it when reply-all on review comments so each thread gets a pointer to the right issue.

Sub-issues of #509 (this PR + follow-ups)

Issue Topic
#505 Turnstile: pass remoteip (or document why not) for site token verification
#506 Preview vs production: align OAuth/callback and redirect hostnames
#541 Admin SPA: localStorage access-token strategy (security / lifecycle) — blocked by #511
#542 refreshSession / non-401 error handling — blocked by #511
#543 Site Worker: generic JSON errors for GitHub /user proxy (no raw upstream error bodies)
#544 Dev tooling: align Go version pin (mise.toml) with CI and repo defaults
#545 CI: avoid duplicate npm ci in site-deploy vs build job
#546 Admin OAuth: explicit GitHub scope on authorize when org/repo APIs are required

All of the above are now sub-issues of #509 for Story #124 indexing.

Loose ends not filed as issues (by design)

  • (2) Plan / ADR doc size vs “living doc” — kept as narrative in the PR/plan; no separate issue.
  • (6) Turnstile async=false script loading — noted in review context only; no separate issue unless we want a perf ticket later.

Suggested one-liners when replying on reviews

If anything here should be in-scope for this PR instead of a follow-up, say which issue to close and we will fold the change into #240.

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 3c95c5bd — all previous issues resolved, LGTM

Ran 4 independent review agents (security, quality, cursor, gemini). All 7 previously-flagged issues are confirmed resolved with no regressions:

  • Original 3 highs (Referer bypass, token expiry dead code, minimal tests)
  • Dead Octokit client → fixed via hook.wrap, test verifies 401 dispatch
  • No Turnstile timeout → 120s deadline via AbortSignal
  • No-op test → now mocks fetch + asserts event
  • Unreachable 401 check → removed

One new finding: missing CSP headers (see inline comment on index.ts). Not blocking for this scaffold PR — recommend tracking under #509 for production hardening.

Existing tracked follow-ups (#505, #506, #541, #542, #543-546) are confirmed still present and properly tracked with issues. No action needed in this PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: No Content-Security-Policy headers on any response

4 independent review agents flagged this as the strongest new finding at this SHA. No CSP is set on Worker API responses or static asset serving. For an admin SPA that stores OAuth tokens in localStorage and dynamically loads Turnstile from challenges.cloudflare.com, the absence of CSP means any XSS on the origin can exfiltrate the token without restriction.

Recommended minimum CSP (via Worker response headers or Cloudflare _headers file):

default-src 'self';
script-src 'self' https://challenges.cloudflare.com;
connect-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' https://avatars.githubusercontent.com;
frame-src https://challenges.cloudflare.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self'

This is the primary mitigation for the localStorage token storage trade-off (#541) and should be addressed before production deployment. Not blocking for this scaffold PR, but worth tracking alongside the existing follow-ups under #509.

@ifireball

Copy link
Copy Markdown
Member Author

CSP follow-up (review thread)

@waynesun09 raised Medium — no Content-Security-Policy on Worker/static responses on this PR (thread on cloudflare_site/worker/src/index.ts): absent CSP stacks with browser-held tokens and third-party script/embed surfaces (e.g. Turnstile), so XSS on the origin is especially costly.

Tracked under the Story #124 / #509 umbrella as #585 — Site / admin SPA: Content-Security-Policy headers for Worker and static responses. That issue links #541 (token-at-rest strategy); CSP is the main companion hardening called out in review if we keep tokens in the browser.

No CSP change on #240 itself—implementation belongs on #585 once we lock real connect-src / script-src / frame-src needs against the shipped SPA + APIs + preview hosts.

@ifireball
ifireball added this pull request to the merge queue May 3, 2026
Merged via the queue into fullsend-ai:main with commit 45df56c May 3, 2026
8 checks passed
@ifireball
ifireball deleted the feat/admin-spa-vite-docs-pr branch May 3, 2026 07:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants