From 8e6cdd104786db2c6de1431cee5ea790fd6bc114 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Thu, 20 Aug 2026 10:59:49 -0400 Subject: [PATCH] chore(web): remove the admin SPA and its OAuth worker routes Work on the web admin SPA is stopped and the feature is being removed rather than left paused. Paused-but-present code kept advertising itself as live work: the pause was recorded only in the title of docs/web-admin-deployment.md, while CI still ran the admin test suite and issue #2528 carried both good-first-issue and ready-to-code, so the code agent and human contributors were both routed at it (PR #6369). Removed: - web/admin/ (Svelte SPA) and web/docs/ (orphan test whose source was already gone) - The OAuth BFF in the site Worker: /api/oauth/authorize, /api/oauth/token and /api/github/user, plus oauthCors.ts and the admin API tests. The Worker is now a passthrough to the ASSETS binding. - The two [[ratelimits]] blocks, run_worker_first, and the wrangler.toml patch script that existed only to keep their namespace ids unique. The deploy already passes --name explicitly, so the name patch was redundant. - GITHUB_APP_* and TURNSTILE_* wiring from site-deploy.yml (both the production deploy and the PR preview upload), sample.env.local, and the matching miniflare test bindings. - The root Vite build, svelte-check, eslint config, the Svelte half of the Prettier config, and every runtime npm dependency, all of which were admin-only. vite.config.ts survives as a vitest-only config so the VitePress theme tests keep running. Note that removing run_worker_first makes the passthrough the Worker's only code path. The ASSETS binding was never declared in wrangler.toml, which was harmless while the Worker only ran for /api/* (those paths returned JSON before reaching the ASSETS branch) but would now 503 every request that reaches the Worker. wrangler.toml declares binding = "ASSETS", and a test asserts it is present. Unaffected, and verified so: - The VitePress documentation site builds and ships unchanged; docs:build passes and /docs/ is still assembled into the deploy bundle. - The public mint at mint.fullsend.sh is a separate Worker provisioned from internal/dispatch/cf/ with its own wrangler.toml. No file under internal/ is touched. - e2e/admin/ is the CLI install e2e suite, not the SPA, and is untouched. Cloudflare-side secrets (GITHUB_APP_CLIENT_SECRET, TURNSTILE_SECRET_KEY) and the FULLSEND_GITHUB_APP_* / FULLSEND_TURNSTILE_* repo secrets and variables are now unused and should be deleted out of band. The admin SPA was never rolled out, so this is not a user-facing breaking change and carries no `!` marker: there is no deployed UI for anyone to migrate off. Installation has always been driven by the CLI (`fullsend github setup`, `fullsend repos`). Stale /admin/* paths fall back to the landing page rather than 404, since not_found_handling stays "single-page-application". Refs #2528 Assisted-by: Claude Signed-off-by: Wayne Sun --- .github/workflows/lint.yml | 5 +- .github/workflows/site-build.yml | 9 - .github/workflows/site-deploy.yml | 39 +- .gitignore | 2 - .pre-commit-config.yaml | 2 +- .prettierrc | 7 +- .stylelintrc.json | 2 +- ...atch-wrangler-rate-limit-namespace-ids.mjs | 142 - cloudflare_site/worker/src/index.ts | 566 +-- .../worker/src/index.worker.test.ts | 653 +-- cloudflare_site/worker/src/oauthCors.test.ts | 128 - cloudflare_site/worker/src/oauthCors.ts | 194 - cloudflare_site/worker/vitest.config.mts | 10 +- cloudflare_site/wrangler.toml | 35 +- docs/.vitepress/config.ts | 7 +- ...9-web-source-and-cloudflare-site-layout.md | 12 + docs/admin-oauth-worker.md | 43 - docs/architecture.md | 10 +- docs/doc-site.md | 2 +- docs/site-deployment.md | 112 + docs/web-admin-deployment.md | 91 - eslint.config.js | 92 - package-lock.json | 3730 +---------------- package.json | 62 +- sample.env.local | 52 - vite.config.ts | 119 +- web/README.md | 11 +- web/admin/README.md | 79 - web/admin/index.html | 12 - web/admin/src/App.svelte | 469 --- web/admin/src/app.css | 8 - web/admin/src/lib/auth/adminSessionCaches.ts | 14 - .../src/lib/auth/githubUnauthorized.test.ts | 12 - web/admin/src/lib/auth/githubUnauthorized.ts | 14 - web/admin/src/lib/auth/oauth.test.ts | 443 -- web/admin/src/lib/auth/oauth.ts | 388 -- web/admin/src/lib/auth/pkce.test.ts | 24 - web/admin/src/lib/auth/pkce.ts | 21 - web/admin/src/lib/auth/previewHandoff.test.ts | 18 - web/admin/src/lib/auth/previewHandoff.ts | 20 - web/admin/src/lib/auth/session.test.ts | 146 - web/admin/src/lib/auth/session.ts | 48 - web/admin/src/lib/auth/tokenStore.test.ts | 82 - web/admin/src/lib/auth/tokenStore.ts | 74 - web/admin/src/lib/auth/turnstile.test.ts | 42 - web/admin/src/lib/auth/turnstile.ts | 180 - web/admin/src/lib/github/client.test.ts | 29 - web/admin/src/lib/github/client.ts | 38 - .../lib/github/githubAppInstallLink.test.ts | 35 - .../src/lib/github/githubAppInstallLink.ts | 7 - web/admin/src/lib/github/user.test.ts | 108 - web/admin/src/lib/github/user.ts | 56 - web/admin/src/lib/layers/analyzeOrg.test.ts | 60 - web/admin/src/lib/layers/analyzeOrg.ts | 35 - web/admin/src/lib/layers/configRepo.test.ts | 113 - web/admin/src/lib/layers/configRepo.ts | 55 - web/admin/src/lib/layers/constants.ts | 19 - web/admin/src/lib/layers/dispatch.test.ts | 34 - web/admin/src/lib/layers/dispatch.ts | 43 - web/admin/src/lib/layers/enrollment.test.ts | 44 - web/admin/src/lib/layers/enrollment.ts | 57 - .../fixtures/configrepo/config-valid.yaml | 8 - web/admin/src/lib/layers/githubClient.test.ts | 21 - web/admin/src/lib/layers/githubClient.ts | 120 - .../src/lib/layers/orgConfigParse.test.ts | 146 - web/admin/src/lib/layers/orgConfigParse.ts | 179 - web/admin/src/lib/layers/preflight.test.ts | 66 - web/admin/src/lib/layers/preflight.ts | 88 - web/admin/src/lib/layers/secrets.test.ts | 64 - web/admin/src/lib/layers/secrets.ts | 69 - web/admin/src/lib/layers/workflows.test.ts | 49 - web/admin/src/lib/layers/workflows.ts | 50 - ...chOrganizationsFullsendRepoGraphql.test.ts | 63 - .../batchOrganizationsFullsendRepoGraphql.ts | 84 - .../src/lib/orgs/deployOAuthScopes.test.ts | 8 - web/admin/src/lib/orgs/deployOAuthScopes.ts | 10 - .../src/lib/orgs/emptyOrgListHint.test.ts | 12 - web/admin/src/lib/orgs/emptyOrgListHint.ts | 15 - web/admin/src/lib/orgs/fetchOrgs.test.ts | 270 -- web/admin/src/lib/orgs/fetchOrgs.ts | 206 - web/admin/src/lib/orgs/filter.test.ts | 23 - web/admin/src/lib/orgs/filter.ts | 13 - .../lib/orgs/githubPermissionHints.test.ts | 166 - .../src/lib/orgs/githubPermissionHints.ts | 137 - .../lib/orgs/installReadinessProbes.test.ts | 111 - .../src/lib/orgs/installReadinessProbes.ts | 137 - .../src/lib/orgs/installationOrgRows.test.ts | 127 - web/admin/src/lib/orgs/installationOrgRows.ts | 57 - .../src/lib/orgs/orgListAnalysisCache.test.ts | 35 - .../src/lib/orgs/orgListAnalysisCache.ts | 30 - web/admin/src/lib/orgs/orgListRow.test.ts | 198 - web/admin/src/lib/orgs/orgListRow.ts | 263 -- web/admin/src/lib/status/engine.test.ts | 36 - web/admin/src/lib/status/engine.ts | 23 - web/admin/src/lib/status/types.test.ts | 9 - web/admin/src/lib/status/types.ts | 26 - web/admin/src/main.ts | 5 - web/admin/src/routes/InstallEntryStub.svelte | 35 - web/admin/src/routes/OrgDashboardStub.svelte | 37 - web/admin/src/routes/OrgList.svelte | 1182 ------ web/admin/src/vite-env.d.ts | 15 - web/admin/svelte.config.js | 4 - web/admin/tsconfig.json | 13 - web/docs/build/vitePluginDocs.test.ts | 77 - 104 files changed, 480 insertions(+), 12771 deletions(-) delete mode 100644 cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs delete mode 100644 cloudflare_site/worker/src/oauthCors.test.ts delete mode 100644 cloudflare_site/worker/src/oauthCors.ts delete mode 100644 docs/admin-oauth-worker.md create mode 100644 docs/site-deployment.md delete mode 100644 docs/web-admin-deployment.md delete mode 100644 eslint.config.js delete mode 100644 sample.env.local delete mode 100644 web/admin/README.md delete mode 100644 web/admin/index.html delete mode 100644 web/admin/src/App.svelte delete mode 100644 web/admin/src/app.css delete mode 100644 web/admin/src/lib/auth/adminSessionCaches.ts delete mode 100644 web/admin/src/lib/auth/githubUnauthorized.test.ts delete mode 100644 web/admin/src/lib/auth/githubUnauthorized.ts delete mode 100644 web/admin/src/lib/auth/oauth.test.ts delete mode 100644 web/admin/src/lib/auth/oauth.ts delete mode 100644 web/admin/src/lib/auth/pkce.test.ts delete mode 100644 web/admin/src/lib/auth/pkce.ts delete mode 100644 web/admin/src/lib/auth/previewHandoff.test.ts delete mode 100644 web/admin/src/lib/auth/previewHandoff.ts delete mode 100644 web/admin/src/lib/auth/session.test.ts delete mode 100644 web/admin/src/lib/auth/session.ts delete mode 100644 web/admin/src/lib/auth/tokenStore.test.ts delete mode 100644 web/admin/src/lib/auth/tokenStore.ts delete mode 100644 web/admin/src/lib/auth/turnstile.test.ts delete mode 100644 web/admin/src/lib/auth/turnstile.ts delete mode 100644 web/admin/src/lib/github/client.test.ts delete mode 100644 web/admin/src/lib/github/client.ts delete mode 100644 web/admin/src/lib/github/githubAppInstallLink.test.ts delete mode 100644 web/admin/src/lib/github/githubAppInstallLink.ts delete mode 100644 web/admin/src/lib/github/user.test.ts delete mode 100644 web/admin/src/lib/github/user.ts delete mode 100644 web/admin/src/lib/layers/analyzeOrg.test.ts delete mode 100644 web/admin/src/lib/layers/analyzeOrg.ts delete mode 100644 web/admin/src/lib/layers/configRepo.test.ts delete mode 100644 web/admin/src/lib/layers/configRepo.ts delete mode 100644 web/admin/src/lib/layers/constants.ts delete mode 100644 web/admin/src/lib/layers/dispatch.test.ts delete mode 100644 web/admin/src/lib/layers/dispatch.ts delete mode 100644 web/admin/src/lib/layers/enrollment.test.ts delete mode 100644 web/admin/src/lib/layers/enrollment.ts delete mode 100644 web/admin/src/lib/layers/fixtures/configrepo/config-valid.yaml delete mode 100644 web/admin/src/lib/layers/githubClient.test.ts delete mode 100644 web/admin/src/lib/layers/githubClient.ts delete mode 100644 web/admin/src/lib/layers/orgConfigParse.test.ts delete mode 100644 web/admin/src/lib/layers/orgConfigParse.ts delete mode 100644 web/admin/src/lib/layers/preflight.test.ts delete mode 100644 web/admin/src/lib/layers/preflight.ts delete mode 100644 web/admin/src/lib/layers/secrets.test.ts delete mode 100644 web/admin/src/lib/layers/secrets.ts delete mode 100644 web/admin/src/lib/layers/workflows.test.ts delete mode 100644 web/admin/src/lib/layers/workflows.ts delete mode 100644 web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.test.ts delete mode 100644 web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.ts delete mode 100644 web/admin/src/lib/orgs/deployOAuthScopes.test.ts delete mode 100644 web/admin/src/lib/orgs/deployOAuthScopes.ts delete mode 100644 web/admin/src/lib/orgs/emptyOrgListHint.test.ts delete mode 100644 web/admin/src/lib/orgs/emptyOrgListHint.ts delete mode 100644 web/admin/src/lib/orgs/fetchOrgs.test.ts delete mode 100644 web/admin/src/lib/orgs/fetchOrgs.ts delete mode 100644 web/admin/src/lib/orgs/filter.test.ts delete mode 100644 web/admin/src/lib/orgs/filter.ts delete mode 100644 web/admin/src/lib/orgs/githubPermissionHints.test.ts delete mode 100644 web/admin/src/lib/orgs/githubPermissionHints.ts delete mode 100644 web/admin/src/lib/orgs/installReadinessProbes.test.ts delete mode 100644 web/admin/src/lib/orgs/installReadinessProbes.ts delete mode 100644 web/admin/src/lib/orgs/installationOrgRows.test.ts delete mode 100644 web/admin/src/lib/orgs/installationOrgRows.ts delete mode 100644 web/admin/src/lib/orgs/orgListAnalysisCache.test.ts delete mode 100644 web/admin/src/lib/orgs/orgListAnalysisCache.ts delete mode 100644 web/admin/src/lib/orgs/orgListRow.test.ts delete mode 100644 web/admin/src/lib/orgs/orgListRow.ts delete mode 100644 web/admin/src/lib/status/engine.test.ts delete mode 100644 web/admin/src/lib/status/engine.ts delete mode 100644 web/admin/src/lib/status/types.test.ts delete mode 100644 web/admin/src/lib/status/types.ts delete mode 100644 web/admin/src/main.ts delete mode 100644 web/admin/src/routes/InstallEntryStub.svelte delete mode 100644 web/admin/src/routes/OrgDashboardStub.svelte delete mode 100644 web/admin/src/routes/OrgList.svelte delete mode 100644 web/admin/src/vite-env.d.ts delete mode 100644 web/admin/svelte.config.js delete mode 100644 web/admin/tsconfig.json delete mode 100644 web/docs/build/vitePluginDocs.test.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f8d169c68a..84db5aebcf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -138,8 +138,5 @@ jobs: - name: Install JS dependencies run: npm ci - - name: Run Vitest (admin SPA) + - name: Run Vitest (docs theme + site Worker) run: npm test - - - name: Run svelte-check - run: npm run check diff --git a/.github/workflows/site-build.yml b/.github/workflows/site-build.yml index 5e347352c8..2f44a0ab9f 100644 --- a/.github/workflows/site-build.yml +++ b/.github/workflows/site-build.yml @@ -8,7 +8,6 @@ on: - "cloudflare_site/**" - "package.json" - "package-lock.json" - - "vite.config.ts" - "experiments" - "experiments/**" - ".gitmodules" @@ -22,7 +21,6 @@ on: - "cloudflare_site/**" - "package.json" - "package-lock.json" - - "vite.config.ts" - "experiments" - "experiments/**" - ".gitmodules" @@ -54,9 +52,6 @@ jobs: - name: Install JS dependencies run: npm ci - - name: Build web SPAs - run: npm run build - - name: Build documentation site run: npm run docs:build @@ -66,10 +61,6 @@ jobs: mkdir -p _bundle/public cp web/public/index.html _bundle/public/index.html cp web/public/graph.html _bundle/public/graph.html - mkdir -p _bundle/public/assets - cp -a web/dist/assets/. _bundle/public/assets/ - mkdir -p _bundle/public/admin - cp -a web/dist/admin/. _bundle/public/admin/ mkdir -p _bundle/public/docs cp -a docs/.vitepress/dist/. _bundle/public/docs/ cp -a cloudflare_site/public/. _bundle/public/ diff --git a/.github/workflows/site-deploy.yml b/.github/workflows/site-deploy.yml index 2bcc5934f7..b9165df3cd 100644 --- a/.github/workflows/site-deploy.yml +++ b/.github/workflows/site-deploy.yml @@ -73,11 +73,6 @@ 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() @@ -122,17 +117,7 @@ jobs: core.setOutput('preview_alias', previewAlias); core.setOutput('pr_number', prNumber != null ? String(prNumber) : ''); - # Same 10214 issue as previews: wrangler-action runs script-level `wrangler secret bulk` - # before `deploy` when `secrets:` is set. Use `wrangler versions secret bulk` in preCommands - # instead (cloudflare/wrangler-action#374). - # preCommands run under `/bin/sh` (dash on Ubuntu): no `pipefail` (bash-only). - # Each newline in preCommands is a separate shell invocation — no line continuations or - # multi-line printf; keep the secret bulk prep on one line so mktemp and the file path match. - # Bare `wrangler` in preCommands doesn't work: wrangler-action's execCommands() only - # rewrites a line to `npx wrangler ...` when it starts with the literal "wrangler" - # (verified against wrangler-action@9acf94a dist/index.mjs — re-check if pin is bumped). - # This compound line starts with `set`, so use `npx --no-install wrangler` to invoke - # the copy already installed by wranglerVersion (see #4861). + # The site Worker is a static-asset passthrough: no vars or secrets are required. - name: Deploy to production (Workers + static assets) id: cf-prod if: github.event.workflow_run.event == 'push' @@ -143,24 +128,8 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} packageManager: npm - preCommands: set -eu; secrets_file="$(mktemp)"; trap 'rm -f "$secrets_file"' EXIT; printf '%s\n' "GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET}" "TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}" >"$secrets_file"; npx --no-install wrangler versions secret bulk "$secrets_file" --message "Production secrets (workflow-run ${{ github.event.workflow_run.id }})" command: deploy --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}" - vars: | - GITHUB_APP_CLIENT_ID - TURNSTILE_SITE_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 }} - # PR previews: wrangler-action runs script-level `wrangler secret bulk` *before* the main command. - # That conflicts with `versions upload` (API 10214: latest version isn't deployed). Apply secrets - # with `wrangler versions secret bulk` in preCommands instead (cloudflare/wrangler-action#374). - # Plain `vars` are only auto-injected for deploy/publish, so pass `--var` on `versions upload`. - # preCommands run under `/bin/sh` (dash on Ubuntu): no `pipefail` (bash-only). - # Each newline in preCommands is a separate shell invocation — keep secret bulk prep on one line. - # Bare `wrangler` in preCommands doesn't work — see the prod deploy step above for why (#4861). - name: Upload preview version (Workers + static assets) id: cf-preview if: github.event.workflow_run.event == 'pull_request' @@ -171,16 +140,10 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} packageManager: npm - preCommands: set -eu; secrets_file="$(mktemp)"; trap 'rm -f "$secrets_file"' EXIT; printf '%s\n' "GITHUB_APP_CLIENT_SECRET=${GITHUB_APP_CLIENT_SECRET}" "TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}" >"$secrets_file"; npx --no-install wrangler versions secret bulk "$secrets_file" --message "PR preview secrets (workflow-run ${{ github.event.workflow_run.id }})" command: >- versions upload --name="${{ vars.CLOUDFLARE_PROJECT_NAME }}" --preview-alias ${{ steps.preview-context.outputs.preview_alias }} - --var GITHUB_APP_CLIENT_ID:${{ vars.FULLSEND_GITHUB_APP_CLIENT_ID }} - --var TURNSTILE_SITE_KEY:${{ vars.FULLSEND_TURNSTILE_SITE_KEY }} - env: - GITHUB_APP_CLIENT_SECRET: ${{ secrets.FULLSEND_GITHUB_APP_CLIENT_SECRET }} - TURNSTILE_SECRET_KEY: ${{ secrets.FULLSEND_TURNSTILE_SECRET_KEY }} - name: Resolve deployment URL id: meta diff --git a/.gitignore b/.gitignore index 51bfd1f863..d02ca6a863 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ .env.local node_modules/ .vite/ -web/dist -web/admin/dist .dev.vars .wrangler .worktrees/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ecd1fab93d..fba9dc8e57 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -180,5 +180,5 @@ repos: name: lint-staged (web) entry: npx lint-staged --allow-empty language: system - files: ^(web/admin/src/|docs/\.vitepress/) + files: ^docs/\.vitepress/ pass_filenames: false diff --git a/.prettierrc b/.prettierrc index f2235d767b..d7454cb780 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,12 +1,7 @@ { - "plugins": ["prettier-plugin-svelte"], - "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], "singleQuote": false, "trailingComma": "all", "printWidth": 100, "tabWidth": 2, - "semi": true, - "svelteSortOrder": "options-scripts-markup-styles", - "svelteIndentScriptAndStyle": true, - "svelteAllowShorthand": true + "semi": true } diff --git a/.stylelintrc.json b/.stylelintrc.json index 4b760c23ad..16f9d61154 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,5 +1,5 @@ { - "extends": ["stylelint-config-standard", "stylelint-config-html/svelte", "stylelint-config-html/vue"], + "extends": ["stylelint-config-standard", "stylelint-config-html/vue"], "rules": { "custom-property-pattern": null, "selector-class-pattern": null diff --git a/cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs b/cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs deleted file mode 100644 index b301531c9d..0000000000 --- a/cloudflare_site/scripts/patch-wrangler-rate-limit-namespace-ids.mjs +++ /dev/null @@ -1,142 +0,0 @@ -#!/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)`, -); diff --git a/cloudflare_site/worker/src/index.ts b/cloudflare_site/worker/src/index.ts index ea65a80ee8..70757d1523 100644 --- a/cloudflare_site/worker/src/index.ts +++ b/cloudflare_site/worker/src/index.ts @@ -1,573 +1,19 @@ /// -import { - authorizeTabBindingOk, - corsHeaders, - effectiveCorsOrigin, - fetchTabBindingOk, - hasNonEmptyTurnstileKeys, - isAllowedOAuthRedirectUri, -} from "./oauthCors"; - /** - * Site Worker: static assets via `[assets]` plus OAuth BFF + `GET /api/github/user` proxy for the admin SPA. - * `GET /api/oauth/authorize` adds `client_id` and redirects to GitHub (SPA has no build-time client id). + * Site Worker: serves the static documentation site via the `[assets]` binding. + * + * This Worker previously hosted an OAuth BFF for the admin SPA under `web/admin/`. + * That SPA was removed, so the Worker is now a plain passthrough to `ASSETS`. + * The public mint is a separate Worker (`internal/dispatch/cf/workersrc/`) and is + * unaffected by anything here. */ export interface Env { ASSETS?: Fetcher; - GITHUB_APP_CLIENT_ID: string; - GITHUB_APP_CLIENT_SECRET: string; - /** Set to `"1"` via `wrangler dev --var DEBUG_LOG:1` (see root `npm run dev:debug`). */ - DEBUG_LOG?: string; - /** - * Required for admin `/api/*` routes. Site key is folded into OAuth `state` at authorize — - * never baked into the SPA build. Missing or empty values fail with HTTP 503 and JSON - * `missing_turnstile_keys` (no silent disable). - */ - TURNSTILE_SITE_KEY?: string; - TURNSTILE_SECRET_KEY?: string; - /** - * Optional. When set (non-empty), folded into OAuth `state` with the Turnstile site key so the - * SPA can build GitHub App install links after sign-in (not a Vite `define`). - */ - GITHUB_APP_SLUG?: string; - OAUTH_TOKEN_RATE_LIMITER: RateLimit; - GITHUB_USER_RATE_LIMITER: RateLimit; -} - -const TOKEN_URL = "https://github.com/login/oauth/access_token"; -const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; -const GITHUB_USER_URL = "https://api.github.com/user"; -const TURNSTILE_SITEVERIFY_URL = - "https://challenges.cloudflare.com/turnstile/v0/siteverify"; - -/** Max length of the client-supplied OAuth `state` nonce (before Worker expansion). */ -const MAX_CLIENT_OAUTH_STATE_LEN = 128; -const MAX_PKCE_CHALLENGE_LEN = 256; -/** Max `state` sent to GitHub (expanded JSON + base64url including Turnstile site key). */ -const MAX_GITHUB_STATE_LEN = 4096; -/** Reject oversized `POST /api/oauth/token` bodies before JSON parse (defense in depth). */ -const MAX_OAUTH_TOKEN_EXCHANGE_JSON_BYTES = 4096; - -/** Same rule as SPA `installationOrgRows.normalizeSlug` — invalid values must not be put in `state`. */ -const GITHUB_APP_SLUG_IN_STATE_RE = /^[a-zA-Z0-9-]{1,99}$/; - -function normalizedGithubAppSlugForState(raw: string): string | null { - const s = raw.trim(); - if (!s || !GITHUB_APP_SLUG_IN_STATE_RE.test(s)) return null; - return s; -} - -function isAdminApiPath(pathname: string): boolean { - return ( - pathname === "/api/oauth/authorize" || - pathname === "/api/oauth/token" || - pathname === "/api/github/user" - ); -} - -function missingTurnstileResponse(): Response { - return new Response( - JSON.stringify({ - error: "server_misconfigured", - detail: "missing_turnstile_keys", - message: - "TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must both be set to non-empty values for admin API routes (Wrangler var + secret). See repo sample.env.local for official dummy keys in dev.", - }), - { - status: 503, - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - }, - }, - ); -} - -function isValidClientOAuthNonce(state: string): boolean { - if (state.length < 8 || state.length > MAX_CLIENT_OAUTH_STATE_LEN) return false; - return /^[A-Za-z0-9._-]+$/.test(state); -} - -function utf8Bytes(s: string): Uint8Array { - return new TextEncoder().encode(s); -} - -function base64UrlEncodeJson(obj: unknown): string { - const bytes = utf8Bytes(JSON.stringify(obj)); - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); - const b64 = btoa(bin); - return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function buildGithubState(env: Env, clientNonce: string): string | null { - const siteKey = (env.TURNSTILE_SITE_KEY ?? "").trim(); - const appSlugRaw = (env.GITHUB_APP_SLUG ?? "").trim(); - const appSlug = appSlugRaw ? normalizedGithubAppSlugForState(appSlugRaw) : null; - const payload: { v: 1; n: string; k: string; g?: string } = { - v: 1, - n: clientNonce, - k: siteKey, - }; - if (appSlug) payload.g = appSlug; - const combined = base64UrlEncodeJson(payload); - if (combined.length > MAX_GITHUB_STATE_LEN) return null; - return combined; -} - -function clientIp(request: Request): string { - return request.headers.get("CF-Connecting-IP") ?? "unknown"; -} - -async function consumeRateLimitOr429( - rl: RateLimit, - pathname: string, - request: Request, - cors: HeadersInit, -): Promise { - const key = `${pathname}:${clientIp(request)}`; - const { success } = await rl.limit({ key }); - if (success) return null; - return new Response(JSON.stringify({ error: "rate_limited" }), { - status: 429, - headers: { "content-type": "application/json", ...cors }, - }); -} - -async function verifyTurnstile(env: Env, token: string): Promise { - const secret = (env.TURNSTILE_SECRET_KEY ?? "").trim(); - const body = new URLSearchParams({ secret, response: token }); - const res = await fetch(TURNSTILE_SITEVERIFY_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - const json: unknown = await res.json().catch(() => ({})); - const rec = json as Record; - return rec.success === true; -} - -/** - * Redirect browser to GitHub authorize with `client_id` from env. Query must include PKCE + state. - */ -async function handleOAuthAuthorize( - request: Request, - env: Env, - url: URL, - corsOrigin: string, -): Promise { - const redirect_uri = url.searchParams.get("redirect_uri")?.trim() ?? ""; - const state = url.searchParams.get("state") ?? ""; - const code_challenge = - url.searchParams.get("code_challenge")?.trim() ?? ""; - const code_challenge_method = - url.searchParams.get("code_challenge_method")?.trim() ?? ""; - - const cors = corsHeaders(corsOrigin); - - if ( - !redirect_uri || - !state || - !code_challenge || - code_challenge_method !== "S256" - ) { - return new Response( - JSON.stringify({ error: "missing_or_invalid_oauth_params" }), - { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }, - ); - } - - if ( - !isValidClientOAuthNonce(state) || - code_challenge.length > MAX_PKCE_CHALLENGE_LEN - ) { - return new Response(JSON.stringify({ error: "param_too_long" }), { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }); - } - - if (!isAllowedOAuthRedirectUri(redirect_uri)) { - return new Response(JSON.stringify({ error: "invalid_redirect_uri" }), { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }); - } - - if (!authorizeTabBindingOk(request, redirect_uri)) { - return new Response(JSON.stringify({ error: "forbidden_origin" }), { - status: 403, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const clientId = (env.GITHUB_APP_CLIENT_ID ?? "").trim(); - if (!clientId) { - return new Response(JSON.stringify({ error: "server_misconfigured" }), { - status: 500, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const githubState = buildGithubState(env, state); - if (githubState == null) { - return new Response(JSON.stringify({ error: "state_too_long" }), { - status: 500, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const gh = new URL(GITHUB_AUTHORIZE_URL); - gh.searchParams.set("client_id", clientId); - gh.searchParams.set("redirect_uri", redirect_uri); - gh.searchParams.set("state", githubState); - gh.searchParams.set("code_challenge", code_challenge); - gh.searchParams.set("code_challenge_method", "S256"); - - return Response.redirect(gh.toString(), 302); -} - -type ExchangeBody = { - code?: string; - redirect_uri?: string; - code_verifier?: string; - turnstile_token?: string; -}; - -async function handleOAuthToken( - request: Request, - env: Env, - url: URL, - corsOrigin: string, -): Promise { - const cors = corsHeaders(corsOrigin); - - const limited = await consumeRateLimitOr429( - env.OAUTH_TOKEN_RATE_LIMITER, - url.pathname, - request, - cors, - ); - if (limited) return limited; - - if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "method_not_allowed" }), { - status: 405, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const ct = request.headers.get("Content-Type") ?? ""; - if (!ct.includes("application/json")) { - return new Response(JSON.stringify({ error: "unsupported_media_type" }), { - status: 415, - headers: { "content-type": "application/json", ...cors }, - }); - } - - let body: ExchangeBody; - try { - const raw = await request.arrayBuffer(); - if (raw.byteLength > MAX_OAUTH_TOKEN_EXCHANGE_JSON_BYTES) { - return new Response(JSON.stringify({ error: "payload_too_large" }), { - status: 413, - headers: { "content-type": "application/json", ...cors }, - }); - } - const text = new TextDecoder().decode(raw); - body = JSON.parse(text) as ExchangeBody; - } catch { - return new Response(JSON.stringify({ error: "invalid_json" }), { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const code = typeof body.code === "string" ? body.code.trim() : ""; - const redirect_uri = - typeof body.redirect_uri === "string" ? body.redirect_uri.trim() : ""; - const code_verifier = - typeof body.code_verifier === "string" ? body.code_verifier.trim() : ""; - const turnstile_token = - typeof body.turnstile_token === "string" ? body.turnstile_token.trim() : ""; - - if (!code || !redirect_uri || !code_verifier) { - return new Response(JSON.stringify({ error: "missing_fields" }), { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }); - } - - if (!isAllowedOAuthRedirectUri(redirect_uri)) { - return new Response(JSON.stringify({ error: "invalid_redirect_uri" }), { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }); - } - - if (!fetchTabBindingOk(request, redirect_uri)) { - return new Response(JSON.stringify({ error: "forbidden_origin" }), { - status: 403, - headers: { "content-type": "application/json", ...cors }, - }); - } - - if (!turnstile_token) { - return new Response(JSON.stringify({ error: "turnstile_required" }), { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }); - } - const ok = await verifyTurnstile(env, turnstile_token); - if (!ok) { - return new Response(JSON.stringify({ error: "turnstile_failed" }), { - status: 403, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const clientId = (env.GITHUB_APP_CLIENT_ID ?? "").trim(); - const clientSecret = (env.GITHUB_APP_CLIENT_SECRET ?? "").trim(); - if (!clientId || !clientSecret) { - return new Response(JSON.stringify({ error: "server_misconfigured" }), { - status: 500, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const ghBody = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri, - code_verifier, - }); - - const ghRes = await fetch(TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: ghBody.toString(), - }); - - const ghJson: unknown = await ghRes.json().catch(() => ({})); - const rec = ghJson as Record; - const access_token = - typeof rec.access_token === "string" ? rec.access_token : undefined; - const error = typeof rec.error === "string" ? rec.error : undefined; - const error_description = - typeof rec.error_description === "string" - ? rec.error_description - : undefined; - - if (!ghRes.ok || error || !access_token) { - return new Response( - JSON.stringify({ - error: error ?? "token_exchange_failed", - error_description: error_description ?? null, - }), - { - status: 400, - headers: { "content-type": "application/json", ...cors }, - }, - ); - } - - const token_type = - typeof rec.token_type === "string" ? rec.token_type : "bearer"; - const expires_in = - typeof rec.expires_in === "number" ? rec.expires_in : undefined; - - return new Response( - JSON.stringify({ - access_token, - token_type, - expires_in: expires_in ?? null, - }), - { - status: 200, - headers: { "content-type": "application/json", ...cors }, - }, - ); -} - -/** - * Server-side GitHub /user so the browser never hits api.github.com (no CORS). - * Returns a minimal JSON object (`login`, `name`, `avatar_url`) so the SPA never receives - * the full GitHub profile (email, plan, etc.). Shape matches fields consumed today and on - * follow-on admin work (e.g. org list UI uses `avatar_url`). - */ -async function handleGithubUser( - request: Request, - env: Env, - url: URL, - corsOrigin: string, -): Promise { - const cors = corsHeaders(corsOrigin); - - const limited = await consumeRateLimitOr429( - env.GITHUB_USER_RATE_LIMITER, - url.pathname, - request, - cors, - ); - if (limited) return limited; - - if (request.method !== "GET") { - return new Response(JSON.stringify({ error: "method_not_allowed" }), { - status: 405, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const auth = request.headers.get("Authorization") ?? ""; - if (!auth.startsWith("Bearer ") || auth.length < 10) { - return new Response(JSON.stringify({ error: "missing_bearer_token" }), { - status: 401, - headers: { "content-type": "application/json", ...cors }, - }); - } - - const accept = - request.headers.get("Accept") ?? "application/vnd.github+json"; - const apiVersion = - request.headers.get("X-GitHub-Api-Version") ?? "2022-11-28"; - - const ghRes = await fetch(GITHUB_USER_URL, { - headers: { - Accept: accept, - Authorization: auth, - "X-GitHub-Api-Version": apiVersion, - "User-Agent": "fullsend-admin-oauth-worker", - }, - }); - - const text = await ghRes.text(); - const forwardHeaders = { - "content-type": ghRes.headers.get("content-type") ?? "application/json", - ...cors, - }; - - if (!ghRes.ok) { - return new Response(text, { status: ghRes.status, headers: forwardHeaders }); - } - - try { - const raw = JSON.parse(text) as Record; - const login = typeof raw.login === "string" ? raw.login : ""; - if (!login) { - return new Response(JSON.stringify({ error: "invalid_github_user_payload" }), { - status: 502, - headers: { "content-type": "application/json", ...cors }, - }); - } - const name = typeof raw.name === "string" ? raw.name : null; - const avatar_url = - typeof raw.avatar_url === "string" && raw.avatar_url.length > 0 - ? raw.avatar_url - : null; - return new Response(JSON.stringify({ login, name, avatar_url }), { - status: 200, - headers: { "content-type": "application/json", ...cors }, - }); - } catch { - return new Response(text, { status: ghRes.status, headers: forwardHeaders }); - } -} - -/** Exported for regression tests (e.g. admin API fallthrough must not reference out-of-scope locals). */ -export async function handleAdminApi( - request: Request, - env: Env, - url: URL, -): Promise { - if (env.DEBUG_LOG === "1") { - console.log("[worker]", request.method, url.pathname); - } - - if (!hasNonEmptyTurnstileKeys(env)) { - if (env.DEBUG_LOG === "1") { - console.error( - "[worker] misconfigured: missing TURNSTILE_SITE_KEY and/or TURNSTILE_SECRET_KEY", - ); - } - return missingTurnstileResponse(); - } - - const corsOrigin = effectiveCorsOrigin(request, url); - - if (request.method === "OPTIONS") { - if (!corsOrigin) { - return new Response(null, { status: 403 }); - } - return new Response(null, { - status: 204, - headers: corsHeaders(corsOrigin), - }); - } - - if (url.pathname === "/api/oauth/authorize") { - if (request.method !== "GET") { - const cors = corsOrigin ? corsHeaders(corsOrigin) : {}; - return new Response(JSON.stringify({ error: "method_not_allowed" }), { - status: 405, - headers: { "content-type": "application/json", ...cors }, - }); - } - if (!corsOrigin) { - return new Response(JSON.stringify({ error: "forbidden_origin" }), { - status: 403, - headers: { "content-type": "application/json" }, - }); - } - return handleOAuthAuthorize(request, env, url, corsOrigin); - } - - if (!corsOrigin) { - return new Response(JSON.stringify({ error: "forbidden_origin" }), { - status: 403, - headers: { "content-type": "application/json" }, - }); - } - - if (url.pathname === "/api/oauth/token") { - return handleOAuthToken(request, env, url, corsOrigin); - } - - if (url.pathname === "/api/github/user") { - return handleGithubUser(request, env, url, corsOrigin); - } - - return new Response(JSON.stringify({ error: "not_found" }), { - status: 404, - headers: { - "content-type": "application/json", - ...corsHeaders(corsOrigin), - }, - }); } export default { async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - - if (isAdminApiPath(url.pathname)) { - return handleAdminApi(request, env, url); - } - - if (url.pathname.startsWith("/api/")) { - return new Response(JSON.stringify({ error: "not_found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - } - if (env.ASSETS != null) { return env.ASSETS.fetch(request); } diff --git a/cloudflare_site/worker/src/index.worker.test.ts b/cloudflare_site/worker/src/index.worker.test.ts index 171fe5673a..985b326e5d 100644 --- a/cloudflare_site/worker/src/index.worker.test.ts +++ b/cloudflare_site/worker/src/index.worker.test.ts @@ -1,608 +1,87 @@ import { env } from "cloudflare:workers"; -import { - createExecutionContext, - waitOnExecutionContext, -} from "cloudflare:test"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import worker, { handleAdminApi, type Env } from "./index"; - -const IncomingRequest = Request; - -const denyRateLimit: Env["OAUTH_TOKEN_RATE_LIMITER"] = { - limit: async () => ({ success: false }), -}; - -const allowRateLimit: Env["OAUTH_TOKEN_RATE_LIMITER"] = { - limit: async () => ({ success: true }), -}; - -function authorizeUrl(redirect: string): URL { - const sp = new URLSearchParams({ - redirect_uri: redirect, - state: "12345678", - code_challenge: "E9Melhoaq2wv2FgFCGTrimrGQ6bc6oVe3gtPKSmlVSs", - code_challenge_method: "S256", - }); - return new URL(`https://worker.test/api/oauth/authorize?${sp}`); +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import worker, { type Env } from "./index"; + +/** ASSETS stub that records what the Worker forwarded to it. */ +function assetsStub(body = "asset") { + const seen: string[] = []; + const fetcher = { + fetch: async (request: Request) => { + seen.push(new URL(request.url).pathname); + return new Response(body, { status: 200 }); + }, + } as unknown as Fetcher; + return { fetcher, seen }; } -describe("site worker admin API", () => { - let originalFetch: typeof fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("OPTIONS returns 204 with CORS headers when Origin is allowed (localhost)", async () => { - const url = new URL("https://worker.test/api/oauth/token"); - const req = new IncomingRequest(url, { - method: "OPTIONS", - headers: { Origin: "http://127.0.0.1:5173" }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(204); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe( - "http://127.0.0.1:5173", - ); - }); - - it("OPTIONS returns 403 without usable CORS origin for admin API", async () => { - const site = "https://worker.test"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "OPTIONS", - headers: { - Origin: "https://attacker.example", - }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(403); - }); - - it("returns 403 forbidden_origin for token POST when Origin does not match redirect_uri", async () => { - const site = "https://worker.test"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: "http://localhost:9999", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "abc", - redirect_uri: "http://localhost:5173/admin/", - code_verifier: "verifierverifierverifierverifier01", - turnstile_token: "dummy", - }), - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(403); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("forbidden_origin"); - }); - - it("returns 413 payload_too_large when OAuth token JSON body exceeds 4 KiB", async () => { - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const big = "x".repeat(5000); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "a", - redirect_uri: redirect, - code_verifier: "verifierverifierverifierverifier01", - turnstile_token: "dummy", - pad: big, - }), - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(413); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("payload_too_large"); - }); - - it("returns 503 missing_turnstile_keys when Turnstile vars are empty", async () => { - const site = "https://worker.test"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "OPTIONS", - headers: { Origin: "http://localhost:5173" }, - }); +describe("site worker", () => { + it("passes requests through to the ASSETS binding", async () => { + const { fetcher, seen } = assetsStub(""); const ctx = createExecutionContext(); - const strippedEnv = { - ...env, - TURNSTILE_SITE_KEY: "", - TURNSTILE_SECRET_KEY: "", - }; - const res = await worker.fetch(req, strippedEnv, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(503); - const body = (await res.json()) as { detail?: string }; - expect(body.detail).toBe("missing_turnstile_keys"); - }); - - it("exchanges token with mocked GitHub and Turnstile (no real network)", async () => { - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.includes("challenges.cloudflare.com/turnstile")) { - return new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url.includes("github.com/login/oauth/access_token")) { - return new Response( - JSON.stringify({ - access_token: "gho_test_token", - token_type: "bearer", - expires_in: 3600, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - return new Response(`unexpected fetch: ${url}`, { status: 500 }); - }) as typeof fetch; - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "exchange-code", - redirect_uri: redirect, - code_verifier: "verifierverifierverifierverifier01", - turnstile_token: "dummy-turnstile", - }), - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(200); - const body = (await res.json()) as { access_token?: string }; - expect(body.access_token).toBe("gho_test_token"); - }); - - it("returns 400 with GitHub OAuth error when token exchange fails (e.g. bad PKCE)", async () => { - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.includes("challenges.cloudflare.com/turnstile")) { - return new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url.includes("github.com/login/oauth/access_token")) { - return new Response( - JSON.stringify({ - error: "bad_verification_code", - error_description: - "The code passed is incorrect or expired, or the PKCE verification failed.", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - return new Response(`unexpected fetch: ${url}`, { status: 500 }); - }) as typeof fetch; - - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "exchange-code", - redirect_uri: redirect, - code_verifier: "wrong-verifier-wrong-verifier-wrong01", - turnstile_token: "dummy-turnstile", - }), - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); + const res = await worker.fetch( + new Request("https://example.test/docs/"), + { ASSETS: fetcher }, + ctx, + ); await waitOnExecutionContext(ctx); - expect(res.status).toBe(400); - const body = (await res.json()) as { - error?: string; - error_description?: string | null; - }; - expect(body.error).toBe("bad_verification_code"); - expect(body.error_description).toContain("PKCE"); - }); - - it("proxies /api/github/user with mocked api.github.com", async () => { - globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.startsWith("https://api.github.com/user")) { - return new Response( - JSON.stringify({ - login: "octocat", - name: "Octo Cat", - avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", - email: "octocat@example.com", - two_factor_authentication: true, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - return new Response(`unexpected fetch: ${url}`, { status: 500 }); - }) as typeof fetch; - const site = "https://worker.test"; - const url = new URL(`${site}/api/github/user`); - const req = new IncomingRequest(url, { - method: "GET", - headers: { - Origin: "http://localhost:5173", - Authorization: "Bearer gho_fake", - Accept: "application/vnd.github+json", - }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toEqual({ - login: "octocat", - name: "Octo Cat", - avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", - }); - expect(body).not.toHaveProperty("email"); - expect(body).not.toHaveProperty("two_factor_authentication"); - }); - - it("returns 429 rate_limited when OAUTH_TOKEN_RATE_LIMITER denies", async () => { - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "c", - redirect_uri: redirect, - code_verifier: "verifierverifierverifierverifier01", - turnstile_token: "t", - }), - }); - const ctx = createExecutionContext(); - const limitedEnv: Env = { - ...env, - OAUTH_TOKEN_RATE_LIMITER: denyRateLimit, - }; - const res = await worker.fetch(req, limitedEnv, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(429); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("rate_limited"); - }); - - it("returns 429 rate_limited when GITHUB_USER_RATE_LIMITER denies", async () => { - const site = "https://worker.test"; - const url = new URL(`${site}/api/github/user`); - const req = new IncomingRequest(url, { - method: "GET", - headers: { - Origin: "http://localhost:5173", - Authorization: "Bearer gho_fake", - }, - }); - const ctx = createExecutionContext(); - const limitedEnv: Env = { - ...env, - GITHUB_USER_RATE_LIMITER: denyRateLimit, - }; - const res = await worker.fetch(req, limitedEnv, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(429); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("rate_limited"); - }); - - it("returns 400 turnstile_required when turnstile_token is missing on token POST", async () => { - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "exchange-code", - redirect_uri: redirect, - code_verifier: "verifierverifierverifierverifier01", - }), - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(400); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("turnstile_required"); - }); - - it("returns 403 turnstile_failed when Turnstile siteverify returns success: false", async () => { - globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.includes("challenges.cloudflare.com/turnstile")) { - return new Response(JSON.stringify({ success: false }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response(`unexpected fetch: ${url}`, { status: 500 }); - }) as typeof fetch; - - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: "exchange-code", - redirect_uri: redirect, - code_verifier: "verifierverifierverifierverifier01", - turnstile_token: "bad-token", - }), - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(403); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("turnstile_failed"); - }); - - it("returns 403 forbidden_origin on authorize when Origin does not match redirect_uri", async () => { - const redirect = "http://localhost:5173/admin/"; - const url = authorizeUrl(redirect); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: "http://localhost:8888" }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(403); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("forbidden_origin"); - }); - - it("returns 403 forbidden_origin on authorize when navigation cannot bind CORS (no Origin)", async () => { - const redirect = "https://evil.example/admin/"; - const url = authorizeUrl(redirect); - const req = new IncomingRequest(url, { method: "GET" }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(403); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("forbidden_origin"); - }); - - it("returns 400 param_too_long on authorize for oversized PKCE challenge", async () => { - const redirect = "http://localhost:5173/admin/"; - const sp = new URLSearchParams({ - redirect_uri: redirect, - state: "12345678", - code_challenge: "a".repeat(257), - code_challenge_method: "S256", - }); - const url = new URL(`https://worker.test/api/oauth/authorize?${sp}`); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: new URL(redirect).origin }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(400); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("param_too_long"); - }); - - it("returns 400 param_too_long on authorize for oversized client state nonce", async () => { - const redirect = "http://localhost:5173/admin/"; - const sp = new URLSearchParams({ - redirect_uri: redirect, - state: "a".repeat(129), - code_challenge: "E9Melhoaq2wv2FgFCGTrimrGQ6bc6oVe3gtPKSmlVSs", - code_challenge_method: "S256", - }); - const url = new URL(`https://worker.test/api/oauth/authorize?${sp}`); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: new URL(redirect).origin }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(400); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("param_too_long"); - }); - - it("redirects authorize to GitHub with state JSON including g when GITHUB_APP_SLUG is set", async () => { - const redirect = "http://localhost:5173/admin/"; - const url = authorizeUrl(redirect); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: new URL(redirect).origin }, - }); - const ctx = createExecutionContext(); - const envWithSlug = { ...env, GITHUB_APP_SLUG: "test-fullsend-app" }; - const res = await worker.fetch(req, envWithSlug, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(302); - const loc = res.headers.get("Location"); - expect(loc).toBeTruthy(); - const gh = new URL(loc!); - expect(gh.hostname).toBe("github.com"); - const state = gh.searchParams.get("state"); - expect(state).toBeTruthy(); - const pad = state!.length % 4 === 0 ? "" : "=".repeat(4 - (state!.length % 4)); - const json = atob(state!.replace(/-/g, "+").replace(/_/g, "/") + pad); - const o = JSON.parse(json) as Record; - expect(o.v).toBe(1); - expect(o.n).toBe("12345678"); - expect(typeof o.k).toBe("string"); - expect(o.g).toBe("test-fullsend-app"); + expect(await res.text()).toBe(""); + expect(seen).toEqual(["/docs/"]); }); - it("omits g from authorize state when GITHUB_APP_SLUG is not a valid slug", async () => { - const redirect = "http://localhost:5173/admin/"; - const url = authorizeUrl(redirect); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: new URL(redirect).origin }, - }); + it("returns 503 when the ASSETS binding is missing", async () => { const ctx = createExecutionContext(); - const envWithBadSlug = { ...env, GITHUB_APP_SLUG: "bad/slug" }; - const res = await worker.fetch(req, envWithBadSlug, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(302); - const loc = res.headers.get("Location"); - expect(loc).toBeTruthy(); - const gh = new URL(loc!); - const state = gh.searchParams.get("state"); - expect(state).toBeTruthy(); - const pad = state!.length % 4 === 0 ? "" : "=".repeat(4 - (state!.length % 4)); - const json = atob(state!.replace(/-/g, "+").replace(/_/g, "/") + pad); - const o = JSON.parse(json) as Record; - expect(o.v).toBe(1); - expect(o.n).toBe("12345678"); - expect(typeof o.k).toBe("string"); - expect("g" in o).toBe(false); - }); - - it("returns 400 missing_or_invalid_oauth_params on authorize when code_challenge_method is not S256", async () => { - const redirect = "http://localhost:5173/admin/"; - const sp = new URLSearchParams({ - redirect_uri: redirect, - state: "12345678", - code_challenge: "E9Melhoaq2wv2FgFCGTrimrGQ6bc6oVe3gtPKSmlVSs", - code_challenge_method: "plain", - }); - const url = new URL(`https://worker.test/api/oauth/authorize?${sp}`); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: new URL(redirect).origin }, - }); - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(400); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("missing_or_invalid_oauth_params"); - }); - it("returns 415 unsupported_media_type for token POST without JSON content-type", async () => { - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "text/plain", - }, - body: "{}", - }); - const ctx = createExecutionContext(); const res = await worker.fetch( - req, - { ...env, OAUTH_TOKEN_RATE_LIMITER: allowRateLimit }, + new Request("https://example.test/"), + {} as Env, ctx, ); await waitOnExecutionContext(ctx); - expect(res.status).toBe(415); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("unsupported_media_type"); - }); - it("returns 400 invalid_json for token POST with malformed JSON body", async () => { - const site = "https://worker.test"; - const redirect = "http://localhost:5173/admin/"; - const url = new URL(`${site}/api/oauth/token`); - const req = new IncomingRequest(url, { - method: "POST", - headers: { - Origin: new URL(redirect).origin, - "Content-Type": "application/json", - }, - body: "{not-json", - }); - const ctx = createExecutionContext(); - const res = await worker.fetch( - req, - { ...env, OAUTH_TOKEN_RATE_LIMITER: allowRateLimit }, - ctx, - ); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(400); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("invalid_json"); + expect(res.status).toBe(503); + expect(await res.text()).toContain("ASSETS binding missing"); + }); + + it("no longer handles the retired admin OAuth routes", async () => { + // Before removal these three were intercepted by the Worker's OAuth BFF and + // never reached ASSETS. Asserting the forwarded paths (not just the body) + // is what proves no stray handler survives: a stub that always returns the + // same body would pass a body-only assertion even if a handler still ran. + const retired = [ + "/api/oauth/authorize", + "/api/oauth/token", + "/api/github/user", + ]; + const { fetcher, seen } = assetsStub(); + + for (const path of retired) { + const ctx = createExecutionContext(); + const res = await worker.fetch( + new Request(`https://example.test${path}`), + { ASSETS: fetcher }, + ctx, + ); + await waitOnExecutionContext(ctx); + expect(res.status).toBe(200); + } + + expect(seen).toEqual(retired); }); +}); - it("handleAdminApi fallthrough returns 404 with CORS (no out-of-scope cors reference)", async () => { - const site = "https://worker.test"; - const url = new URL(`${site}/api/oauth/future-slot`); - const req = new IncomingRequest(url, { - method: "GET", - headers: { Origin: "http://localhost:5173" }, - }); - const ctx = createExecutionContext(); - const res = await handleAdminApi(req, env, url); - await waitOnExecutionContext(ctx); - expect(res.status).toBe(404); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe("not_found"); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe( - "http://localhost:5173", - ); +describe("wrangler bindings", () => { + // Regression guard: the Worker only ever ran for /api/* while + // `run_worker_first = ["/api/*"]` was set, so a missing ASSETS binding was + // harmless. Without `binding = "ASSETS"` in wrangler.toml, every request that + // *reaches* the Worker now 503s. That is not every request — exact asset + // matches and unmatched navigations are served without invoking the Worker — + // but it does include unmatched non-navigation requests (fetch/XHR, probes). + it("provides env.ASSETS from wrangler.toml", () => { + expect((env as Env).ASSETS).toBeDefined(); }); }); diff --git a/cloudflare_site/worker/src/oauthCors.test.ts b/cloudflare_site/worker/src/oauthCors.test.ts deleted file mode 100644 index e7d93a1596..0000000000 --- a/cloudflare_site/worker/src/oauthCors.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - effectiveCorsOrigin, - hasNonEmptyTurnstileKeys, - isAllowedOAuthRedirectUri, - isLocalhostHttpOrigin, -} from "./oauthCors"; - -describe("isAllowedOAuthRedirectUri", () => { - it("allows HTTPS redirect URIs under /admin/", () => { - expect( - isAllowedOAuthRedirectUri("https://preview.example.com/admin/"), - ).toBe(true); - expect( - isAllowedOAuthRedirectUri("https://preview.example.com/admin/oauth/callback.html"), - ).toBe(true); - }); - - it("allows any HTTPS origin at /admin/ (preview URLs; GitHub-registered callbacks + PKCE gate abuse)", () => { - expect(isAllowedOAuthRedirectUri("https://evil.com/admin/")).toBe(true); - }); - - it("allows loopback HTTP with /admin/ path", () => { - expect( - isAllowedOAuthRedirectUri("http://localhost:5173/admin/"), - ).toBe(true); - expect( - isAllowedOAuthRedirectUri("http://127.0.0.1:8787/admin/oauth/callback.html"), - ).toBe(true); - expect(isAllowedOAuthRedirectUri("http://[::1]:3000/admin/")).toBe(true); - }); - - it("rejects wrong path, protocol, or host", () => { - expect(isAllowedOAuthRedirectUri("https://evil.com/other/")).toBe(false); - expect(isAllowedOAuthRedirectUri("https://evil.com/admin/extra")).toBe( - false, - ); - expect( - isAllowedOAuthRedirectUri("http://evil.com/admin/"), - ).toBe(false); - expect(isAllowedOAuthRedirectUri("ftp://localhost/admin/")).toBe(false); - expect(isAllowedOAuthRedirectUri("not-a-url")).toBe(false); - }); -}); - -describe("isLocalhostHttpOrigin", () => { - it("matches loopback HTTP origins", () => { - expect(isLocalhostHttpOrigin("http://localhost:5173")).toBe(true); - expect(isLocalhostHttpOrigin("http://127.0.0.1:8787")).toBe(true); - }); - - it("rejects HTTPS loopback and empty", () => { - expect(isLocalhostHttpOrigin("https://localhost:5173")).toBe(false); - expect(isLocalhostHttpOrigin("")).toBe(false); - }); -}); - -describe("effectiveCorsOrigin", () => { - it("returns loopback Origin for admin API paths", () => { - const url = new URL("https://worker.example/api/oauth/token"); - const req = new Request(url, { - method: "OPTIONS", - headers: { Origin: "http://localhost:5173" }, - }); - expect(effectiveCorsOrigin(req, url)).toBe("http://localhost:5173"); - }); - - it("returns deployed origin only when Origin matches the worker URL", () => { - const site = "https://worker.example"; - const tokenUrl = new URL(`${site}/api/oauth/token`); - const ok = new Request(tokenUrl, { - method: "OPTIONS", - headers: { Origin: site }, - }); - expect(effectiveCorsOrigin(ok, tokenUrl)).toBe(site); - - const bad = new Request(tokenUrl, { - method: "OPTIONS", - headers: { Origin: "https://evil.example" }, - }); - expect(effectiveCorsOrigin(bad, tokenUrl)).toBe(null); - }); - - it("for authorize without Origin, returns redirect_uri origin when navigation binding passes", () => { - const site = "https://worker.example"; - const authorizeUrl = new URL( - `${site}/api/oauth/authorize?redirect_uri=${encodeURIComponent(`${site}/admin/`)}&state=nonce12345&code_challenge=x&code_challenge_method=S256`, - ); - const req = new Request(authorizeUrl, { method: "GET" }); - expect(effectiveCorsOrigin(req, authorizeUrl)).toBe(site); - }); - - it("for /api/github/user without Origin, infers from Sec-Fetch-Site + Referer", () => { - const site = "https://worker.example"; - const userUrl = new URL(`${site}/api/github/user`); - const req = new Request(userUrl, { - method: "OPTIONS", - headers: { - "Sec-Fetch-Site": "same-origin", - Referer: `${site}/admin/`, - }, - }); - expect(effectiveCorsOrigin(req, userUrl)).toBe(site); - }); -}); - -describe("hasNonEmptyTurnstileKeys", () => { - it("requires both keys non-empty after trim", () => { - expect( - hasNonEmptyTurnstileKeys({ - TURNSTILE_SITE_KEY: "a", - TURNSTILE_SECRET_KEY: "b", - }), - ).toBe(true); - expect( - hasNonEmptyTurnstileKeys({ - TURNSTILE_SITE_KEY: " ", - TURNSTILE_SECRET_KEY: "b", - }), - ).toBe(false); - expect( - hasNonEmptyTurnstileKeys({ - TURNSTILE_SITE_KEY: "a", - TURNSTILE_SECRET_KEY: "", - }), - ).toBe(false); - }); -}); diff --git a/cloudflare_site/worker/src/oauthCors.ts b/cloudflare_site/worker/src/oauthCors.ts deleted file mode 100644 index 93f83a1533..0000000000 --- a/cloudflare_site/worker/src/oauthCors.ts +++ /dev/null @@ -1,194 +0,0 @@ -/// - -/** Env fields used to decide whether admin `/api/*` may run Turnstile-backed flows. */ -export interface TurnstileEnvKeys { - TURNSTILE_SITE_KEY?: string; - TURNSTILE_SECRET_KEY?: string; -} - -function adminSpaCallbackPath(pathname: string): boolean { - return ( - pathname === "/admin/" || - pathname === "/admin" || - pathname === "/admin/oauth/callback.html" - ); -} - -/** Dev: any localhost / 127.0.0.1 HTTP port (Vite may not use 5173). */ -export function isLocalhostHttpOrigin(origin: string): boolean { - if (!origin) return false; - try { - const u = new URL(origin); - return ( - u.protocol === "http:" && - (u.hostname === "localhost" || - u.hostname === "127.0.0.1" || - u.hostname === "[::1]") - ); - } catch { - return false; - } -} - -/** - * `redirect_uri` for the GitHub App web flow: SPA entry under `/admin/` on loopback (dev) - * or HTTPS (production / Workers previews). - * - * **HTTPS entries intentionally accept any host** (e.g. `https://example.com/admin/`), including - * hosts you do not control, because Cloudflare preview URLs are not enumerable. This predicate only - * constrains **protocol + path shape**. Actual redirect safety comes from: **GitHub App callback - * URL registration** (GitHub will not redirect to arbitrary hosts), **PKCE** (`code_verifier`), - * **`Origin` tab-binding** on `POST /api/oauth/token`, Turnstile, and rate limits. - */ -export function isAllowedOAuthRedirectUri(redirectUri: string): boolean { - let u: URL; - try { - u = new URL(redirectUri); - } catch { - return false; - } - if (!adminSpaCallbackPath(u.pathname)) return false; - if (u.protocol === "http:") { - return ( - u.hostname === "localhost" || - u.hostname === "127.0.0.1" || - u.hostname === "[::1]" - ); - } - return u.protocol === "https:"; -} - -/** - * Browser tab origin from **`Origin` only** (no `Referer` fallback — it is not authentication). - */ -export function getBrowserOrigin(request: Request): string | null { - const originHeader = request.headers.get("Origin") ?? ""; - if (!originHeader) return null; - try { - return new URL(originHeader).origin; - } catch { - return null; - } -} - -export function workerPublicOrigin(request: Request): string | null { - try { - return new URL(request.url).origin; - } catch { - return null; - } -} - -function redirectUriOrigin(redirectUri: string): string | null { - try { - return new URL(redirectUri).origin; - } catch { - return null; - } -} - -/** - * Full-page navigations to `GET /api/oauth/authorize` often omit `Origin`. Allow the hop when - * `redirect_uri` is same-origin as this Worker, or both are loopback HTTP (Vite port vs Wrangler). - */ -function authorizeNavigationWithoutOriginAllowed( - request: Request, - redirectUri: string, -): boolean { - const ro = redirectUriOrigin(redirectUri); - const site = workerPublicOrigin(request); - if (!ro || !site) return false; - if (ro === site) return true; - return isLocalhostHttpOrigin(ro) && isLocalhostHttpOrigin(site); -} - -/** - * CORS for `/api/*`: loopback (dev), or browser origin equals this Worker’s origin (deployed). - * For `GET /api/oauth/authorize` without `Origin`, uses `redirect_uri` origin when navigation rule passes. - */ -export function effectiveCorsOrigin(request: Request, url: URL): string | null { - const browser = getBrowserOrigin(request); - if (browser) { - if (isLocalhostHttpOrigin(browser)) return browser; - const site = workerPublicOrigin(request); - if (site && browser === site) return browser; - return null; - } - - if ( - request.method === "GET" && - url.pathname === "/api/oauth/authorize" - ) { - const redirect_uri = url.searchParams.get("redirect_uri")?.trim() ?? ""; - if (!redirect_uri || !isAllowedOAuthRedirectUri(redirect_uri)) return null; - const ro = redirectUriOrigin(redirect_uri); - if (!ro) return null; - if (authorizeNavigationWithoutOriginAllowed(request, redirect_uri)) return ro; - } - - // `GET /api/github/user` (and its CORS preflight) may omit `Origin` for same-origin fetches or - // after some dev proxies. OAuth tab-binding does not apply (Bearer only). Infer ACAO from - // Fetch Metadata + `Referer` origin only for this path — not used for token exchange. - if ( - (request.method === "GET" || request.method === "OPTIONS") && - url.pathname === "/api/github/user" - ) { - const sec = (request.headers.get("Sec-Fetch-Site") ?? "").toLowerCase(); - if (sec === "same-origin" || sec === "same-site") { - const ref = request.headers.get("Referer")?.trim() ?? ""; - if (ref) { - try { - const refOrigin = new URL(ref).origin; - const site = workerPublicOrigin(request); - if (!site) return null; - if (refOrigin === site) return refOrigin; - if (isLocalhostHttpOrigin(refOrigin) && isLocalhostHttpOrigin(site)) { - return refOrigin; - } - } catch { - /* ignore */ - } - } - } - } - - return null; -} - -/** Authorize: `Origin` when present, else navigation binding (see `authorizeNavigationWithoutOriginAllowed`). */ -export function authorizeTabBindingOk( - request: Request, - redirectUri: string, -): boolean { - const browser = getBrowserOrigin(request); - if (browser) { - const ro = redirectUriOrigin(redirectUri); - return Boolean(ro && browser === ro); - } - return authorizeNavigationWithoutOriginAllowed(request, redirectUri); -} - -/** Token exchange and API `fetch`: require `Origin` and match `redirect_uri` origin. */ -export function fetchTabBindingOk(request: Request, redirectUri: string): boolean { - const browser = getBrowserOrigin(request); - if (!browser) return false; - const ro = redirectUriOrigin(redirectUri); - return Boolean(ro && browser === ro); -} - -export function corsHeaders(allowOrigin: string): HeadersInit { - return { - "Access-Control-Allow-Origin": allowOrigin, - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, Accept, X-GitHub-Api-Version", - "Access-Control-Max-Age": "86400", - }; -} - -export function hasNonEmptyTurnstileKeys(env: TurnstileEnvKeys): boolean { - return ( - (env.TURNSTILE_SITE_KEY ?? "").trim() !== "" && - (env.TURNSTILE_SECRET_KEY ?? "").trim() !== "" - ); -} diff --git a/cloudflare_site/worker/vitest.config.mts b/cloudflare_site/worker/vitest.config.mts index e96f66af0a..35cea8b491 100644 --- a/cloudflare_site/worker/vitest.config.mts +++ b/cloudflare_site/worker/vitest.config.mts @@ -9,17 +9,11 @@ export default defineConfig({ root: workerRoot, plugins: [ cloudflareTest({ + // The Worker is a static-asset passthrough: the only binding it needs is + // ASSETS, which comes from wrangler.toml. No vars or secrets. wrangler: { configPath: path.join(workerRoot, "..", "wrangler.toml"), }, - miniflare: { - bindings: { - GITHUB_APP_CLIENT_ID: "test_github_client_id", - GITHUB_APP_CLIENT_SECRET: "test_github_client_secret", - TURNSTILE_SITE_KEY: "1x00000000000000000000AA", - TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA", - }, - }, }), ], test: { diff --git a/cloudflare_site/wrangler.toml b/cloudflare_site/wrangler.toml index 1436eb6a3c..cb8d2d15d8 100644 --- a/cloudflare_site/wrangler.toml +++ b/cloudflare_site/wrangler.toml @@ -9,33 +9,16 @@ compatibility_date = "2026-04-09" workers_dev = true preview_urls = true -# Local dev: `npm run dev` sets CLOUDFLARE_INCLUDE_PROCESS_ENV so Wrangler reads `GITHUB_APP_*` -# from the same process environment as Vite. Deployed/preview: set vars or secrets in Cloudflare / CI. +# Local dev: `npm run dev:worker` serves ./public through the ASSETS binding. [assets] directory = "./public" not_found_handling = "single-page-application" -# With compatibility_date >= 2025-04-01, navigations (Sec-Fetch-Mode: navigate) skip the Worker by -# default. Admin sign-in uses location.assign to GET /api/oauth/authorize — without this, that hop -# is served as SPA fallback (/index.html) instead of the OAuth redirect. See: -# https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/ -run_worker_first = ["/api/*"] - -# Native rate limits (Wrangler ≥ 4.36). namespace_id must be a string of a positive integer (Cloudflare docs). -# Local dev: bases below. CI (site-deploy.yml): patch-wrangler-rate-limit-namespace-ids.mjs sets -# top-level name = CLOUDFLARE_PROJECT_NAME (so wrangler secret bulk matches deploy --name) and -# namespace_id = base + offset(sha256(CLOUDFLARE_PROJECT_NAME)) so different Worker names get different namespaces. -# Limits are per key per Cloudflare location. Keys are path + IP. -[[ratelimits]] -name = "OAUTH_TOKEN_RATE_LIMITER" -namespace_id = "482401" - [ratelimits.simple] - limit = 30 - period = 60 - -[[ratelimits]] -name = "GITHUB_USER_RATE_LIMITER" -namespace_id = "482402" - [ratelimits.simple] - limit = 120 - period = 60 +# Exposes env.ASSETS to worker/src/index.ts, which is now a plain passthrough. +# Previously `run_worker_first = ["/api/*"]` meant the Worker only ran for /api/* +# (which returned JSON before touching ASSETS), so the binding was never needed. +# Most traffic still never reaches the Worker: an exact asset match is served +# directly, and an unmatched *navigation* gets the SPA fallback. But an unmatched +# non-navigation request (fetch/XHR, curl, monitoring probes) does reach it, and +# without this binding those 503. Verified against `wrangler dev`. +binding = "ASSETS" diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e8ee68efac..e67191ea58 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -312,14 +312,9 @@ export default defineConfig({ items: getMarkdownFiles("experiments", "experiments"), }, { text: "Doc Site", link: "/doc-site" }, - { text: "Web Admin (On Hold)", link: "/web-admin-deployment" }, + { text: "Site Deployment", link: "/site-deployment" }, ], }, - { - text: "Internals", - collapsed: true, - items: [{ text: "Admin OAuth Worker", link: "/admin-oauth-worker" }], - }, ], }, diff --git a/docs/ADRs/0019-web-source-and-cloudflare-site-layout.md b/docs/ADRs/0019-web-source-and-cloudflare-site-layout.md index d822a4323d..dea93cfb13 100644 --- a/docs/ADRs/0019-web-source-and-cloudflare-site-layout.md +++ b/docs/ADRs/0019-web-source-and-cloudflare-site-layout.md @@ -13,6 +13,18 @@ topics: Date: 2026-04-15 +> **Note (2026-08-20):** The admin installation SPA described in points 1 and 3 was +> removed. `web/` now holds only the static landing page and document graph under +> `web/public/`; there is no Vite build. Point 1's principle still holds — Node tooling +> stays at the repository root — but its example scripts are stale: `npm run dev` and +> `npm run build` no longer exist. The root scripts today are `npm test`, +> `npm run dev:worker`, and `npm run docs:*`. The site Worker +> under `cloudflare_site/worker/` is a static-asset passthrough — its OAuth BFF was +> removed with the SPA. The `web/` vs `cloudflare_site/` split this ADR decided is +> unchanged, as is the Build Site / Deploy Site artifact contract. The public mint +> Worker (`internal/dispatch/cf/workersrc/`, `mint.fullsend.sh`) is a separate +> deployment and was never covered by this ADR. + ## Status Accepted diff --git a/docs/admin-oauth-worker.md b/docs/admin-oauth-worker.md deleted file mode 100644 index 8c27670360..0000000000 --- a/docs/admin-oauth-worker.md +++ /dev/null @@ -1,43 +0,0 @@ -# Admin OAuth site Worker (hardening notes) - -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/web-admin-deployment.md`](web-admin-deployment.md). - -## `GET /api/github/user` CORS: missing `Origin` - -Most admin `/api/*` traffic is evaluated with a single **effective browser origin** derived from the **`Origin` header** (loopback dev, or same origin as the Worker in production). Same-origin `fetch` from the SPA normally sends `Origin`. - -Some **same-origin** or **dev-proxy** patterns can omit `Origin` on `GET /api/github/user` (and on its **`OPTIONS`** preflight): for example certain same-origin fetches or proxies in front of Wrangler/Vite. Without a reflected `Access-Control-Allow-Origin`, the browser would block the response even though the request is harmless from a token-exchange perspective: this route is **Bearer-only** (no OAuth `code`, no `client_secret`, no session cookie minted by the Worker). - -For **this path only**, when `Origin` is absent, the Worker may still compute `Access-Control-Allow-Origin` by combining: - -1. **[Fetch Metadata](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site):** `Sec-Fetch-Site` must be **`same-origin`** or **`same-site`** (browser signal that the initiating context is not cross-site in the usual sense). -2. **`Referer`:** the request’s `Referer` must parse to an **origin** that matches the Worker’s public origin, or a **paired loopback** rule (both Worker URL and referer origin are loopback HTTP, for typical Vite + Wrangler port splits). - -This inference is **not** used for: - -- **`POST /api/oauth/token`** — tab binding requires **`Origin`** present and equal to the `redirect_uri` origin (`fetchTabBindingOk` in `oauthCors.ts`). Missing or mismatched `Origin` yields **403** `forbidden_origin`. -- **`GET /api/oauth/authorize`** — uses **`Origin`** when present; without it, the Worker uses the **navigation** rule (`redirect_uri` allowlist + same Worker origin or loopback pairing), not the `Sec-Fetch-Site` + `Referer` path used for `/api/github/user`. - -### Intentional trade-off - -Using `Referer` (even gated on `Sec-Fetch-Site`) is **weaker** than `Origin` for reflecting CORS: it is **not** treated as proof of who the caller is. It is an **accepted, intentional** trade-off **only** to keep the **GitHub `/user` proxy** usable when `Origin` is missing, while keeping **token exchange** strict. - -**Explicitly:** `Referer` is **not** used to authenticate or authorize the GitHub **token exchange**. Exchange still requires a valid **`Origin`**, PKCE, Turnstile verification, `redirect_uri` allowlist, GitHub `code` + app credentials, and rate limits (see below). - -## `GET /api/github/user` response shape - -Successful responses are **not** a verbatim pass-through of GitHub’s `/user` JSON. The Worker returns only **`login`**, **`name`**, and **`avatar_url`** (all other GitHub fields are stripped) so the browser receives the smallest profile the admin UI needs, including for follow-on features that display avatars. - -## Why there is no `ADMIN_OAUTH_ENABLED`-style flag - -Admin OAuth is not gated on a separate **boolean** environment variable (“feature off”). Instead, **misconfiguration or absence of required secrets** naturally prevents the surface from working or from being meaningfully exploitable: - -- **Turnstile:** both **`TURNSTILE_SITE_KEY`** and **`TURNSTILE_SECRET_KEY`** must be **non-empty** for any admin `/api/oauth/*` or `/api/github/user` handling. If either is missing or blank, the Worker responds with **503** and JSON **`error: "server_misconfigured"`**, **`detail: "missing_turnstile_keys"`**. That is the operational **“off”** story: there is no silent “Turnstile disabled” mode for these routes. -- **GitHub App:** **`POST /api/oauth/token`** requires **`GITHUB_APP_CLIENT_ID`** and **`GITHUB_APP_CLIENT_SECRET`** (non-empty); otherwise the Worker returns **500** `server_misconfigured`. The SPA does not embed `client_secret`; exchange runs only in the Worker. -- **Rate limits:** Wrangler **native rate limits** apply per path and client IP (e.g. token exchange vs. `/api/github/user`), reducing abuse volume. -- **Origin rules on exchange:** **`POST /api/oauth/token`** requires **`Origin`** matching the **`redirect_uri`** origin; **`redirect_uri`** must be on the **allowlist** (HTTPS or loopback, `/admin/` callback paths). -- **Authorize:** **`GET /api/oauth/authorize`** enforces **`redirect_uri`** allowlist and tab/navigation binding (`authorizeTabBindingOk`); Turnstile site key is folded into `state` server-side. - -Together, these layers define the **safety model** without a second “master switch” that could be forgotten left-on in production or flipped without audit. diff --git a/docs/architecture.md b/docs/architecture.md index 197d6ebce4..99f50814de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -788,4 +788,12 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo) ## Repository layout (design workspace vs. web delivery) -The repository combines design documents, Go CLI code, and a small **public web** surface. **Decided:** Browser-oriented static source and future bundled UI live under **`web/`** (the landing page is `web/public/index.html` at `/` and the interactive document graph is `web/public/graph.html` at `/graph.html`). Cloudflare Wrangler configuration and deploy-time static assets live under **`cloudflare_site/`** (single `wrangler.toml`; CI stages **`_bundle/`** on the deploy runner and copies only **`public/`** and **`worker/`** from the artifact into that tree so **`wrangler.toml` is never taken from the PR-built zip**). See [ADR 0019](ADRs/0019-web-source-and-cloudflare-site-layout.md). +The repository combines design documents, Go CLI code, and a small **public web** surface. **Decided:** Browser-oriented static source lives under **`web/`** (the landing page is `web/public/index.html` at `/` and the interactive document graph is `web/public/graph.html` at `/graph.html`). The user-facing documentation site is built from **`docs/`** by VitePress (`npm run docs:build`) and served under `/docs/`. Cloudflare Wrangler configuration and deploy-time static assets live under **`cloudflare_site/`** (single `wrangler.toml`; CI stages **`_bundle/`** on the deploy runner and copies only **`public/`** and **`worker/`** from the artifact into that tree so **`wrangler.toml` is never taken from the PR-built zip**). See [ADR 0019](ADRs/0019-web-source-and-cloudflare-site-layout.md). + +The **admin installation SPA** that formerly lived under `web/admin/` was removed on +2026-08-20, together with the OAuth BFF it required in the site Worker. The site Worker +(`cloudflare_site/worker/`) is now a static-asset passthrough that needs no vars or secrets. +Installation is driven entirely by the `fullsend` CLI (`fullsend github setup`, +`fullsend repos`). This is unrelated to the **public mint** Worker, which is a separate +deployment provisioned from `internal/dispatch/cf/` and served at `mint.fullsend.sh` +([ADR 0068](ADRs/0068-public-community-mint-architecture.md)). diff --git a/docs/doc-site.md b/docs/doc-site.md index ba96750cc3..e48fcd6f1e 100644 --- a/docs/doc-site.md +++ b/docs/doc-site.md @@ -42,7 +42,7 @@ The `docs:dev` and `docs:build` scripts in the root `package.json` handle submod - **`.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). +For Cloudflare Worker setup and troubleshooting, see [`site-deployment.md`](site-deployment.md). ## Documentation versioning (investigation) diff --git a/docs/site-deployment.md b/docs/site-deployment.md new file mode 100644 index 0000000000..56875ac829 --- /dev/null +++ b/docs/site-deployment.md @@ -0,0 +1,112 @@ +# Site deployment (Cloudflare Worker) + +How the public site — the landing page, the document graph, and the VitePress +documentation — is built and deployed. For authoring the documentation itself, see +[`doc-site.md`](doc-site.md). + +## Overview + +The site is served by a **Cloudflare Worker with [static assets](https://developers.cloudflare.com/workers/static-assets/)** +(not the legacy **Pages direct-upload** / `wrangler pages deploy` flow). The Worker entry point is +[`cloudflare_site/worker/src/index.ts`](../cloudflare_site/worker/src/index.ts) and is a plain +passthrough to the `ASSETS` binding — it requires **no vars and no secrets**. + +**Build Site** (`.github/workflows/site-build.yml`) builds the VitePress site and assembles +`_bundle/`: + +| Bundle path | Source | +|---|---| +| `_bundle/public/index.html` | `web/public/index.html` (landing page, served at `/`) | +| `_bundle/public/graph.html` | `web/public/graph.html` (document graph, served at `/graph.html`) | +| `_bundle/public/docs/` | `docs/.vitepress/dist/` (documentation, served at `/docs/`) | +| `_bundle/public/` | `cloudflare_site/public/` (`robots.txt`, `llms.txt`) | +| `_bundle/worker/` | `cloudflare_site/worker/` | + +**Deploy Site** (`.github/workflows/site-deploy.yml`) checks out **only the default branch** so +[`cloudflare_site/wrangler.toml`](../cloudflare_site/wrangler.toml) is always trusted and never +taken from a PR-built zip, downloads the artifact, **copies only** `_bundle/public/` and +`_bundle/worker/` into `cloudflare_site/`, then runs Wrangler. + +Repository layout for `web/` vs `cloudflare_site/` is decided in +[ADR 0019](ADRs/0019-web-source-and-cloudflare-site-layout.md). + +> **Not the mint.** The public token mint at `mint.fullsend.sh` is a **separate** Cloudflare +> Worker, provisioned from `internal/dispatch/cf/` with its own `wrangler.toml` under +> `internal/dispatch/cf/workersrc/`. Nothing in this document affects it. See +> [ADR 0068](ADRs/0068-public-community-mint-architecture.md). + +## Cloudflare setup + +### 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`. +3. Optional: set a **[workers.dev](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/)** subdomain for your account. + +### API token + +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** → **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. + +### Account ID and Worker name + +- Copy **Account ID** → secret **`CLOUDFLARE_ACCOUNT_ID`**. +- Set **`CLOUDFLARE_PROJECT_NAME`** as a GitHub **Actions variable**: value = **Worker name** in the dashboard. The deploy workflow passes it as `wrangler deploy --name=…` / `versions upload --name=…`. + +### Custom domains + +Attach routes or custom domains to the **Worker** (Workers → your Worker → **Domains & Routes**), not to a Pages project. Production URLs in GitHub Deployments follow the hostname Wrangler reports (often `*.workers.dev` until a custom domain is primary). + +### Migrating from an old Pages project + +If you previously used **Cloudflare Pages** with `wrangler pages deploy`, create the Worker as above, point DNS/custom hostnames to the Worker, then disable or delete the old Pages project to avoid confusion. + +## GitHub setup + +### Fork + +On a **fork**, open **Settings → Secrets and variables → Actions**. Add secrets **`CLOUDFLARE_API_TOKEN`**, **`CLOUDFLARE_ACCOUNT_ID`**, and variable **`CLOUDFLARE_PROJECT_NAME`** (Worker name). + +Under **Settings → Actions → General**, allow **Fork pull request workflows** from contributors so fork PRs can run **Build Site** without Cloudflare credentials in the fork. + +**Deploy Site** runs in the base repository with secrets; fork workflow logs should not show those values. + +### Upstream + +Configure the same secrets/variables at org or repo scope. Confirm **`pull-requests: write`** on the deploy workflow matches org policy for fork PR comments. + +Disable **GitHub Pages** under **Settings → Pages** if it was only used for this site. + +## Local preview + +For documentation authoring, `npm run docs:dev` is usually what you want (hot reload, no Wrangler). + +To preview the assembled production layout through the Worker: + +```bash +npm ci +npm run docs:build +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 docs/.vitepress/dist/. cloudflare_site/public/docs/ +npm run dev:worker +``` + +Requires a Cloudflare login or API token in the environment per [Wrangler docs](https://developers.cloudflare.com/workers/wrangler/). + +## Troubleshooting + +**Deploy job skipped.** The triggering workflow display name must be **Build Site** exactly, and `workflow_run.repository` must match the current repo. + +**`Could not determine Workers deployment URL`.** The workflow reads `deployment-url` from `cloudflare/wrangler-action`, then falls back to parsing Wrangler stdout/stderr for a `workers.dev` URL. Upgrade **`wranglerVersion`** in the workflow if Wrangler output format changed. + +**Preview upload fails (PR builds).** Requires Wrangler **≥ 4.21.0** for `--preview-alias`. The pinned version is `wranglerVersion` in [`site-deploy.yml`](https://github.com/fullsend-ai/fullsend/blob/main/.github/workflows/site-deploy.yml) — check there rather than trusting a number copied into this page. + +**Artifact download 404.** **Build Site** must upload artifact **`site`**; **Deploy Site** needs `actions: read`. + +**Stale `/admin/*` links.** The admin SPA was removed. `not_found_handling = "single-page-application"` means old admin URLs fall back to the landing page rather than returning 404. diff --git a/docs/web-admin-deployment.md b/docs/web-admin-deployment.md deleted file mode 100644 index 9410dd35ad..0000000000 --- a/docs/web-admin-deployment.md +++ /dev/null @@ -1,91 +0,0 @@ -# Web admin deployment (on hold) - -> **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). - -## Overview - -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). - -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. - -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") - -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`. -3. Optional: set a **[workers.dev](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/)** subdomain for your account. - -### API token - -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** → **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. - -### Account ID and Worker name - -- Copy **Account ID** → secret **`CLOUDFLARE_ACCOUNT_ID`**. -- Set **`CLOUDFLARE_PROJECT_NAME`** as a GitHub **Actions variable** (same name as before for compatibility): value = **Worker name** in the dashboard. The deploy workflow passes it as `wrangler deploy --name=…` / `versions upload --name=…`. - -### Custom domains (e.g. fork demo or `konflux.sh`) - -Attach routes or custom domains to the **Worker** (Workers → your Worker → **Domains & Routes**), not to a Pages project. Production URLs in GitHub Deployments will follow the hostname Wrangler reports (often `*.workers.dev` until a custom domain is primary). - -### Migrating from an old Pages project - -If you previously used **Cloudflare Pages** with `wrangler pages deploy`, create the Worker as above, point DNS/custom hostnames to the Worker, then disable or delete the old Pages project to avoid confusion. - -## GitHub fork phase 1 - -On a **fork**, open **Settings → Secrets and variables → Actions**. Add secrets **`CLOUDFLARE_API_TOKEN`**, **`CLOUDFLARE_ACCOUNT_ID`**, and variable **`CLOUDFLARE_PROJECT_NAME`** (Worker name). - -Under **Settings → Actions → General**, allow **Fork pull request workflows** from contributors so fork PRs can run **Build Site** without Cloudflare credentials in the fork. - -**Deploy Site** runs in the base repository with secrets; fork workflow logs should not show those values. - -## GitHub upstream phase 2 - -Configure the same secrets/variables at org or repo scope. Confirm **`pull-requests: write`** on the deploy workflow matches org policy for fork PR comments. - -Disable **GitHub Pages** under **Settings → Pages** if it was only used for this site. - -## Local preview (optional) - -**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 the admin SPA, assemble the layout CI uses under `cloudflare_site/public/`, then run Wrangler: - -```bash -npm ci -npm run build -mkdir -p cloudflare_site/public/assets -mkdir -p cloudflare_site/public/admin -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/ -cd cloudflare_site && npx wrangler@4 dev -``` - -Requires a Cloudflare login or API token in the environment per [Wrangler docs](https://developers.cloudflare.com/workers/wrangler/). - -## Troubleshooting - -**Deploy job skipped.** The triggering workflow display name must be **Build Site** exactly, and `workflow_run.repository` must match the current repo. - -**`Could not determine Workers deployment URL`.** The workflow reads `deployment-url` from `cloudflare/wrangler-action`, then falls back to parsing Wrangler stdout/stderr for a `workers.dev` URL. Upgrade **`wranglerVersion`** in the workflow if Wrangler output format changed. - -**Preview upload fails (PR builds).** Requires Wrangler **≥ 4.21.0** for `--preview-alias`. The deploy workflow pins **4.110.0**. - -**Artifact download 404.** **Build Site** must upload artifact **`site`**; **Deploy Site** needs `actions: read`. - -**No PR comment.** Same as before: ambiguous `head` when resolving the PR number; see the design spec. - -**Secrets missing on the deployed Worker / `missing_turnstile_keys` after deploy.** `cloudflare/wrangler-action` runs `wrangler secret bulk` using the **`name` in `wrangler.toml`**, not the `--name` flag on `wrangler deploy`. The deploy workflow patches `name` to match **`CLOUDFLARE_PROJECT_NAME`** before Wrangler runs so secrets and deploy target the same Worker. If you deploy manually with only `--name=…`, run `wrangler secret bulk` (or `wrangler secret put`) with the same **`--name`**. - -**Dashboard:** Worker **Variables and Secrets** lists plain **vars** and encrypted **secrets**. Secret values are never shown after save; only names appear. diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index cc7d821bbc..0000000000 --- a/eslint.config.js +++ /dev/null @@ -1,92 +0,0 @@ -import { defineConfig } from "eslint/config"; -import js from "@eslint/js"; -import ts from "typescript-eslint"; -import svelte from "eslint-plugin-svelte"; -import globals from "globals"; -import adminSvelteConfig from "./web/admin/svelte.config.js"; - -export default defineConfig([ - // Global ignores must be first entry - { - ignores: [ - "dist/", - "node_modules/", - "cloudflare_site/", - "internal/", - "hack/", - "docs/", - "web/public/", - ], - }, - - js.configs.recommended, - ...ts.configs.recommended, - svelte.configs.recommended, - svelte.configs.prettier, - - { - files: ["web/admin/src/**/*.{ts,js,svelte}"], - languageOptions: { - globals: { - ...globals.browser, - }, - }, - }, - - // Svelte file overrides: TypeScript parser with per-app svelte config - { - files: ["web/admin/**/*.svelte", "web/admin/**/*.svelte.ts", "web/admin/**/*.svelte.js"], - languageOptions: { - parserOptions: { - parser: ts.parser, - svelteConfig: adminSvelteConfig, - }, - }, - }, - // Custom rules for all linted files - { - rules: { - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - "no-console": ["warn", { allow: ["warn", "error", "info"] }], - }, - }, - - // Svelte-specific rules - { - files: ["**/*.svelte"], - rules: { - "svelte/no-at-html-tags": "error", - "svelte/require-each-key": "error", - "svelte/no-unused-class-name": "warn", - "svelte/no-inline-styles": ["warn", { allowTransitions: true }], - "svelte/block-lang": [ - "error", - { script: ["ts"], style: ["css", null] }, - ], - "svelte/max-lines-per-block": [ - "warn", - { - script: 100, - template: 80, - style: 120, - }, - ], - }, - }, - - // Svelte component file-length limit - { - files: ["web/admin/src/**/*.svelte"], - rules: { - "max-lines": ["warn", { max: 150, skipBlankLines: true, skipComments: true }], - }, - }, - -]); diff --git a/package-lock.json b/package-lock.json index 95beccf5b6..e1476ae39d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,38 +5,12 @@ "packages": { "": { "name": "fullsend-web", - "dependencies": { - "@octokit/rest": "^21.1.1", - "github-slugger": "^2.0.0", - "gray-matter": "^4.0.3", - "mdast-util-to-string": "^4.0.0", - "rehype-sanitize": "^6.0.0", - "rehype-slug": "^6.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "svelte-spa-router": "^4.0.1", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", - "yaml": "^2.4.2" - }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.18.0", "@cloudflare/workers-types": "^5.20260708.1", - "@eslint/js": "^10.0.1", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tsconfig/svelte": "^5.0.0", - "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", "@types/node": "^22.0.0", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", - "concurrently": "^9.1.2", - "cross-env": "^7.0.3", - "eslint": "^10.3.0", - "eslint-plugin-svelte": "^3.17.1", - "globals": "^17.6.0", "jsdom": "^25.0.0", "lint-staged": "^16.4.0", "mark.js": "8.11.1", @@ -44,15 +18,10 @@ "minisearch": "^7.1.1", "postcss-html": "^1.8.1", "prettier": "^3.8.3", - "prettier-plugin-svelte": "~3.5.1", "stylelint": "^17.10.0", "stylelint-config-html": "^1.1.0", "stylelint-config-standard": "^40.0.0", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "toml-eslint-parser": "^1.0.3", "typescript": "^5.6.0", - "typescript-eslint": "^8.59.1", "vite": "^6.0.0", "vitepress": "1.6.4", "vitest": "^4.1.4", @@ -1336,200 +1305,6 @@ "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@iconify-json/simple-icons": { "version": "1.2.93", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.93.tgz", @@ -2049,50 +1824,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2176,190 +1907,6 @@ "node": ">= 8" } }, - "node_modules/@octokit/auth-token": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", - "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.2.2", - "@octokit/request": "^9.2.3", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "before-after-hook": "^3.0.2", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/endpoint": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", - "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", - "license": "MIT", - "dependencies": { - "@octokit/request": "^9.2.3", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", - "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.10.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", - "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", - "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.10.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", - "license": "MIT" - }, - "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/@octokit/request": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", - "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^10.1.4", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "fast-content-type-parse": "^2.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^14.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/rest": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", - "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", - "license": "MIT", - "dependencies": { - "@octokit/core": "^6.1.4", - "@octokit/plugin-paginate-rest": "^11.4.2", - "@octokit/plugin-request-log": "^5.3.1", - "@octokit/plugin-rest-endpoint-methods": "^13.3.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^25.1.0" - } - }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -2879,73 +2426,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/load-config": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", - "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@tsconfig/svelte": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", - "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3241,15 +2721,6 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3257,13 +2728,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3282,18 +2746,12 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3316,6 +2774,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -3328,12 +2787,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -3349,12 +2802,14 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/web-bluetooth": { @@ -3364,240 +2819,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.67.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, "license": "ISC" }, "node_modules/@upsetjs/venn.js": { @@ -4014,29 +3240,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4047,23 +3250,6 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/algoliasearch": { "version": "5.56.0", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", @@ -4135,25 +3321,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4181,42 +3348,6 @@ "dev": true, "license": "MIT" }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "license": "Apache-2.0" - }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -4234,19 +3365,6 @@ "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -4312,6 +3430,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4328,50 +3447,11 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4382,28 +3462,13 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -4444,108 +3509,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "node": ">=7.0.0" } }, "node_modules/color-name": { @@ -4586,6 +3560,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4602,31 +3577,6 @@ "node": ">=20" } }, - "node_modules/concurrently": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", - "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.9.0", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4731,40 +3681,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/css-functions-list": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", @@ -5406,6 +4322,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5426,36 +4343,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -5480,6 +4367,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5495,17 +4383,11 @@ "node": ">=8" } }, - "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, - "license": "MIT" - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -5791,426 +4673,115 @@ "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12.0.0" } }, - "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "node": ">=8.6.0" } }, - "node_modules/eslint-plugin-svelte": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.0.tgz", - "integrity": "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@eslint-community/eslint-utils": "^4.6.1", - "@jridgewell/sourcemap-codec": "^1.5.0", - "esutils": "^2.0.3", - "globals": "^16.0.0", - "known-css-properties": "^0.37.0", - "postcss": "^8.4.49", - "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^7.0.0", - "semver": "^7.6.3", - "svelte-eslint-parser": "^1.7.0" + "is-glob": "^4.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } + "node": ">= 6" } }, - "node_modules/eslint-plugin-svelte/node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4.9.1" } }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "reusify": "^1.0.4" } }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrap": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", - "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-content-type-parse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" + "node": ">=12.0.0" }, "peerDependencies": { "picomatch": "^3 || ^4" @@ -6221,19 +4792,6 @@ } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6247,37 +4805,6 @@ "node": ">=8" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -6337,16 +4864,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -6399,25 +4916,6 @@ "node": ">= 0.4" } }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -6459,19 +4957,6 @@ "which": "bin/which" } }, - "node_modules/globals": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", - "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globby": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.1.tgz", @@ -6523,21 +5008,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -6545,16 +5015,6 @@ "dev": true, "license": "MIT" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6610,38 +5070,11 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-heading-rank": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", - "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-sanitize": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", - "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "unist-util-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -6661,23 +5094,11 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -6731,6 +5152,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -6811,16 +5233,6 @@ "node": ">=0.10.0" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6849,16 +5261,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -6883,15 +5285,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6954,18 +5347,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -6973,16 +5354,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -7010,19 +5381,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -7064,13 +5422,6 @@ } } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -7078,20 +5429,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -7119,16 +5456,6 @@ "node": ">= 12" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", @@ -7139,6 +5466,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7154,13 +5482,6 @@ "node": ">=6" } }, - "node_modules/known-css-properties": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", - "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", - "dev": true, - "license": "MIT" - }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -7168,30 +5489,6 @@ "dev": true, "license": "MIT" }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7241,29 +5538,6 @@ "node": ">=20.0.0" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -7328,16 +5602,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -7362,16 +5626,6 @@ "dev": true, "license": "MIT" }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/marked": { "version": "16.4.2", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", @@ -7406,688 +5660,93 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, "license": "MIT", "dependencies": { + "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", + "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "engines": { + "node": ">=20" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/meow": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", - "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/mermaid": { - "version": "11.16.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", - "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@braintree/sanitize-url": "^7.1.2", - "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.2.0", - "@types/d3": "^7.4.3", - "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.3", - "cytoscape-cose-bilkent": "^4.1.0", - "cytoscape-fcose": "^2.2.0", - "d3": "^7.9.0", - "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.20", - "dompurify": "^3.3.3", - "es-toolkit": "^1.45.1", - "katex": "^0.16.45", - "khroma": "^2.1.0", - "marked": "^16.3.0", - "roughjs": "^4.6.6", - "stylis": "^4.3.6", - "ts-dedent": "^2.2.0", - "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" + "engines": { + "node": ">= 8" } }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/mermaid": { + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", + "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", + "dev": true, "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8100,16 +5759,15 @@ ], "license": "MIT", "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-util-encode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8122,64 +5780,11 @@ ], "license": "MIT" }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, "node_modules/micromark-util-sanitize-uri": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8197,32 +5802,11 @@ "micromark-util-symbol": "^2.0.0" } }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, "node_modules/micromark-util-symbol": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8239,6 +5823,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8335,22 +5920,6 @@ "node": ">=22.0.0" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minisearch": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", @@ -8365,20 +5934,11 @@ "dev": true, "license": "MIT" }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8400,13 +5960,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8466,56 +6019,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/package-manager-detector": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", @@ -8575,26 +6078,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -8716,77 +6199,10 @@ "postcss": "^8.3.3" } }, - "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss-safe-parser": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-scss": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -8795,7 +6211,7 @@ }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-scss" + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" }, { "type": "github", @@ -8804,10 +6220,10 @@ ], "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=18.0" }, "peerDependencies": { - "postcss": "^8.4.29" + "postcss": "^8.4.31" } }, "node_modules/postcss-selector-parser": { @@ -8850,16 +6266,6 @@ } } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prettier": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", @@ -8876,21 +6282,11 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-plugin-svelte": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", - "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" - } - }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -8948,20 +6344,6 @@ ], "license": "MIT" }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -8989,137 +6371,6 @@ "dev": true, "license": "MIT" }, - "node_modules/regexparam": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", - "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/rehype-sanitize": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", - "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-sanitize": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", - "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "github-slugger": "^2.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-stringify": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", - "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-html": "^9.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9278,29 +6529,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9329,19 +6557,6 @@ "license": "MIT", "peer": true }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -9400,42 +6615,6 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/shiki": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", @@ -9530,6 +6709,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9546,12 +6726,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9597,6 +6771,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", @@ -9623,15 +6798,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/stylelint": { "version": "17.14.1", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", @@ -9872,247 +7038,76 @@ } }, "node_modules/stylelint/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/stylis": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", - "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/supports-hyperlinks": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", - "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^5.0.1", - "supports-color": "^10.2.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", - "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/svelte": { - "version": "5.56.9", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", - "integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.12", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", - "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "^0.2.3", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.0.0 || ^6.0.0" + "node": ">= 4" } }, - "node_modules/svelte-check/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } + "license": "MIT" }, - "node_modules/svelte-eslint-parser": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", - "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==", + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", "dev": true, "license": "MIT", "dependencies": { - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.0.0", - "postcss": "^8.4.49", - "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0", - "semver": "^7.7.2" + "copy-anything": "^4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.34.1" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } + "node": ">=16" } }, - "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/supports-hyperlinks": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", + "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/svelte-eslint-parser/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/svelte-spa-router": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.2.tgz", - "integrity": "sha512-T1WYYk+ymwCr5m5U+n91k4dRAT6cw5HgmoPaI/TpKgAmuugymFoSBlfzkcKIK83QH4H8gUMn4tdQ0B9enFBM6g==", "license": "MIT", - "dependencies": { - "regexparam": "2.0.2" + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ItalyPaleAle" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/svg-tags": { @@ -10319,22 +7314,6 @@ "node": ">=8.0" } }, - "node_modules/toml-eslint-parser": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-1.0.3.tgz", - "integrity": "sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - } - }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -10361,49 +7340,17 @@ "node": ">=18" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/ts-dedent": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", @@ -10419,20 +7366,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } + "license": "0BSD", + "optional": true }, "node_modules/typescript": { "version": "5.9.3", @@ -10448,30 +7383,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -10512,29 +7423,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -10548,6 +7441,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -10561,6 +7455,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -10574,6 +7469,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10589,6 +7485,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10599,22 +7496,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universal-user-agent": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", - "license": "ISC" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10640,6 +7521,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10654,6 +7536,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -11223,26 +8106,6 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, "node_modules/vitepress": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -11948,22 +8811,6 @@ "node": ">=18" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -11981,16 +8828,6 @@ "node": ">=8" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/workerd": { "version": "1.20260721.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", @@ -12156,20 +8993,11 @@ "dev": true, "license": "MIT" }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -12181,96 +9009,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -12296,13 +9034,6 @@ "error-stack-parser-es": "^1.0.5" } }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -12317,6 +9048,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 1794abb152..04e43c255f 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,12 @@ "node": ">=22" }, "scripts": { - "dev": "concurrently -k -n worker,vite -c blue,green \"npm run dev:worker\" \"vite\"", - "dev:worker": "cross-env CLOUDFLARE_INCLUDE_PROCESS_ENV=true wrangler dev --config cloudflare_site/wrangler.toml --port 8787 --ip 127.0.0.1", - "dev:debug": "concurrently -k -n worker,vite -c blue,green \"cross-env CLOUDFLARE_INCLUDE_PROCESS_ENV=true wrangler dev --config cloudflare_site/wrangler.toml --port 8787 --ip 127.0.0.1 --log-level debug --var DEBUG_LOG:1\" \"cross-env ADMIN_DEBUG_PROXY=1 vite -d --clearScreen false\"", - "dev:vite": "vite", - "build": "vite build", - "preview": "vite preview", + "dev:worker": "wrangler dev --config cloudflare_site/wrangler.toml --port 8787 --ip 127.0.0.1", "test": "vitest run --config vite.config.ts && vitest run --config cloudflare_site/worker/vitest.config.mts", - "check": "svelte-check --tsconfig web/admin/tsconfig.json", - "lint": "eslint web/admin/src/", - "lint:fix": "eslint web/admin/src/ --fix", - "format": "prettier --write 'web/admin/src/**/*.{svelte,ts,js,css}' 'docs/.vitepress/**/*.{vue,ts,js,css}'", - "format:check": "prettier --check 'web/admin/src/**/*.{svelte,ts,js,css}' 'docs/.vitepress/**/*.{vue,ts,js,css}'", - "stylelint": "stylelint 'web/admin/src/**/*.{svelte,css}' 'docs/.vitepress/**/*.{vue,css}'", - "stylelint:fix": "stylelint 'web/admin/src/**/*.{svelte,css}' 'docs/.vitepress/**/*.{vue,css}' --fix", + "format": "prettier --write 'docs/.vitepress/**/*.{vue,ts,js,css}'", + "format:check": "prettier --check 'docs/.vitepress/**/*.{vue,ts,js,css}'", + "stylelint": "stylelint 'docs/.vitepress/**/*.{vue,css}'", + "stylelint:fix": "stylelint 'docs/.vitepress/**/*.{vue,css}' --fix", "docs:dev": "git submodule update --init && vitepress dev docs", "docs:build": "git submodule update --init && vitepress build docs", "docs:preview": "vitepress preview docs" @@ -27,30 +19,15 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.18.0", "@cloudflare/workers-types": "^5.20260708.1", - "@eslint/js": "^10.0.1", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tsconfig/svelte": "^5.0.0", - "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", "@types/node": "^22.0.0", - "concurrently": "^9.1.2", - "cross-env": "^7.0.3", - "eslint": "^10.3.0", - "eslint-plugin-svelte": "^3.17.1", - "globals": "^17.6.0", "jsdom": "^25.0.0", "lint-staged": "^16.4.0", "postcss-html": "^1.8.1", "prettier": "^3.8.3", - "prettier-plugin-svelte": "~3.5.1", "stylelint": "^17.10.0", "stylelint-config-html": "^1.1.0", "stylelint-config-standard": "^40.0.0", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "toml-eslint-parser": "^1.0.3", "typescript": "^5.6.0", - "typescript-eslint": "^8.59.1", "vite": "^6.0.0", "vitest": "^4.1.4", "wrangler": "^4.110.0", @@ -63,19 +40,6 @@ "vue": "^3.5.14" }, "lint-staged": { - "web/admin/src/**/*.{ts,js}": [ - "eslint --fix", - "prettier --write" - ], - "web/admin/src/**/*.svelte": [ - "eslint --fix", - "stylelint --fix", - "prettier --write" - ], - "web/admin/src/**/*.css": [ - "stylelint --fix", - "prettier --write" - ], "docs/.vitepress/**/*.{ts,js}": [ "prettier --write" ], @@ -87,21 +51,5 @@ "stylelint --fix", "prettier --write" ] - }, - "dependencies": { - "@octokit/rest": "^21.1.1", - "github-slugger": "^2.0.0", - "gray-matter": "^4.0.3", - "mdast-util-to-string": "^4.0.0", - "rehype-sanitize": "^6.0.0", - "rehype-slug": "^6.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "svelte-spa-router": "^4.0.1", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", - "yaml": "^2.4.2" } } diff --git a/sample.env.local b/sample.env.local deleted file mode 100644 index 0d58fb48d4..0000000000 --- a/sample.env.local +++ /dev/null @@ -1,52 +0,0 @@ -# Example you can copy to repo-root `.env.local` (gitignored) — not read by npm scripts. -# -# If you use mise: `mise.toml` loads `.env.local` into your shell when that file exists (after -# `mise trust`). Otherwise set GITHUB_APP_* and TURNSTILE_* however you prefer (export, direnv, -# editor, etc.) before `npm run dev`. Vite and Wrangler only see `process.env`. -# -# The admin SPA does not embed `GITHUB_APP_CLIENT_ID` in the bundle; the Worker adds it when -# redirecting to GitHub (`GET /api/oauth/authorize`). Both id and secret must still be in the -# environment for Wrangler during `npm run dev`. -# -# Turnstile keys are **required** for the site Worker admin API (`/api/oauth/*`, `/api/github/user`). -# The Worker returns **503** with `missing_turnstile_keys` if either value is missing or empty. - -# --- GitHub App (user OAuth) ------------------------------------------------- -# Create: GitHub → Settings → Developer settings → GitHub Apps → New. -# Callback URL (exact match; pick localhost OR 127.0.0.1 to match your browser): -# http://localhost:5173/admin/ -# Optional: -# http://127.0.0.1:5173/admin/ -# Homepage URL can be the same as the callback (SPA entry under Vite base `/admin/`). - -GITHUB_APP_CLIENT_ID=your_github_app_client_id_here -GITHUB_APP_CLIENT_SECRET=your_github_app_client_secret_here - -# Optional: GitHub App **slug** (URL segment under github.com/apps/). When set, the Worker -# folds it into OAuth `state` with the Turnstile site key so the admin SPA can build the -# “install app” link after sign-in if `GET /user/installations` does not return a slug. -# Use only letters, digits, and hyphens (1–99 chars), matching the app slug on GitHub; invalid -# values are omitted from `state` so sign-in still works. -# GITHUB_APP_SLUG=your_app_slug_here - -# --- Cloudflare Turnstile (required for Worker; never Vite `define` / VITE_*) --------------- -# Official **dummy** keys for local development (always pass; work on localhost). Replace with -# production keys from the Cloudflare dashboard for real deployments. -# Docs: https://developers.cloudflare.com/turnstile/troubleshooting/testing/ -# - Invisible / “always passes” test site key + “always passes validation” test secret (pair for dev). -TURNSTILE_SITE_KEY=1x00000000000000000000BB -TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA - -# --- GitHub Actions + Cloudflare (production / PR previews) ------------------- -# Deploy Site: variable `FULLSEND_GITHUB_APP_CLIENT_ID`, secret `FULLSEND_GITHUB_APP_CLIENT_SECRET`, -# variable `FULLSEND_TURNSTILE_SITE_KEY`, secret `FULLSEND_TURNSTILE_SECRET_KEY` (all required — -# deploy fails if unset). Build Site does not need the GitHub client id. Register the production -# admin callback on the GitHub App, e.g. `https:///admin/` - -# --- Debugging (no vars required here) --------------------------------------- -# Verbose dev logs + proxy + Worker request lines (from repo root): -# npm run dev:debug -# Manual equivalents: -# Wrangler: npm run dev:worker -- --log-level debug --var DEBUG_LOG:1 -# Vite: ADMIN_DEBUG_PROXY=1 npx vite -d -# Extra Vite noise: DEBUG=vite:* npx vite diff --git a/vite.config.ts b/vite.config.ts index 48ab7540cc..070e67447a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,118 +1,21 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { ProxyOptions } from "vite"; -import { normalizePath } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vitest/config"; -import type { Plugin } from "vite"; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); -const webRoot = path.join(repoRoot, "web"); -const debugProxy = process.env.ADMIN_DEBUG_PROXY === "1"; - -function spaFallbackPlugin(): Plugin { - return { - name: "fullsend-spa-fallback", - configureServer(server) { - server.middlewares.use((req, _res, next) => { - const url = req.url?.split("?")[0] ?? ""; - if (url === "/") { - req.url = "/index.html"; - } else if (url.startsWith("/admin/") && !path.extname(url)) { - req.url = "/admin/index.html"; - } - next(); - }); - }, - }; -} - -function adminDevEnvLogPlugin(): Plugin { - return { - name: "admin-dev-env-log", - configResolved(config) { - if (config.command !== "serve" || process.env.VITEST) return; - if (debugProxy) { - console.info( - "\n[fullsend] ADMIN_DEBUG_PROXY=1 — logging Vite requests and /api → Worker proxy traffic.\n", - ); - } - }, - }; -} - -function adminRequestLogPlugin(): Plugin { - return { - name: "admin-request-log", - configureServer(server) { - if (!debugProxy) return; - server.middlewares.use((req, _res, next) => { - console.info("[vite] request", req.method, req.url); - next(); - }); - }, - }; -} - -function apiProxy(): ProxyOptions { - const base: ProxyOptions = { - target: "http://127.0.0.1:8787", - changeOrigin: true, - }; - if (!debugProxy) return base; - return { - ...base, - configure(proxy) { - proxy.on("error", (err, req) => { - console.error("[vite-proxy] error", req?.url, err.message); - }); - proxy.on("proxyReq", (_proxyReq, req) => { - console.info("[vite-proxy] → Worker", req.method, req.url); - }); - proxy.on("proxyRes", (proxyRes, req) => { - console.info("[vite-proxy] ← Worker", proxyRes.statusCode, req.url); - }); - }, - }; -} - -export default defineConfig(({ command }) => ({ - root: webRoot, - base: "/", - publicDir: command === "serve" ? path.join(webRoot, "public") : false, - plugins: [ - svelte({ - configFile: path.join(webRoot, "admin/svelte.config.js"), - include: [ - normalizePath(path.join(webRoot, "admin/**/*.svelte")), - normalizePath( - path.join(repoRoot, "node_modules/svelte-spa-router/**/*.svelte"), - ), - ], - }), - spaFallbackPlugin(), - adminDevEnvLogPlugin(), - adminRequestLogPlugin(), - ], - build: { - rollupOptions: { - input: { - admin: path.join(webRoot, "admin/index.html"), - }, - }, - }, - server: { - proxy: { - "/api": apiProxy(), - }, - }, +/** + * Vitest-only config for browser-side unit tests. + * + * The admin SPA under `web/admin/` was removed, so there is no Vite build here + * any more — the documentation site is built by VitePress (`npm run docs:build`) + * and the Worker is configured in `cloudflare_site/wrangler.toml`. + */ +export default defineConfig({ + root: repoRoot, test: { environment: "jsdom", - include: [ - "admin/src/**/*.test.ts", - "../docs/.vitepress/**/*.test.ts", - ], + include: ["docs/.vitepress/**/*.test.ts"], passWithNoTests: true, }, -})); +}); diff --git a/web/README.md b/web/README.md index edee6f5ab1..062f913779 100644 --- a/web/README.md +++ b/web/README.md @@ -1,3 +1,12 @@ # Web -Browser-delivered assets for the public site: static files under `public/` today (document graph as `index.html`), plus the **admin installation SPA** under [`admin/`](admin/) (Svelte + Vite; **`vite.config.ts`** at the repository root). Local admin OAuth expects **`GITHUB_APP_*` in the environment** (see [`admin/README.md`](admin/README.md)); **mise** can load repo-root **`.env.local`** for you if you use it. Build output is `web/admin/dist/` and CI stages `_bundle/public/admin/`. **`package.json` / `npm run dev` stay at the repository root** (see [ADR 0019](../docs/ADRs/0019-web-source-and-cloudflare-site-layout.md)). Wrangler configuration and the Worker live only under [`../cloudflare_site/`](../cloudflare_site/). +Browser-delivered static assets for the public site: the landing page and the interactive +document graph under [`public/`](public/) (`index.html` and `graph.html`). + +The **admin installation SPA** that used to live under `web/admin/` has been removed — see +[ADR 0019](../docs/ADRs/0019-web-source-and-cloudflare-site-layout.md) for the removal note. +There is no Vite build in this directory any more. + +The documentation site is built separately by VitePress from [`../docs/`](../docs/) +(`npm run docs:build`). Wrangler configuration and the site Worker live only under +[`../cloudflare_site/`](../cloudflare_site/); that Worker is now a static-asset passthrough. diff --git a/web/admin/README.md b/web/admin/README.md deleted file mode 100644 index 5b90e88fc7..0000000000 --- a/web/admin/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Fullsend admin (Svelte SPA) - -This directory holds the **admin installation UI**: a **Svelte 5 + Vite** single-page app served under the `/admin/` base path. Local **GitHub OAuth** token exchange runs in the repository’s **Cloudflare Worker** under [`../../cloudflare_site/worker/`](../../cloudflare_site/worker/); the browser never sends `client_secret` to GitHub directly. **Vite** is configured at the repository root (`vite.config.ts`). Local **`GITHUB_APP_*` values must be present in the environment** of the processes that run Vite and Wrangler (see **Environment** below). - -**Hardening (CORS + config model):** for review and security context, see [`docs/admin-oauth-worker.md`](../../docs/admin-oauth-worker.md) — **`GET /api/github/user`** when `Origin` is missing (Sec-Fetch-Site + Referer, path-limited), and **why there is no `ADMIN_OAUTH_ENABLED` flag** (Turnstile + 503 `missing_turnstile_keys`, etc.). - -Layout follows [ADR 0019](../../docs/ADRs/0019-web-source-and-cloudflare-site-layout.md) (`web/` + root `package.json` + `cloudflare_site/`). - -## Tooling with mise - -The repository root includes `mise.toml`, which pins **Node 22** and **Go** (for `make lint` / Go tests). **Optionally**, mise also loads repo-root **`.env.local`** into your shell when that file exists (so `GITHUB_APP_*` are available to `npm run dev` without extra steps). This is **mise-only**; other setups are fine. - -1. Install [mise](https://mise.jdx.dev/) if you do not already use it. -2. From the **repository root**: `mise trust` (required once per clone so mise will read `mise.toml`). -3. `cd` into the repo; run `mise install` if needed; `node`, `npm`, and `go` should come from mise. - -You can use any Node 22 + npm install without mise; put credentials in the environment your own way. - -## GitHub App (OAuth) - -Create a **GitHub App** (“**Fullsend Admin**”) used for **user** sign-in to the admin UI (not the same as per-org Fullsend agent apps from deployment): - -1. GitHub → **Settings** → **Developer settings** → **GitHub Apps** → **New GitHub App** (or your org’s equivalent). -2. **Homepage URL:** e.g. `http://localhost:5173` (or match your dev origin). -3. **Callback URL** must match what the SPA uses **exactly** (same host and path). With the default Vite dev server, register **one** of: - - `http://localhost:5173/admin/` - - `http://127.0.0.1:5173/admin/` - Use the same host in the browser when testing (`localhost` vs `127.0.0.1` are different origins). -4. Webhooks can stay inactive for local dev. -5. After creation, note the **Client ID** and generate a **client secret** once; treat the secret like a password. - -## Environment - -**`GITHUB_APP_CLIENT_ID`**, **`GITHUB_APP_CLIENT_SECRET`**, **`TURNSTILE_SITE_KEY`**, and **`TURNSTILE_SECRET_KEY`** must be available to Wrangler when you run **`npm run dev`** (same shell / `.env.local` as **`GITHUB_APP_*`** if you use mise). The site Worker **refuses** admin `/api/*` traffic with **503** and JSON **`missing_turnstile_keys`** if either Turnstile value is missing or empty — there is no silent “Turnstile off” mode. Root **`vite.config.ts`** and Wrangler (**`CLOUDFLARE_INCLUDE_PROCESS_ENV=true`**) read the environment only; they do not open env files themselves. For Wrangler-only secrets in local dev, use **`cloudflare_site/.dev.vars`** (see [Wrangler secrets](https://developers.cloudflare.com/workers/configuration/secrets/)); keep it gitignored alongside **`.env.local`**. - -**mise users:** with repo-root **`.env.local`** present, mise injects those values into the shell (see **`mise.toml`**). **Everyone else:** use `export`, direnv, your editor, CI secrets, etc. - -The SPA **does not** embed the GitHub App OAuth **client id** at build time. Sign-in navigates to **`GET /api/oauth/authorize`** on the site Worker, which **redirects** to `https://github.com/login/oauth/authorize` with `client_id` from Worker configuration (local env / Wrangler deploy only). - -**Origin (primary; `Referer` is not identity):** the Worker uses the **`Origin` header** for CORS on most `/api/*` routes and **requires** `Origin` for **`POST /api/oauth/token`** tab-binding to the `redirect_uri` origin. Full-page navigations to **`GET /api/oauth/authorize`** often omit `Origin`; in that case the Worker allows the request when `redirect_uri` is on the same public origin as the Worker, or when both the Worker URL and `redirect_uri` are **loopback HTTP** (typical Vite + Wrangler dev with different ports). The **narrow exception** for missing `Origin` — **`Sec-Fetch-Site` + `Referer` only for `GET`/`OPTIONS` `/api/github/user`** — is documented in [`docs/admin-oauth-worker.md`](../../docs/admin-oauth-worker.md); it does **not** apply to token exchange or authorize binding. - -**Turnstile (required):** the Worker always folds the site key into GitHub’s `state` at authorize time; the SPA decodes it after redirect and runs an invisible Turnstile challenge before **`POST /api/oauth/token`**. Do **not** put Turnstile keys in the Vite build (`define` / `VITE_*`). - -**GitHub App slug (optional):** the org list uses **`GET /user/installations`** and prefers the **app slug** from that response for the “install app on GitHub” link. If the API does not include a slug, set optional **`GITHUB_APP_SLUG`** in the Worker environment (same shell as other Worker vars); the Worker adds it to OAuth **`state`** next to the Turnstile site key so the SPA can persist it after sign-in — still **no** `VITE_*` slug in the static bundle. - -The committed **`sample.env.local`** at the repository root lists **official Cloudflare dummy Turnstile keys** for local dev plus the GitHub App checklist; copy it to **`.env.local`** (and align **`.dev.vars`** if you store the secret there only). - -## Global auth errors (401) - -When GitHub returns **401** for a request using the signed-in user’s token, the shell must show **Re-authenticate**, not a local “Retry only” banner. - -- **`createUserOctokit`** (`web/admin/src/lib/github/client.ts`) dispatches **`notifyGitHubUserUnauthorized()`** from its request hook (see `web/admin/src/lib/auth/githubUnauthorized.ts`). -- Any **other** browser call with the user token that can return **401** must call **`notifyGitHubUserUnauthorized()`** as well (or use that Octokit factory) so **`App.svelte`** runs **`signOut({ suggestReauth: true })`** consistently. - -## Production and CI (Cloudflare Workers) - -**Build Site** runs **`npm run build`** with no GitHub App id in CI env (the static bundle stays id-agnostic). - -**Deploy Site** requires repository variable **`FULLSEND_TURNSTILE_SITE_KEY`** and secret **`FULLSEND_TURNSTILE_SECRET_KEY`** alongside the GitHub App variable/secret. Wrangler receives **`TURNSTILE_SITE_KEY`** as a var and **`TURNSTILE_SECRET_KEY`** via `wrangler-action`’s `secrets:` list; **missing values fail the deploy** (by design). - -On the GitHub App, add a **Callback URL** that matches your deployed admin entry, for example `https://..workers.dev/admin/` (use the exact URL your users open; trailing slash must match what the SPA sends as `redirect_uri`). - -## Run the site locally - -From the **repository root**: - -```bash -npm ci -npm run dev -``` - -Then open **`http://localhost:5173/admin/`** (or `127.0.0.1` if that matches your callback URL). You should see the shell; **Sign in with GitHub** runs the OAuth flow against the Worker on port **8787**, proxied by Vite as **`/api/*`**. - -Other useful commands: - -- **`npm run dev:debug`** — noisier Worker + Vite proxy logging. -- **`npm run dev:vite`** — Vite only (no token exchange; sign-in will not work). -- **`npm run build`** / **`npm run preview`** — production build and static preview. -- **`npm run test`** / **`npm run check`** — Vitest and `svelte-check`. diff --git a/web/admin/index.html b/web/admin/index.html deleted file mode 100644 index 49616104b0..0000000000 --- a/web/admin/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Fullsend Admin - - -
- - - diff --git a/web/admin/src/App.svelte b/web/admin/src/App.svelte deleted file mode 100644 index f9ad7a7751..0000000000 --- a/web/admin/src/App.svelte +++ /dev/null @@ -1,469 +0,0 @@ - - -{#if $authBootPending} -
- -

Signing in as:

- {#if $githubUser} -
- {#if $githubUser.avatarUrl} - - {/if} -
- - {#if $githubUser.name} - {$githubUser.name} - {/if} -
-
- {:else} -

Hang on while we verify this session with Cloudflare and GitHub.

- {/if} - -
-{:else if $githubUser} - - - {#if $reauthenticateSuggested} - - {/if} - - {#if oauthErr} - - {/if} - -
- -
-{:else} - {#if oauthErr} - - {/if} - - {#if $reauthenticateSuggested} - - {/if} - - -{/if} - - diff --git a/web/admin/src/app.css b/web/admin/src/app.css deleted file mode 100644 index be0f7dbd0b..0000000000 --- a/web/admin/src/app.css +++ /dev/null @@ -1,8 +0,0 @@ -:root { - font-family: system-ui, sans-serif; - line-height: 1.4; -} - -body { - margin: 0; -} diff --git a/web/admin/src/lib/auth/adminSessionCaches.ts b/web/admin/src/lib/auth/adminSessionCaches.ts deleted file mode 100644 index b5da5c6ec2..0000000000 --- a/web/admin/src/lib/auth/adminSessionCaches.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { clearOrgListMemoryCache } from "../orgs/fetchOrgs"; -import { clearOrgListAnalysisCache } from "../orgs/orgListAnalysisCache"; -import { clearSession } from "./tokenStore"; - -/** - * Clears persisted OAuth/session data and all in-memory org-list caches. - * Use from {@link signOut}; add new user-scoped admin caches here so sign-out - * stays a single contract. - */ -export function clearAllAdminSessionCaches(): void { - clearSession(); - clearOrgListMemoryCache(); - clearOrgListAnalysisCache(); -} diff --git a/web/admin/src/lib/auth/githubUnauthorized.test.ts b/web/admin/src/lib/auth/githubUnauthorized.test.ts deleted file mode 100644 index 386f594206..0000000000 --- a/web/admin/src/lib/auth/githubUnauthorized.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { GITHUB_USER_UNAUTHORIZED_EVENT, notifyGitHubUserUnauthorized } from "./githubUnauthorized"; - -describe("notifyGitHubUserUnauthorized", () => { - it("dispatches the shared event name", () => { - const listener = vi.fn(); - window.addEventListener(GITHUB_USER_UNAUTHORIZED_EVENT, listener); - notifyGitHubUserUnauthorized(); - expect(listener).toHaveBeenCalledOnce(); - window.removeEventListener(GITHUB_USER_UNAUTHORIZED_EVENT, listener); - }); -}); diff --git a/web/admin/src/lib/auth/githubUnauthorized.ts b/web/admin/src/lib/auth/githubUnauthorized.ts deleted file mode 100644 index c1e89b790d..0000000000 --- a/web/admin/src/lib/auth/githubUnauthorized.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * When GitHub returns **401** for a request made with the signed-in user’s token, the admin SPA - * must **not** treat it as an ordinary retryable screen error. The shell clears the session and - * shows the global **Re-authenticate** banner (see UX spec: global banners). - * - * `createUserOctokit` dispatches this event from its request hook. Call - * {@link notifyGitHubUserUnauthorized} from any other user-token GitHub path that can return 401 - * so behaviour stays consistent. - */ -export const GITHUB_USER_UNAUTHORIZED_EVENT = "fullsend:github-unauthorized" as const; - -export function notifyGitHubUserUnauthorized(): void { - window.dispatchEvent(new CustomEvent(GITHUB_USER_UNAUTHORIZED_EVENT)); -} diff --git a/web/admin/src/lib/auth/oauth.test.ts b/web/admin/src/lib/auth/oauth.test.ts deleted file mode 100644 index 4ac5ec6a3e..0000000000 --- a/web/admin/src/lib/auth/oauth.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("./turnstile", () => ({ - obtainTurnstileToken: vi.fn(), -})); - -vi.mock("./session", () => ({ - refreshSession: vi.fn(), -})); - -import { obtainTurnstileToken } from "./turnstile"; -import { refreshSession } from "./session"; -import { challengeS256 } from "./pkce"; -import { - completeGithubOAuthFromHandoff, - consumeIntendedHashAfterGithubOAuth, - consumeOAuthParamsFromDocumentUrl, - getOAuthRedirectUri, - isSafeSimpleHashRoute, - SIGNING_IN_CANCELLED_MESSAGE, - startGithubSignIn, - tryParseWorkerExpandedOauthState, -} from "./oauth"; -import { loadGithubAppSlug, loadToken } from "./tokenStore"; - -const originalWindowLocation = window.location; - -function installLocationStub(stub: Record) { - Reflect.deleteProperty(window, "location"); - Object.defineProperty(window, "location", { - configurable: true, - writable: true, - value: stub as unknown as Location, - }); -} - -function restoreWindowLocation() { - Reflect.deleteProperty(window, "location"); - Object.defineProperty(window, "location", { - configurable: true, - writable: true, - value: originalWindowLocation, - }); -} - -const OAUTH_DOC_HANDOFF_KEY = "fullsend_admin_oauth_doc_handoff"; -const OAUTH_STATE_KEY = "fullsend_admin_oauth_state"; -const PKCE_VERIFIER_KEY = "fullsend_admin_pkce_verifier"; -const INTENDED_HASH_KEY = "fullsend_admin_intended_hash"; - -function workerExpandedStateB64(n: string, k = "0x4AAA_sitekey", g?: string): string { - const payload: { v: number; n: string; k: string; g?: string } = { v: 1, n, k }; - if (g !== undefined) payload.g = g; - const bytes = new TextEncoder().encode(JSON.stringify(payload)); - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -describe("tryParseWorkerExpandedOauthState", () => { - it("returns null for raw UUID state", () => { - expect(tryParseWorkerExpandedOauthState("550e8400-e29b-41d4-a716-446655440000")).toBeNull(); - }); - - it("parses worker-expanded base64url JSON state", () => { - const b64 = workerExpandedStateB64("nonce-value"); - expect(tryParseWorkerExpandedOauthState(b64)).toEqual({ - v: 1, - n: "nonce-value", - k: "0x4AAA_sitekey", - }); - }); - - it("parses optional g when it is a valid app slug", () => { - const b64 = workerExpandedStateB64("n1", "k1", "my-app"); - expect(tryParseWorkerExpandedOauthState(b64)).toEqual({ - v: 1, - n: "n1", - k: "k1", - g: "my-app", - }); - }); - - it("omits g when present but not a valid slug (sign-in still works)", () => { - const b64 = workerExpandedStateB64("n1", "k1", "bad slug"); - expect(tryParseWorkerExpandedOauthState(b64)).toEqual({ - v: 1, - n: "n1", - k: "k1", - }); - }); -}); - -describe("startGithubSignIn", () => { - let randomUUIDSpy: { mockRestore(): void }; - - beforeEach(() => { - sessionStorage.clear(); - randomUUIDSpy = vi - .spyOn(crypto, "randomUUID") - .mockReturnValue("00000000-0000-4000-8000-000000000001"); - const assign = vi.fn(); - installLocationStub({ - origin: "https://oauth-start.test", - search: "", - hash: "#/orgs", - href: "https://oauth-start.test/admin/#/orgs", - assign, - }); - }); - - afterEach(() => { - randomUUIDSpy.mockRestore(); - restoreWindowLocation(); - }); - - it("stores PKCE verifier and state, then assigns authorize URL with S256 challenge", async () => { - const assign = window.location.assign as ReturnType; - - await startGithubSignIn(); - - const verifier = sessionStorage.getItem(PKCE_VERIFIER_KEY); - const state = sessionStorage.getItem(OAUTH_STATE_KEY); - expect(verifier).toBeTruthy(); - expect(state).toBe("00000000-0000-4000-8000-000000000001"); - - expect(assign).toHaveBeenCalledOnce(); - const target = assign.mock.calls[0]![0] as string; - const u = new URL(target); - expect(u.origin).toBe("https://oauth-start.test"); - expect(u.pathname).toBe("/api/oauth/authorize"); - expect(u.searchParams.get("code_challenge_method")).toBe("S256"); - expect(u.searchParams.get("state")).toBe(state); - expect(u.searchParams.get("redirect_uri")).toBe(getOAuthRedirectUri()); - expect(await challengeS256(verifier!)).toBe(u.searchParams.get("code_challenge")); - - expect(sessionStorage.getItem(INTENDED_HASH_KEY)).toBe("#/orgs"); - }); -}); - -describe("consumeOAuthParamsFromDocumentUrl", () => { - beforeEach(() => { - sessionStorage.clear(); - vi.spyOn(history, "replaceState").mockImplementation(() => {}); - }); - - afterEach(() => { - vi.mocked(history.replaceState).mockRestore(); - restoreWindowLocation(); - }); - - it("returns false and does not replaceState when there is no code param", () => { - installLocationStub({ - origin: "https://consume.test", - search: "?foo=1", - href: "https://consume.test/admin/?foo=1", - assign: vi.fn(), - }); - - expect(consumeOAuthParamsFromDocumentUrl()).toBe(false); - expect(sessionStorage.getItem(OAUTH_DOC_HANDOFF_KEY)).toBeNull(); - expect(history.replaceState).not.toHaveBeenCalled(); - }); - - it("stashes code/state, replaces URL to admin hash route, and returns true", () => { - installLocationStub({ - origin: "https://consume.test", - search: "?code=ghcode&state=rawstate", - href: "https://consume.test/admin/?code=ghcode&state=rawstate", - assign: vi.fn(), - }); - - expect(consumeOAuthParamsFromDocumentUrl()).toBe(true); - expect(JSON.parse(sessionStorage.getItem(OAUTH_DOC_HANDOFF_KEY)!)).toEqual({ - code: "ghcode", - state: "rawstate", - }); - const expected = new URL("/admin/", "https://consume.test"); - expected.search = ""; - expected.hash = "#/"; - expect(history.replaceState).toHaveBeenCalledOnce(); - expect(history.replaceState).toHaveBeenCalledWith(null, "", expected.href); - }); - - it("treats present-but-empty code as a handoff (key exists in query)", () => { - installLocationStub({ - origin: "https://consume.test", - search: "?code=", - href: "https://consume.test/admin/?code=", - assign: vi.fn(), - }); - - expect(consumeOAuthParamsFromDocumentUrl()).toBe(true); - expect(JSON.parse(sessionStorage.getItem(OAUTH_DOC_HANDOFF_KEY)!)).toEqual({ - code: "", - state: "", - }); - expect(history.replaceState).toHaveBeenCalledOnce(); - }); -}); - -describe("completeGithubOAuthFromHandoff", () => { - beforeEach(() => { - sessionStorage.clear(); - localStorage.clear(); - vi.mocked(obtainTurnstileToken).mockReset().mockResolvedValue("ts-token"); - vi.mocked(refreshSession).mockReset().mockResolvedValue(undefined); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - access_token: "access-xyz", - token_type: "Bearer", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ), - ); - }); - - it("returns error when document handoff is missing", async () => { - const r = await completeGithubOAuthFromHandoff(); - expect(r).toEqual({ - ok: false, - error: "Missing OAuth handoff — try signing in again.", - }); - expect(obtainTurnstileToken).not.toHaveBeenCalled(); - }); - - it("rejects non-Worker-expanded state and clears OAuth state", async () => { - const nonce = "550e8400-e29b-41d4-a716-446655440000"; - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "gh-code", state: nonce }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, nonce); - sessionStorage.setItem(PKCE_VERIFIER_KEY, "verifier"); - - const r = await completeGithubOAuthFromHandoff(); - - expect(r.ok).toBe(false); - expect(r).toMatchObject({ ok: false }); - if (!r.ok) { - expect(r.error).toContain("Worker-expanded"); - } - expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull(); - expect(obtainTurnstileToken).not.toHaveBeenCalled(); - }); - - it("rejects Worker-expanded nonce mismatch", async () => { - const expanded = workerExpandedStateB64("expected-nonce"); - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "gh-code", state: expanded }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, "other-nonce"); - sessionStorage.setItem(PKCE_VERIFIER_KEY, "verifier"); - - const r = await completeGithubOAuthFromHandoff(); - - expect(r).toEqual({ - ok: false, - error: "OAuth state mismatch — try signing in again.", - }); - expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull(); - expect(obtainTurnstileToken).not.toHaveBeenCalled(); - }); - - it("rejects missing PKCE verifier after valid handoff", async () => { - const nonce = "11111111-1111-1111-1111-111111111111"; - const expanded = workerExpandedStateB64(nonce); - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "gh-code", state: expanded }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, nonce); - - const r = await completeGithubOAuthFromHandoff(); - - expect(r).toEqual({ - ok: false, - error: - "Missing PKCE verifier (session expired or this tab did not start sign-in). Open the app from /admin/ and try again.", - }); - expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull(); - expect(obtainTurnstileToken).not.toHaveBeenCalled(); - }); - - it("persists token and clears OAuth session storage on success", async () => { - const nonce = "22222222-2222-2222-2222-222222222222"; - const expanded = workerExpandedStateB64(nonce, "site-key-1"); - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "exchange-code", state: expanded }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, nonce); - sessionStorage.setItem(PKCE_VERIFIER_KEY, "pkce-verifier-value"); - - const r = await completeGithubOAuthFromHandoff(); - - expect(r).toEqual({ ok: true }); - expect(obtainTurnstileToken).toHaveBeenCalledWith("site-key-1", undefined); - expect(refreshSession).toHaveBeenCalledOnce(); - expect(loadToken()).toEqual({ - accessToken: "access-xyz", - tokenType: "Bearer", - expiresAt: expect.any(Number), - }); - expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull(); - expect(sessionStorage.getItem(PKCE_VERIFIER_KEY)).toBeNull(); - expect(sessionStorage.getItem(OAUTH_DOC_HANDOFF_KEY)).toBeNull(); - - const fetchMock = vi.mocked(fetch); - expect(fetchMock).toHaveBeenCalledOnce(); - const [, init] = fetchMock.mock.calls[0]!; - expect(init?.method).toBe("POST"); - const body = JSON.parse(init?.body as string) as Record; - expect(body).toMatchObject({ - code: "exchange-code", - code_verifier: "pkce-verifier-value", - turnstile_token: "ts-token", - }); - expect(typeof body.redirect_uri).toBe("string"); - }); - - it("persists GitHub App slug from expanded state on success", async () => { - const nonce = "55555555-5555-5555-5555-555555555555"; - const expanded = workerExpandedStateB64(nonce, "site-key-1", "slug-from-state"); - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "exchange-code", state: expanded }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, nonce); - sessionStorage.setItem(PKCE_VERIFIER_KEY, "pkce-verifier-value"); - - const r = await completeGithubOAuthFromHandoff(); - - expect(r).toEqual({ ok: true }); - expect(loadGithubAppSlug()).toBe("slug-from-state"); - }); - - it("persists null expiresAt when token response omits expires_in", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - access_token: "no-expiry", - token_type: "Bearer", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - const nonce = "33333333-3333-3333-3333-333333333333"; - const expanded = workerExpandedStateB64(nonce); - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "code-no-exp", state: expanded }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, nonce); - sessionStorage.setItem(PKCE_VERIFIER_KEY, "verifier"); - - const r = await completeGithubOAuthFromHandoff(); - - expect(r).toEqual({ ok: true }); - expect(loadToken()).toEqual({ - accessToken: "no-expiry", - tokenType: "Bearer", - expiresAt: null, - }); - }); - - it("treats abort while reading token JSON as cancelled and clears OAuth state", async () => { - const nonce = "44444444-4444-4444-4444-444444444444"; - const expanded = workerExpandedStateB64(nonce); - sessionStorage.setItem( - OAUTH_DOC_HANDOFF_KEY, - JSON.stringify({ code: "exchange-code", state: expanded }), - ); - sessionStorage.setItem(OAUTH_STATE_KEY, nonce); - sessionStorage.setItem(PKCE_VERIFIER_KEY, "pkce-verifier-value"); - - const jsonNever = new Promise(() => { - /* hang until aborted */ - }); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => jsonNever, - } as unknown as Response), - ); - - const ac = new AbortController(); - const p = completeGithubOAuthFromHandoff({ signal: ac.signal }); - await Promise.resolve(); - ac.abort(); - - const r = await p; - expect(r).toEqual({ - ok: false, - error: SIGNING_IN_CANCELLED_MESSAGE, - }); - expect(loadToken()).toBeNull(); - expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull(); - }); -}); - -describe("isSafeSimpleHashRoute", () => { - it("accepts #/, #/orgs, and multi-segment paths", () => { - expect(isSafeSimpleHashRoute("#/")).toBe(true); - expect(isSafeSimpleHashRoute("#/orgs")).toBe(true); - expect(isSafeSimpleHashRoute("#/foo/bar_baz.qux-1")).toBe(true); - }); - - it("rejects schemes, queries, slashes, and long fragments", () => { - expect(isSafeSimpleHashRoute("#javascript:evil")).toBe(false); - expect(isSafeSimpleHashRoute("#/orgs?x=1")).toBe(false); - expect(isSafeSimpleHashRoute("#//evil")).toBe(false); - expect(isSafeSimpleHashRoute("#/orgs/../x")).toBe(false); - expect(isSafeSimpleHashRoute("#" + "/a".repeat(200))).toBe(false); - }); -}); - -describe("consumeIntendedHashAfterGithubOAuth", () => { - afterEach(() => { - sessionStorage.removeItem(INTENDED_HASH_KEY); - }); - - it("returns null when stash is missing or unsafe", () => { - expect(consumeIntendedHashAfterGithubOAuth()).toBeNull(); - sessionStorage.setItem(INTENDED_HASH_KEY, "#/orgs?bad=1"); - expect(consumeIntendedHashAfterGithubOAuth()).toBeNull(); - expect(sessionStorage.getItem(INTENDED_HASH_KEY)).toBeNull(); - }); - - it("returns normalized safe hash and clears stash", () => { - sessionStorage.setItem(INTENDED_HASH_KEY, "#/orgs"); - expect(consumeIntendedHashAfterGithubOAuth()).toBe("#/orgs"); - expect(sessionStorage.getItem(INTENDED_HASH_KEY)).toBeNull(); - }); -}); diff --git a/web/admin/src/lib/auth/oauth.ts b/web/admin/src/lib/auth/oauth.ts deleted file mode 100644 index 05a7a3687b..0000000000 --- a/web/admin/src/lib/auth/oauth.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { challengeS256, randomVerifier } from "./pkce"; -import { refreshSession } from "./session"; -import { obtainTurnstileToken } from "./turnstile"; -import { normalizeSlug } from "../orgs/installationOrgRows"; -import { clearSession, persistGithubAppSlugFromOAuth, saveToken } from "./tokenStore"; - -const PKCE_VERIFIER_KEY = "fullsend_admin_pkce_verifier"; -const OAUTH_STATE_KEY = "fullsend_admin_oauth_state"; -const OAUTH_DOC_HANDOFF_KEY = "fullsend_admin_oauth_doc_handoff"; -/** Hash route to restore after successful OAuth (e.g. `#/orgs`). */ -const INTENDED_HASH_KEY = "fullsend_admin_intended_hash"; - -/** Returned when `AbortSignal` aborts during `completeGithubOAuthFromHandoff` (user chose another account). */ -export const SIGNING_IN_CANCELLED_MESSAGE = "Signing in was cancelled." as const; - -/** Clears OAuth-related `sessionStorage` so a cancelled sign-in can restart cleanly. */ -export function clearSigningInBrowserState(): void { - clearOAuthState(); - clearIntendedHashStash(); - try { - sessionStorage.removeItem(PKCE_VERIFIER_KEY); - sessionStorage.removeItem(OAUTH_DOC_HANDOFF_KEY); - } catch { - /* ignore */ - } -} - -const DEFAULT_ADMIN_BASE = "/admin/"; - -/** - * Public URL path where the admin SPA is served (`/admin/`), independent of Vite - * `base` (shared build uses `base: '/'`). Do not use `import.meta.env.BASE` for OAuth - * `redirect_uri` — it would be wrong when `base` is `/`. - */ -function adminAppBasePath(): string { - return DEFAULT_ADMIN_BASE; -} - -/** - * Canonical SPA OAuth callback URL. GitHub redirects here with `?code=&state=`; the app - * strips query via `history.replaceState` after stashing. - */ -export function getOAuthRedirectUri(): string { - return new URL(adminAppBasePath(), window.location.origin).href; -} - -type WorkerExpandedOauthState = { v: 1; n: string; k: string; g?: string }; - -function base64UrlToUtf8(s: string): string { - const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); - const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad; - const bin = atob(b64); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return new TextDecoder().decode(bytes); -} - -/** - * Parses Worker-built OAuth `state` (base64url JSON `{ v, n, k, g? }`). Returns `null` for malformed - * values or payloads that are not Worker-expanded state. Optional `g` is dropped when it fails slug - * validation so a misconfigured Worker cannot block sign-in. - */ -export function tryParseWorkerExpandedOauthState( - stateParam: string, -): WorkerExpandedOauthState | null { - const t = stateParam.trim(); - if (!t || t.length > 4096) return null; - if (!t.startsWith("eyJ")) return null; - try { - const json = base64UrlToUtf8(t); - const o = JSON.parse(json) as unknown; - if (!o || typeof o !== "object") return null; - const r = o as Record; - if (r.v !== 1) return null; - const n = typeof r.n === "string" ? r.n : ""; - const k = typeof r.k === "string" ? r.k : ""; - if (!n || !k) return null; - let g: string | undefined; - if ("g" in r) { - if (typeof r.g !== "string") return null; - const gt = normalizeSlug(r.g); - if (gt) g = gt; - /* Invalid slug: omit g so sign-in still works (install link falls back to API slug). */ - } - return g ? { v: 1, n, k, g } : { v: 1, n, k }; - } catch { - return null; - } -} - -/** Stash current `location.hash` so we can restore after OAuth (companion UX: anonymous deep link). */ -export function stashIntendedHashBeforeGithubOAuth(): void { - const h = window.location.hash?.trim(); - sessionStorage.setItem(INTENDED_HASH_KEY, h && h.length > 0 ? h : "#/"); -} - -/** Remove stashed hash (e.g. after a failed exchange). */ -export function clearIntendedHashStash(): void { - sessionStorage.removeItem(INTENDED_HASH_KEY); -} - -/** Max length for a post-OAuth hash fragment (including `#`). */ -const MAX_INTENDED_HASH_LEN = 256; - -/** - * True when `hash` is a safe, simple in-app route fragment: `#` plus path segments - * using only `/`, letters, digits, `_`, `.`, and `-` (no schemes, queries, or `//`). - */ -export function isSafeSimpleHashRoute(hash: string): boolean { - if (hash.length === 0 || hash.length > MAX_INTENDED_HASH_LEN) return false; - if (!hash.startsWith("#")) return false; - if (hash.includes("//") || hash.includes("..")) return false; - /* # alone, #/, #/orgs, #/foo-bar — no :, ?, &, %, @, \, spaces, etc. */ - return /^#(?:\/[\w.-]+)*\/?$/.test(hash); -} - -/** - * Returns the stashed hash (with `#`) and removes it. Returns `null` if none or if the - * value is not a {@link isSafeSimpleHashRoute} (tampered sessionStorage or legacy junk). - */ -export function consumeIntendedHashAfterGithubOAuth(): string | null { - const raw = sessionStorage.getItem(INTENDED_HASH_KEY); - sessionStorage.removeItem(INTENDED_HASH_KEY); - if (raw == null || raw === "") return null; - const normalized = raw.startsWith("#") ? raw : `#/${raw}`; - return isSafeSimpleHashRoute(normalized) ? normalized : null; -} - -/** - * Starts GitHub OAuth: stores PKCE + state, then navigates to the site Worker - * `/api/oauth/authorize`, which redirects to GitHub with `client_id` (never embedded in the SPA bundle). - */ -export async function startGithubSignIn(): Promise { - stashIntendedHashBeforeGithubOAuth(); - const redirectUri = getOAuthRedirectUri(); - const verifier = randomVerifier(); - const challenge = await challengeS256(verifier); - const state = crypto.randomUUID(); - - sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier); - sessionStorage.setItem(OAUTH_STATE_KEY, state); - - const u = new URL("/api/oauth/authorize", window.location.origin); - u.searchParams.set("redirect_uri", redirectUri); - u.searchParams.set("state", state); - u.searchParams.set("code_challenge", challenge); - u.searchParams.set("code_challenge_method", "S256"); - - window.location.assign(u.toString()); -} - -export function takePkceVerifier(): string | null { - const v = sessionStorage.getItem(PKCE_VERIFIER_KEY); - sessionStorage.removeItem(PKCE_VERIFIER_KEY); - return v; -} - -export function peekOAuthState(): string | null { - return sessionStorage.getItem(OAUTH_STATE_KEY); -} - -export function clearOAuthState(): void { - sessionStorage.removeItem(OAUTH_STATE_KEY); -} - -/** - * If the document URL contains an OAuth `code`, stash `{ code, state }` for one-shot - * consumption and replace the URL with `/admin/#/` (no query, no `code` visible). - */ -export function consumeOAuthParamsFromDocumentUrl(): boolean { - const sp = new URLSearchParams(window.location.search); - if (!sp.has("code")) return false; - - const code = sp.get("code")?.trim() ?? ""; - const state = sp.get("state") ?? ""; - sessionStorage.setItem(OAUTH_DOC_HANDOFF_KEY, JSON.stringify({ code, state })); - - const clean = new URL(adminAppBasePath(), window.location.origin); - clean.search = ""; - clean.hash = "#/"; - history.replaceState(null, "", clean.href); - return true; -} - -type OAuthHandoff = { code: string; state: string }; - -function takeDocHandoff(): OAuthHandoff | null { - const raw = sessionStorage.getItem(OAUTH_DOC_HANDOFF_KEY); - sessionStorage.removeItem(OAUTH_DOC_HANDOFF_KEY); - if (!raw) return null; - try { - const o = JSON.parse(raw) as unknown; - if (!o || typeof o !== "object") return null; - const rec = o as Record; - const code = typeof rec.code === "string" ? rec.code.trim() : ""; - const state = typeof rec.state === "string" ? rec.state : ""; - if (!code) return null; - return { code, state }; - } catch { - return null; - } -} - -export type OAuthCompleteResult = { ok: true } | { ok: false; error: string }; - -export type CompleteGithubOAuthOptions = { - /** When aborted (unmount or “different account”), Turnstile + token exchange are skipped. */ - signal?: AbortSignal; -}; - -function isAbortError(e: unknown): boolean { - return e instanceof DOMException && e.name === "AbortError"; -} - -/** - * Reads JSON from a `fetch` `Response` while honoring `AbortSignal` (unlike `res.json()` alone). - * Malformed JSON yields `{}` unless the read was aborted. - */ -async function readJsonBodyWithSignal( - res: Response, - signal?: AbortSignal, -): Promise> { - if (!signal) { - return (await res.json().catch(() => ({}))) as Record; - } - if (signal.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - - let rejectAbort!: (e: DOMException) => void; - const abortPromise = new Promise((_, reject) => { - rejectAbort = reject; - }); - const onAbort = () => rejectAbort(new DOMException("Aborted", "AbortError")); - signal.addEventListener("abort", onAbort, { once: true }); - - try { - const raw = await Promise.race([res.json().catch(() => ({})), abortPromise]); - if (signal.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - return raw as Record; - } finally { - signal.removeEventListener("abort", onAbort); - } -} - -/** - * Completes GitHub OAuth using a one-shot document handoff (see - * `consumeOAuthParamsFromDocumentUrl`). Call only after URL cleanup. - */ -export async function completeGithubOAuthFromHandoff( - options?: CompleteGithubOAuthOptions, -): Promise { - const signal = options?.signal; - const aborted = () => Boolean(signal?.aborted); - - const handoff = takeDocHandoff(); - if (!handoff) { - return { ok: false, error: "Missing OAuth handoff — try signing in again." }; - } - - const { code, state } = handoff; - const expected = peekOAuthState(); - const expanded = tryParseWorkerExpandedOauthState(state); - if (!expanded) { - clearOAuthState(); - return { - ok: false, - error: - "OAuth callback state is not Worker-expanded (Turnstile). Ensure the site Worker has TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY set and sign in again.", - }; - } - if (!expected || expanded.n !== expected) { - clearOAuthState(); - return { ok: false, error: "OAuth state mismatch — try signing in again." }; - } - - const verifier = takePkceVerifier(); - if (!verifier) { - clearOAuthState(); - return { - ok: false, - error: - "Missing PKCE verifier (session expired or this tab did not start sign-in). Open the app from /admin/ and try again.", - }; - } - - const redirect_uri = getOAuthRedirectUri(); - - let turnstile_token: string; - try { - turnstile_token = await obtainTurnstileToken(expanded.k, signal); - } catch (e) { - clearOAuthState(); - if (isAbortError(e)) { - return { ok: false, error: SIGNING_IN_CANCELLED_MESSAGE }; - } - return { - ok: false, - error: - e instanceof Error ? e.message : "Turnstile verification failed — try signing in again.", - }; - } - - if (aborted()) { - clearOAuthState(); - return { ok: false, error: SIGNING_IN_CANCELLED_MESSAGE }; - } - - let res: Response; - try { - res = await fetch("/api/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - redirect_uri, - code_verifier: verifier, - turnstile_token, - }), - signal, - }); - } catch (e) { - clearOAuthState(); - if (isAbortError(e)) { - return { ok: false, error: SIGNING_IN_CANCELLED_MESSAGE }; - } - return { - ok: false, - error: - e instanceof Error - ? e.message - : "Network error calling token exchange (is `npm run dev` running?)", - }; - } - - let body: Record; - try { - body = await readJsonBodyWithSignal(res, signal); - } catch (e) { - clearOAuthState(); - if (isAbortError(e)) { - return { ok: false, error: SIGNING_IN_CANCELLED_MESSAGE }; - } - return { - ok: false, - error: e instanceof Error ? e.message : "Failed to read token exchange response.", - }; - } - - if (!res.ok) { - const desc = - typeof body.error_description === "string" - ? body.error_description - : typeof body.error === "string" - ? body.error - : "token_exchange_failed"; - clearOAuthState(); - return { ok: false, error: `GitHub token exchange failed: ${desc}` }; - } - - const access_token = typeof body.access_token === "string" ? body.access_token : ""; - if (!access_token) { - clearOAuthState(); - return { ok: false, error: "Token response missing access_token." }; - } - - const token_type = typeof body.token_type === "string" ? body.token_type : "bearer"; - const expires_in = typeof body.expires_in === "number" ? body.expires_in : null; - const expiresAt = expires_in != null ? Date.now() + expires_in * 1000 : null; - - saveToken({ - accessToken: access_token, - tokenType: token_type, - expiresAt, - }); - persistGithubAppSlugFromOAuth(expanded.g); - clearOAuthState(); - - if (aborted()) { - clearSession(); - return { ok: false, error: SIGNING_IN_CANCELLED_MESSAGE }; - } - - await refreshSession(); - return { ok: true }; -} diff --git a/web/admin/src/lib/auth/pkce.test.ts b/web/admin/src/lib/auth/pkce.test.ts deleted file mode 100644 index 44a7d64c9c..0000000000 --- a/web/admin/src/lib/auth/pkce.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @vitest-environment node -import { describe, expect, it } from "vitest"; -import { challengeS256, randomVerifier } from "./pkce"; - -describe("pkce", () => { - it("randomVerifier length and charset", () => { - const v = randomVerifier(); - expect(v.length).toBeGreaterThanOrEqual(43); - expect(v).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("challengeS256 is stable for a fixed verifier", async () => { - const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; - const c = await challengeS256(verifier); - expect(c).toMatch(/^[A-Za-z0-9_-]+$/); - expect(c).toBe(await challengeS256(verifier)); - }); - - it("challengeS256 matches RFC 7636 Appendix B test vector", async () => { - const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; - const challenge = await challengeS256(verifier); - expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); - }); -}); diff --git a/web/admin/src/lib/auth/pkce.ts b/web/admin/src/lib/auth/pkce.ts deleted file mode 100644 index c64faef5ea..0000000000 --- a/web/admin/src/lib/auth/pkce.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** URL-safe base64 without padding (RFC 7636). */ -function base64UrlEncode(bytes: Uint8Array): string { - let bin = ""; - for (const b of bytes) { - bin += String.fromCharCode(b); - } - return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -/** 32 random bytes → ~43 char verifier (within RFC 7636 length). */ -export function randomVerifier(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes); -} - -export async function challengeS256(verifier: string): Promise { - const data = new TextEncoder().encode(verifier); - const digest = await crypto.subtle.digest("SHA-256", data); - return base64UrlEncode(new Uint8Array(digest)); -} diff --git a/web/admin/src/lib/auth/previewHandoff.test.ts b/web/admin/src/lib/auth/previewHandoff.test.ts deleted file mode 100644 index 53c6434122..0000000000 --- a/web/admin/src/lib/auth/previewHandoff.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { assertAllowedReturnTo } from "./previewHandoff"; - -describe("assertAllowedReturnTo", () => { - it("accepts exact https preview origin", () => { - expect(() => - assertAllowedReturnTo("https://pr-123.fullsend-admin.pages.dev/", [ - "https://pr-123.fullsend-admin.pages.dev", - ]), - ).not.toThrow(); - }); - - it("rejects mismatched host", () => { - expect(() => - assertAllowedReturnTo("https://evil.example/", ["https://pr-123.fullsend-admin.pages.dev"]), - ).toThrow(/return_to/); - }); -}); diff --git a/web/admin/src/lib/auth/previewHandoff.ts b/web/admin/src/lib/auth/previewHandoff.ts deleted file mode 100644 index 04ecb52b92..0000000000 --- a/web/admin/src/lib/auth/previewHandoff.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Validates return_to against an explicit allowlist of preview origins - * (scheme + host, no path). Caller supplies allowlist from production config. - */ -export function assertAllowedReturnTo(returnTo: string, allowedOrigins: string[]): URL { - let url: URL; - try { - url = new URL(returnTo); - } catch { - throw new Error("return_to is not a valid URL"); - } - if (url.protocol !== "https:") { - throw new Error("return_to must use https"); - } - const origin = `${url.protocol}//${url.host}`; - if (!allowedOrigins.includes(origin)) { - throw new Error("return_to origin is not allowlisted"); - } - return url; -} diff --git a/web/admin/src/lib/auth/session.test.ts b/web/admin/src/lib/auth/session.test.ts deleted file mode 100644 index 16574a5298..0000000000 --- a/web/admin/src/lib/auth/session.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { get } from "svelte/store"; - -vi.mock("../github/user", async (importOriginal) => { - const mod = await importOriginal(); - return { ...mod, fetchGitHubUser: vi.fn() }; -}); - -import { fetchGitHubUser, GitHubUserRequestError } from "../github/user"; -import { - githubLogin, - githubUser, - reauthenticateSuggested, - refreshSession, - signOut, -} from "./session"; -import { saveToken } from "./tokenStore"; - -beforeEach(() => { - localStorage.clear(); - vi.mocked(fetchGitHubUser).mockReset(); - githubUser.set(null); - reauthenticateSuggested.set(false); -}); - -describe("refreshSession", () => { - it("clears githubUser when there is no stored token", async () => { - githubUser.set({ login: "ghost", name: null, avatarUrl: null }); - await refreshSession(); - expect(get(githubUser)).toBeNull(); - expect(get(githubLogin)).toBeNull(); - expect(fetchGitHubUser).not.toHaveBeenCalled(); - }); - - it("sets githubUser from fetchGitHubUser when token exists", async () => { - saveToken({ - accessToken: "tok", - tokenType: "bearer", - expiresAt: Date.now() + 60_000, - }); - vi.mocked(fetchGitHubUser).mockResolvedValue({ - login: "alice", - name: "Alice L", - avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4", - }); - - await refreshSession(); - - expect(fetchGitHubUser).toHaveBeenCalledWith("tok"); - expect(get(githubUser)).toEqual({ - login: "alice", - name: "Alice L", - avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4", - }); - expect(get(githubLogin)).toBe("alice"); - expect(get(reauthenticateSuggested)).toBe(false); - }); - - it("clears reauthenticateSuggested after successful refresh", async () => { - saveToken({ - accessToken: "tok", - tokenType: "bearer", - expiresAt: Date.now() + 60_000, - }); - reauthenticateSuggested.set(true); - vi.mocked(fetchGitHubUser).mockResolvedValue({ - login: "alice", - name: null, - avatarUrl: null, - }); - - await refreshSession(); - - expect(get(reauthenticateSuggested)).toBe(false); - }); - - it("clears githubUser but keeps stored token when fetchGitHubUser throws generically", async () => { - saveToken({ - accessToken: "bad", - tokenType: "bearer", - expiresAt: Date.now() + 60_000, - }); - githubUser.set({ login: "stale", name: null, avatarUrl: null }); - vi.mocked(fetchGitHubUser).mockRejectedValue(new Error("network")); - - await refreshSession(); - - expect(get(githubUser)).toBeNull(); - expect(get(githubLogin)).toBeNull(); - expect(localStorage.getItem("fullsend_admin_github_token")).not.toBeNull(); - }); - - it("clears stored token and githubUser when fetchGitHubUser rejects with 401", async () => { - saveToken({ - accessToken: "revoked", - tokenType: "bearer", - expiresAt: Date.now() + 60_000, - }); - githubUser.set({ login: "stale", name: null, avatarUrl: null }); - vi.mocked(fetchGitHubUser).mockRejectedValue( - new GitHubUserRequestError(401, "GitHub /user failed: 401 "), - ); - - await refreshSession(); - - expect(localStorage.getItem("fullsend_admin_github_token")).toBeNull(); - expect(get(githubUser)).toBeNull(); - expect(get(githubLogin)).toBeNull(); - expect(get(reauthenticateSuggested)).toBe(true); - }); - - it("does not call fetchGitHubUser when stored token is already expired", async () => { - saveToken({ - accessToken: "tok", - tokenType: "bearer", - expiresAt: Date.now() - 1, - }); - githubUser.set({ login: "stale", name: null, avatarUrl: null }); - - await refreshSession(); - - expect(fetchGitHubUser).not.toHaveBeenCalled(); - expect(get(githubUser)).toBeNull(); - expect(get(githubLogin)).toBeNull(); - expect(localStorage.getItem("fullsend_admin_github_token")).toBeNull(); - }); -}); - -describe("signOut", () => { - it("clears token and githubUser", () => { - saveToken({ - accessToken: "x", - tokenType: "bearer", - expiresAt: Date.now() + 60_000, - }); - reauthenticateSuggested.set(true); - githubUser.set({ login: "bob", name: null, avatarUrl: null }); - - signOut(); - - expect(localStorage.getItem("fullsend_admin_github_token")).toBeNull(); - expect(get(githubUser)).toBeNull(); - expect(get(githubLogin)).toBeNull(); - expect(get(reauthenticateSuggested)).toBe(false); - }); -}); diff --git a/web/admin/src/lib/auth/session.ts b/web/admin/src/lib/auth/session.ts deleted file mode 100644 index 435e033c8f..0000000000 --- a/web/admin/src/lib/auth/session.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { derived, writable } from "svelte/store"; -import { clearAllAdminSessionCaches } from "./adminSessionCaches"; -import { loadToken } from "./tokenStore"; -import { fetchGitHubUser, GitHubUserRequestError, type GitHubUser } from "../github/user"; - -/** Cached GitHub profile from `refreshSession()` (single `/api/github/user` source). */ -export const githubUser = writable(null); - -/** GitHub login for signed-in user, or null (derived from `githubUser`). */ -export const githubLogin = derived(githubUser, ($u) => $u?.login ?? null); - -/** True until initial auth boot (OAuth handoff or `refreshSession`) finishes. */ -export const authBootPending = writable(true); - -/** Set after 401 so the shell can show Re-authenticate (admin SPA UX spec: global banners). */ -export const reauthenticateSuggested = writable(false); - -export type SignOutOptions = { - /** When true, show re-auth banner after clearing session (invalid/expired token). */ - suggestReauth?: boolean; -}; - -export async function refreshSession(): Promise { - const t = loadToken(); - if (!t?.accessToken) { - githubUser.set(null); - return; - } - try { - const u = await fetchGitHubUser(t.accessToken); - githubUser.set(u); - reauthenticateSuggested.set(false); - } catch (e) { - if (e instanceof GitHubUserRequestError && e.status === 401) { - signOut({ suggestReauth: true }); - return; - } - githubUser.set(null); - } -} - -export function signOut(options?: SignOutOptions): void { - // Token, slug, OAuth scope + install-readiness caches, org list memory + analysis - // (see `adminSessionCaches.ts` — extend there when adding user-scoped admin caches). - clearAllAdminSessionCaches(); - githubUser.set(null); - reauthenticateSuggested.set(Boolean(options?.suggestReauth)); -} diff --git a/web/admin/src/lib/auth/tokenStore.test.ts b/web/admin/src/lib/auth/tokenStore.test.ts deleted file mode 100644 index ea2eed950a..0000000000 --- a/web/admin/src/lib/auth/tokenStore.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { - clearSession, - loadGithubAppSlug, - loadToken, - persistGithubAppSlugFromOAuth, - saveToken, -} from "./tokenStore"; - -beforeEach(() => { - localStorage.clear(); - clearSession(); -}); - -describe("tokenStore", () => { - it("saveToken and loadToken round-trip with future expiresAt", () => { - const future = Date.now() + 60_000; - saveToken({ accessToken: "abc", tokenType: "bearer", expiresAt: future }); - expect(loadToken()).toEqual({ - accessToken: "abc", - tokenType: "bearer", - expiresAt: future, - }); - }); - - it("saveToken and loadToken round-trip with null expiresAt", () => { - saveToken({ accessToken: "abc", tokenType: "bearer", expiresAt: null }); - expect(loadToken()).toEqual({ - accessToken: "abc", - tokenType: "bearer", - expiresAt: null, - }); - }); - - it("loadToken clears session and returns null when token is expired", () => { - saveToken({ - accessToken: "abc", - tokenType: "bearer", - expiresAt: Date.now() - 1, - }); - expect(loadToken()).toBeNull(); - expect(localStorage.getItem("fullsend_admin_github_token")).toBeNull(); - }); - - it("loadToken normalizes legacy expiresAt 0 to null", () => { - localStorage.setItem( - "fullsend_admin_github_token", - JSON.stringify({ - accessToken: "legacy", - tokenType: "bearer", - expiresAt: 0, - }), - ); - expect(loadToken()).toEqual({ - accessToken: "legacy", - tokenType: "bearer", - expiresAt: null, - }); - }); - - it("clearSession removes token", () => { - saveToken({ - accessToken: "x", - tokenType: "bearer", - expiresAt: Date.now() + 60_000, - }); - clearSession(); - expect(loadToken()).toBeNull(); - }); - - it("clearSession removes persisted GitHub App slug", () => { - persistGithubAppSlugFromOAuth("my-app-slug"); - expect(loadGithubAppSlug()).toBe("my-app-slug"); - clearSession(); - expect(loadGithubAppSlug()).toBeNull(); - }); - - it("loadToken returns null for invalid JSON", () => { - localStorage.setItem("fullsend_admin_github_token", "not-json{"); - expect(loadToken()).toBeNull(); - }); -}); diff --git a/web/admin/src/lib/auth/tokenStore.ts b/web/admin/src/lib/auth/tokenStore.ts deleted file mode 100644 index a86b31301d..0000000000 --- a/web/admin/src/lib/auth/tokenStore.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { clearOAuthScopeHeaderCache } from "../layers/preflight"; -import { clearInstallReadinessProbeCache } from "../orgs/installReadinessProbes"; - -export type StoredToken = { - accessToken: string; - tokenType: string; - /** Absolute expiry in ms since epoch, or null when unknown (no client-side TTL). */ - expiresAt: number | null; -}; - -const KEY = "fullsend_admin_github_token"; -const GITHUB_APP_SLUG_KEY = "fullsend_admin_github_app_slug"; - -/** - * Persists the GitHub App slug from Worker-expanded OAuth `state` after a successful token - * exchange. Clears stored slug when `slug` is missing or blank (e.g. older Workers). - */ -export function persistGithubAppSlugFromOAuth(slug: string | undefined): void { - const s = typeof slug === "string" ? slug.trim() : ""; - if (!s) { - localStorage.removeItem(GITHUB_APP_SLUG_KEY); - return; - } - localStorage.setItem(GITHUB_APP_SLUG_KEY, s); -} - -/** Slug for the admin OAuth GitHub App (install URL), or null if not provided at last sign-in. */ -export function loadGithubAppSlug(): string | null { - const s = localStorage.getItem(GITHUB_APP_SLUG_KEY)?.trim() ?? ""; - return s.length > 0 ? s : null; -} - -export function saveToken(t: StoredToken): void { - localStorage.setItem(KEY, JSON.stringify(t)); -} - -function parseExpiresAt(raw: unknown): number | null { - if (raw === null || raw === undefined) return null; - if (raw === 0) return null; - if (typeof raw !== "number" || !Number.isFinite(raw)) return null; - return raw; -} - -export function loadToken(): StoredToken | null { - const raw = localStorage.getItem(KEY); - if (!raw) return null; - let o: unknown; - try { - o = JSON.parse(raw); - } catch { - return null; - } - if (!o || typeof o !== "object") return null; - const t = o as Record; - const accessToken = typeof t.accessToken === "string" ? t.accessToken.trim() : ""; - if (!accessToken) return null; - const tokenType = - typeof t.tokenType === "string" && t.tokenType.length > 0 ? t.tokenType : "bearer"; - - const expiresAt = parseExpiresAt(t.expiresAt); - if (typeof expiresAt === "number" && expiresAt > 0 && Date.now() > expiresAt) { - clearSession(); - return null; - } - - return { accessToken, tokenType, expiresAt }; -} - -export function clearSession(): void { - localStorage.removeItem(KEY); - localStorage.removeItem(GITHUB_APP_SLUG_KEY); - clearOAuthScopeHeaderCache(); - clearInstallReadinessProbeCache(); -} diff --git a/web/admin/src/lib/auth/turnstile.test.ts b/web/admin/src/lib/auth/turnstile.test.ts deleted file mode 100644 index 30566e3387..0000000000 --- a/web/admin/src/lib/auth/turnstile.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { obtainTurnstileToken } from "./turnstile"; - -describe("obtainTurnstileToken", () => { - beforeEach(() => { - document.body.replaceChildren(); - document.head.querySelectorAll("script[data-fullsend-turnstile]").forEach((n) => n.remove()); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - delete (globalThis as unknown as { turnstile?: unknown }).turnstile; - }); - - it("rejects with timeout when Turnstile never invokes the token callback", async () => { - const existing = document.createElement("script"); - existing.dataset.fullsendTurnstile = "1"; - existing.dataset.loaded = "1"; - document.head.appendChild(existing); - - (globalThis as unknown as { turnstile: object }).turnstile = { - render: vi.fn(() => "widget-id"), - execute: vi.fn(), - remove: vi.fn(), - }; - - const p = obtainTurnstileToken("0x4AAA_sitekey"); - const assertRejected = expect(p).rejects.toThrow(/Turnstile token timed out/); - await vi.advanceTimersByTimeAsync(120_000 + 1); - await assertRejected; - }); - - it("rejects with AbortError when signal is already aborted", async () => { - const ac = new AbortController(); - ac.abort(); - await expect(obtainTurnstileToken("k", ac.signal)).rejects.toMatchObject({ - name: "AbortError", - }); - }); -}); diff --git a/web/admin/src/lib/auth/turnstile.ts b/web/admin/src/lib/auth/turnstile.ts deleted file mode 100644 index 51a0f5a43b..0000000000 --- a/web/admin/src/lib/auth/turnstile.ts +++ /dev/null @@ -1,180 +0,0 @@ -type TurnstileApi = { - render: (container: string | HTMLElement, params: Record) => string; - execute: (container: string | HTMLElement) => void; - remove: (widgetId: string) => void; -}; - -/** Wall-clock cap so Turnstile cannot leave OAuth boot hanging indefinitely (review / UX). */ -const TURNSTILE_TOKEN_DEADLINE_MS = 120_000; - -function turnstileGlobal(): TurnstileApi | undefined { - return (globalThis as unknown as { turnstile?: TurnstileApi }).turnstile; -} - -function loadTurnstileScript(): Promise { - const existing = document.querySelector( - "script[data-fullsend-turnstile]", - ) as HTMLScriptElement | null; - if (existing) { - return existing.dataset.loaded === "1" - ? Promise.resolve() - : new Promise((resolve, reject) => { - existing.addEventListener("load", () => resolve(), { once: true }); - existing.addEventListener("error", () => reject(new Error("Turnstile script failed")), { - once: true, - }); - }); - } - - return new Promise((resolve, reject) => { - const s = document.createElement("script"); - s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; - // Dynamically appended scripts default to async=true; Turnstile errors if async/defer is set - // when using turnstile.ready(). We use onload + render/execute instead and force async off. - s.async = false; - s.dataset.fullsendTurnstile = "1"; - s.onload = () => { - s.dataset.loaded = "1"; - resolve(); - }; - s.onerror = () => reject(new Error("Turnstile script failed")); - document.head.appendChild(s); - }); -} - -/** Abort `out` when either input signal aborts (used to combine user cancel + deadline). */ -function mergeAbortSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | undefined { - if (!a && !b) return undefined; - if (!a) return b; - if (!b) return a; - const out = new AbortController(); - const fire = () => { - try { - out.abort(); - } catch { - /* ignore */ - } - }; - if (a.aborted || b.aborted) { - fire(); - return out.signal; - } - a.addEventListener("abort", fire, { once: true }); - b.addEventListener("abort", fire, { once: true }); - return out.signal; -} - -/** - * Runs an invisible Turnstile challenge and resolves with the one-time token for - * `POST /api/oauth/token` (Worker validates with siteverify). - * - * Honors optional `signal` (caller abort / navigation). Also enforces a maximum wait - * (120s) so a stuck widget cannot block OAuth forever. - */ -export async function obtainTurnstileToken( - siteKey: string, - userSignal?: AbortSignal, -): Promise { - if (userSignal?.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - - const deadline = new AbortController(); - const tid = window.setTimeout(() => deadline.abort(), TURNSTILE_TOKEN_DEADLINE_MS); - const merged = mergeAbortSignals(userSignal, deadline.signal); - - try { - return await obtainTurnstileTokenWithSignal(siteKey, merged); - } catch (e) { - if (deadline.signal.aborted && !userSignal?.aborted) { - // eslint-disable-next-line preserve-caught-error -- TODO: attach cause once callers handle it - throw new Error("Turnstile token timed out"); - } - throw e; - } finally { - window.clearTimeout(tid); - } -} - -async function obtainTurnstileTokenWithSignal( - siteKey: string, - signal?: AbortSignal, -): Promise { - if (signal?.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - - await loadTurnstileScript(); - if (signal?.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - - const turnstile = turnstileGlobal(); - if (!turnstile?.render || !turnstile.execute) { - throw new Error("Turnstile API unavailable"); - } - - return new Promise((resolve, reject) => { - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.width = "1px"; - host.style.height = "1px"; - host.style.left = "-9999px"; - host.style.top = "0"; - document.body.appendChild(host); - - let widgetId: string | undefined; - - const detachAbort = () => { - signal?.removeEventListener("abort", onAbort); - }; - - const teardown = (id: string | null) => { - detachAbort(); - if (id) { - try { - turnstile.remove(id); - } catch { - /* ignore */ - } - } - host.remove(); - }; - - const onAbort = () => { - teardown(widgetId ?? null); - reject(new DOMException("Aborted", "AbortError")); - }; - - signal?.addEventListener("abort", onAbort, { once: true }); - - const run = () => { - try { - widgetId = turnstile.render(host, { - sitekey: siteKey, - execution: "execute", - callback: (token: string) => { - teardown(widgetId ?? null); - resolve(token); - }, - "error-callback": () => { - teardown(widgetId ?? null); - reject(new Error("Turnstile challenge failed")); - }, - }); - } catch (e) { - teardown(null); - reject(e instanceof Error ? e : new Error(String(e))); - return; - } - try { - turnstile.execute(host); - } catch (e) { - teardown(widgetId ?? null); - reject(e instanceof Error ? e : new Error(String(e))); - } - }; - - run(); - }); -} diff --git a/web/admin/src/lib/github/client.test.ts b/web/admin/src/lib/github/client.test.ts deleted file mode 100644 index b10802128c..0000000000 --- a/web/admin/src/lib/github/client.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { GITHUB_USER_UNAUTHORIZED_EVENT } from "../auth/githubUnauthorized"; -import { createUserOctokit } from "./client"; - -describe("createUserOctokit", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("dispatches GITHUB_USER_UNAUTHORIZED_EVENT when GitHub returns 401", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - new Response(JSON.stringify({ message: "Bad credentials" }), { - status: 401, - headers: { "content-type": "application/json" }, - }), - ), - ), - ); - const listener = vi.fn(); - window.addEventListener(GITHUB_USER_UNAUTHORIZED_EVENT, listener); - const o = createUserOctokit("tok"); - await expect(o.request("GET /user")).rejects.toThrow(); - expect(listener).toHaveBeenCalledOnce(); - window.removeEventListener(GITHUB_USER_UNAUTHORIZED_EVENT, listener); - }); -}); diff --git a/web/admin/src/lib/github/client.ts b/web/admin/src/lib/github/client.ts deleted file mode 100644 index 4986000060..0000000000 --- a/web/admin/src/lib/github/client.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Octokit } from "@octokit/rest"; -import type { OctokitResponse } from "@octokit/types"; -import { notifyGitHubUserUnauthorized } from "../auth/githubUnauthorized"; - -/** - * GitHub REST client for **user** tokens. Some routes (e.g. org memberships) work from the - * browser against `api.github.com`; `/user` still uses the same-origin Worker in [`user.ts`](./user.ts). - * - * A `request` hook dispatches {@link notifyGitHubUserUnauthorized} on HTTP **401** so `App.svelte` can sign out. - * - * Note: `new Octokit({ request: { hook } })` is ignored — `@octokit/core` replaces `request.hook` - * with its own collection. We register via `octokit.hook.wrap("request", …)` instead. - */ -export function createUserOctokit(accessToken: string): Octokit { - const octokit = new Octokit({ auth: accessToken }); - octokit.hook.wrap("request", async (request, options) => { - try { - const response = await request(options as never); - // Octokit throws on 401 before returning; the branch below handles that. - return response as OctokitResponse; - } catch (e: unknown) { - const rec = e && typeof e === "object" ? (e as Record) : null; - const status = - typeof rec?.status === "number" - ? rec.status - : rec?.response && - typeof rec.response === "object" && - typeof (rec.response as { status?: unknown }).status === "number" - ? (rec.response as { status: number }).status - : undefined; - if (status === 401) { - notifyGitHubUserUnauthorized(); - } - throw e; - } - }); - return octokit; -} diff --git a/web/admin/src/lib/github/githubAppInstallLink.test.ts b/web/admin/src/lib/github/githubAppInstallLink.test.ts deleted file mode 100644 index b33c5c998f..0000000000 --- a/web/admin/src/lib/github/githubAppInstallLink.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { githubAppInstallationsNewUrl } from "./githubAppInstallLink"; - -describe("githubAppInstallationsNewUrl", () => { - it("returns install URL for a valid slug", () => { - expect(githubAppInstallationsNewUrl("my-app-1")).toBe( - "https://github.com/apps/my-app-1/installations/new", - ); - }); - - it("trims whitespace before validating", () => { - expect(githubAppInstallationsNewUrl(" valid-slug ")).toBe( - "https://github.com/apps/valid-slug/installations/new", - ); - }); - - it("returns null for empty and whitespace-only", () => { - expect(githubAppInstallationsNewUrl("")).toBeNull(); - expect(githubAppInstallationsNewUrl(" ")).toBeNull(); - }); - - it("returns null for invalid slug characters", () => { - expect(githubAppInstallationsNewUrl("bad/slug")).toBeNull(); - expect(githubAppInstallationsNewUrl("a.b")).toBeNull(); - expect(githubAppInstallationsNewUrl("bad slug")).toBeNull(); - }); - - it("returns null when slug exceeds max length", () => { - expect(githubAppInstallationsNewUrl("a".repeat(100))).toBeNull(); - }); - - it("returns null for non-ASCII", () => { - expect(githubAppInstallationsNewUrl("café-app")).toBeNull(); - }); -}); diff --git a/web/admin/src/lib/github/githubAppInstallLink.ts b/web/admin/src/lib/github/githubAppInstallLink.ts deleted file mode 100644 index 922c181615..0000000000 --- a/web/admin/src/lib/github/githubAppInstallLink.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { normalizeSlug } from "../orgs/installationOrgRows"; - -export function githubAppInstallationsNewUrl(slug: string): string | null { - const s = normalizeSlug(slug); - if (s == null) return null; - return `https://github.com/apps/${encodeURIComponent(s)}/installations/new`; -} diff --git a/web/admin/src/lib/github/user.test.ts b/web/admin/src/lib/github/user.test.ts deleted file mode 100644 index 1e63a6c218..0000000000 --- a/web/admin/src/lib/github/user.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchGitHubUser, GitHubUserRequestError } from "./user"; - -beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); -}); - -describe("fetchGitHubUser", () => { - it("returns login and name when response is ok", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - login: "u", - name: "User Name", - avatar_url: "https://avatars.githubusercontent.com/u/99?v=4", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ), - ); - - await expect(fetchGitHubUser("secret-token")).resolves.toEqual({ - login: "u", - name: "User Name", - avatarUrl: "https://avatars.githubusercontent.com/u/99?v=4", - }); - const [url, init] = vi.mocked(fetch).mock.calls[0]!; - expect(url).toBe("/api/github/user"); - expect(init?.headers).toMatchObject({ - Authorization: "Bearer secret-token", - }); - }); - - it("uses null name when response omits name", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ login: "onlylogin" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - - await expect(fetchGitHubUser("t")).resolves.toEqual({ - login: "onlylogin", - name: null, - avatarUrl: null, - }); - }); - - it("accepts Worker-narrowed /user JSON including avatar_url (field reserved for follow-on UI)", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - login: "u", - name: null, - avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - - await expect(fetchGitHubUser("t")).resolves.toEqual({ - login: "u", - name: null, - avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4", - }); - }); - - it("throws GitHubUserRequestError when response is not ok", async () => { - vi.mocked(fetch).mockResolvedValueOnce(new Response("forbidden body", { status: 403 })); - - const err = await fetchGitHubUser("t").catch((e) => e); - expect(err).toBeInstanceOf(GitHubUserRequestError); - expect((err as GitHubUserRequestError).status).toBe(403); - expect((err as GitHubUserRequestError).message).toMatch(/403/); - }); - - it("drops avatar_url when it is not an https URL", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - login: "u", - name: null, - avatar_url: "javascript:alert(1)", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - - await expect(fetchGitHubUser("t")).resolves.toEqual({ - login: "u", - name: null, - avatarUrl: null, - }); - }); - - it("throws when login is missing", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ id: 1 }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - - await expect(fetchGitHubUser("t")).rejects.toThrow(/missing login/); - }); -}); diff --git a/web/admin/src/lib/github/user.ts b/web/admin/src/lib/github/user.ts deleted file mode 100644 index b6b68e3878..0000000000 --- a/web/admin/src/lib/github/user.ts +++ /dev/null @@ -1,56 +0,0 @@ -export type GitHubUser = { - login: string; - name: string | null; - /** Profile image URL from GitHub `avatar_url`, or null if absent. */ - avatarUrl: string | null; -}; - -/** Only allow https avatars in `` (reject javascript:, data:, etc.). */ -export function normalizeGithubAvatarUrl(raw: string | null): string | null { - if (raw == null || raw.length === 0) return null; - try { - const u = new URL(raw); - if (u.protocol !== "https:") return null; - return u.href; - } catch { - return null; - } -} - -export class GitHubUserRequestError extends Error { - readonly status: number; - - constructor(status: number, message: string) { - super(message); - this.name = "GitHubUserRequestError"; - this.status = status; - } -} - -/** Same-origin BFF (Vite → Wrangler) — GitHub REST does not allow browser CORS for /user. */ -export async function fetchGitHubUser(accessToken: string): Promise { - const res = await fetch("/api/github/user", { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${accessToken}`, - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - if (!res.ok) { - const text = await res.text(); - throw new GitHubUserRequestError( - res.status, - `GitHub /user failed: ${res.status} ${text.slice(0, 200)}`, - ); - } - const data = (await res.json()) as Record; - const login = typeof data.login === "string" ? data.login : ""; - if (!login) { - throw new Error("GitHub /user: missing login"); - } - const name = typeof data.name === "string" ? data.name : null; - const rawAvatar = - typeof data.avatar_url === "string" && data.avatar_url.length > 0 ? data.avatar_url : null; - const avatarUrl = normalizeGithubAvatarUrl(rawAvatar); - return { login, name, avatarUrl }; -} diff --git a/web/admin/src/lib/layers/analyzeOrg.test.ts b/web/admin/src/lib/layers/analyzeOrg.test.ts deleted file mode 100644 index 3186bb6554..0000000000 --- a/web/admin/src/lib/layers/analyzeOrg.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LayerGithub } from "./githubClient"; -import { analyzeOrgLayers } from "./analyzeOrg"; -import { - AGENT_WORKFLOW_PATH, - CODEOWNERS_PATH, - CONFIG_FILE_PATH, - CONFIG_REPO_NAME, - ONBOARD_WORKFLOW_PATH, - SHIM_WORKFLOW_PATH, -} from "./constants"; -import { secretNameForRole, variableNameForRole } from "./secrets"; - -const validConfig = `version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] -repos: - r1: - enabled: true -`; - -function fullStackMock(): LayerGithub { - return { - getRepoExists: async (_o, repo) => repo === CONFIG_REPO_NAME, - getRepoFileUtf8: async (org, repo, path) => { - if (repo === CONFIG_REPO_NAME && path === CONFIG_FILE_PATH) return validConfig; - if (repo === CONFIG_REPO_NAME) - return [AGENT_WORKFLOW_PATH, ONBOARD_WORKFLOW_PATH, CODEOWNERS_PATH].includes(path) - ? "ok" - : null; - if (repo === "r1" && path === SHIM_WORKFLOW_PATH) return "shim"; - return null; - }, - repoSecretExists: async (_o, _r, name) => name === secretNameForRole("fullsend"), - repoVariableExists: async (_o, _r, name) => name === variableNameForRole("fullsend"), - orgSecretExists: async () => ({ kind: "ok", exists: true }), - }; -} - -describe("analyzeOrgLayers", () => { - it("returns installed rollup when mock stack is healthy", async () => { - const { reports, rollup } = await analyzeOrgLayers({ - org: "acme", - gh: fullStackMock(), - agents: [{ role: "fullsend" }], - enabledRepos: ["r1"], - }); - expect(reports).toHaveLength(5); - expect(reports.map((r) => r.name)).toEqual([ - "config-repo", - "workflows", - "secrets", - "enrollment", - "dispatch-token", - ]); - expect(rollup).toBe("installed"); - }); -}); diff --git a/web/admin/src/lib/layers/analyzeOrg.ts b/web/admin/src/lib/layers/analyzeOrg.ts deleted file mode 100644 index d9b5680001..0000000000 --- a/web/admin/src/lib/layers/analyzeOrg.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { LayerReport } from "../status/types"; -import { rollupOrgLayerStatus } from "../status/engine"; -import { analyzeConfigRepoLayer } from "./configRepo"; -import { analyzeDispatchTokenLayer } from "./dispatch"; -import { analyzeEnrollmentLayer } from "./enrollment"; -import type { LayerGithub } from "./githubClient"; -import { analyzeSecretsLayer } from "./secrets"; -import { analyzeWorkflowsLayer } from "./workflows"; - -export type AnalyzeOrgLayersInput = { - org: string; - gh: LayerGithub; - /** Agent roles from org config (drives secret/variable names). */ - agents: { role: string }[]; - /** Repos with `enabled: true` in config (drives enrollment checks). */ - enabledRepos: string[]; -}; - -/** - * Runs read-only Analyze for all stack layers (order matches typical install stack). - */ -export async function analyzeOrgLayers(input: AnalyzeOrgLayersInput): Promise<{ - reports: LayerReport[]; - rollup: ReturnType; -}> { - const { org, gh, agents, enabledRepos } = input; - const reports: LayerReport[] = await Promise.all([ - analyzeConfigRepoLayer(org, gh), - analyzeWorkflowsLayer(org, gh), - analyzeSecretsLayer(org, gh, agents), - analyzeEnrollmentLayer(org, gh, enabledRepos), - analyzeDispatchTokenLayer(org, gh), - ]); - return { reports, rollup: rollupOrgLayerStatus(reports) }; -} diff --git a/web/admin/src/lib/layers/configRepo.test.ts b/web/admin/src/lib/layers/configRepo.test.ts deleted file mode 100644 index 6b49b178e8..0000000000 --- a/web/admin/src/lib/layers/configRepo.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LayerGithub } from "./githubClient"; -import { analyzeConfigRepoLayer } from "./configRepo"; -import type { LayerReport } from "../status/types"; - -const validYaml = `version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] - max_implementation_retries: 2 - auto_merge: false -agents: [] -repos: {} -`; - -function mockGh(overrides: Partial): LayerGithub { - const base: LayerGithub = { - getRepoExists: async () => false, - getRepoFileUtf8: async () => null, - repoSecretExists: async () => false, - repoVariableExists: async () => false, - orgSecretExists: async () => ({ kind: "ok", exists: false }), - }; - return { ...base, ...overrides }; -} - -type Want = Pick & { - details?: string[]; - detailIncludes?: string; -}; - -describe("analyzeConfigRepoLayer", () => { - const cases: { name: string; gh: LayerGithub; want: Want }[] = [ - { - name: "no repo", - gh: mockGh({ getRepoExists: async () => false }), - want: { - status: "not_installed", - details: [], - wouldInstall: ["create .fullsend repository", "write config.yaml"], - wouldFix: [], - }, - }, - { - name: "repo exists, config missing", - gh: mockGh({ - getRepoExists: async () => true, - getRepoFileUtf8: async () => null, - }), - want: { - status: "degraded", - details: ["repo exists but config.yaml is missing"], - wouldFix: ["write config.yaml"], - wouldInstall: [], - }, - }, - { - name: "config exists and is valid", - gh: mockGh({ - getRepoExists: async () => true, - getRepoFileUtf8: async () => validYaml, - }), - want: { - status: "installed", - details: ["config.yaml exists and is valid"], - wouldInstall: [], - wouldFix: [], - }, - }, - { - name: "config exists but YAML parse fails", - gh: mockGh({ - getRepoExists: async () => true, - getRepoFileUtf8: async () => "{\nnot yaml", - }), - want: { - status: "degraded", - wouldFix: ["rewrite config.yaml"], - wouldInstall: [], - detailIncludes: "config.yaml exists but is invalid", - }, - }, - { - name: "config exists but validation fails", - gh: mockGh({ - getRepoExists: async () => true, - getRepoFileUtf8: async () => - "version: '2'\ndispatch:\n platform: github-actions\ndefaults:\n roles: []\n", - }), - want: { - status: "degraded", - wouldFix: ["rewrite config.yaml"], - wouldInstall: [], - detailIncludes: "unsupported version", - }, - }, - ]; - - it.each(cases)("$name", async ({ gh, want }) => { - const got = await analyzeConfigRepoLayer("acme", gh); - expect(got.name).toBe("config-repo"); - expect(got.status).toBe(want.status); - expect(got.wouldInstall).toEqual(want.wouldInstall); - expect(got.wouldFix).toEqual(want.wouldFix); - if (want.details !== undefined) { - expect(got.details).toEqual(want.details); - } - if (want.detailIncludes !== undefined) { - expect(got.details.join("\n")).toContain(want.detailIncludes); - } - }); -}); diff --git a/web/admin/src/lib/layers/configRepo.ts b/web/admin/src/lib/layers/configRepo.ts deleted file mode 100644 index a72c15914b..0000000000 --- a/web/admin/src/lib/layers/configRepo.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { LayerReport } from "../status/types"; -import { CONFIG_FILE_PATH, CONFIG_REPO_NAME } from "./constants"; -import type { LayerGithub } from "./githubClient"; -import { parseOrgConfigYaml, validateOrgConfig } from "./orgConfigParse"; - -/** - * Read-only port of `ConfigRepoLayer.Analyze` (`internal/layers/configrepo.go`). - */ -export async function analyzeConfigRepoLayer(org: string, gh: LayerGithub): Promise { - const report: LayerReport = { - name: "config-repo", - status: "unknown", - details: [], - wouldInstall: [], - wouldFix: [], - }; - - const exists = await gh.getRepoExists(org, CONFIG_REPO_NAME); - if (!exists) { - report.status = "not_installed"; - report.wouldInstall = [`create ${CONFIG_REPO_NAME} repository`, `write ${CONFIG_FILE_PATH}`]; - return report; - } - - const content = await gh.getRepoFileUtf8(org, CONFIG_REPO_NAME, CONFIG_FILE_PATH); - if (content === null) { - report.status = "degraded"; - report.details = [`repo exists but ${CONFIG_FILE_PATH} is missing`]; - report.wouldFix = [`write ${CONFIG_FILE_PATH}`]; - return report; - } - - let parsed; - try { - parsed = parseOrgConfigYaml(content); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - report.status = "degraded"; - report.details = [`${CONFIG_FILE_PATH} exists but is invalid: ${msg}`]; - report.wouldFix = [`rewrite ${CONFIG_FILE_PATH}`]; - return report; - } - - const validateErr = validateOrgConfig(parsed); - if (validateErr) { - report.status = "degraded"; - report.details = [`${CONFIG_FILE_PATH} exists but is invalid: ${validateErr}`]; - report.wouldFix = [`rewrite ${CONFIG_FILE_PATH}`]; - return report; - } - - report.status = "installed"; - report.details = [`${CONFIG_FILE_PATH} exists and is valid`]; - return report; -} diff --git a/web/admin/src/lib/layers/constants.ts b/web/admin/src/lib/layers/constants.ts deleted file mode 100644 index fdc5f292fb..0000000000 --- a/web/admin/src/lib/layers/constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** Mirrors `internal/forge/forge.go` ConfigRepoName. */ -export const CONFIG_REPO_NAME = ".fullsend"; - -export const CONFIG_FILE_PATH = "config.yaml"; - -export const AGENT_WORKFLOW_PATH = ".github/workflows/agent.yaml"; -export const ONBOARD_WORKFLOW_PATH = ".github/workflows/repo-onboard.yaml"; -export const CODEOWNERS_PATH = "CODEOWNERS"; - -/** Managed workflow files in write order (matches `internal/layers/workflows.go`). */ -export const WORKFLOWS_MANAGED_FILES = [ - AGENT_WORKFLOW_PATH, - ONBOARD_WORKFLOW_PATH, - CODEOWNERS_PATH, -] as const; - -export const SHIM_WORKFLOW_PATH = ".github/workflows/fullsend.yaml"; - -export const DISPATCH_TOKEN_SECRET_NAME = "FULLSEND_DISPATCH_TOKEN"; diff --git a/web/admin/src/lib/layers/dispatch.test.ts b/web/admin/src/lib/layers/dispatch.test.ts deleted file mode 100644 index b83d29c2fb..0000000000 --- a/web/admin/src/lib/layers/dispatch.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LayerGithub } from "./githubClient"; -import { DISPATCH_TOKEN_SECRET_NAME } from "./constants"; -import { analyzeDispatchTokenLayer } from "./dispatch"; - -function mockGh(orgSecret: Awaited>): LayerGithub { - return { - getRepoExists: async () => true, - getRepoFileUtf8: async () => null, - repoSecretExists: async () => false, - repoVariableExists: async () => false, - orgSecretExists: async () => orgSecret, - }; -} - -describe("analyzeDispatchTokenLayer", () => { - it("installed when org secret exists", async () => { - const r = await analyzeDispatchTokenLayer("acme", mockGh({ kind: "ok", exists: true })); - expect(r.status).toBe("installed"); - expect(r.details[0]).toContain(DISPATCH_TOKEN_SECRET_NAME); - }); - - it("not_installed when org secret missing", async () => { - const r = await analyzeDispatchTokenLayer("acme", mockGh({ kind: "ok", exists: false })); - expect(r.status).toBe("not_installed"); - expect(r.wouldInstall[0]).toContain("create"); - }); - - it("unknown when forbidden", async () => { - const r = await analyzeDispatchTokenLayer("acme", mockGh({ kind: "forbidden" })); - expect(r.status).toBe("unknown"); - expect(r.details[0]).toContain("insufficient permissions"); - }); -}); diff --git a/web/admin/src/lib/layers/dispatch.ts b/web/admin/src/lib/layers/dispatch.ts deleted file mode 100644 index 43d61ac777..0000000000 --- a/web/admin/src/lib/layers/dispatch.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { LayerReport } from "../status/types"; -import { DISPATCH_TOKEN_SECRET_NAME } from "./constants"; -import type { LayerGithub } from "./githubClient"; - -/** - * Read-only port of `DispatchTokenLayer.Analyze` (`internal/layers/dispatch.go`). - */ -export async function analyzeDispatchTokenLayer( - org: string, - gh: LayerGithub, -): Promise { - const report: LayerReport = { - name: "dispatch-token", - status: "unknown", - details: [], - wouldInstall: [], - wouldFix: [], - }; - - const res = await gh.orgSecretExists(org, DISPATCH_TOKEN_SECRET_NAME); - if (res.kind === "forbidden") { - report.status = "unknown"; - report.details.push( - `cannot verify ${DISPATCH_TOKEN_SECRET_NAME} org secret (insufficient permissions; admin:org scope may be required)`, - ); - return report; - } - if (res.kind === "error") { - report.status = "unknown"; - report.details.push(`cannot verify ${DISPATCH_TOKEN_SECRET_NAME} org secret: ${res.message}`); - return report; - } - - if (res.exists) { - report.status = "installed"; - report.details.push(`${DISPATCH_TOKEN_SECRET_NAME} org secret exists`); - } else { - report.status = "not_installed"; - report.wouldInstall.push(`create ${DISPATCH_TOKEN_SECRET_NAME} org secret`); - } - - return report; -} diff --git a/web/admin/src/lib/layers/enrollment.test.ts b/web/admin/src/lib/layers/enrollment.test.ts deleted file mode 100644 index 4b885f237f..0000000000 --- a/web/admin/src/lib/layers/enrollment.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LayerGithub } from "./githubClient"; -import { SHIM_WORKFLOW_PATH } from "./constants"; -import { analyzeEnrollmentLayer } from "./enrollment"; - -function mockGh(shimByRepo: Record): LayerGithub { - return { - getRepoExists: async () => true, - getRepoFileUtf8: async (_org, repo, path) => { - if (path !== SHIM_WORKFLOW_PATH) return null; - return shimByRepo[repo] ?? null; - }, - repoSecretExists: async () => false, - repoVariableExists: async () => false, - orgSecretExists: async () => ({ kind: "ok", exists: false }), - }; -} - -describe("analyzeEnrollmentLayer", () => { - it("installed when no enabled repos", async () => { - const r = await analyzeEnrollmentLayer("acme", mockGh({}), []); - expect(r.status).toBe("installed"); - expect(r.details).toEqual(["no repositories enrolled"]); - }); - - it("installed when all enabled repos have shim", async () => { - const r = await analyzeEnrollmentLayer("acme", mockGh({ a: "yaml", b: "yaml" }), ["a", "b"]); - expect(r.status).toBe("installed"); - expect(r.details).toEqual(["a enrolled", "b enrolled"]); - }); - - it("not_installed when none enrolled", async () => { - const r = await analyzeEnrollmentLayer("acme", mockGh({}), ["x", "y"]); - expect(r.status).toBe("not_installed"); - expect(r.wouldInstall).toEqual(["create enrollment PR for x", "create enrollment PR for y"]); - }); - - it("degraded when mixed", async () => { - const r = await analyzeEnrollmentLayer("acme", mockGh({ a: "ok" }), ["a", "b"]); - expect(r.status).toBe("degraded"); - expect(r.details).toContain("a enrolled"); - expect(r.wouldFix).toContain("create enrollment PR for b"); - }); -}); diff --git a/web/admin/src/lib/layers/enrollment.ts b/web/admin/src/lib/layers/enrollment.ts deleted file mode 100644 index 9208175f45..0000000000 --- a/web/admin/src/lib/layers/enrollment.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { LayerReport } from "../status/types"; -import { SHIM_WORKFLOW_PATH } from "./constants"; -import type { LayerGithub } from "./githubClient"; - -/** - * Read-only port of `EnrollmentLayer.Analyze` (`internal/layers/enrollment.go`). - */ -export async function analyzeEnrollmentLayer( - org: string, - gh: LayerGithub, - enabledRepos: string[], -): Promise { - const report: LayerReport = { - name: "enrollment", - status: "unknown", - details: [], - wouldInstall: [], - wouldFix: [], - }; - - const enrolled: string[] = []; - const notEnrolled: string[] = []; - - for (const repo of enabledRepos) { - const body = await gh.getRepoFileUtf8(org, repo, SHIM_WORKFLOW_PATH); - if (body !== null) { - enrolled.push(repo); - } else { - notEnrolled.push(repo); - } - } - - if (enabledRepos.length === 0) { - report.status = "installed"; - report.details.push("no repositories enrolled"); - } else if (notEnrolled.length === 0) { - report.status = "installed"; - for (const r of enrolled) { - report.details.push(`${r} enrolled`); - } - } else if (enrolled.length === 0) { - report.status = "not_installed"; - for (const r of notEnrolled) { - report.wouldInstall.push(`create enrollment PR for ${r}`); - } - } else { - report.status = "degraded"; - for (const r of enrolled) { - report.details.push(`${r} enrolled`); - } - for (const r of notEnrolled) { - report.wouldFix.push(`create enrollment PR for ${r}`); - } - } - - return report; -} diff --git a/web/admin/src/lib/layers/fixtures/configrepo/config-valid.yaml b/web/admin/src/lib/layers/fixtures/configrepo/config-valid.yaml deleted file mode 100644 index cfe2136618..0000000000 --- a/web/admin/src/lib/layers/fixtures/configrepo/config-valid.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] - max_implementation_retries: 2 - auto_merge: false -repos: {} diff --git a/web/admin/src/lib/layers/githubClient.test.ts b/web/admin/src/lib/layers/githubClient.test.ts deleted file mode 100644 index 8069077141..0000000000 --- a/web/admin/src/lib/layers/githubClient.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { Octokit } from "@octokit/rest"; -import { createLayerGithub } from "./githubClient"; - -describe("createLayerGithub getRepoFileUtf8", () => { - it("wraps invalid base64 from the Contents API in a normal Error", async () => { - const octokit = { - repos: { - getContent: vi.fn().mockResolvedValue({ - data: { - type: "file", - content: "not-valid-base64!!!", - }, - }), - }, - } as unknown as Octokit; - - const gh = createLayerGithub(octokit); - await expect(gh.getRepoFileUtf8("o", "r", "p")).rejects.toThrow(/not valid base64/i); - }); -}); diff --git a/web/admin/src/lib/layers/githubClient.ts b/web/admin/src/lib/layers/githubClient.ts deleted file mode 100644 index 7ba8c257b0..0000000000 --- a/web/admin/src/lib/layers/githubClient.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { Octokit } from "@octokit/rest"; -import { RequestError } from "@octokit/request-error"; - -export type OrgSecretCheckResult = - | { kind: "ok"; exists: boolean } - | { kind: "forbidden" } - | { kind: "error"; message: string }; - -/** - * Minimal GitHub surface used by read-only layer Analyze ports. - * Implemented with Octokit in the SPA; mocked in Vitest. - */ -export type LayerGithub = { - getRepoExists(owner: string, repo: string): Promise; - /** File body as UTF-8, or null when the path is missing (404). */ - getRepoFileUtf8(owner: string, repo: string, path: string): Promise; - repoSecretExists(owner: string, repo: string, secretName: string): Promise; - repoVariableExists(owner: string, repo: string, varName: string): Promise; - orgSecretExists(org: string, secretName: string): Promise; -}; - -function isNotFound(err: unknown): boolean { - return err instanceof RequestError && err.status === 404; -} - -function decodeContentBase64(b64: string): string { - const normalized = b64.replace(/\n/g, ""); - let binary: string; - try { - binary = atob(normalized); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(`GitHub file content is not valid base64: ${msg}`, { cause: e }); - } - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return new TextDecoder("utf-8").decode(bytes); -} - -export function createLayerGithub(octokit: Octokit): LayerGithub { - return { - async getRepoExists(owner, repo) { - try { - await octokit.repos.get({ owner, repo }); - return true; - } catch (err) { - if (isNotFound(err)) return false; - throw err; - } - }, - - async getRepoFileUtf8(owner, repo, path) { - try { - const { data } = await octokit.repos.getContent({ - owner, - repo, - path, - }); - if (Array.isArray(data)) return null; - if (data.type !== "file" || !("content" in data) || typeof data.content !== "string") { - return null; - } - return decodeContentBase64(data.content); - } catch (err) { - if (isNotFound(err)) return null; - throw err; - } - }, - - async repoSecretExists(owner, repo, secretName) { - try { - await octokit.request("GET /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner, - repo, - secret_name: secretName, - }); - return true; - } catch (err) { - if (isNotFound(err)) return false; - throw err; - } - }, - - async repoVariableExists(owner, repo, varName) { - try { - await octokit.request("GET /repos/{owner}/{repo}/actions/variables/{name}", { - owner, - repo, - name: varName, - }); - return true; - } catch (err) { - if (isNotFound(err)) return false; - throw err; - } - }, - - async orgSecretExists(org, secretName) { - try { - const res = await octokit.request("GET /orgs/{org}/actions/secrets/{secret_name}", { - org, - secret_name: secretName, - }); - if (res.status === 200) return { kind: "ok", exists: true }; - return { kind: "ok", exists: false }; - } catch (err) { - if (isNotFound(err)) return { kind: "ok", exists: false }; - if (err instanceof RequestError && err.status === 403) { - return { kind: "forbidden" }; - } - if (err instanceof RequestError) { - return { kind: "error", message: err.message }; - } - return { kind: "error", message: String(err) }; - } - }, - }; -} diff --git a/web/admin/src/lib/layers/orgConfigParse.test.ts b/web/admin/src/lib/layers/orgConfigParse.test.ts deleted file mode 100644 index 85b7fe1f4c..0000000000 --- a/web/admin/src/lib/layers/orgConfigParse.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - agentsFromConfig, - enabledReposFromConfig, - MAX_ORG_CONFIG_YAML_DEPTH, - MAX_ORG_CONFIG_YAML_UTF8_BYTES, - OrgConfigYamlLimitError, - parseOrgConfigYaml, - validateOrgConfig, -} from "./orgConfigParse"; - -describe("validateOrgConfig", () => { - it("accepts minimal valid config", () => { - const cfg = parseOrgConfigYaml(`version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] - max_implementation_retries: 0 -repos: {} -`); - expect(validateOrgConfig(cfg)).toBeNull(); - }); - - it("rejects bad version", () => { - expect( - validateOrgConfig( - parseOrgConfigYaml(`version: "9" -dispatch: - platform: github-actions -`), - ), - ).toContain("unsupported version"); - }); - - it("rejects agents as a string", () => { - expect(() => - parseOrgConfigYaml(`version: "1" -dispatch: - platform: github-actions -agents: not-a-list -`), - ).toThrow(/agents must be a list/); - }); - - it("rejects repos as a list", () => { - expect(() => - parseOrgConfigYaml(`version: "1" -dispatch: - platform: github-actions -repos: [] -`), - ).toThrow(/repos must be a mapping/); - }); - - it("rejects non-integer max_implementation_retries", () => { - const cfg = parseOrgConfigYaml(`version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] - max_implementation_retries: 2.5 -repos: {} -`); - expect(validateOrgConfig(cfg)).toMatch(/non-negative integer/); - }); - - it("rejects invalid agent role", () => { - const cfg = parseOrgConfigYaml(`version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] -agents: - - role: not-a-valid-role -repos: {} -`); - expect(validateOrgConfig(cfg)).toMatch(/invalid agent role/); - }); - - it("lists agents and enabled repos from config", () => { - const cfg = parseOrgConfigYaml(`version: "1" -dispatch: - platform: github-actions -defaults: - roles: [fullsend] -agents: - - role: triage - slug: t -repos: - zed: - enabled: false - alpha: - enabled: true - beta: - enabled: true -`); - expect(validateOrgConfig(cfg)).toBeNull(); - expect(agentsFromConfig(cfg)).toEqual([{ role: "triage" }]); - expect(enabledReposFromConfig(cfg)).toEqual(["alpha", "beta"]); - }); - - it("rejects YAML larger than the UTF-8 byte limit with a clear message", () => { - const oversize = " ".repeat(MAX_ORG_CONFIG_YAML_UTF8_BYTES + 1); - expect(() => parseOrgConfigYaml(oversize)).toThrow(OrgConfigYamlLimitError); - try { - parseOrgConfigYaml(oversize); - } catch (e) { - expect(e).toBeInstanceOf(OrgConfigYamlLimitError); - expect((e as Error).message).toContain("maximum file size"); - expect((e as Error).message).toMatch(/\d+ bytes/); - } - }); - - it("rejects YAML nested deeper than the depth limit with a clear message", () => { - const lines: string[] = ['version: "1"', "dispatch:", " platform: github-actions"]; - let indent = " "; - for (let i = 0; i < MAX_ORG_CONFIG_YAML_DEPTH + 2; i++) { - lines.push(`${indent}L${i}:`); - indent += " "; - } - lines.push(`${indent}x: 1`); - const deep = lines.join("\n"); - expect(() => parseOrgConfigYaml(deep)).toThrow(OrgConfigYamlLimitError); - try { - parseOrgConfigYaml(deep); - } catch (e) { - expect(e).toBeInstanceOf(OrgConfigYamlLimitError); - expect((e as Error).message).toContain("nested too deeply"); - expect((e as Error).message).toContain(String(MAX_ORG_CONFIG_YAML_DEPTH)); - } - }); - - it("rejects flow-style sequence nesting deeper than the depth limit", () => { - let flow = "1"; - for (let i = 0; i < MAX_ORG_CONFIG_YAML_DEPTH + 3; i++) { - flow = `[${flow}]`; - } - const doc = `version: "1" -dispatch: - platform: github-actions -k: ${flow} -`; - expect(() => parseOrgConfigYaml(doc)).toThrow(OrgConfigYamlLimitError); - }); -}); diff --git a/web/admin/src/lib/layers/orgConfigParse.ts b/web/admin/src/lib/layers/orgConfigParse.ts deleted file mode 100644 index 229fed8fff..0000000000 --- a/web/admin/src/lib/layers/orgConfigParse.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { parse } from "yaml"; - -/** Parsed shape of `config.yaml` (mirrors `internal/config/config.go`). */ -export type OrgConfigYaml = { - version?: string; - dispatch?: { platform?: string }; - defaults?: { - roles?: string[]; - max_implementation_retries?: number; - auto_merge?: boolean; - }; - agents?: { role: string; name?: string; slug?: string }[]; - repos?: Record; -}; - -const VALID_ROLES = new Set(["fullsend", "triage", "coder", "review"]); - -/** 512 KiB — more than sufficient for any realistic org `config.yaml`. */ -export const MAX_ORG_CONFIG_YAML_UTF8_BYTES = 512 * 1024; - -/** - * Maximum nesting depth of mappings and sequences after parse (mitigates YAML bombs). - * Real configs are shallow; this is intentionally generous. - */ -export const MAX_ORG_CONFIG_YAML_DEPTH = 64; - -/** Thrown when `config.yaml` exceeds size or structural depth limits (see parse helpers). */ -export class OrgConfigYamlLimitError extends Error { - constructor(message: string) { - super(message); - this.name = "OrgConfigYamlLimitError"; - } -} - -function utf8ByteLength(s: string): number { - return new TextEncoder().encode(s).length; -} - -/** - * Deepest path from `value` through nested objects/arrays (scalar leaves report their `depth`). - * Stops recursing once depth would exceed {@link MAX_ORG_CONFIG_YAML_DEPTH} so flow-style - * nesting cannot force a full deep walk before the limit check. - */ -function measureYamlTreeDepth(value: unknown, depth: number): number { - if (value === null || typeof value !== "object") return depth; - if (depth >= MAX_ORG_CONFIG_YAML_DEPTH) { - return depth + 1; - } - if (Array.isArray(value)) { - let m = depth; - for (const el of value) { - m = Math.max(m, measureYamlTreeDepth(el, depth + 1)); - if (m > MAX_ORG_CONFIG_YAML_DEPTH) return m; - } - return m; - } - let m = depth; - for (const k of Object.keys(value as object)) { - m = Math.max(m, measureYamlTreeDepth((value as Record)[k], depth + 1)); - if (m > MAX_ORG_CONFIG_YAML_DEPTH) return m; - } - return m; -} - -export function parseOrgConfigYaml(data: string): OrgConfigYaml { - const bytes = utf8ByteLength(data); - if (bytes > MAX_ORG_CONFIG_YAML_UTF8_BYTES) { - throw new OrgConfigYamlLimitError( - `Organisation config YAML exceeds the maximum file size (limit ${MAX_ORG_CONFIG_YAML_UTF8_BYTES} bytes, 512 KiB). This file is ${bytes} bytes. Reduce the file size to continue.`, - ); - } - - let doc: unknown; - try { - doc = parse(data, { schema: "core", version: "1.2" }) as unknown; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(`parsing org config YAML: ${msg}`, { cause: e }); - } - - if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { - throw new Error("parsing org config: root must be a mapping"); - } - - const deepest = measureYamlTreeDepth(doc, 0); - if (deepest > MAX_ORG_CONFIG_YAML_DEPTH) { - throw new OrgConfigYamlLimitError( - `Organisation config YAML is nested too deeply (depth ${deepest}, maximum ${MAX_ORG_CONFIG_YAML_DEPTH}). Simplify mapping and list nesting so the document stays within the limit.`, - ); - } - - assertOrgConfigShape(doc as Record); - return doc as OrgConfigYaml; -} - -/** Runtime shape checks so callers do not hit confusing errors from bad YAML types. */ -function assertOrgConfigShape(doc: Record): void { - if ("dispatch" in doc && doc.dispatch !== undefined) { - if (doc.dispatch === null || typeof doc.dispatch !== "object" || Array.isArray(doc.dispatch)) { - throw new Error("parsing org config: dispatch must be a mapping"); - } - } - if ("defaults" in doc && doc.defaults !== undefined) { - if (doc.defaults === null || typeof doc.defaults !== "object" || Array.isArray(doc.defaults)) { - throw new Error("parsing org config: defaults must be a mapping"); - } - } - if ("agents" in doc && doc.agents !== undefined) { - if (!Array.isArray(doc.agents)) { - throw new Error("parsing org config: agents must be a list"); - } - for (let i = 0; i < doc.agents.length; i++) { - const el = doc.agents[i]; - if (el === null || typeof el !== "object" || Array.isArray(el)) { - throw new Error(`parsing org config: agents[${i}] must be a mapping with a string role`); - } - const role = (el as Record).role; - if (typeof role !== "string") { - throw new Error(`parsing org config: agents[${i}].role must be a string`); - } - } - } - if ("repos" in doc && doc.repos !== undefined) { - if (doc.repos === null || typeof doc.repos !== "object" || Array.isArray(doc.repos)) { - throw new Error("parsing org config: repos must be a mapping"); - } - for (const [name, v] of Object.entries(doc.repos as Record)) { - if (v !== null && (typeof v !== "object" || Array.isArray(v))) { - throw new Error(`parsing org config: repos.${JSON.stringify(name)} must be a mapping`); - } - } - } -} - -/** @returns null if valid, otherwise a human-readable error string (matches Go Validate errors). */ -export function validateOrgConfig(cfg: OrgConfigYaml): string | null { - if (cfg.version !== "1") { - return `unsupported version ${JSON.stringify(cfg.version)}: must be "1"`; - } - if (cfg.dispatch?.platform !== "github-actions") { - return `unsupported platform ${JSON.stringify(cfg.dispatch?.platform)}: must be "github-actions"`; - } - const retries = cfg.defaults?.max_implementation_retries; - if (retries !== undefined && retries !== null) { - if ( - typeof retries !== "number" || - !Number.isFinite(retries) || - !Number.isInteger(retries) || - retries < 0 - ) { - return `max_implementation_retries must be a non-negative integer, got ${JSON.stringify(retries)}`; - } - } - for (const role of cfg.defaults?.roles ?? []) { - if (!VALID_ROLES.has(role)) { - return `invalid role ${JSON.stringify(role)}: must be one of fullsend, triage, coder, review`; - } - } - for (const agent of cfg.agents ?? []) { - if (!VALID_ROLES.has(agent.role)) { - return `invalid agent role ${JSON.stringify(agent.role)}: must be one of fullsend, triage, coder, review`; - } - } - return null; -} - -/** Agent rows for secrets-layer analyze (mirrors `config.OrgConfig.Agents`). */ -export function agentsFromConfig(cfg: OrgConfigYaml): { role: string }[] { - return (cfg.agents ?? []).map((a) => ({ role: a.role })); -} - -/** Enabled repo names for enrollment-layer analyze (sorted). */ -export function enabledReposFromConfig(cfg: OrgConfigYaml): string[] { - const repos = cfg.repos ?? {}; - return Object.entries(repos) - .filter(([, v]) => v?.enabled === true) - .map(([name]) => name) - .sort((a, b) => a.localeCompare(b)); -} diff --git a/web/admin/src/lib/layers/preflight.test.ts b/web/admin/src/lib/layers/preflight.test.ts deleted file mode 100644 index 4462bc3e52..0000000000 --- a/web/admin/src/lib/layers/preflight.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Octokit } from "@octokit/rest"; -import { describe, expect, it, vi } from "vitest"; -import { - clearOAuthScopeHeaderCache, - computePreflight, - parseXOauthScopesHeader, - preflightOk, - readTokenScopesHeaderCached, -} from "./preflight"; - -describe("parseXOauthScopesHeader", () => { - it("returns null for empty or missing", () => { - expect(parseXOauthScopesHeader(undefined)).toBeNull(); - expect(parseXOauthScopesHeader("")).toBeNull(); - expect(parseXOauthScopesHeader(" ")).toBeNull(); - }); - - it("splits comma-separated scopes", () => { - expect(parseXOauthScopesHeader("repo, workflow")).toEqual(["repo", "workflow"]); - }); -}); - -describe("computePreflight", () => { - it("marks skipped when granted unknown", () => { - const r = computePreflight(["repo", "admin:org"], null); - expect(r.skipped).toBe(true); - expect(r.missing).toEqual([]); - expect(preflightOk(r)).toBe(true); - }); - - it("lists missing scopes", () => { - const r = computePreflight(["repo", "workflow"], ["repo"]); - expect(r.skipped).toBe(false); - expect(r.missing).toEqual(["workflow"]); - expect(preflightOk(r)).toBe(false); - }); - - it("ok when all present", () => { - const r = computePreflight(["repo"], ["repo", "read:org"]); - expect(preflightOk(r)).toBe(true); - }); -}); - -describe("readTokenScopesHeaderCached", () => { - it("issues one HEAD /user per access token until cache clear", async () => { - clearOAuthScopeHeaderCache(); - const request = vi.fn().mockResolvedValue({ - headers: { "x-oauth-scopes": "repo, workflow" }, - }); - const octokit = { request } as unknown as Octokit; - - await expect(readTokenScopesHeaderCached(octokit, "tok-a")).resolves.toEqual([ - "repo", - "workflow", - ]); - await readTokenScopesHeaderCached(octokit, "tok-a"); - expect(request).toHaveBeenCalledTimes(1); - - await readTokenScopesHeaderCached(octokit, "tok-b"); - expect(request).toHaveBeenCalledTimes(2); - - clearOAuthScopeHeaderCache(); - await readTokenScopesHeaderCached(octokit, "tok-a"); - expect(request).toHaveBeenCalledTimes(3); - }); -}); diff --git a/web/admin/src/lib/layers/preflight.ts b/web/admin/src/lib/layers/preflight.ts deleted file mode 100644 index 01bbcf32ba..0000000000 --- a/web/admin/src/lib/layers/preflight.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Octokit } from "@octokit/rest"; - -/** - * Mirrors `PreflightResult` from `internal/layers/preflight.go`. - */ -export type PreflightResult = { - required: string[]; - granted: string[] | null; - missing: string[]; - skipped: boolean; -}; - -export function parseXOauthScopesHeader(header: string | undefined): string[] | null { - if (header === undefined || header.trim() === "") { - return null; - } - return header - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); -} - -/** - * Compare required OAuth scopes to granted list. - * When `granted` is null (no `X-OAuth-Scopes`, e.g. GitHub App user-to-server tokens), - * returns `skipped: true` per Go `Stack.Preflight`. The org list then uses - * `installReadinessProbes.ts` instead of assuming classic scopes exist. - */ -export function computePreflight(required: string[], granted: string[] | null): PreflightResult { - if (required.length === 0) { - return { required: [], granted: granted ?? null, missing: [], skipped: false }; - } - if (granted === null) { - return { required, granted: null, missing: [], skipped: true }; - } - const grantedSet = new Set(granted); - const missing = required.filter((scope) => !grantedSet.has(scope)); - return { required, granted, missing, skipped: false }; -} - -export function preflightOk(result: PreflightResult): boolean { - return result.skipped || result.missing.length === 0; -} - -/** Reads `X-OAuth-Scopes` from `HEAD /user` (classic OAuth / PAT only). */ -export async function readTokenScopesHeader(octokit: Octokit): Promise { - const { headers } = await octokit.request("HEAD /user"); - const raw = headers["x-oauth-scopes"] as string | string[] | undefined; - let joined: string | undefined; - if (typeof raw === "string") { - joined = raw; - } else if (Array.isArray(raw)) { - joined = raw.join(","); - } - return parseXOauthScopesHeader(joined); -} - -let scopeHeaderCacheToken: string | null = null; -/** `undefined` means unset; `null` is a valid cached “no scopes header” result. */ -let scopeHeaderCacheResult: string[] | null | undefined; - -/** Cleared on sign-out so the next session always re-reads headers. */ -export function clearOAuthScopeHeaderCache(): void { - scopeHeaderCacheToken = null; - scopeHeaderCacheResult = undefined; -} - -/** - * Same as {@link readTokenScopesHeader}, but at most **one** `HEAD /user` per stored - * access token for the SPA lifetime (until {@link clearOAuthScopeHeaderCache}). - * Org list re-runs preflight whenever `displayedOrgs` changes; without this, each run - * would hit GitHub’s `/user` quota again. - */ -export async function readTokenScopesHeaderCached( - octokit: Octokit, - accessToken: string, -): Promise { - if (!accessToken) { - return readTokenScopesHeader(octokit); - } - if (scopeHeaderCacheToken === accessToken && scopeHeaderCacheResult !== undefined) { - return scopeHeaderCacheResult; - } - const result = await readTokenScopesHeader(octokit); - scopeHeaderCacheToken = accessToken; - scopeHeaderCacheResult = result; - return result; -} diff --git a/web/admin/src/lib/layers/secrets.test.ts b/web/admin/src/lib/layers/secrets.test.ts deleted file mode 100644 index 47e6728289..0000000000 --- a/web/admin/src/lib/layers/secrets.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LayerGithub } from "./githubClient"; -import { analyzeSecretsLayer, secretNameForRole, variableNameForRole } from "./secrets"; - -function mockGh(opts: { secrets?: Set; variables?: Set }): LayerGithub { - const secrets = opts.secrets ?? new Set(); - const variables = opts.variables ?? new Set(); - return { - getRepoExists: async () => true, - getRepoFileUtf8: async () => null, - repoSecretExists: async (_o, _r, name) => secrets.has(name), - repoVariableExists: async (_o, _r, name) => variables.has(name), - orgSecretExists: async () => ({ kind: "ok", exists: false }), - }; -} - -describe("analyzeSecretsLayer", () => { - it("naming matches Go helpers", () => { - expect(secretNameForRole("coder")).toBe("FULLSEND_CODER_APP_PRIVATE_KEY"); - expect(variableNameForRole("coder")).toBe("FULLSEND_CODER_CLIENT_ID"); - }); - - it("installed when all secrets and variables exist", async () => { - const r = await analyzeSecretsLayer( - "acme", - mockGh({ - secrets: new Set(["FULLSEND_TRIAGE_APP_PRIVATE_KEY"]), - variables: new Set(["FULLSEND_TRIAGE_CLIENT_ID"]), - }), - [{ role: "triage" }], - ); - expect(r.status).toBe("installed"); - expect(r.details).toContain("FULLSEND_TRIAGE_APP_PRIVATE_KEY exists"); - expect(r.details).toContain("FULLSEND_TRIAGE_CLIENT_ID exists"); - }); - - it("not_installed when nothing present", async () => { - const r = await analyzeSecretsLayer("acme", mockGh({}), [{ role: "fullsend" }]); - expect(r.status).toBe("not_installed"); - expect(r.wouldInstall).toEqual([ - "create FULLSEND_FULLSEND_APP_PRIVATE_KEY", - "create FULLSEND_FULLSEND_CLIENT_ID", - ]); - }); - - it("degraded when partially present", async () => { - const r = await analyzeSecretsLayer( - "acme", - mockGh({ - secrets: new Set(["FULLSEND_FULLSEND_APP_PRIVATE_KEY"]), - variables: new Set(), - }), - [{ role: "fullsend" }], - ); - expect(r.status).toBe("degraded"); - expect(r.wouldFix).toContain("create missing FULLSEND_FULLSEND_CLIENT_ID"); - }); - - it("installed with no agents configured", async () => { - const r = await analyzeSecretsLayer("acme", mockGh({}), []); - expect(r.status).toBe("installed"); - expect(r.details).toEqual([]); - }); -}); diff --git a/web/admin/src/lib/layers/secrets.ts b/web/admin/src/lib/layers/secrets.ts deleted file mode 100644 index 1c572d3451..0000000000 --- a/web/admin/src/lib/layers/secrets.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { LayerReport } from "../status/types"; -import { CONFIG_REPO_NAME } from "./constants"; -import type { LayerGithub } from "./githubClient"; - -export function secretNameForRole(role: string): string { - return `FULLSEND_${role.toUpperCase()}_APP_PRIVATE_KEY`; -} - -export function variableNameForRole(role: string): string { - return `FULLSEND_${role.toUpperCase()}_CLIENT_ID`; -} - -/** - * Read-only port of `SecretsLayer.Analyze` (`internal/layers/secrets.go`). - */ -export async function analyzeSecretsLayer( - org: string, - gh: LayerGithub, - agents: { role: string }[], -): Promise { - const report: LayerReport = { - name: "secrets", - status: "unknown", - details: [], - wouldInstall: [], - wouldFix: [], - }; - - const present: string[] = []; - const missing: string[] = []; - - for (const agent of agents) { - const sName = secretNameForRole(agent.role); - if (await gh.repoSecretExists(org, CONFIG_REPO_NAME, sName)) { - present.push(sName); - } else { - missing.push(sName); - } - - const vName = variableNameForRole(agent.role); - if (await gh.repoVariableExists(org, CONFIG_REPO_NAME, vName)) { - present.push(vName); - } else { - missing.push(vName); - } - } - - if (missing.length === 0) { - report.status = "installed"; - for (const name of present) { - report.details.push(`${name} exists`); - } - } else if (present.length === 0) { - report.status = "not_installed"; - for (const name of missing) { - report.wouldInstall.push(`create ${name}`); - } - } else { - report.status = "degraded"; - for (const name of present) { - report.details.push(`${name} exists`); - } - for (const name of missing) { - report.wouldFix.push(`create missing ${name}`); - } - } - - return report; -} diff --git a/web/admin/src/lib/layers/workflows.test.ts b/web/admin/src/lib/layers/workflows.test.ts deleted file mode 100644 index 12da1109a0..0000000000 --- a/web/admin/src/lib/layers/workflows.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LayerGithub } from "./githubClient"; -import { analyzeWorkflowsLayer } from "./workflows"; -import { AGENT_WORKFLOW_PATH, CODEOWNERS_PATH, ONBOARD_WORKFLOW_PATH } from "./constants"; - -function mockGh(map: Record): LayerGithub { - return { - getRepoExists: async () => true, - getRepoFileUtf8: async (_o, _r, path) => map[path] ?? null, - repoSecretExists: async () => false, - repoVariableExists: async () => false, - orgSecretExists: async () => ({ kind: "ok", exists: false }), - }; -} - -describe("analyzeWorkflowsLayer", () => { - it("installed when all managed files exist", async () => { - const stub = "x"; - const r = await analyzeWorkflowsLayer( - "acme", - mockGh({ - [AGENT_WORKFLOW_PATH]: stub, - [ONBOARD_WORKFLOW_PATH]: stub, - [CODEOWNERS_PATH]: stub, - }), - ); - expect(r.status).toBe("installed"); - expect(r.details).toHaveLength(3); - expect(r.wouldInstall).toEqual([]); - expect(r.wouldFix).toEqual([]); - }); - - it("not_installed when all missing", async () => { - const r = await analyzeWorkflowsLayer("acme", mockGh({})); - expect(r.status).toBe("not_installed"); - expect(r.wouldInstall).toEqual([ - `write ${AGENT_WORKFLOW_PATH}`, - `write ${ONBOARD_WORKFLOW_PATH}`, - `write ${CODEOWNERS_PATH}`, - ]); - }); - - it("degraded when partially present", async () => { - const r = await analyzeWorkflowsLayer("acme", mockGh({ [AGENT_WORKFLOW_PATH]: "a" })); - expect(r.status).toBe("degraded"); - expect(r.details).toEqual([`${AGENT_WORKFLOW_PATH} exists`]); - expect(r.wouldFix).toEqual([`write ${ONBOARD_WORKFLOW_PATH}`, `write ${CODEOWNERS_PATH}`]); - }); -}); diff --git a/web/admin/src/lib/layers/workflows.ts b/web/admin/src/lib/layers/workflows.ts deleted file mode 100644 index a7027382cd..0000000000 --- a/web/admin/src/lib/layers/workflows.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { LayerReport } from "../status/types"; -import { CONFIG_REPO_NAME, WORKFLOWS_MANAGED_FILES } from "./constants"; -import type { LayerGithub } from "./githubClient"; - -/** - * Read-only port of `WorkflowsLayer.Analyze` (`internal/layers/workflows.go`). - */ -export async function analyzeWorkflowsLayer(org: string, gh: LayerGithub): Promise { - const report: LayerReport = { - name: "workflows", - status: "unknown", - details: [], - wouldInstall: [], - wouldFix: [], - }; - - const present: string[] = []; - const missing: string[] = []; - - for (const path of WORKFLOWS_MANAGED_FILES) { - const body = await gh.getRepoFileUtf8(org, CONFIG_REPO_NAME, path); - if (body === null) { - missing.push(path); - } else { - present.push(path); - } - } - - if (missing.length === 0) { - report.status = "installed"; - for (const p of present) { - report.details.push(`${p} exists`); - } - } else if (present.length === 0) { - report.status = "not_installed"; - for (const m of missing) { - report.wouldInstall.push(`write ${m}`); - } - } else { - report.status = "degraded"; - for (const p of present) { - report.details.push(`${p} exists`); - } - for (const m of missing) { - report.wouldFix.push(`write ${m}`); - } - } - - return report; -} diff --git a/web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.test.ts b/web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.test.ts deleted file mode 100644 index e6df6d31e9..0000000000 --- a/web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { Octokit } from "@octokit/rest"; -import { batchOrganizationsFullsendRepoExists } from "./batchOrganizationsFullsendRepoGraphql"; - -function mockOctokit(request: ReturnType): Octokit { - return { request } as unknown as Octokit; -} - -describe("batchOrganizationsFullsendRepoExists", () => { - it("maps repository id to true and missing repo to false", async () => { - const request = vi.fn().mockResolvedValue({ - data: { - data: { - o0: { repository: { id: "R1" } }, - o1: { repository: null }, - }, - }, - }); - const map = await batchOrganizationsFullsendRepoExists(mockOctokit(request), [ - "Acme", - "Beta-Org", - ]); - expect(request).toHaveBeenCalledTimes(1); - expect(map.get("acme")).toBe(true); - expect(map.get("beta-org")).toBe(false); - }); - - it("maps null organization to unknown (null)", async () => { - const request = vi.fn().mockResolvedValue({ - data: { - data: { - o0: null, - }, - }, - }); - const map = await batchOrganizationsFullsendRepoExists(mockOctokit(request), ["ghost"]); - expect(map.get("ghost")).toBeNull(); - }); - - it("leaves hints null when GraphQL returns errors", async () => { - const request = vi.fn().mockResolvedValue({ - data: { - data: {}, - errors: [{ message: "Something went wrong" }], - }, - }); - const map = await batchOrganizationsFullsendRepoExists(mockOctokit(request), ["acme"]); - expect(map.get("acme")).toBeNull(); - }); - - it("chunks requests for many orgs", async () => { - const request = vi.fn().mockImplementation(async () => ({ - data: { - data: Object.fromEntries( - Array.from({ length: 10 }, (_, j) => [`o${j}`, { repository: null }]), - ), - }, - })); - const logins = Array.from({ length: 25 }, (_, i) => `org${i}`); - await batchOrganizationsFullsendRepoExists(mockOctokit(request), logins); - expect(request).toHaveBeenCalledTimes(3); - }); -}); diff --git a/web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.ts b/web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.ts deleted file mode 100644 index 8a1e166c32..0000000000 --- a/web/admin/src/lib/orgs/batchOrganizationsFullsendRepoGraphql.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Octokit } from "@octokit/rest"; - -const REPO = ".fullsend"; -/** Keep each GraphQL document small to avoid complexity limits. */ -const CHUNK = 10; - -/** - * Escapes `"` and `\` for use inside GraphQL double-quoted strings. - * Safe only with inputs already constrained (e.g. org logins validated with - * `/^[a-zA-Z0-9-]+$/` before interpolation); not a general-purpose GraphQL - * literal encoder (control characters are not escaped). - */ -function escapeGraphqlString(s: string): string { - return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); -} - -/** - * One GraphQL round-trip per chunk: for each org, whether `owner/.fullsend` exists. - * Returns `null` for an org when the hint is unknown (GraphQL error, org field null, etc.). - */ -export async function batchOrganizationsFullsendRepoExists( - octokit: Octokit, - logins: readonly string[], -): Promise> { - const out = new Map(); - for (const raw of logins) { - out.set(raw.trim().toLowerCase(), null); - } - - for (let start = 0; start < logins.length; start += CHUNK) { - const chunk = logins.slice(start, start + CHUNK); - const parts: string[] = []; - const aliasKeys: string[] = []; - for (let i = 0; i < chunk.length; i++) { - const login = chunk[i]!.trim(); - const key = login.toLowerCase(); - if (!/^[a-zA-Z0-9-]+$/.test(login)) { - continue; - } - const alias = `o${parts.length}`; - aliasKeys.push(key); - const escLogin = escapeGraphqlString(login); - const escRepo = escapeGraphqlString(REPO); - parts.push( - `${alias}: organization(login: "${escLogin}") { repository(name: "${escRepo}") { id } }`, - ); - } - if (parts.length === 0) { - continue; - } - const query = `query BatchFullsendRepo {\n${parts.join("\n")}\n}`; - try { - const res = await octokit.request("POST /graphql", { query }); - const body = res.data as { - data?: Record | null; - errors?: { message: string }[]; - }; - if (body.errors?.length) { - continue; - } - const gqlData = body.data; - if (!gqlData) { - continue; - } - for (let j = 0; j < parts.length; j++) { - const alias = `o${j}`; - const key = aliasKeys[j]!; - const node = gqlData[alias]; - if (node === undefined) { - continue; - } - if (node === null) { - out.set(key, null); - continue; - } - out.set(key, node.repository != null); - } - } catch { - /* leave null for this chunk */ - } - } - - return out; -} diff --git a/web/admin/src/lib/orgs/deployOAuthScopes.test.ts b/web/admin/src/lib/orgs/deployOAuthScopes.test.ts deleted file mode 100644 index fc4c8077cb..0000000000 --- a/web/admin/src/lib/orgs/deployOAuthScopes.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deployRequiredOAuthScopes } from "./deployOAuthScopes"; - -describe("deployRequiredOAuthScopes", () => { - it("matches Go CollectRequiredScopes(OpInstall) dedupe", () => { - expect([...deployRequiredOAuthScopes()]).toEqual(["repo", "workflow", "admin:org"]); - }); -}); diff --git a/web/admin/src/lib/orgs/deployOAuthScopes.ts b/web/admin/src/lib/orgs/deployOAuthScopes.ts deleted file mode 100644 index 210b457654..0000000000 --- a/web/admin/src/lib/orgs/deployOAuthScopes.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Classic OAuth scopes required for `fullsend admin install` (Go `Stack.CollectRequiredScopes(OpInstall)`). - * Used in the admin SPA only when GitHub returns `X-OAuth-Scopes` on `HEAD /user`. - * GitHub App user tokens usually omit that header — use read-only API probes instead - * (`installReadinessProbes.ts`). - * @see internal/layers/configrepo.go, workflows.go, secrets.go, enrollment.go, dispatch.go — RequiredScopes(OpInstall) - */ -export function deployRequiredOAuthScopes(): readonly string[] { - return ["repo", "workflow", "admin:org"] as const; -} diff --git a/web/admin/src/lib/orgs/emptyOrgListHint.test.ts b/web/admin/src/lib/orgs/emptyOrgListHint.test.ts deleted file mode 100644 index 6f10d14e34..0000000000 --- a/web/admin/src/lib/orgs/emptyOrgListHint.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildEmptyInstallationsHint } from "./emptyOrgListHint"; - -describe("buildEmptyInstallationsHint", () => { - it("returns non-empty guidance string", () => { - const h = buildEmptyInstallationsHint(); - expect(h.length).toBeGreaterThan(40); - expect(h.toLowerCase()).toContain("no github organisations"); - expect(h.toLowerCase()).toContain("fullsend"); - expect(h.toLowerCase()).toContain("add the app"); - }); -}); diff --git a/web/admin/src/lib/orgs/emptyOrgListHint.ts b/web/admin/src/lib/orgs/emptyOrgListHint.ts deleted file mode 100644 index 73aa6f136a..0000000000 --- a/web/admin/src/lib/orgs/emptyOrgListHint.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copy when the org picker is empty after listing **GitHub App installations** - * (`GET /user/installations`). - */ - -/** - * Shown when the installations API returned **200** and there are **no** organisation - * installations in the aggregated result (success-empty, not an error). - */ -export function buildEmptyInstallationsHint(): string { - return ( - "No GitHub organisations in this list have the Fullsend app installed for your account yet. " + - "Use “Add the app…” below to install it on the organisations you administer." - ); -} diff --git a/web/admin/src/lib/orgs/fetchOrgs.test.ts b/web/admin/src/lib/orgs/fetchOrgs.test.ts deleted file mode 100644 index 1c8943ea53..0000000000 --- a/web/admin/src/lib/orgs/fetchOrgs.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../github/client", () => ({ - createUserOctokit: vi.fn(), -})); - -import { createUserOctokit } from "../github/client"; -import { - clearOrgListMemoryCache, - fetchOrgs, - fetchOrgsWithProgress, - FetchOrgsError, - MAX_INSTALLATION_LIST_PAGES, -} from "./fetchOrgs"; - -const testLogin = "octouser"; - -function mockOctokit(iterator: () => AsyncIterableIterator) { - vi.mocked(createUserOctokit).mockReturnValue({ - paginate: { - iterator: vi.fn(iterator), - }, - rest: { - apps: { - listInstallationsForAuthenticatedUser: {}, - }, - }, - } as never); -} - -describe("fetchOrgs (installations)", () => { - beforeEach(() => { - clearOrgListMemoryCache(); - vi.mocked(createUserOctokit).mockReset(); - }); - - it("maps installations when page.data is a bare array (paginator shape)", async () => { - mockOctokit(() => - (async function* () { - yield { - status: 200, - data: [ - { - id: 1, - app_slug: "fullsend-admin", - account: { login: "array-org", type: "Organization" }, - }, - ], - }; - })(), - ); - - const r = await fetchOrgs("token", { - githubLogin: testLogin, - force: true, - }); - expect(r.orgs.map((o) => o.login)).toEqual(["array-org"]); - expect(r.appSlugFromApi).toBe("fullsend-admin"); - expect(r.installationListTruncated).toBe(false); - }); - - it("maps Organization installations and returns appSlugFromApi", async () => { - mockOctokit(() => - (async function* () { - yield { - status: 200, - data: { - installations: [ - { - id: 1, - app_slug: "fullsend-app", - account: { login: "zebra", type: "Organization" }, - }, - { - id: 2, - app_slug: "fullsend-app", - account: { login: "alpha", type: "Organization" }, - }, - ], - }, - }; - })(), - ); - - const r = await fetchOrgs("token", { - githubLogin: testLogin, - force: true, - }); - expect(r.orgs.map((o) => o.login)).toEqual(["alpha", "zebra"]); - expect(r.emptyHint).toBeNull(); - expect(r.appSlugFromApi).toBe("fullsend-app"); - expect(r.installationListTruncated).toBe(false); - }); - - it("returns emptyHint when no org installations", async () => { - mockOctokit(() => - (async function* () { - yield { - status: 200, - data: { - installations: [{ id: 1, account: { login: "alice", type: "User" } }], - }, - }; - })(), - ); - - const r = await fetchOrgs("token", { - githubLogin: testLogin, - force: true, - }); - expect(r.orgs).toEqual([]); - expect(r.emptyHint).toBeTruthy(); - expect(r.appSlugFromApi).toBeNull(); - expect(r.installationListTruncated).toBe(false); - }); - - it("sets installationListTruncated when pagination hits the page cap", async () => { - mockOctokit(() => - (async function* () { - for (let p = 0; p < MAX_INSTALLATION_LIST_PAGES + 1; p++) { - yield { - status: 200, - data: { - installations: [ - { - id: p, - app_slug: "fullsend-app", - account: { - login: `org-page-${p}`, - type: "Organization", - }, - }, - ], - }, - }; - } - })(), - ); - - const r = await fetchOrgs("token", { - githubLogin: testLogin, - force: true, - }); - expect(r.installationListTruncated).toBe(true); - expect(r.orgs.map((o) => o.login)).not.toContain("org-page-20"); - }); - - it("throws FetchOrgsError for 401 (Octokit hook notifies in production; not duplicated here)", async () => { - mockOctokit(() => - // eslint-disable-next-line require-yield - (async function* () { - throw Object.assign(new Error("Unauthorized"), { status: 401 }); - })(), - ); - - await expect(fetchOrgs("token", { githubLogin: testLogin, force: true })).rejects.toSatisfy( - (e: unknown) => - e instanceof FetchOrgsError && e.status === 401 && e.message.includes("sign in again"), - ); - }); - - it("throws FetchOrgsError for 403", async () => { - mockOctokit(() => - // eslint-disable-next-line require-yield - (async function* () { - throw Object.assign(new Error("Forbidden"), { status: 403 }); - })(), - ); - - await expect(fetchOrgs("token", { githubLogin: testLogin, force: true })).rejects.toSatisfy( - (e: unknown) => e instanceof FetchOrgsError && e.status === 403 && e.message.includes("403"), - ); - }); - - it("throws AbortError when signal is already aborted", async () => { - const ac = new AbortController(); - ac.abort(); - - mockOctokit(() => - (async function* () { - yield { status: 200, data: { installations: [] } }; - })(), - ); - - await expect( - fetchOrgs("token", { - githubLogin: testLogin, - force: true, - signal: ac.signal, - }), - ).rejects.toMatchObject({ name: "AbortError" }); - }); - - it("calls onProgress with installationPagesFetched", async () => { - mockOctokit(() => - (async function* () { - yield { - status: 200, - data: { - installations: [{ account: { login: "a", type: "Organization" }, app_slug: "x" }], - }, - }; - yield { - status: 200, - data: { - installations: [{ account: { login: "b", type: "Organization" }, app_slug: "x" }], - }, - }; - })(), - ); - - const metas: { done: boolean; installationPagesFetched: number }[] = []; - await fetchOrgsWithProgress("token", { - githubLogin: testLogin, - force: true, - onProgress: (_orgs, meta) => { - metas.push({ ...meta }); - }, - }); - - expect(metas.length).toBeGreaterThanOrEqual(2); - expect(metas.at(-1)?.done).toBe(true); - expect(metas.at(-1)?.installationPagesFetched).toBe(2); - }); - - it("reuses in-memory org list keyed by GitHub login (different token, no second paginator)", async () => { - mockOctokit(() => - (async function* () { - yield { - status: 200, - data: { - installations: [ - { - id: 1, - app_slug: "fullsend-app", - account: { login: "acme", type: "Organization" }, - }, - ], - }, - }; - })(), - ); - - const login = "CasePreserved_Login"; - const onProgress = vi.fn(); - await fetchOrgsWithProgress("first-token", { - githubLogin: login, - force: true, - onProgress, - }); - expect(createUserOctokit).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalled(); - - onProgress.mockClear(); - const r2 = await fetchOrgsWithProgress("second-token", { - githubLogin: login, - force: false, - onProgress, - }); - expect(createUserOctokit).toHaveBeenCalledTimes(1); - expect(r2.orgs.map((o) => o.login)).toEqual(["acme"]); - expect(onProgress).toHaveBeenCalledTimes(1); - const last = onProgress.mock.calls.at(-1)![1] as { - done: boolean; - installationPagesFetched: number; - }; - expect(last.done).toBe(true); - expect(last.installationPagesFetched).toBe(0); - }); -}); diff --git a/web/admin/src/lib/orgs/fetchOrgs.ts b/web/admin/src/lib/orgs/fetchOrgs.ts deleted file mode 100644 index a62a888073..0000000000 --- a/web/admin/src/lib/orgs/fetchOrgs.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { createUserOctokit } from "../github/client"; -import { buildEmptyInstallationsHint } from "./emptyOrgListHint"; -import type { OrgRow } from "./filter"; -import { orgRowsAndSlugFromInstallations, type MinimalInstallation } from "./installationOrgRows"; - -export const INSTALLATIONS_PER_PAGE = 30; - -/** Cap pages when paginating `GET /user/installations`. */ -export const MAX_INSTALLATION_LIST_PAGES = 20; - -export type FetchOrgsResult = { - orgs: OrgRow[]; - /** - * When `orgs` is empty after a **successful** installations scan, explains that no org - * installs were found (not HTTP error text). - */ - emptyHint: string | null; - /** First app slug from installation payloads in page order, if any. */ - appSlugFromApi: string | null; - /** - * True when pagination stopped at {@link MAX_INSTALLATION_LIST_PAGES} because GitHub may - * have more installation pages (list may be incomplete). - */ - installationListTruncated: boolean; -}; - -export type FetchOrgsProgressMeta = { - done: boolean; - /** Number of GitHub installation list pages processed so far. */ - installationPagesFetched: number; -}; - -let memoryCache: { - /** Normalized GitHub login (`trim` + lowercase), same pattern as install-readiness probe cache. */ - githubLogin: string; - orgs: OrgRow[]; - emptyHint: string | null; - appSlugFromApi: string | null; - installationListTruncated: boolean; -} | null = null; - -function orgListMemoryCacheKey(githubLogin: string | null | undefined): string | null { - const s = typeof githubLogin === "string" ? githubLogin.trim().toLowerCase() : ""; - return s.length > 0 ? s : null; -} - -/** Clears the in-memory org list cache (call on sign-out or when switching accounts). */ -export function clearOrgListMemoryCache(): void { - memoryCache = null; -} - -export class FetchOrgsError extends Error { - readonly status: number; - - constructor(status: number, message: string) { - super(message); - this.name = "FetchOrgsError"; - this.status = status; - } -} - -function octokitErrorStatus(e: unknown): number { - if ( - typeof e === "object" && - e !== null && - "status" in e && - typeof (e as { status: unknown }).status === "number" - ) { - return (e as { status: number }).status; - } - return 502; -} - -function friendlyInstallationsListHttpError(status: number, githubMessage: string): string { - if (status === 403) { - return ( - "GitHub refused to list app installations (403). " + - "The Fullsend Admin app may need additional permissions, or your account cannot access installations. " + - "If you operate this deployment, check the app’s settings; otherwise ask an org admin to install the app." - ); - } - if (status === 401) { - return "Could not list installations — sign in again if your token expired."; - } - return githubMessage; -} - -function installationsFromPageData(data: unknown): MinimalInstallation[] { - // Octokit’s paginate iterator may expose either `{ installations: [...] }` or a bare array. - if (Array.isArray(data)) return data as MinimalInstallation[]; - if (!data || typeof data !== "object") return []; - const rec = data as Record; - const raw = rec.installations; - if (!Array.isArray(raw)) return []; - return raw as MinimalInstallation[]; -} - -/** - * Paginates **`GET /user/installations`**, calling `onProgress` after each page with the - * cumulative organisation list derived from **Organization** installations. - */ -export async function fetchOrgsWithProgress( - accessToken: string, - options: { - /** - * GitHub login for the authenticated user. When set (non-blank after trim), - * in-memory results are keyed by normalized login instead of the access token. - */ - githubLogin?: string | null; - force?: boolean; - signal?: AbortSignal; - onProgress: (orgs: OrgRow[], meta: FetchOrgsProgressMeta) => void; - }, -): Promise { - const cacheKey = orgListMemoryCacheKey(options.githubLogin); - if (!options.force && cacheKey && memoryCache?.githubLogin === cacheKey) { - if (options.signal?.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - const { orgs, emptyHint, appSlugFromApi, installationListTruncated } = memoryCache; - options.onProgress(orgs, { done: true, installationPagesFetched: 0 }); - return { - orgs, - emptyHint, - appSlugFromApi, - installationListTruncated, - }; - } - - const octokit = createUserOctokit(accessToken); - - try { - if (options.signal?.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - - const iterator = octokit.paginate.iterator( - octokit.rest.apps.listInstallationsForAuthenticatedUser, - { per_page: INSTALLATIONS_PER_PAGE }, - ); - - const accumulated: MinimalInstallation[] = []; - let pages = 0; - - for await (const page of iterator) { - if (options.signal?.aborted) { - throw new DOMException("Aborted", "AbortError"); - } - pages += 1; - if (pages > MAX_INSTALLATION_LIST_PAGES) break; - - accumulated.push(...installationsFromPageData(page.data)); - - const { orgs, appSlug: _appSlug } = orgRowsAndSlugFromInstallations(accumulated); - options.onProgress(orgs, { - done: false, - installationPagesFetched: pages, - }); - } - - const { orgs, appSlug } = orgRowsAndSlugFromInstallations(accumulated); - const emptyHint = orgs.length === 0 ? buildEmptyInstallationsHint() : null; - const installationListTruncated = pages > MAX_INSTALLATION_LIST_PAGES; - - if (cacheKey) { - memoryCache = { - githubLogin: cacheKey, - orgs, - emptyHint, - appSlugFromApi: appSlug, - installationListTruncated, - }; - } - options.onProgress(orgs, { done: true, installationPagesFetched: pages }); - return { - orgs, - emptyHint, - appSlugFromApi: appSlug, - installationListTruncated, - }; - } catch (e) { - if (e instanceof DOMException && e.name === "AbortError") { - throw e; - } - const status = octokitErrorStatus(e); - /* 401: createUserOctokit request hook already notifies + App signs out — avoid duplicate events. */ - const msg = e instanceof Error ? e.message : "GitHub installation listing failed."; - throw new FetchOrgsError(status, friendlyInstallationsListHttpError(status, msg)); - } -} - -export async function fetchOrgs( - accessToken: string, - options?: { - githubLogin?: string | null; - force?: boolean; - signal?: AbortSignal; - }, -): Promise { - return fetchOrgsWithProgress(accessToken, { - githubLogin: options?.githubLogin, - force: options?.force, - signal: options?.signal, - onProgress: () => {}, - }); -} diff --git a/web/admin/src/lib/orgs/filter.test.ts b/web/admin/src/lib/orgs/filter.test.ts deleted file mode 100644 index 32cb64ec3a..0000000000 --- a/web/admin/src/lib/orgs/filter.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { filterOrgsBySearch } from "./filter"; - -describe("filterOrgsBySearch", () => { - it("matches prefix case-insensitively", () => { - expect( - filterOrgsBySearch([{ login: "Alpha" }, { login: "bee" }], "a").map((o) => o.login), - ).toEqual(["Alpha"]); - }); - - it("matches substring anywhere in login", () => { - expect( - filterOrgsBySearch([{ login: "foo-bar-org" }, { login: "other" }], "bar").map((o) => o.login), - ).toEqual(["foo-bar-org"]); - }); - - it("sorts alphabetically when query is empty", () => { - expect(filterOrgsBySearch([{ login: "z" }, { login: "a" }], "").map((o) => o.login)).toEqual([ - "a", - "z", - ]); - }); -}); diff --git a/web/admin/src/lib/orgs/filter.ts b/web/admin/src/lib/orgs/filter.ts deleted file mode 100644 index 1e8d552026..0000000000 --- a/web/admin/src/lib/orgs/filter.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type OrgRow = { - login: string; -}; - -/** - * Case-insensitive substring search over org logins, then alphabetical sort. - */ -export function filterOrgsBySearch(orgs: OrgRow[], q: string): OrgRow[] { - const p = q.trim().toLowerCase(); - const sorted = [...orgs].sort((a, b) => a.login.localeCompare(b.login)); - if (!p) return sorted; - return sorted.filter((o) => o.login.toLowerCase().includes(p)); -} diff --git a/web/admin/src/lib/orgs/githubPermissionHints.test.ts b/web/admin/src/lib/orgs/githubPermissionHints.test.ts deleted file mode 100644 index dce82a0510..0000000000 --- a/web/admin/src/lib/orgs/githubPermissionHints.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { RequestError } from "@octokit/request-error"; -import { - forbidden403HintsFromRequestError, - humanLineFromAcceptedOAuthScopes, - isLikelyGitHubRateLimit403, - userGitHubRestRateLimitShortMessage, -} from "./githubPermissionHints"; - -describe("humanLineFromAcceptedOAuthScopes", () => { - it("returns an actionable line when scopes are present", () => { - const line = humanLineFromAcceptedOAuthScopes("repo, workflow"); - expect(line).toContain("repo, workflow"); - expect(line).toContain("OAuth scopes"); - }); -}); - -describe("forbidden403HintsFromRequestError", () => { - it("uses only browser-exposed OAuth scope headers, not GitHub-Permissions (invisible under CORS)", () => { - const err = new RequestError("Forbidden", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { - "x-accepted-github-permissions": "secrets=read", - "x-accepted-oauth-scopes": "repo", - }, - data: { message: "Resource not accessible by integration" }, - }, - }); - expect(forbidden403HintsFromRequestError(err)).toEqual({ - missingPermissionLines: [ - "GitHub reports this API call would be allowed with these OAuth scopes: repo. Your account or token may need them, or an organisation owner may need to adjust app access.", - ], - githubApiMessage: "Resource not accessible by integration", - }); - }); - - it("returns empty lines when only GitHub-Permissions header is set (SPA cannot read it in real browsers)", () => { - const err = new RequestError("Forbidden", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { - "x-accepted-github-permissions": "contents=read", - }, - data: { message: "Not allowed" }, - }, - }); - expect(forbidden403HintsFromRequestError(err)).toEqual({ - missingPermissionLines: [], - githubApiMessage: "Not allowed", - }); - }); -}); - -describe("isLikelyGitHubRateLimit403", () => { - it("detects x-ratelimit-remaining: 0 when the JSON body is not a permission denial", () => { - const err = new RequestError("Forbidden", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { "x-ratelimit-remaining": "0" }, - data: {}, - }, - }); - expect(isLikelyGitHubRateLimit403(err)).toBe(true); - }); - - it("returns false for normal permission 403", () => { - const err = new RequestError("Forbidden", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { "x-ratelimit-remaining": "4999" }, - data: { message: "Resource not accessible by integration" }, - }, - }); - expect(isLikelyGitHubRateLimit403(err)).toBe(false); - }); - - it("does not treat exhausted quota + permission JSON message as rate limit", () => { - const err = new RequestError("Forbidden", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { "x-ratelimit-remaining": "0" }, - data: { message: "Resource not accessible by personal access token" }, - }, - }); - expect(isLikelyGitHubRateLimit403(err)).toBe(false); - }); - - it("detects primary rate limit from JSON message even when quota header is not zero", () => { - const err = new RequestError("Forbidden", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { "x-ratelimit-remaining": "1" }, - data: { message: "API rate limit exceeded for user ID 123" }, - }, - }); - expect(isLikelyGitHubRateLimit403(err)).toBe(true); - }); - - it("ignores Octokit error.message text (documentation_url) for heuristics", () => { - const err = new RequestError( - "Resource not accessible - https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api", - 403, - { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { "x-ratelimit-remaining": "4999" }, - data: { message: "Resource not accessible by personal access token" }, - }, - }, - ); - expect(isLikelyGitHubRateLimit403(err)).toBe(false); - }); -}); - -describe("userGitHubRestRateLimitShortMessage", () => { - it("includes reset time and limit from GitHub headers", () => { - const err = new RequestError("API rate limit exceeded", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { - "x-ratelimit-limit": "5000", - "x-ratelimit-remaining": "0", - "x-ratelimit-reset": "1776944389", - "x-ratelimit-resource": "core", - }, - data: { message: "API rate limit exceeded for user ID 1" }, - }, - }); - const msg = userGitHubRestRateLimitShortMessage(err); - expect(msg).toContain("5000"); - expect(msg).toContain("core"); - expect(msg).toContain("Thu, 23 Apr 2026 11:39:49 GMT"); - }); - - it("falls back when reset header is missing", () => { - const err = new RequestError("API rate limit exceeded", 403, { - request: { method: "GET", url: "https://api.github.com/test", headers: {} }, - response: { - status: 403, - url: "https://api.github.com/test", - headers: { "x-ratelimit-limit": "60" }, - data: { message: "API rate limit exceeded" }, - }, - }); - const msg = userGitHubRestRateLimitShortMessage(err); - expect(msg).toContain("60"); - expect(msg).toContain("Wait up to an hour"); - }); -}); diff --git a/web/admin/src/lib/orgs/githubPermissionHints.ts b/web/admin/src/lib/orgs/githubPermissionHints.ts deleted file mode 100644 index bf567c3434..0000000000 --- a/web/admin/src/lib/orgs/githubPermissionHints.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { RequestError } from "@octokit/request-error"; - -/** - * Browser calls to `api.github.com` only see response headers listed in GitHub’s - * `Access-Control-Expose-Headers` (CORS). That list includes rate-limit headers and - * `X-OAuth-Scopes` / `X-Accepted-OAuth-Scopes`, but **not** `X-Accepted-GitHub-Permissions`. - * So user-to-server GitHub App tokens never surface fine-grained App permission hints to - * this SPA via headers — only the JSON body and the exposed OAuth scope headers are reliable. - * - * @see https://docs.github.com/en/rest/using-the-rest-api/using-cors-and-jsonp-to-make-cross-origin-requests - */ - -/** Lowercase header names as returned by Octokit. */ -function headerGet(headers: Record | undefined, name: string): string | undefined { - if (!headers) return undefined; - const direct = headers[name]; - if (direct !== undefined) return direct; - const lower = name.toLowerCase(); - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === lower) return v; - } - return undefined; -} - -function githubApiMessage(data: unknown): string | undefined { - if (!data || typeof data !== "object") return undefined; - const m = (data as { message?: unknown }).message; - return typeof m === "string" && m.trim() ? m.trim() : undefined; -} - -/** GitHub JSON `message` on 403s that are access / scope / org-policy — not REST rate limits. */ -function github403BodyLooksLikePermissionDenied(apiMsg: string | undefined): boolean { - if (!apiMsg?.trim()) return false; - const m = apiMsg.toLowerCase(); - return ( - m.includes("resource not accessible") || - m.includes("although you appear to have the correct authorization credentials") || - m.includes("oauth app access restrictions") || - m.includes("organization has enabled or enforced") || - m.includes("saml") || - (m.includes("sso") && m.includes("token")) - ); -} - -function github403BodyLooksLikeRateLimit(apiMsg: string | undefined): boolean { - if (!apiMsg?.trim()) return false; - const m = apiMsg.toLowerCase(); - return ( - m.includes("api rate limit") || - m.includes("secondary rate limit") || - m.includes("abuse detection mechanism") || - m.includes("abuse detection") || - m.includes("too many requests") - ); -} - -function rateLimitRemainingIsZero(headers: Record | undefined): boolean { - const raw = headerGet(headers, "x-ratelimit-remaining"); - if (raw === undefined) return false; - return String(raw).trim() === "0"; -} - -/** - * True when this 403 is almost certainly GitHub REST primary/secondary rate limiting - * or abuse throttling — not missing OAuth scopes / org access. - * - * Uses the JSON `message` field only (not {@link RequestError#message}), because Octokit - * appends `documentation_url` to `error.message`, which can mention “rate limits” for - * unrelated errors and mislead callers. - */ -export function isLikelyGitHubRateLimit403(error: RequestError): boolean { - if (error.status !== 403) return false; - const headers = error.response?.headers as Record | undefined; - const apiMsg = githubApiMessage(error.response?.data); - if (github403BodyLooksLikePermissionDenied(apiMsg)) { - return false; - } - if (github403BodyLooksLikeRateLimit(apiMsg)) { - return true; - } - if (rateLimitRemainingIsZero(headers)) { - return true; - } - return false; -} - -/** - * User-facing line when {@link isLikelyGitHubRateLimit403} is true — uses - * `X-RateLimit-Reset` / `X-RateLimit-Limit` / `X-RateLimit-Resource` when GitHub sends them. - */ -export function userGitHubRestRateLimitShortMessage(error: RequestError): string { - const headers = error.response?.headers as Record | undefined; - const resetRaw = headerGet(headers, "x-ratelimit-reset"); - const limitRaw = headerGet(headers, "x-ratelimit-limit"); - const resourceRaw = headerGet(headers, "x-ratelimit-resource"); - const resetSec = resetRaw ? Number.parseInt(String(resetRaw).trim(), 10) : NaN; - - const limitPart = - limitRaw?.trim() && limitRaw.trim() !== "0" - ? ` This sign-in is allowed ${limitRaw.trim()} REST requests per hour on GitHub’s “${resourceRaw?.trim() || "core"}” budget.` - : ""; - - if (Number.isFinite(resetSec) && resetSec > 1_000_000_000) { - const whenUtc = new Date(resetSec * 1000).toUTCString(); - return `GitHub’s hourly REST API quota for this account is exhausted.${limitPart} It resets at ${whenUtc}. Use Retry or Refresh after that.`; - } - - return `GitHub’s hourly REST API quota for this account is exhausted.${limitPart} Wait up to an hour, then use Retry or Refresh.`; -} - -/** Classic OAuth / PAT: scopes GitHub says would have worked (readable in browser per GitHub CORS). */ -export function humanLineFromAcceptedOAuthScopes(raw: string | undefined): string | undefined { - if (!raw?.trim()) return undefined; - return `GitHub reports this API call would be allowed with these OAuth scopes: ${raw.trim()}. Your account or token may need them, or an organisation owner may need to adjust app access.`; -} - -export type Forbidden403Hints = { - /** Lines derived only from browser-visible sources (exposed OAuth scope headers + caller fallbacks). */ - missingPermissionLines: string[]; - githubApiMessage?: string; -}; - -/** - * Collects permission hints available to the **browser** Octokit client. - * Does not read `X-Accepted-GitHub-Permissions` — that header is not exposed to cross-origin JS. - */ -export function forbidden403HintsFromRequestError(error: RequestError): Forbidden403Hints { - const headers = error.response?.headers as Record | undefined; - const rawOAuth = headerGet(headers, "x-accepted-oauth-scopes"); - const lines: string[] = []; - const oauthLine = humanLineFromAcceptedOAuthScopes(rawOAuth); - if (oauthLine) lines.push(oauthLine); - return { - missingPermissionLines: lines, - githubApiMessage: githubApiMessage(error.response?.data), - }; -} diff --git a/web/admin/src/lib/orgs/installReadinessProbes.test.ts b/web/admin/src/lib/orgs/installReadinessProbes.test.ts deleted file mode 100644 index 166934c096..0000000000 --- a/web/admin/src/lib/orgs/installReadinessProbes.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { Octokit } from "@octokit/rest"; -import { RequestError } from "@octokit/request-error"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { computePreflight } from "../layers/preflight"; -import type { LayerReport } from "../status/types"; -import { deployRequiredOAuthScopes } from "./deployOAuthScopes"; -import { - clearInstallReadinessProbeCache, - probeGitHubAppInstallReadiness, - resolveOrgListDeployRowCluster, -} from "./installReadinessProbes"; -import type { OrgListAnalysisOk } from "./orgListRow"; - -function rep(name: string, status: LayerReport["status"]): LayerReport { - return { - name, - status, - details: [], - wouldInstall: [], - wouldFix: [], - }; -} - -const notInstalledOk: OrgListAnalysisOk = { - kind: "ok", - rollup: "not_installed", - reports: [ - rep("config-repo", "not_installed"), - rep("workflows", "not_installed"), - rep("secrets", "not_installed"), - rep("enrollment", "installed"), - rep("dispatch-token", "not_installed"), - ], -}; - -describe("probeGitHubAppInstallReadiness", () => { - beforeEach(() => { - clearInstallReadinessProbeCache(); - }); - - it("returns ok when listForOrg, workflows, and org secrets public-key succeed", async () => { - const octokit = { - rest: { - repos: { - listForOrg: vi.fn().mockResolvedValue({ data: [{ name: "r1" }] }), - }, - actions: { - listRepoWorkflows: vi.fn().mockResolvedValue({ data: [] }), - }, - }, - request: vi.fn().mockResolvedValue({ data: { key: "k", key_id: "kid" } }), - } as unknown as Octokit; - await expect(probeGitHubAppInstallReadiness(octokit, "acme", {})).resolves.toEqual({ - ok: true, - missing: [], - }); - }); - - it("returns not ok when listForOrg is 403", async () => { - const octokit = { - rest: { - repos: { - listForOrg: vi.fn().mockRejectedValue( - new RequestError("Forbidden", 403, { - request: { - method: "GET", - headers: {}, - url: "https://api.github.com/orgs/acme/repos", - }, - }), - ), - }, - }, - } as unknown as Octokit; - const r = await probeGitHubAppInstallReadiness(octokit, "acme", {}); - expect(r.ok).toBe(false); - expect(r.missing.length).toBeGreaterThan(0); - }); -}); - -describe("resolveOrgListDeployRowCluster", () => { - beforeEach(() => { - clearInstallReadinessProbeCache(); - }); - - it("runs probes when preflight skipped and config not installed", async () => { - const granted = computePreflight([...deployRequiredOAuthScopes()], null); - expect(granted.skipped).toBe(true); - - const octokit = { - rest: { - repos: { - listForOrg: vi.fn().mockResolvedValue({ data: [{ name: "r1" }] }), - }, - actions: { - listRepoWorkflows: vi.fn().mockResolvedValue({ data: [] }), - }, - }, - request: vi.fn().mockResolvedValue({ data: { key: "k", key_id: "kid" } }), - } as unknown as Octokit; - - const row = await resolveOrgListDeployRowCluster( - notInstalledOk, - granted, - octokit, - "alice", - "acme", - ); - expect(row).toEqual({ kind: "deploy" }); - }); -}); diff --git a/web/admin/src/lib/orgs/installReadinessProbes.ts b/web/admin/src/lib/orgs/installReadinessProbes.ts deleted file mode 100644 index 9f3d972bb2..0000000000 --- a/web/admin/src/lib/orgs/installReadinessProbes.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { Octokit } from "@octokit/rest"; -import { RequestError } from "@octokit/request-error"; -import type { PreflightResult } from "../layers/preflight"; -import { - orgListRowFromAnalysis, - type GitHubAppInstallReadiness, - type OrgListAnalysisErr, - type OrgListAnalysisOk, - type OrgListRowCluster, -} from "./orgListRow"; - -const probeCache = new Map(); - -/** Cleared on sign-out so the next account does not reuse another user’s probe results. */ -export function clearInstallReadinessProbeCache(): void { - probeCache.clear(); -} - -function cacheKey(githubLogin: string, org: string): string { - return `${githubLogin.trim().toLowerCase()}\0${org.trim().toLowerCase()}`; -} - -function isForbidden(err: unknown): boolean { - return err instanceof RequestError && err.status === 403; -} - -/** - * Read-only checks for GitHub App user access tokens (and other tokens) that do not - * populate `X-OAuth-Scopes`. Probes mirror the practical needs of `fullsend admin install`: - * org repository visibility, Actions workflows, and org-level Actions secrets. - * - * @see internal/layers/preflight.go — when `GetTokenScopes` is nil, Go skips scope comparison - * and proceeds; the SPA uses these probes instead of assuming classic OAuth scopes exist. - */ -export async function probeGitHubAppInstallReadiness( - octokit: Octokit, - org: string, - options?: { signal?: AbortSignal }, -): Promise { - const missing: string[] = []; - const signal = options?.signal; - const request = signal ? { signal } : undefined; - - let firstRepo: string | null = null; - try { - const { data } = await octokit.rest.repos.listForOrg({ - org, - per_page: 1, - type: "all", - request, - }); - if (data[0]?.name) { - firstRepo = data[0].name; - } - } catch (e) { - if (isForbidden(e)) { - missing.push("View and manage repositories in this organisation"); - return { ok: false, missing }; - } - throw e; - } - - if (firstRepo) { - try { - await octokit.rest.actions.listRepoWorkflows({ - owner: org, - repo: firstRepo, - per_page: 1, - request, - }); - } catch (e) { - if (isForbidden(e)) { - missing.push("Use GitHub Actions on repositories in this organisation"); - return { ok: false, missing }; - } - throw e; - } - } - - try { - await octokit.request("GET /orgs/{org}/actions/secrets/public-key", { - org, - request, - }); - } catch (e) { - if (isForbidden(e)) { - missing.push("Organisation-level GitHub Actions secrets"); - return { ok: false, missing }; - } - throw e; - } - - return { ok: true, missing: [] }; -} - -export async function probeGitHubAppInstallReadinessCached( - octokit: Octokit, - githubLogin: string, - org: string, - options?: { signal?: AbortSignal }, -): Promise { - const key = cacheKey(githubLogin, org); - const hit = probeCache.get(key); - if (hit) { - return hit; - } - const result = await probeGitHubAppInstallReadiness(octokit, org, options); - probeCache.set(key, result); - return result; -} - -/** - * Resolves org-list Deploy / Cannot deploy / Configure using classic scope preflight when - * `X-OAuth-Scopes` is present, otherwise GitHub App install probes for `not_installed` rows. - */ -export async function resolveOrgListDeployRowCluster( - result: OrgListAnalysisOk | OrgListAnalysisErr, - deployPreflight: PreflightResult, - octokit: Octokit, - githubUserLogin: string, - orgLogin: string, - options?: { signal?: AbortSignal }, -): Promise { - let githubAppReadiness: GitHubAppInstallReadiness | null = null; - if (deployPreflight.skipped && result.kind === "ok") { - const configReport = result.reports.find((r) => r.name === "config-repo"); - if (configReport?.status === "not_installed") { - githubAppReadiness = await probeGitHubAppInstallReadinessCached( - octokit, - githubUserLogin, - orgLogin, - options, - ); - } - } - return orgListRowFromAnalysis(result, deployPreflight, githubAppReadiness); -} diff --git a/web/admin/src/lib/orgs/installationOrgRows.test.ts b/web/admin/src/lib/orgs/installationOrgRows.test.ts deleted file mode 100644 index d75a5312b6..0000000000 --- a/web/admin/src/lib/orgs/installationOrgRows.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - normalizeSlug, - orgRowsAndSlugFromInstallations, - slugFromInstallation, -} from "./installationOrgRows"; - -describe("normalizeSlug", () => { - it("accepts alphanumeric and hyphens within length", () => { - expect(normalizeSlug("my-app-1")).toBe("my-app-1"); - }); - - it("rejects empty and whitespace-only", () => { - expect(normalizeSlug("")).toBeNull(); - expect(normalizeSlug(" ")).toBeNull(); - }); - - it("rejects slashes dots and spaces inside slug", () => { - expect(normalizeSlug("bad/slug")).toBeNull(); - expect(normalizeSlug("a.b")).toBeNull(); - expect(normalizeSlug("bad slug")).toBeNull(); - }); - - it("rejects over 99 chars", () => { - expect(normalizeSlug("a".repeat(100))).toBeNull(); - }); - - it("rejects non-ASCII", () => { - expect(normalizeSlug("café-app")).toBeNull(); - }); -}); - -describe("slugFromInstallation", () => { - it("uses nested app.slug when app_slug is invalid", () => { - expect( - slugFromInstallation({ - app_slug: "bad slug", - app: { slug: "good-slug" }, - }), - ).toBe("good-slug"); - }); -}); - -describe("orgRowsAndSlugFromInstallations", () => { - it("returns empty orgs and null slug for empty input", () => { - expect(orgRowsAndSlugFromInstallations([])).toEqual({ - orgs: [], - appSlug: null, - }); - }); - - it("ignores installations with null account for rows but still reads slug", () => { - const { orgs, appSlug } = orgRowsAndSlugFromInstallations([ - { account: null, app_slug: "only-slug" }, - { account: { login: "o", type: "Organization" } }, - ]); - expect(orgs).toEqual([{ login: "o" }]); - expect(appSlug).toBe("only-slug"); - }); - - it("returns Organization accounts sorted by login and appSlug from app_slug", () => { - const { orgs, appSlug } = orgRowsAndSlugFromInstallations([ - { - account: { login: "zebra-org", type: "Organization" }, - app_slug: "my-github-app", - }, - { - account: { login: "alpha-org", type: "Organization" }, - app_slug: "my-github-app", - }, - ]); - expect(orgs.map((o) => o.login)).toEqual(["alpha-org", "zebra-org"]); - expect(appSlug).toBe("my-github-app"); - }); - - it("treats account type as organisation case-insensitively", () => { - const { orgs, appSlug } = orgRowsAndSlugFromInstallations([ - { - account: { login: "lower-org", type: "organization" }, - app_slug: "ci-app", - }, - ]); - expect(orgs).toEqual([{ login: "lower-org" }]); - expect(appSlug).toBe("ci-app"); - }); - - it("drops User installations", () => { - const { orgs, appSlug } = orgRowsAndSlugFromInstallations([ - { - account: { login: "alice", type: "User" }, - app_slug: "some-app", - }, - { - account: { login: "real-org", type: "Organization" }, - app_slug: "some-app", - }, - ]); - expect(orgs).toEqual([{ login: "real-org" }]); - expect(appSlug).toBe("some-app"); - }); - - it("dedupes the same org from two installation records", () => { - const { orgs, appSlug } = orgRowsAndSlugFromInstallations([ - { - account: { login: "dup-org", type: "Organization" }, - app_slug: "dedupe-app", - }, - { - account: { login: "dup-org", type: "Organization" }, - app_slug: "dedupe-app", - }, - ]); - expect(orgs).toEqual([{ login: "dup-org" }]); - expect(appSlug).toBe("dedupe-app"); - }); - - it("uses nested app.slug when app_slug is absent", () => { - const { orgs, appSlug } = orgRowsAndSlugFromInstallations([ - { - account: { login: "nested-org", type: "Organization" }, - app: { slug: "from-nested-slug" }, - }, - ]); - expect(orgs).toEqual([{ login: "nested-org" }]); - expect(appSlug).toBe("from-nested-slug"); - }); -}); diff --git a/web/admin/src/lib/orgs/installationOrgRows.ts b/web/admin/src/lib/orgs/installationOrgRows.ts deleted file mode 100644 index 52b78e79a2..0000000000 --- a/web/admin/src/lib/orgs/installationOrgRows.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { OrgRow } from "./filter"; - -/** Safe GitHub App slug: alphanumeric and hyphen, length 1–99. */ -export const SLUG_RE = /^[a-zA-Z0-9-]{1,99}$/; - -/** Subset of GitHub `GET /user/installations` item fields used for org picker mapping. */ -export type MinimalInstallation = { - account?: { login?: string; type?: string } | null; - app_slug?: string | null; - app?: { slug?: string | null } | null; -}; - -export function normalizeSlug(raw: string | null | undefined): string | null { - if (raw == null) return null; - const s = String(raw).trim(); - if (!s) return null; - return SLUG_RE.test(s) ? s : null; -} - -export function slugFromInstallation(inst: MinimalInstallation): string | null { - const fromTop = normalizeSlug(inst.app_slug ?? undefined); - if (fromTop) return fromTop; - return normalizeSlug(inst.app?.slug ?? undefined); -} - -/** - * Maps installation list to unique Organization rows (sorted by login) and the - * first safe app slug found in array order (`app_slug` if valid, else `app.slug`). - */ -export function orgRowsAndSlugFromInstallations(installations: MinimalInstallation[]): { - orgs: OrgRow[]; - appSlug: string | null; -} { - let appSlug: string | null = null; - const byLogin = new Map(); - - for (const inst of installations) { - if (appSlug == null) { - const s = slugFromInstallation(inst); - if (s) appSlug = s; - } - const acc = inst.account; - const accType = acc?.type?.trim(); - if (!acc?.login || !accType || accType.toLowerCase() !== "organization") { - continue; - } - const login = acc.login.trim(); - if (!login) continue; - if (!byLogin.has(login)) { - byLogin.set(login, { login }); - } - } - - const orgs = [...byLogin.values()].sort((a, b) => a.login.localeCompare(b.login)); - - return { orgs, appSlug }; -} diff --git a/web/admin/src/lib/orgs/orgListAnalysisCache.test.ts b/web/admin/src/lib/orgs/orgListAnalysisCache.test.ts deleted file mode 100644 index 8643a0e966..0000000000 --- a/web/admin/src/lib/orgs/orgListAnalysisCache.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OrgListAnalysisOk } from "./orgListRow"; -import { - clearOrgListAnalysisCache, - getOrgListAnalysisCached, - hasOrgListAnalysisCacheEntry, - invalidateOrgListAnalysisCacheEntry, - setOrgListAnalysisCached, -} from "./orgListAnalysisCache"; - -const sampleOk = (): OrgListAnalysisOk => ({ - kind: "ok", - rollup: "not_installed", - reports: [], -}); - -describe("orgListAnalysisCache", () => { - it("normalises org keys", () => { - clearOrgListAnalysisCache(); - setOrgListAnalysisCached("Acme", sampleOk()); - expect(hasOrgListAnalysisCacheEntry("acme")).toBe(true); - expect(getOrgListAnalysisCached("ACME")?.kind).toBe("ok"); - invalidateOrgListAnalysisCacheEntry("acme"); - expect(hasOrgListAnalysisCacheEntry("acme")).toBe(false); - }); - - it("clear removes all entries", () => { - clearOrgListAnalysisCache(); - setOrgListAnalysisCached("a", sampleOk()); - setOrgListAnalysisCached("b", sampleOk()); - clearOrgListAnalysisCache(); - expect(hasOrgListAnalysisCacheEntry("a")).toBe(false); - expect(hasOrgListAnalysisCacheEntry("b")).toBe(false); - }); -}); diff --git a/web/admin/src/lib/orgs/orgListAnalysisCache.ts b/web/admin/src/lib/orgs/orgListAnalysisCache.ts deleted file mode 100644 index 9a05fdd077..0000000000 --- a/web/admin/src/lib/orgs/orgListAnalysisCache.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { OrgListAnalysisOk } from "./orgListRow"; - -const cache = new Map(); - -function norm(org: string): string { - return org.trim().toLowerCase(); -} - -/** Successful org-list analysis only (errors are never cached). */ -export function getOrgListAnalysisCached(org: string): OrgListAnalysisOk | undefined { - return cache.get(norm(org)); -} - -export function hasOrgListAnalysisCacheEntry(org: string): boolean { - return cache.has(norm(org)); -} - -export function setOrgListAnalysisCached(org: string, result: OrgListAnalysisOk): void { - cache.set(norm(org), result); -} - -/** Cleared on org list Refresh / Retry (full list) and sign-out. */ -export function clearOrgListAnalysisCache(): void { - cache.clear(); -} - -/** Cleared when the per-row Retry button runs after a failed analysis. */ -export function invalidateOrgListAnalysisCacheEntry(org: string): void { - cache.delete(norm(org)); -} diff --git a/web/admin/src/lib/orgs/orgListRow.test.ts b/web/admin/src/lib/orgs/orgListRow.test.ts deleted file mode 100644 index 15cf205c40..0000000000 --- a/web/admin/src/lib/orgs/orgListRow.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { computePreflight } from "../layers/preflight"; -import type { LayerReport } from "../status/types"; -import { deployRequiredOAuthScopes } from "./deployOAuthScopes"; -import { - buildDeployPreflight, - orgListRowFromAnalysis, - type GitHubAppInstallReadiness, - type OrgListAnalysisErr, - type OrgListAnalysisOk, -} from "./orgListRow"; - -function preflightAllGranted() { - return computePreflight([...deployRequiredOAuthScopes()], ["repo", "workflow", "admin:org"]); -} - -function rep(name: string, status: LayerReport["status"]): LayerReport { - return { - name, - status, - details: [], - wouldInstall: [], - wouldFix: [], - }; -} - -const notInstalledOk: OrgListAnalysisOk = { - kind: "ok", - rollup: "not_installed", - reports: [ - rep("config-repo", "not_installed"), - rep("workflows", "not_installed"), - rep("secrets", "not_installed"), - rep("enrollment", "installed"), - rep("dispatch-token", "not_installed"), - ], -}; - -describe("orgListRowFromAnalysis", () => { - it("cannot_deploy on forbidden error", () => { - const err: OrgListAnalysisErr = { - kind: "error", - message: "no access", - forbidden: true, - }; - const row = orgListRowFromAnalysis(err, preflightAllGranted()); - expect(row.kind).toBe("cannot_deploy"); - if (row.kind === "cannot_deploy") { - expect(row.reason).toContain("cannot reach everything"); - expect(row.missingInstallRequirements?.length).toBeGreaterThanOrEqual(3); - expect(row.helpBullets?.length).toBeGreaterThanOrEqual(2); - } - }); - - it("cannot_deploy on forbidden includes analysis lines and GitHub API message", () => { - const err: OrgListAnalysisErr = { - kind: "error", - message: "Insufficient permissions", - forbidden: true, - missingPermissionLines: [ - "GitHub reports this API call would be allowed with these OAuth scopes: repo.", - ], - githubApiMessage: "Resource not accessible by integration", - }; - const row = orgListRowFromAnalysis(err, preflightAllGranted()); - expect(row.kind).toBe("cannot_deploy"); - if (row.kind === "cannot_deploy") { - expect(row.missingInstallRequirements?.[0]).toContain("OAuth scopes"); - expect(row.missingInstallRequirements).toContain("Resource not accessible by integration"); - } - }); - - it("error on non-forbidden failure", () => { - const err: OrgListAnalysisErr = { - kind: "error", - message: "network", - forbidden: false, - }; - expect(orgListRowFromAnalysis(err, preflightAllGranted())).toEqual({ - kind: "error", - message: "network", - }); - }); - - it("deploy when config repo not installed and OAuth preflight ok", () => { - expect(orgListRowFromAnalysis(notInstalledOk, preflightAllGranted())).toEqual({ - kind: "deploy", - }); - }); - - it("error when preflight skipped and GitHub App readiness not supplied", () => { - const pf = buildDeployPreflight(null); - expect(pf.skipped).toBe(true); - const row = orgListRowFromAnalysis(notInstalledOk, pf); - expect(row.kind).toBe("error"); - if (row.kind === "error") { - expect(row.message).toContain("Try Refresh"); - } - }); - - it("deploy when preflight skipped and GitHub App probes pass", () => { - const pf = buildDeployPreflight(null); - const ready: GitHubAppInstallReadiness = { ok: true, missing: [] }; - expect(orgListRowFromAnalysis(notInstalledOk, pf, ready)).toEqual({ kind: "deploy" }); - }); - - it("cannot_deploy when preflight skipped and GitHub App probes fail", () => { - const pf = buildDeployPreflight(null); - const ready: GitHubAppInstallReadiness = { - ok: false, - missing: ["Organisation-level GitHub Actions secrets"], - }; - const row = orgListRowFromAnalysis(notInstalledOk, pf, ready); - expect(row.kind).toBe("cannot_deploy"); - if (row.kind === "cannot_deploy") { - expect(row.missingInstallRequirements).toEqual(["Organisation-level GitHub Actions secrets"]); - expect(row.helpBullets?.length).toBeGreaterThan(0); - } - }); - - it("cannot_deploy when config not installed and classic OAuth scopes missing", () => { - const pf = computePreflight([...deployRequiredOAuthScopes()], ["repo"]); - const row = orgListRowFromAnalysis(notInstalledOk, pf); - expect(row.kind).toBe("cannot_deploy"); - if (row.kind === "cannot_deploy") { - expect(row.missingInstallRequirements?.some((s) => s.includes("GitHub Actions"))).toBe(true); - expect(row.helpBullets?.length).toBeGreaterThanOrEqual(2); - } - }); - - it("configure when config repo exists (installed)", () => { - const ok: OrgListAnalysisOk = { - kind: "ok", - rollup: "degraded", - reports: [ - rep("config-repo", "installed"), - rep("workflows", "degraded"), - rep("secrets", "not_installed"), - rep("enrollment", "installed"), - rep("dispatch-token", "not_installed"), - ], - }; - expect(orgListRowFromAnalysis(ok, preflightAllGranted())).toEqual({ - kind: "configure", - }); - }); - - it("configure when config repo degraded", () => { - const ok: OrgListAnalysisOk = { - kind: "ok", - rollup: "degraded", - reports: [ - rep("config-repo", "degraded"), - rep("workflows", "not_installed"), - rep("secrets", "not_installed"), - rep("enrollment", "installed"), - rep("dispatch-token", "not_installed"), - ], - }; - expect(orgListRowFromAnalysis(ok, preflightAllGranted())).toEqual({ - kind: "configure", - }); - }); - - it("configure when scopes missing but config already present (no deploy gate)", () => { - const ok: OrgListAnalysisOk = { - kind: "ok", - rollup: "degraded", - reports: [ - rep("config-repo", "installed"), - rep("workflows", "not_installed"), - rep("secrets", "not_installed"), - rep("enrollment", "installed"), - rep("dispatch-token", "not_installed"), - ], - }; - const pf = computePreflight([...deployRequiredOAuthScopes()], ["repo"]); - expect(orgListRowFromAnalysis(ok, pf)).toEqual({ kind: "configure" }); - }); - - it("configure when preflight skipped but config installed (ignores readiness)", () => { - const ok: OrgListAnalysisOk = { - kind: "ok", - rollup: "degraded", - reports: [ - rep("config-repo", "installed"), - rep("workflows", "not_installed"), - rep("secrets", "not_installed"), - rep("enrollment", "installed"), - rep("dispatch-token", "not_installed"), - ], - }; - const pf = buildDeployPreflight(null); - expect(orgListRowFromAnalysis(ok, pf, { ok: false, missing: ["should be ignored"] })).toEqual({ - kind: "configure", - }); - }); -}); diff --git a/web/admin/src/lib/orgs/orgListRow.ts b/web/admin/src/lib/orgs/orgListRow.ts deleted file mode 100644 index b0372fcb46..0000000000 --- a/web/admin/src/lib/orgs/orgListRow.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Octokit } from "@octokit/rest"; -import { RequestError } from "@octokit/request-error"; -import { analyzeOrgLayers } from "../layers/analyzeOrg"; -import { - forbidden403HintsFromRequestError, - isLikelyGitHubRateLimit403, - userGitHubRestRateLimitShortMessage, -} from "./githubPermissionHints"; -import { CONFIG_FILE_PATH, CONFIG_REPO_NAME } from "../layers/constants"; -import { createLayerGithub } from "../layers/githubClient"; -import { - agentsFromConfig, - enabledReposFromConfig, - OrgConfigYamlLimitError, - parseOrgConfigYaml, - validateOrgConfig, -} from "../layers/orgConfigParse"; -import { computePreflight, type PreflightResult } from "../layers/preflight"; -import type { LayerReport, LayerStatus } from "../status/types"; -import { deployRequiredOAuthScopes } from "./deployOAuthScopes"; - -/** Result of GitHub App install probes when classic `X-OAuth-Scopes` is absent. */ -export type GitHubAppInstallReadiness = { - ok: boolean; - missing: string[]; -}; - -export type OrgListAnalysisOk = { - kind: "ok"; - rollup: LayerStatus; - reports: LayerReport[]; -}; - -export type OrgListAnalysisErr = { - kind: "error"; - message: string; - /** True when GitHub returned 403 (token cannot read this org’s installation state). */ - forbidden: boolean; - /** - * Actionable bullets for org owners / the signed-in user (browser-visible OAuth scope hints, - * then defaults — never relies on `X-Accepted-GitHub-Permissions`, which CORS hides from JS). - */ - missingPermissionLines?: string[]; - /** GitHub JSON `message` on the failing response, when available. */ - githubApiMessage?: string; -}; - -/** - * Runs the same read-only layer stack as the org dashboard **analyze** path - * (`analyzeOrgLayers`) so permission failures on workflows, Actions, enrollment, or - * org secrets surface as actionable permission errors — not only the config repo. - * - * Deploy vs Configure uses **config-repo** plus install readiness: classic tokens compare - * `X-OAuth-Scopes` to {@link deployRequiredOAuthScopes}; GitHub App user tokens use - * read-only API probes (see `installReadinessProbes.ts`) instead of assuming OAuth scopes exist. - */ -export type AnalyzeOrgForOrgListOptions = { - /** - * When not `null`/`undefined`, skips REST `GET /repos/{org}/.fullsend` and uses this - * as the config-repo existence hint (from GraphQL batching on the org list). - */ - fullsendRepoExistsHint?: boolean | null; -}; - -export async function analyzeOrgForOrgList( - org: string, - octokit: Octokit, - options?: AnalyzeOrgForOrgListOptions, -): Promise { - const gh = createLayerGithub(octokit); - try { - let exists: boolean; - const hint = options?.fullsendRepoExistsHint; - if (hint === true) { - exists = true; - } else if (hint === false) { - exists = false; - } else { - exists = await gh.getRepoExists(org, CONFIG_REPO_NAME); - } - let agents: { role: string }[] = []; - let enabledRepos: string[] = []; - if (exists) { - const raw = await gh.getRepoFileUtf8(org, CONFIG_REPO_NAME, CONFIG_FILE_PATH); - if (raw) { - try { - const cfg = parseOrgConfigYaml(raw); - if (validateOrgConfig(cfg) === null) { - agents = agentsFromConfig(cfg); - enabledRepos = enabledReposFromConfig(cfg); - } - } catch (e) { - if (e instanceof OrgConfigYamlLimitError) { - return { - kind: "error", - message: e.message, - forbidden: false, - }; - } - /* invalid YAML — still analyze other layers with empty agents/repos */ - } - } - } - const { reports, rollup } = await analyzeOrgLayers({ - org, - gh, - agents, - enabledRepos, - }); - return { kind: "ok", reports, rollup }; - } catch (e) { - if (e instanceof RequestError && e.status === 403) { - if (isLikelyGitHubRateLimit403(e)) { - return { - kind: "error", - message: userGitHubRestRateLimitShortMessage(e), - forbidden: false, - }; - } - const hints = forbidden403HintsFromRequestError(e); - const lines = - hints.missingPermissionLines.length > 0 - ? [...hints.missingPermissionLines] - : [...DEFAULT_FORBIDDEN_ACTION_LINES]; - return { - kind: "error", - message: "Insufficient permissions to evaluate Fullsend state for this organisation.", - forbidden: true, - missingPermissionLines: lines, - githubApiMessage: hints.githubApiMessage, - }; - } - return { - kind: "error", - message: e instanceof Error ? e.message : String(e), - forbidden: false, - }; - } -} - -export type OrgListRowCluster = - | { kind: "checking" } - | { kind: "configure" } - | { kind: "deploy" } - | { - kind: "cannot_deploy"; - reason: string; - /** Plain-language access gaps to request from an organisation owner when needed. */ - missingInstallRequirements?: string[]; - helpBullets?: string[]; - } - | { kind: "error"; message: string }; - -const ORG_OWNER_HELP = [ - "If you are not an organisation owner, ask an owner to approve the Fullsend Admin application for this organisation and the access it needs. Organisations that use SAML may require an owner to authorize the app for your account afterward.", - "If you are an owner, use your organisation’s settings on GitHub to approve the app and the permissions it requests.", -] as const; - -/** When GitHub returns 403 without browser-visible OAuth scope hints on the response. */ -const DEFAULT_FORBIDDEN_ACTION_LINES = [ - "An organisation owner may need to approve or install the Fullsend Admin GitHub App and accept the permissions it requests.", - "If the organisation uses SAML single sign-on, an owner may need to authorize the app for your GitHub account after you sign in.", - "Organisation policies may need to allow this app access to the org and its repositories (including the `.fullsend` configuration repository).", -] as const; - -function userFacingPermissionForClassicScope(scope: string): string { - switch (scope.trim()) { - case "repo": - return "Repositories in this organisation (read and manage contents)"; - case "workflow": - return "GitHub Actions on organisation repositories"; - case "admin:org": - return "Organisation-level GitHub Actions settings and secrets"; - default: - return `Access related to: ${scope}`; - } -} - -function cannotDeployClusterForMissingClassicScopes( - deployPreflight: PreflightResult, -): OrgListRowCluster { - return { - kind: "cannot_deploy", - reason: - "Your GitHub account does not have everything it needs to deploy Fullsend in this organisation yet.", - missingInstallRequirements: deployPreflight.missing.map(userFacingPermissionForClassicScope), - helpBullets: [...ORG_OWNER_HELP], - }; -} - -/** - * Maps layer analysis to the org list trailing cluster. - * - **403 / rate limits / network** from `analyzeOrgForOrgList` → `cannot_deploy` or `error`. - * - **Deploy vs Configure** (when analysis succeeds): **config-repo** status plus install - * readiness — classic `X-OAuth-Scopes` when present, else {@link GitHubAppInstallReadiness} - * from read-only API probes (`installReadinessProbes.ts` async wrapper supplies this). - */ -export function orgListRowFromAnalysis( - result: OrgListAnalysisOk | OrgListAnalysisErr, - deployPreflight: PreflightResult, - githubAppReadiness?: GitHubAppInstallReadiness | null, -): OrgListRowCluster { - if (result.kind === "error") { - if (result.forbidden) { - let req: string[] = [...(result.missingPermissionLines ?? [])]; - const api = result.githubApiMessage?.trim(); - if (api && !req.some((line) => line.includes(api))) { - req.push(api); - } - if (req.length === 0) { - req = [...DEFAULT_FORBIDDEN_ACTION_LINES]; - } - return { - kind: "cannot_deploy", - reason: - "Your account cannot reach everything in this organisation that Fullsend needs to deploy or manage here.", - missingInstallRequirements: req, - helpBullets: [...ORG_OWNER_HELP], - }; - } - return { kind: "error", message: result.message }; - } - - const configReport = result.reports.find((r: LayerReport) => r.name === "config-repo"); - if (!configReport) { - return { - kind: "error", - message: - "Could not determine configuration status for this organisation. Try Refresh, or ask an organisation owner to check app access.", - }; - } - - if (configReport.status === "not_installed") { - if (!deployPreflight.skipped) { - if (deployPreflight.missing.length > 0) { - return cannotDeployClusterForMissingClassicScopes(deployPreflight); - } - return { kind: "deploy" }; - } - if (githubAppReadiness == null) { - return { - kind: "error", - message: "Could not confirm deploy access for this organisation yet. Try Refresh.", - }; - } - if (!githubAppReadiness.ok) { - return { - kind: "cannot_deploy", - reason: - "Your GitHub account does not have everything it needs to deploy Fullsend in this organisation yet.", - missingInstallRequirements: [...githubAppReadiness.missing], - helpBullets: [...ORG_OWNER_HELP], - }; - } - return { kind: "deploy" }; - } - - return { kind: "configure" }; -} - -export function buildDeployPreflight(granted: string[] | null): PreflightResult { - return computePreflight([...deployRequiredOAuthScopes()], granted); -} diff --git a/web/admin/src/lib/status/engine.test.ts b/web/admin/src/lib/status/engine.test.ts deleted file mode 100644 index 78c82e6742..0000000000 --- a/web/admin/src/lib/status/engine.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mergeLayerStatuses, rollupOrgLayerStatus } from "./engine"; -import type { LayerReport } from "./types"; - -function rep(status: LayerReport["status"]): LayerReport { - return { - name: "x", - status, - details: [], - wouldInstall: [], - wouldFix: [], - }; -} - -describe("rollupOrgLayerStatus", () => { - it("returns installed for empty reports", () => { - expect(rollupOrgLayerStatus([])).toBe("installed"); - }); - - it("returns installed when all installed", () => { - expect(rollupOrgLayerStatus([rep("installed"), rep("installed")])).toBe("installed"); - }); - - it("picks worst status", () => { - expect(rollupOrgLayerStatus([rep("installed"), rep("degraded")])).toBe("degraded"); - expect(rollupOrgLayerStatus([rep("not_installed"), rep("installed")])).toBe("not_installed"); - expect(rollupOrgLayerStatus([rep("unknown"), rep("degraded")])).toBe("unknown"); - }); -}); - -describe("mergeLayerStatuses", () => { - it("is commutative for severity", () => { - expect(mergeLayerStatuses("installed", "degraded")).toBe("degraded"); - expect(mergeLayerStatuses("degraded", "installed")).toBe("degraded"); - }); -}); diff --git a/web/admin/src/lib/status/engine.ts b/web/admin/src/lib/status/engine.ts deleted file mode 100644 index aadfba1a6a..0000000000 --- a/web/admin/src/lib/status/engine.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { LayerReport, LayerStatus } from "./types"; - -/** Higher = worse for rollup UX (matches CLI-style pessimism). */ -const severity: Record = { - installed: 0, - not_installed: 1, - degraded: 2, - unknown: 3, -}; - -/** Picks the more severe of two layer statuses. */ -export function mergeLayerStatuses(a: LayerStatus, b: LayerStatus): LayerStatus { - return severity[a] >= severity[b] ? a : b; -} - -/** - * Single org-level rollup across layer reports (for Pane A — Fullsend status). - * Empty input is treated as fully installed (no layers to disagree). - */ -export function rollupOrgLayerStatus(reports: LayerReport[]): LayerStatus { - if (reports.length === 0) return "installed"; - return reports.reduce((acc, r) => mergeLayerStatuses(acc, r.status), "installed" as LayerStatus); -} diff --git a/web/admin/src/lib/status/types.test.ts b/web/admin/src/lib/status/types.test.ts deleted file mode 100644 index 4ee4b8ea3f..0000000000 --- a/web/admin/src/lib/status/types.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { layerStatusLabel, type LayerStatus } from "./types"; - -describe("layerStatusLabel", () => { - it("maps not_installed", () => { - const s: LayerStatus = "not_installed"; - expect(layerStatusLabel(s)).toBe("not installed"); - }); -}); diff --git a/web/admin/src/lib/status/types.ts b/web/admin/src/lib/status/types.ts deleted file mode 100644 index e3da5a7aa2..0000000000 --- a/web/admin/src/lib/status/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type LayerStatus = "not_installed" | "installed" | "degraded" | "unknown"; - -export type LayerReport = { - name: string; - status: LayerStatus; - details: string[]; - wouldInstall: string[]; - wouldFix: string[]; -}; - -export function layerStatusLabel(s: LayerStatus): string { - switch (s) { - case "not_installed": - return "not installed"; - case "installed": - return "installed"; - case "degraded": - return "degraded"; - case "unknown": - return "unknown"; - default: { - const _x: never = s; - return _x; - } - } -} diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts deleted file mode 100644 index 068cce4dfc..0000000000 --- a/web/admin/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { mount } from "svelte"; -import App from "./App.svelte"; -import "./app.css"; - -mount(App, { target: document.getElementById("app")! }); diff --git a/web/admin/src/routes/InstallEntryStub.svelte b/web/admin/src/routes/InstallEntryStub.svelte deleted file mode 100644 index 5741e410da..0000000000 --- a/web/admin/src/routes/InstallEntryStub.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
-

Deploy Fullsend — {org}

-

- Install / onboard wizard flows are planned in Tasks 13–14 of the admin SPA plan. -

-

- ← Back to organisations -

-
- - diff --git a/web/admin/src/routes/OrgDashboardStub.svelte b/web/admin/src/routes/OrgDashboardStub.svelte deleted file mode 100644 index c9ffa03e3c..0000000000 --- a/web/admin/src/routes/OrgDashboardStub.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -
-

Organisation: {org}

-

- Organisation dashboard (repo union, status panes) is planned in Task 12 - of the admin SPA plan. -

-

- ← Back to organisations -

-
- - diff --git a/web/admin/src/routes/OrgList.svelte b/web/admin/src/routes/OrgList.svelte deleted file mode 100644 index e759b862d7..0000000000 --- a/web/admin/src/routes/OrgList.svelte +++ /dev/null @@ -1,1182 +0,0 @@ - - -
-

Select an organisation to deploy or configure Fullsend

- - {#if !$githubUser} -

Sign in to load this list.

- {:else} - {#if loading && serverOrgs.length === 0 && !inlineListRefresh} -
- -

Loading organisations…

-
- {:else} -
- - -
- - {#if listCheckAt != null && !error && !loading} -

- {#if serverOrgs.length === 0} - Last checked at {formatListCheckTime(listCheckAt)}. If you just installed the app, - GitHub can take a minute or longer before it shows up here. After you use Refresh, this - page also rechecks a few times in the background while you stay on it. - {:else} - Organisations last refreshed at {formatListCheckTime(listCheckAt)}. Use Refresh after - you add or remove installs on GitHub; changes can take a short while to appear. - {/if} -

- {/if} - - {#if showCapHint} -

Showing up to 15 organisations

- {/if} - - {#if installationListTruncated && !error} -

- Organisation list may be incomplete — loading stopped after - {MAX_INSTALLATION_LIST_PAGES} pages of GitHub app installations ({INSTALLATIONS_PER_PAGE} per - page). Use Refresh after you change installs; if you need more rows, ask your - operator to raise the page cap in code. -

- {/if} - - {#if error} - - {:else if filteredAll.length === 0} - {#if serverOrgs.length === 0} - {#if emptyHint} -

{emptyHint}

- {:else} -

No organisations found for this account.

- {/if} - {:else} -

No matching organisations.

- {/if} - {:else} -
    - {#each displayedOrgs as o (o.login)} - {@const ui = rowUi[o.login]} -
  • -
    - - {o.login} -
    -
    - {#if ui === undefined || ui === "pending"} -
    - -
    - {:else if ui.kind === "configure"} - - Configure - - {:else if ui.kind === "deploy"} - - Deploy Fullsend - - {:else if ui.kind === "cannot_deploy"} - {@const cdId = cannotDeployPopoverId(o.login)} -
    - - Cannot deploy - -
    -

    {ui.reason}

    - {#if ui.missingInstallRequirements?.length} -

    - Access an organisation owner may need to approve: -

    -
      - {#each ui.missingInstallRequirements as line, i (i)} -
    • {line}
    • - {/each} -
    - {/if} - {#if ui.helpBullets?.length} -

    Next steps

    -
      - {#each ui.helpBullets as line, i (i)} -
    • {line}
    • - {/each} -
    - {/if} -
    -
    - {:else if ui.kind === "error"} - {@const errId = rowErrPopoverId(o.login)} -
    - - Error - -
    -

    {ui.message}

    -
    - -
    - {/if} -
    -
  • - {/each} -
- {#if loading && displayedOrgs.length > 0} -
- - Refreshing organisation list -
- {/if} - {/if} - {/if} - -
-

Fullsend Admin app

- {#if serverOrgs.length === 0} -

- After you install or change access on GitHub, click Refresh at the top of this - page. GitHub does not return you here automatically. It can take a minute or longer before a - new install appears in GitHub’s data — after you refresh, we also recheck a few times in the - background when the list is still empty. -

-

- {#if installAppHref} - - Install the Fullsend Admin app on GitHub - - (opens in a new tab) - {:else} - - Install link is unavailable (app slug not known). Your operator can set - GITHUB_APP_SLUG on the site Worker or ensure installations return a slug. - - {/if} -

- {:else} -

- To deploy or configure Fullsend for another organisation, install the Fullsend Admin app - there. When you return from GitHub, click Refresh at the top of this page. -

- {#if installAppHref} -

- - Install the Fullsend Admin app on GitHub - - (opens in a new tab) -

- {:else} -

- Install link is unavailable (app slug not known). Your operator can set - GITHUB_APP_SLUG on the site Worker or ensure installations return a slug. -

- {/if} - {/if} -
- {/if} -
- - diff --git a/web/admin/src/vite-env.d.ts b/web/admin/src/vite-env.d.ts deleted file mode 100644 index 4a8be4a591..0000000000 --- a/web/admin/src/vite-env.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/// -/// - -/** - * Merged with `vite/client`. Root Vite project uses `base: '/'` for a shared build; - * the admin app is still deployed at the public path `/admin/` (see OAuth helpers). - */ -interface ImportMetaEnv { - /** Vite `base` (`'/'` in this repo; not the admin public path). */ - readonly BASE: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} diff --git a/web/admin/svelte.config.js b/web/admin/svelte.config.js deleted file mode 100644 index a2f9997d9b..0000000000 --- a/web/admin/svelte.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig} */ -export default { preprocess: vitePreprocess() }; diff --git a/web/admin/tsconfig.json b/web/admin/tsconfig.json deleted file mode 100644 index ae8413d49b..0000000000 --- a/web/admin/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "verbatimModuleSyntax": true, - "strict": true, - "skipLibCheck": true, - "types": ["vitest/globals"] - }, - "include": ["src/**/*.ts", "src/**/*.svelte"] -} diff --git a/web/docs/build/vitePluginDocs.test.ts b/web/docs/build/vitePluginDocs.test.ts deleted file mode 100644 index 258dbcc54a..0000000000 --- a/web/docs/build/vitePluginDocs.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildTree, type ManifestNode } from "./vitePluginDocs"; - -describe("buildTree", () => { - it("sorts files alphabetically when no order is specified", () => { - const result = buildTree([ - { routeKey: "guides/b", title: "B", segments: ["guides", "b"] }, - { routeKey: "guides/a", title: "A", segments: ["guides", "a"] }, - { routeKey: "guides/c", title: "C", segments: ["guides", "c"] }, - ]); - - const dir = result[0] as Extract; - expect(dir.type).toBe("dir"); - expect(dir.name).toBe("guides"); - const names = dir.children.map((c) => c.name); - expect(names).toEqual(["a", "b", "c"]); - }); - - it("sorts files by order when frontmatter order is specified", () => { - const result = buildTree([ - { - routeKey: "guides/getting-started/configuring-github", - title: "Configuring GitHub", - segments: ["guides", "getting-started", "configuring-github"], - order: 3, - }, - { - routeKey: "guides/getting-started/README", - title: "Getting Started", - segments: ["guides", "getting-started", "README"], - order: 1, - }, - { - routeKey: "guides/getting-started/getting-inference", - title: "Getting Inference", - segments: ["guides", "getting-started", "getting-inference"], - order: 2, - }, - { - routeKey: "guides/getting-started/org-mode", - title: "Organization Mode", - segments: ["guides", "getting-started", "org-mode"], - order: 4, - }, - ]); - - const guidesDir = result[0] as Extract; - expect(guidesDir.name).toBe("guides"); - const gsDir = guidesDir.children[0] as Extract< - ManifestNode, - { type: "dir" } - >; - expect(gsDir.name).toBe("getting-started"); - - const names = gsDir.children.map((c) => c.name); - expect(names).toEqual([ - "README", - "getting-inference", - "configuring-github", - "org-mode", - ]); - }); - - it("sorts ordered files before unordered files", () => { - const result = buildTree([ - { routeKey: "d/z", title: "Z", segments: ["d", "z"] }, - { routeKey: "d/a", title: "A", segments: ["d", "a"], order: 2 }, - { routeKey: "d/m", title: "M", segments: ["d", "m"], order: 1 }, - { routeKey: "d/b", title: "B", segments: ["d", "b"] }, - ]); - - const dir = result[0] as Extract; - const names = dir.children.map((c) => c.name); - // Ordered files first (by order), then unordered (alphabetically) - expect(names).toEqual(["m", "a", "b", "z"]); - }); -});