Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8479af3
chore(toolchain): pin Node and Go with mise
ifireball Apr 16, 2026
72fddac
docs(plan): admin SPA implementation plan and spec updates
ifireball Apr 16, 2026
6121ed2
feat(admin): SPA package, Vite workspace, worker merge, and site CI
ifireball Apr 16, 2026
6794c45
docs(web): admin local dev and root Vite layout
ifireball Apr 16, 2026
e975c48
feat(admin): plan tasks 3–7 for production OAuth
ifireball Apr 16, 2026
caaa429
ci: run Vitest and svelte-check in CI workflow
ifireball Apr 16, 2026
b384b1c
refactor(oauth): Worker-mediated GitHub authorize; drop SPA build-tim…
ifireball Apr 16, 2026
f8bd0fc
docs(admin): align OAuth plans with Worker layout and High 1 hardening
ifireball Apr 19, 2026
2f12ea0
feat(admin): require Turnstile, OAuth BFF hardening, Wrangler rate li…
ifireball Apr 19, 2026
5f841a6
fix(admin): Turnstile loader, /api/github/user CORS, looser rate limits
ifireball Apr 19, 2026
be2c28d
feat(admin): boot spinner and single GitHub user fetch
ifireball Apr 19, 2026
afa2de2
test(admin): expand OAuth and auth BFF coverage for PR #240 High 3
ifireball Apr 19, 2026
e41cff8
fix(admin): honor token expiry in storage and OAuth completion
ifireball Apr 19, 2026
da3b8c7
test(admin): cover startGithubSignIn and consumeOAuthParamsFromDocume…
ifireball Apr 19, 2026
dc6682b
ci(site): derive ratelimit namespace_id from Worker name in deploy
ifireball Apr 19, 2026
e6bbf90
fix(admin): clear stored token on GitHub /user 401
ifireball Apr 19, 2026
9f08d61
test(worker): add vitest coverage for admin API worker
ifireball Apr 19, 2026
b87513f
docs: admin Worker OAuth CORS and safety model
ifireball Apr 19, 2026
2d2ccf2
test(worker): expand admin API Vitest coverage
ifireball Apr 19, 2026
6dc57d6
fix(site-deploy): wrangle multi-var syntax
ifireball Apr 20, 2026
d537b8a
fix(site): run worker first for /api so OAuth authorize navigations work
ifireball Apr 20, 2026
e6341e3
fix(site-deploy): patch wrangler.toml name for secret bulk target
ifireball Apr 20, 2026
5bab281
fix(admin-oauth): narrow BFF /github/user; document redirect allowlist
ifireball Apr 29, 2026
fc70542
fix(worker): handleAdminApi 404 fallthrough must use corsHeaders(cors…
ifireball Apr 29, 2026
ef49506
fix(admin): Turnstile deadline + OAuth abort; fix Octokit 401 hook
ifireball Apr 29, 2026
3c95c5b
chore(admin): drop unreachable Octokit 401 check on success path
ifireball Apr 30, 2026
475392a
chore(gitignore): ignore .claude/settings.local.json (align with main…
ifireball May 1, 2026
bce3fef
Merge upstream/main into feat/admin-spa-vite-docs-pr
ifireball May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,23 @@ jobs:

- name: Run ADR linting
run: make lint

web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2

- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json

- name: Install JS dependencies
run: npm ci

- name: Run Vitest (admin SPA)
run: npm test

- name: Run svelte-check
run: npm run check
15 changes: 14 additions & 1 deletion .github/workflows/site-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,27 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}

- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json

- name: Install JS dependencies
run: npm ci

- name: Build admin SPA
run: npm run build

- name: Prepare deploy bundle
run: |
set -euo pipefail
mkdir -p _bundle/public
# Static tree → artifact public/ (Deploy copies only _bundle/public and _bundle/worker into cloudflare_site/).
# Root page: interactive graph (source of truth: web/public/index.html).
# When a Vite app exists under web/, extend this step (e.g. _bundle/public/admin/).
cp web/public/index.html _bundle/public/index.html
mkdir -p _bundle/public/admin
cp -a web/admin/dist/. _bundle/public/admin/
# Worker source from this checkout (PR head on PR builds) — deploy never clones PR for Wrangler; only this tree is untrusted.
mkdir -p _bundle/worker
cp -a cloudflare_site/worker/. _bundle/worker/
Expand Down
40 changes: 38 additions & 2 deletions .github/workflows/site-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ jobs:
# PR/fork Worker + static files ship in the Build Site artifact under _bundle/; we copy only public/ and worker/ (never extract TOML from the zip into cloudflare_site/).
- uses: actions/checkout@v6.0.2

- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json

- name: Install JS dependencies
run: npm ci

- name: Download build artifact
uses: actions/download-artifact@v4
with:
Expand Down Expand Up @@ -64,6 +73,11 @@ jobs:
cp -a _bundle/public/. cloudflare_site/public/
cp -a _bundle/worker/. cloudflare_site/worker/

- name: Patch wrangler.toml for CI (worker name + rate limit namespaces)
env:
CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
run: node cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs

- name: Resolve preview context (PR number + preview alias)
id: preview-context
if: success()
Expand Down Expand Up @@ -113,25 +127,47 @@ jobs:
if: github.event.workflow_run.event == 'push'
uses: cloudflare/wrangler-action@v3.14.1
with:
wranglerVersion: "4.30.0"
wranglerVersion: "4.36.0"
workingDirectory: cloudflare_site
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}"
vars: |
GITHUB_APP_CLIENT_ID
TURNSTILE_SITE_KEY
secrets: |
GITHUB_APP_CLIENT_SECRET
TURNSTILE_SECRET_KEY
env:
GITHUB_APP_CLIENT_ID: ${{ vars.FULLSEND_GITHUB_APP_CLIENT_ID }}
GITHUB_APP_CLIENT_SECRET: ${{ secrets.FULLSEND_GITHUB_APP_CLIENT_SECRET }}
TURNSTILE_SITE_KEY: ${{ vars.FULLSEND_TURNSTILE_SITE_KEY }}
TURNSTILE_SECRET_KEY: ${{ secrets.FULLSEND_TURNSTILE_SECRET_KEY }}

- name: Upload preview version (Workers + static assets)
id: cf-preview
if: github.event.workflow_run.event == 'pull_request'
uses: cloudflare/wrangler-action@v3.14.1
with:
wranglerVersion: "4.30.0"
wranglerVersion: "4.36.0"
workingDirectory: cloudflare_site
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: >-
versions upload
--name="${{ vars.CLOUDFLARE_PROJECT_NAME }}"
--preview-alias ${{ steps.preview-context.outputs.preview_alias }}
vars: |
GITHUB_APP_CLIENT_ID
TURNSTILE_SITE_KEY
secrets: |
GITHUB_APP_CLIENT_SECRET
TURNSTILE_SECRET_KEY
env:
GITHUB_APP_CLIENT_ID: ${{ vars.FULLSEND_GITHUB_APP_CLIENT_ID }}
GITHUB_APP_CLIENT_SECRET: ${{ secrets.FULLSEND_GITHUB_APP_CLIENT_SECRET }}
TURNSTILE_SITE_KEY: ${{ vars.FULLSEND_TURNSTILE_SITE_KEY }}
TURNSTILE_SECRET_KEY: ${{ secrets.FULLSEND_TURNSTILE_SECRET_KEY }}

- name: Resolve deployment URL
id: meta
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
.env.local
node_modules/
web/admin/dist
.dev.vars
.wrangler
.worktrees/
__pycache__/
*.pyc
Expand All @@ -6,3 +11,4 @@ __pycache__/
_site/
bin/
.playwright/
.vscode/
142 changes: 142 additions & 0 deletions cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* CI: align Wrangler config with CLOUDFLARE_PROJECT_NAME.
*
* 1. Set top-level `name` to that value so `wrangler secret bulk` (used by cloudflare/wrangler-action
* before deploy) targets the same Worker as `wrangler deploy --name=…`. The action does not pass
* `--name` to secret bulk; it always uses the name from wrangler.toml.
* 2. Set Workers ratelimit `namespace_id` to base + offset(CLOUDFLARE_PROJECT_NAME).
*
* Local dev uses the committed `name` and bases in wrangler.toml; this script is not run for wrangler dev.
*
* Uses toml-eslint-parser so edits are by source range (comments and layout stay intact).
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseTOML } from "toml-eslint-parser";

const __dirname = dirname(fileURLToPath(import.meta.url));
const WRANGLER_TOML = join(__dirname, "..", "wrangler.toml");

const BASE_OAUTH = 482401n;
const BASE_USER = 482402n;
/** Max positive int32; keeps offset bounded so namespace_id stays a normal decimal string. */
const MOD = 2147483647n;

const deploymentName = (process.env.CLOUDFLARE_PROJECT_NAME ?? "").trim();
if (!deploymentName) {
console.error(
"patch-wrangler-rate-limit-namespace-ids: CLOUDFLARE_PROJECT_NAME is required",
);
process.exit(1);
}

const digest = createHash("sha256").update(deploymentName, "utf8").digest();
const offset = BigInt(digest.readUInt32BE(0)) % BigInt(MOD);

const oauthId = (BASE_OAUTH + offset).toString();
const userId = (BASE_USER + offset).toString();

const targets = {
OAUTH_TOKEN_RATE_LIMITER: oauthId,
GITHUB_USER_RATE_LIMITER: userId,
};

let source = readFileSync(WRANGLER_TOML, "utf8");
const ast = parseTOML(source, { tomlVersion: "1.0.0" });

function singleKeyName(key) {
if (key.keys.length !== 1) return null;
const k = key.keys[0];
if (k.type === "TOMLBare") return k.name;
if (k.type === "TOMLQuoted") return k.value;
return null;
}

function stringValueOf(kv) {
const v = kv.value;
if (v.type !== "TOMLValue" || v.kind !== "string") return null;
return v.value;
}

function namespaceIdValueRange(kv) {
const v = kv.value;
if (v.type !== "TOMLValue" || v.kind !== "string") return null;
return v.range;
}

const patches = [];
const seen = new Set();

const top = ast.body[0];
if (!top || top.type !== "TOMLTopLevelTable") {
throw new Error("patch-wrangler-rate-limit-namespace-ids: unexpected TOML root");
}

let sawName = false;
for (const node of top.body) {
if (node.type !== "TOMLKeyValue") continue;
const kn = singleKeyName(node.key);
if (kn !== "name") continue;
sawName = true;
const v = node.value;
if (v.type !== "TOMLValue" || v.kind !== "string") {
throw new Error(
"patch-wrangler-rate-limit-namespace-ids: top-level name must be a string",
);
}
const quotedName = JSON.stringify(deploymentName);
const current = source.slice(v.range[0], v.range[1]);
if (current !== quotedName) {
patches.push({ start: v.range[0], end: v.range[1], text: quotedName });
}
break;
}
if (!sawName) {
throw new Error("patch-wrangler-rate-limit-namespace-ids: missing top-level name");
}

for (const node of top.body) {
if (node.type !== "TOMLTable" || node.kind !== "array") continue;
if (node.resolvedKey[0] !== "ratelimits") continue;

let bindingName = null;
let idRange = null;

for (const kv of node.body) {
const kn = singleKeyName(kv.key);
if (kn === "name") bindingName = stringValueOf(kv);
if (kn === "namespace_id") idRange = namespaceIdValueRange(kv);
}

if (!bindingName || idRange == null) continue;
const newId = targets[bindingName];
if (newId === undefined) continue;

seen.add(bindingName);
const quoted = `"${newId}"`;
const current = source.slice(idRange[0], idRange[1]);
if (current !== quoted) {
patches.push({ start: idRange[0], end: idRange[1], text: quoted });
}
}

for (const name of Object.keys(targets)) {
if (!seen.has(name)) {
throw new Error(
`patch-wrangler-rate-limit-namespace-ids: missing [[ratelimits]] for ${name}`,
);
}
}

patches.sort((a, b) => b.start - a.start);
for (const { start, end, text } of patches) {
source = source.slice(0, start) + text + source.slice(end);
}

writeFileSync(WRANGLER_TOML, source, "utf8");
console.log(
`patched wrangler.toml name=${JSON.stringify(deploymentName)}; ratelimit namespace_id: OAUTH=${oauthId} USER=${userId} (offset=${offset.toString()} from CLOUDFLARE_PROJECT_NAME)`,
);
Loading
Loading