From 3a54688cf861a0e6cebd7cf1c9482e08d9cf246c Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Sat, 24 Oct 2026 09:32:10 -0700 Subject: [PATCH 1/7] Add access.modelcontextprotocol.io: GitHub sign-in for Google Workspace A Cloudflare Worker (Hono, jose, D1) in access-site/ that acts as the OIDC provider for Google Workspace's custom OIDC SSO profile, keyed on the numeric GitHub user ID, and lets eligible maintainers create or link their own @modelcontextprotocol.io account. Eligibility comes from the same provisionUser rule Pulumi uses. A nightly job refreshes the account cache and suspends accounts of people who left eligible teams (report-only by default, capped per run, fail-safe on lookup errors). Also: - scripts/backfill-github-ids.ts: stamp existing accounts with GitHub IDs - scripts/sync-team-groups.ts + workflow: mirror GitHub teams as Google groups - src/google.ts: retainOnDelete on Workspace users (suspend, never delete) - src/config/utils.ts: githubTeamsFor, shared by the Worker and the sync - .github/workflows/access-site.yml: test on PR, migrate and deploy on main Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EwaAHtQd625Lwv121Y6PTn --- .github/workflows/access-site.yml | 72 + .github/workflows/team-groups.yml | 44 + README.md | 4 + access-site/README.md | 97 + access-site/migrations/0001_init.sql | 56 + access-site/package-lock.json | 2665 +++++++++++++++++++++++++ access-site/package.json | 27 + access-site/scripts/generate-keys.mjs | 35 + access-site/src/accounts.ts | 127 ++ access-site/src/cron.ts | 108 + access-site/src/crypto.ts | 65 + access-site/src/db.ts | 220 ++ access-site/src/eligibility.ts | 75 + access-site/src/env.ts | 29 + access-site/src/github.ts | 32 + access-site/src/google.ts | 191 ++ access-site/src/index.ts | 199 ++ access-site/src/notify.ts | 15 + access-site/src/oidc.ts | 266 +++ access-site/src/pages.ts | 241 +++ access-site/src/validation.ts | 61 + access-site/test/account.test.ts | 151 ++ access-site/test/cron.test.ts | 101 + access-site/test/helpers.ts | 242 +++ access-site/test/oidc.test.ts | 211 ++ access-site/test/validation.test.ts | 59 + access-site/tsconfig.json | 16 + access-site/vitest.config.ts | 10 + access-site/wrangler.toml | 44 + scripts/backfill-github-ids.ts | 92 + scripts/lib/google-admin.ts | 134 ++ scripts/sync-team-groups.ts | 150 ++ src/config/utils.ts | 27 + src/google.ts | 4 + 34 files changed, 5870 insertions(+) create mode 100644 .github/workflows/access-site.yml create mode 100644 .github/workflows/team-groups.yml create mode 100644 access-site/README.md create mode 100644 access-site/migrations/0001_init.sql create mode 100644 access-site/package-lock.json create mode 100644 access-site/package.json create mode 100644 access-site/scripts/generate-keys.mjs create mode 100644 access-site/src/accounts.ts create mode 100644 access-site/src/cron.ts create mode 100644 access-site/src/crypto.ts create mode 100644 access-site/src/db.ts create mode 100644 access-site/src/eligibility.ts create mode 100644 access-site/src/env.ts create mode 100644 access-site/src/github.ts create mode 100644 access-site/src/google.ts create mode 100644 access-site/src/index.ts create mode 100644 access-site/src/notify.ts create mode 100644 access-site/src/oidc.ts create mode 100644 access-site/src/pages.ts create mode 100644 access-site/src/validation.ts create mode 100644 access-site/test/account.test.ts create mode 100644 access-site/test/cron.test.ts create mode 100644 access-site/test/helpers.ts create mode 100644 access-site/test/oidc.test.ts create mode 100644 access-site/test/validation.test.ts create mode 100644 access-site/tsconfig.json create mode 100644 access-site/vitest.config.ts create mode 100644 access-site/wrangler.toml create mode 100644 scripts/backfill-github-ids.ts create mode 100644 scripts/lib/google-admin.ts create mode 100644 scripts/sync-team-groups.ts diff --git a/.github/workflows/access-site.yml b/.github/workflows/access-site.yml new file mode 100644 index 00000000..c2335ddf --- /dev/null +++ b/.github/workflows/access-site.yml @@ -0,0 +1,72 @@ +name: access-site + +# Tests and deploys access.modelcontextprotocol.io (access-site/). The Worker +# bundles src/config, so config changes redeploy it too. +on: + pull_request: + paths: + - 'access-site/**' + - 'src/config/**' + - '.github/workflows/access-site.yml' + push: + branches: + - main + paths: + - 'access-site/**' + - 'src/config/**' + - '.github/workflows/access-site.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Typecheck and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: access-site + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: access-site/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + + deploy: + name: Deploy + needs: test + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: production + concurrency: + group: deploy-access-site + cancel-in-progress: false + defaults: + run: + working-directory: access-site + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: access-site/package-lock.json + - run: npm ci + # Token scoped to: Workers Scripts edit, D1 edit, and Workers Routes / + # DNS edit on the modelcontextprotocol.io zone (for the custom domain). + - name: Apply D1 migrations + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_ACCESS_SITE_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: npx wrangler d1 migrations apply access-site --remote + - name: Deploy Worker + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_ACCESS_SITE_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: npx wrangler deploy diff --git a/.github/workflows/team-groups.yml b/.github/workflows/team-groups.yml new file mode 100644 index 00000000..c3fd61ed --- /dev/null +++ b/.github/workflows/team-groups.yml @@ -0,0 +1,44 @@ +name: Sync team Google groups + +# Mirrors GitHub teams as Google groups (scripts/sync-team-groups.ts). +# Runs in dry-run mode until the repository variable TEAM_GROUP_SYNC is set to +# "apply". +on: + schedule: + - cron: '41 5 * * *' + push: + branches: + - main + paths: + - 'src/config/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + # A separate environment without required reviewers, holding only + # GCP_PROD_SERVICE_ACCOUNT_KEY, so the nightly run never waits for approval. + environment: workspace-sync + concurrency: + group: sync-team-groups + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + # The script only needs ts-node and the config files. + - run: npm ci --ignore-scripts + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }} + - name: Sync + env: + GITHUB_TOKEN: ${{ github.token }} + MODE: ${{ vars.TEAM_GROUP_SYNC == 'apply' && '--apply' || '' }} + run: npx ts-node scripts/sync-team-groups.ts $MODE diff --git a/README.md b/README.md index b46bd811..519bf13c 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,16 @@ Infrastructure as Code for managing access to MCP community resources using Pulu - **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts - **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security. - **Google Workspace User Accounts**: Provisions @modelcontextprotocol.io accounts for members of roles with `provisionUser: true` (directly, or via a role nested under one through `github.parent` — e.g. SDK teams under `sdk-maintainers`, working groups under `working-groups`) +- **GitHub sign-in and self-service accounts** ([`access-site/`](access-site/README.md)): [access.modelcontextprotocol.io](https://access.modelcontextprotocol.io) is the OIDC identity provider for Google Workspace, so maintainers sign in to Google with GitHub. Eligible members create or link their own account there, and accounts of people who leave eligible teams are suspended (never deleted). +- **Team Google groups** ([`scripts/sync-team-groups.ts`](scripts/sync-team-groups.ts)): every GitHub team without a Pulumi-managed group is mirrored as `@modelcontextprotocol.io`, synced nightly from the team's Workspace accounts. - **Discord**: Syncs the Discord roles declared in [`src/config/roles.ts`](src/config/roles.ts) to the members' Discord IDs in `users.ts`, and manages the settings of the channels declared in [`src/config/channels.ts`](src/config/channels.ts), including thread-only forum/media channels. See [Discord channels](#discord-channels) below. - **Cloudflare Access (security-room)**: Syncs the Cloudflare Zero Trust Access policy that decides who can sign in to `securityroom.modelcontextprotocol.io` from the roles declared in [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts). See [Cloudflare Access (security-room)](#cloudflare-access-security-room) below. - **npm & PyPI Package Publishing Access** (declared, not applied): Expected registry access is declared in [`src/config/packageAccess.ts`](src/config/packageAccess.ts) and drift against the live npm registry is detected by CI — but changes are applied manually by a maintainer. See [npm & PyPI Package Publishing Access](#npm--pypi-package-publishing-access) below for why and how. ### Opting in to a Google Workspace account (maintainers) +Once [access.modelcontextprotocol.io](https://access.modelcontextprotocol.io) is live, sign in there with GitHub and create your account; no PR or password needed. Until then, or if you prefer to reserve a specific address in config: + If you're a maintainer — explicitly or implicitly (SDK maintainers, working group members, etc.) — and want an `@modelcontextprotocol.io` account, open a PR adding the following fields to your entry in [`src/config/users.ts`](src/config/users.ts): ```ts diff --git a/access-site/README.md b/access-site/README.md new file mode 100644 index 00000000..620c753a --- /dev/null +++ b/access-site/README.md @@ -0,0 +1,97 @@ +# access.modelcontextprotocol.io + +Sign in to MCP Google Workspace with GitHub, and self-service `@modelcontextprotocol.io` accounts. + +A Cloudflare Worker (Hono, jose, D1) that: + +- is a minimal OpenID Connect provider for Google Workspace's custom OIDC SSO profile, keyed on the numeric GitHub user ID +- lets eligible maintainers create their own account, or link the one already configured for them in `users.ts` +- runs nightly to refresh its account cache and suspend accounts whose owners are no longer eligible + +Eligibility is exactly the Pulumi rule: a member of a role with `provisionUser` (directly or through a child team). The Worker bundles `src/config`, so it is redeployed whenever the config changes. + +## How sign-in works + +```mermaid +sequenceDiagram + actor M as Maintainer + participant G as Google + participant W as access.modelcontextprotocol.io + participant GH as GitHub + participant D as Google Directory + M->>G: Open Gmail + G->>W: /authorize (OIDC) + W->>GH: GitHub App sign-in + GH-->>W: user ID, login + W->>W: eligible in users.ts? + W->>D: account with externalId github=? (cached in D1) + W-->>G: code + G->>W: /token + W-->>G: ID token: sub=, email=@modelcontextprotocol.io + G-->>M: Signed in +``` + +Users who are eligible but have no account, or whose account is suspended, land on the account page instead of being sent back to Google. + +## Account page + +- **Configured in `users.ts`** (`googleEmailPrefix` set): confirms that address and links it to the GitHub ID, creating it if it doesn't exist yet. +- **Eligible, not configured**: picks a name and email prefix; the account is created immediately in the MCP OU with the GitHub ID in `externalIds`. Reserved names and team slugs are refused. +- **Not eligible**: explains which roles grant accounts. + +Accounts are never deleted. Leaving every eligible team makes the next sign-in fail, and the nightly job suspends the account (report-only until `OFFBOARDING_MODE = "enforce"`; it refuses to suspend more than `OFFBOARDING_MAX_PER_RUN` in one run, and skips any account whose GitHub lookup fails). + +## Setup + +One-time, in this order. + +1. **GitHub App** (org-owned). Callback URL `https://access.modelcontextprotocol.io/login`. Permissions: Account → Email addresses: read-only (the sign-in library reads the primary email; it is not used). No repository or organization permissions. Note the client ID and generate a client secret. +2. **Google service account**. Create it in the `mcp-access-prod` GCP project and download a JSON key. In the Admin console, create a custom admin role with Users: Create, Read, Update (not Delete), assign it to the service account, and scope the assignment to the **Model Context Protocol** OU. +3. **Cloudflare**: + ```sh + cd access-site + npm ci + npx wrangler d1 create access-site # put the database_id in wrangler.toml + npm run migrate + ``` + Set `GITHUB_CLIENT_ID` in `wrangler.toml`, then the secrets: + ```sh + npm run generate-keys # OIDC_SIGNING_KEY + OIDC_CLIENT_SECRET (printed once) + npx wrangler secret put GITHUB_CLIENT_SECRET + npx wrangler secret put GOOGLE_SA_KEY < key.json + npx wrangler secret put DISCORD_WEBHOOK_URL # optional + npx wrangler secret put GITHUB_API_TOKEN # optional, read-only token for the nightly job + ``` +4. **Backfill** existing accounts with GitHub IDs (from the repo root, with the Pulumi service account key): + ```sh + GOOGLE_SA_KEY="$(cat sa-key.json)" GITHUB_TOKEN=... npx ts-node scripts/backfill-github-ids.ts # dry run + GOOGLE_SA_KEY="$(cat sa-key.json)" GITHUB_TOKEN=... npx ts-node scripts/backfill-github-ids.ts --apply + ``` + Fix anything it reports. Anyone missed can still link themselves on the account page. +5. **Google SSO profile** (super admin): Security → Authentication → SSO with third-party IdP → add a custom **OIDC** profile. + - Issuer: `https://access.modelcontextprotocol.io` + - Client ID: `google-workspace`, client secret: the value from `generate-keys` + - Put the redirect URI Google shows into `OIDC_REDIRECT_URIS` in `wrangler.toml` and deploy. + - Assign the profile to a pilot sub-OU first, then to **Model Context Protocol**. Super admins always keep Google sign-in. +6. **CI**: add `CLOUDFLARE_ACCESS_SITE_TOKEN` (Workers Scripts edit, D1 edit, Workers Routes / DNS edit on the zone) to the `production` environment and `CLOUDFLARE_ACCOUNT_ID` as a repository variable. Merges to `main` then test, migrate and deploy (`.github/workflows/access-site.yml`). + +## Operations + +- **Turn on offboarding**: after a few clean report-only runs, set `OFFBOARDING_MODE = "enforce"`. +- **Rotate the signing key**: copy the current key from `/jwks` into the `OIDC_PREVIOUS_JWK` secret, run `node scripts/generate-keys.mjs --rotate`, deploy; remove `OIDC_PREVIOUS_JWK` a day later. +- **Audit**: `npx wrangler d1 execute access-site --remote --command "SELECT * FROM audit_log ORDER BY id DESC LIMIT 50"`. +- **Break-glass**: super admins are exempt from the SSO profile, so they can always sign in to fix things. + +## Development + +```sh +npm ci +npm test # vitest, Node 22+ (uses node:sqlite in place of D1) +npm run typecheck +npm run dev # wrangler dev +``` + +## To verify against the live services + +- Directory API search by `externalId:` (`findUserByGithubId`); the nightly job rebuilds the D1 cache from a full OU listing, so sign-in works even if this search does not. +- Whether Google sends PKCE in its OIDC requests (both cases are supported) and the exact redirect URI it uses. diff --git a/access-site/migrations/0001_init.sql b/access-site/migrations/0001_init.sql new file mode 100644 index 00000000..f5bb9292 --- /dev/null +++ b/access-site/migrations/0001_init.sql @@ -0,0 +1,56 @@ +-- Sign-in requests from Google, held while the user is at GitHub. +CREATE TABLE pending_authorizations ( + id_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + state TEXT, + nonce TEXT, + code_challenge TEXT, + expires_at INTEGER NOT NULL +); + +-- Single-use authorization codes (stored hashed; consumed with DELETE ... RETURNING). +CREATE TABLE authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + nonce TEXT, + code_challenge TEXT, + claims TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Sessions for the account page (session IDs stored hashed). +CREATE TABLE sessions ( + id_hash TEXT PRIMARY KEY, + github_id TEXT NOT NULL, + github_login TEXT NOT NULL, + github_name TEXT, + expires_at INTEGER NOT NULL +); + +-- Cache of GitHub user ID -> Workspace account. Google (externalIds) is the +-- source of truth; the nightly job rebuilds this table from the Directory. +CREATE TABLE accounts ( + github_id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + suspended INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +); + +-- One account creation in flight per GitHub user. +CREATE TABLE creation_locks ( + github_id TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL +); + +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + subject TEXT, + detail TEXT +); + +CREATE INDEX audit_log_at ON audit_log (at); diff --git a/access-site/package-lock.json b/access-site/package-lock.json new file mode 100644 index 00000000..aabb0d7a --- /dev/null +++ b/access-site/package-lock.json @@ -0,0 +1,2665 @@ +{ + "name": "@mcp/access-site", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mcp/access-site", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@hono/oauth-providers": "^0.9.0", + "hono": "^4.13.13", + "jose": "^6.2.13" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20261024.1", + "typescript": "^5.9.2", + "vitest": "^5.0.5", + "wrangler": "^4.152.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261019.1.tgz", + "integrity": "sha512-5ZILbAta/fBQvhmAYeCE7dKMMYIr7N9wJEX50fEGLKSIwGtvT0HzSfHNIK/O7cBQ4gHQ2WfIiPClvwcye6E0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261019.1.tgz", + "integrity": "sha512-cww2Ab6lVMxpTiEEjKmPI58p5EMo0vYTZSH+3WkqXJdQgc3ES66GuL247+U2B2YWajhhu2tqj7jySrXneU749g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261019.1.tgz", + "integrity": "sha512-4++VRi9vkQROYqrpZYOixIJuVDmujkwrT4kszsxNFlvcmsX6Mr2GumvxIN3gIixS3vJUh3Umdtkthqj5X0S3Xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261019.1.tgz", + "integrity": "sha512-75PVtJOwfjScQ53sbLtQXI7D8yP1c21t+WEUPaYbqkzaz4ZqfR5wAd+zuWs8G5Q7kOj2SQIvEfY5Sn8Y+UVrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261019.1.tgz", + "integrity": "sha512-z49fvKTETMCnG6wnMcQwxx9CpryFceiKz04FRjq26EkhCyzRNJ6c3UXhyZz3r5f1CXnnBZc5DcU8ZouPkUJCqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20261024.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261024.1.tgz", + "integrity": "sha512-sNhbgBsuVvmumSurgCcIrXShqACPfRt/XlYFstBeANu5BHEJhXW+e7PmHxDNYRXxKYZV57toUfkLwFp8/0SZKA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/oauth-providers": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@hono/oauth-providers/-/oauth-providers-0.9.0.tgz", + "integrity": "sha512-OgDPB+AM6OgA5ys1y+7G24bkb8V4OINr+6JFDSuSYni80Mmd7BGpJ0e02StQDMPL9ebuWI8ABhxx1bk+9VBcBA==", + "license": "MIT", + "engines": { + "node": ">=18.4.0" + }, + "peerDependencies": { + "hono": ">=3.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "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/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/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/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" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/miniflare": { + "version": "5.20261019.2-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261019.2-beta.tgz", + "integrity": "sha512-QNgCA/axizZ7kv7RV3nwbRoz+w4watBlwIsyXjwgYHw4eSw5BrR1F2152s3Kz+Bzy/YxjKFHrZl3LzBRbPgYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.29.1", + "workerd": "1.20261019.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "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/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/workerd": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261019.1.tgz", + "integrity": "sha512-UUYRUq/7DmeCl11YriNbcVbjUZVLtt8uuCXFu+nz77hGFf8d4DEVXoCYtnA+MlBatE4+wxoj47DVqzK3xGBAiw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261019.1", + "@cloudflare/workerd-darwin-arm64": "1.20261019.1", + "@cloudflare/workerd-linux-64": "1.20261019.1", + "@cloudflare/workerd-linux-arm64": "1.20261019.1", + "@cloudflare/workerd-windows-64": "1.20261019.1" + } + }, + "node_modules/wrangler": { + "version": "4.152.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.152.0.tgz", + "integrity": "sha512-lmXTF1sCDEqBaEMpreGczvg/x3AIh1d/qxc4BN7utQ4jEDwdA4YG0L6kr3ev0TtVLHRb6QLySNAOAunwn+dHeA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261019.2-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261019.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261019.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/access-site/package.json b/access-site/package.json new file mode 100644 index 00000000..7a45589a --- /dev/null +++ b/access-site/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp/access-site", + "version": "0.1.0", + "private": true, + "description": "access.modelcontextprotocol.io: GitHub sign-in (OIDC) and self-service Google Workspace accounts for MCP maintainers", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "migrate": "wrangler d1 migrations apply access-site --remote", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "generate-keys": "node scripts/generate-keys.mjs" + }, + "license": "MIT", + "dependencies": { + "@hono/oauth-providers": "^0.9.0", + "hono": "^4.13.13", + "jose": "^6.2.13" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20261024.1", + "typescript": "^5.9.2", + "vitest": "^5.0.5", + "wrangler": "^4.152.0" + } +} diff --git a/access-site/scripts/generate-keys.mjs b/access-site/scripts/generate-keys.mjs new file mode 100644 index 00000000..565a6e07 --- /dev/null +++ b/access-site/scripts/generate-keys.mjs @@ -0,0 +1,35 @@ +// Generates the Worker's OIDC signing key and the client secret shared with +// Google, and pipes each straight into `wrangler secret put`. Nothing is +// written to disk; the client secret is printed once because Google needs it. +// +// node scripts/generate-keys.mjs # set both secrets +// node scripts/generate-keys.mjs --rotate # new signing key only; prints the +// # old public JWK to set as OIDC_PREVIOUS_JWK +import { generateKeyPairSync, randomBytes } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; + +function putSecret(name, value) { + const res = spawnSync('npx', ['wrangler', 'secret', 'put', name], { + input: value, + stdio: ['pipe', 'inherit', 'inherit'], + }); + if (res.status !== 0) process.exit(res.status ?? 1); +} + +const rotate = process.argv.includes('--rotate'); +const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +putSecret('OIDC_SIGNING_KEY', privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()); + +if (!rotate) { + const clientSecret = randomBytes(32).toString('base64url'); + putSecret('OIDC_CLIENT_SECRET', clientSecret); + // Google's SSO profile needs the same value, so show it once. + console.log( + `\nOIDC_CLIENT_SECRET (enter this in the Google SSO profile, then clear your terminal):\n${clientSecret}` + ); +} else { + console.log( + '\nNew signing key set. Before deploying, copy the previous key from /jwks and set it as\n' + + 'OIDC_PREVIOUS_JWK so tokens signed with it keep verifying; remove it a day later.' + ); +} diff --git a/access-site/src/accounts.ts b/access-site/src/accounts.ts new file mode 100644 index 00000000..b64dc297 --- /dev/null +++ b/access-site/src/accounts.ts @@ -0,0 +1,127 @@ +/** + * Account logic: who may have an account, finding a user's account, and + * creating or linking one. HTTP-free so it can be tested directly. + */ +import type { Env } from './env'; +import { + type Account, + acquireCreationLock, + audit, + getAccount, + releaseCreationLock, + upsertAccount, +} from './db'; +import { findMember, isEligible, prefixOwner } from './eligibility'; +import { createUser, findUserByGithubId, getUser, githubIdOf, linkGithubId } from './google'; +import { notify } from './notify'; +import { validateName, validatePrefix } from './validation'; + +export interface GitHubIdentity { + id: string; + login: string; + name: string | null; +} + +/** The user's Workspace account: D1 cache first, then the Directory. */ +export async function resolveAccount(env: Env, githubId: string): Promise { + const cached = await getAccount(env.DB, githubId); + if (cached) return cached; + const user = await findUserByGithubId(env, githubId); + if (!user) return null; + const account = { github_id: githubId, email: user.primaryEmail, suspended: !!user.suspended }; + await upsertAccount(env.DB, account); + return account; +} + +export type AccountRequest = + | { kind: 'link'; email: string } + | { kind: 'create'; suggestedPrefix: string | null }; + +/** + * What the account page should offer someone with no linked account: + * members with a googleEmailPrefix in users.ts already have (or were promised) + * that address, so they link it; everyone else picks a new address. + */ +export function accountRequestFor(env: Env, identity: GitHubIdentity): AccountRequest | null { + const member = findMember(identity.login); + if (!isEligible(member)) return null; + if (member?.googleEmailPrefix) { + return { + kind: 'link', + email: `${member.googleEmailPrefix.toLowerCase()}@${env.WORKSPACE_DOMAIN}`, + }; + } + return { kind: 'create', suggestedPrefix: null }; +} + +export type EnsureResult = + | { ok: true; email: string; created: boolean } + | { ok: false; error: string }; + +export async function ensureAccount( + env: Env, + identity: GitHubIdentity, + form: { firstName?: unknown; lastName?: unknown; prefix?: unknown } +): Promise { + const request = accountRequestFor(env, identity); + if (!request) + return { ok: false, error: 'You are not in a team that can have a Workspace account.' }; + + const existing = await resolveAccount(env, identity.id); + if (existing) return { ok: true, email: existing.email, created: false }; + + const firstName = validateName(form.firstName, 'First name'); + if (!firstName.ok) return firstName; + const lastName = validateName(form.lastName, 'Last name'); + if (!lastName.ok) return lastName; + + let email: string; + if (request.kind === 'link') { + email = request.email; + } else { + const prefix = validatePrefix(form.prefix); + if (!prefix.ok) return prefix; + const owner = prefixOwner(prefix.value); + if (owner && owner.github?.toLowerCase() !== identity.login.toLowerCase()) { + return { ok: false, error: `${prefix.value}@${env.WORKSPACE_DOMAIN} is already taken.` }; + } + email = `${prefix.value}@${env.WORKSPACE_DOMAIN}`; + } + + if (!(await acquireCreationLock(env.DB, identity.id))) { + return { ok: false, error: 'Your account is already being set up. Refresh in a minute.' }; + } + try { + const current = await getUser(env, email); + if (current) { + // The address exists. Link it only if users.ts says it is this person's + // and it is not already linked to another GitHub account. + const linkedTo = githubIdOf(current); + if (request.kind !== 'link' || (linkedTo && linkedTo !== identity.id)) { + return { ok: false, error: `${email} is already taken.` }; + } + if (!linkedTo) await linkGithubId(env, current, identity.id); + await upsertAccount(env.DB, { + github_id: identity.id, + email, + suspended: !!current.suspended, + }); + await audit(env.DB, identity.login, 'account.link', email, { github_id: identity.id }); + return { ok: true, email, created: false }; + } + + const result = await createUser(env, { + email, + firstName: firstName.value, + lastName: lastName.value, + githubId: identity.id, + }); + if (result.status === 'conflict') return { ok: false, error: `${email} is already taken.` }; + await upsertAccount(env.DB, { github_id: identity.id, email, suspended: false }); + await audit(env.DB, identity.login, 'account.create', email, { github_id: identity.id }); + await notify(env, `New Workspace account ${email} for @${identity.login}`); + return { ok: true, email, created: true }; + } finally { + await releaseCreationLock(env.DB, identity.id); + } +} diff --git a/access-site/src/cron.ts b/access-site/src/cron.ts new file mode 100644 index 00000000..72afe94f --- /dev/null +++ b/access-site/src/cron.ts @@ -0,0 +1,108 @@ +/** + * Nightly job: clean up expired rows, rebuild the account cache from the + * Directory, and suspend accounts whose GitHub user is no longer eligible. + * + * Offboarding fails safe: an account is only suspended when its GitHub user + * was looked up successfully and is not eligible under the current config. + * Lookup errors skip the account, and a run that would suspend more than + * OFFBOARDING_MAX_PER_RUN accounts suspends none. + */ +import type { Env } from './env'; +import { type Account, audit, deleteExpired, replaceAccounts } from './db'; +import { findMember, isEligible } from './eligibility'; +import { githubLoginForId } from './github'; +import { githubIdOf, listUsersInOu, suspendUser } from './google'; +import { notify } from './notify'; + +export interface NightlyReport { + linked: number; + unlinked: string[]; + toSuspend: { email: string; reason: string }[]; + suspended: string[]; + skipped: { email: string; reason: string }[]; + aborted: boolean; +} + +export async function runNightly(env: Env): Promise { + await deleteExpired(env.DB); + + const users = (await listUsersInOu(env)).filter((u) => !u.isAdmin); + const accounts: Account[] = []; + const report: NightlyReport = { + linked: 0, + unlinked: [], + toSuspend: [], + suspended: [], + skipped: [], + aborted: false, + }; + + for (const user of users) { + const githubId = githubIdOf(user); + if (!githubId) { + if (!user.suspended) report.unlinked.push(user.primaryEmail); + continue; + } + accounts.push({ github_id: githubId, email: user.primaryEmail, suspended: !!user.suspended }); + if (user.suspended) continue; + + const lookup = await githubLoginForId(githubId, env.GITHUB_API_TOKEN); + if (lookup.status === 'error') { + report.skipped.push({ + email: user.primaryEmail, + reason: `GitHub lookup failed: ${lookup.detail}`, + }); + } else if (lookup.status === 'not_found') { + report.toSuspend.push({ + email: user.primaryEmail, + reason: 'GitHub account no longer exists', + }); + } else if (!isEligible(findMember(lookup.login))) { + report.toSuspend.push({ + email: user.primaryEmail, + reason: `@${lookup.login} is no longer in an eligible team`, + }); + } + } + report.linked = accounts.length; + await replaceAccounts(env.DB, accounts); + + const max = Number.parseInt(env.OFFBOARDING_MAX_PER_RUN, 10) || 0; + const enforce = env.OFFBOARDING_MODE === 'enforce'; + if (enforce && report.toSuspend.length > max) { + report.aborted = true; + } else if (enforce) { + for (const { email, reason } of report.toSuspend) { + await suspendUser(env, email); + await audit(env.DB, 'nightly', 'account.suspend', email, { reason }); + report.suspended.push(email); + } + if (report.suspended.length > 0) { + const suspendedSet = new Set(report.suspended); + await replaceAccounts( + env.DB, + accounts.map((a) => (suspendedSet.has(a.email) ? { ...a, suspended: true } : a)) + ); + } + } + + await audit(env.DB, 'nightly', 'nightly.run', null, report); + const lines: string[] = []; + if (report.suspended.length) lines.push(`Suspended: ${report.suspended.join(', ')}`); + if (!enforce && report.toSuspend.length) { + lines.push( + `Would suspend (report mode): ${report.toSuspend.map((s) => `${s.email} (${s.reason})`).join(', ')}` + ); + } + if (report.aborted) { + lines.push( + `Offboarding aborted: ${report.toSuspend.length} accounts flagged, limit is ${max}. Check the config.` + ); + } + if (report.unlinked.length) lines.push(`Not linked to GitHub: ${report.unlinked.join(', ')}`); + if (report.skipped.length) + lines.push(`Skipped: ${report.skipped.map((s) => s.email).join(', ')}`); + if (lines.length) + await notify(env, `access.modelcontextprotocol.io nightly\n${lines.join('\n')}`); + return report; +} diff --git a/access-site/src/crypto.ts b/access-site/src/crypto.ts new file mode 100644 index 00000000..a749cb8c --- /dev/null +++ b/access-site/src/crypto.ts @@ -0,0 +1,65 @@ +/** + * Small helpers around jose and WebCrypto. All JWT/JWK work goes through jose; + * this file only adds key caching and the few primitives jose doesn't cover. + */ +import { calculateJwkThumbprint, exportJWK, importPKCS8, type JWK } from 'jose'; + +const encoder = new TextEncoder(); + +export function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function base64Url(bytes: ArrayBuffer | Uint8Array): string { + const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (const b of arr) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function randomToken(bytes = 32): string { + return base64Url(crypto.getRandomValues(new Uint8Array(bytes))); +} + +/** SHA-256, base64url. Used for PKCE and to store codes and session IDs hashed. */ +export async function sha256(input: string): Promise { + return base64Url(await crypto.subtle.digest('SHA-256', encoder.encode(input))); +} + +/** Constant-time comparison for secrets. */ +export function timingSafeEqual(a: string, b: string): boolean { + const ab = encoder.encode(a); + const bb = encoder.encode(b); + let diff = ab.length ^ bb.length; + for (let i = 0; i < Math.max(ab.length, bb.length); i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + return diff === 0; +} + +/** RFC 7636 S256 check. */ +export async function verifyPkce(verifier: string, challenge: string): Promise { + if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return false; + return timingSafeEqual(await sha256(verifier), challenge); +} + +export interface SigningKey { + privateKey: CryptoKey; + publicJwk: JWK & { kid: string }; +} + +const signingKeys = new Map>(); + +/** Loads the OIDC signing key once per isolate; kid is the RFC 7638 thumbprint. */ +export function loadSigningKey(pkcs8Pem: string): Promise { + let cached = signingKeys.get(pkcs8Pem); + if (!cached) { + cached = (async () => { + const privateKey = await importPKCS8(pkcs8Pem, 'RS256', { extractable: true }); + const { kty, n, e } = await exportJWK(privateKey); + const publicJwk = { kty, n, e, alg: 'RS256', use: 'sig' }; + const kid = await calculateJwkThumbprint(publicJwk); + return { privateKey, publicJwk: { ...publicJwk, kid } }; + })(); + signingKeys.set(pkcs8Pem, cached); + } + return cached; +} diff --git a/access-site/src/db.ts b/access-site/src/db.ts new file mode 100644 index 00000000..f15a81e4 --- /dev/null +++ b/access-site/src/db.ts @@ -0,0 +1,220 @@ +import { nowSeconds, sha256 } from './crypto'; + +export interface PendingAuthorization { + client_id: string; + redirect_uri: string; + state: string | null; + nonce: string | null; + /** PKCE S256 challenge; null if the client did not send one (it still authenticates with its secret). */ + code_challenge: string | null; +} + +export interface AuthorizationCode extends Omit { + claims: Record; +} + +export interface Session { + github_id: string; + github_login: string; + github_name: string | null; +} + +export interface Account { + github_id: string; + email: string; + suspended: boolean; +} + +// --- Sign-in flow (rows are single-use: read with DELETE ... RETURNING) ---- + +export async function savePending( + db: D1Database, + id: string, + p: PendingAuthorization, + ttlSeconds: number +): Promise { + await db + .prepare( + `INSERT INTO pending_authorizations + (id_hash, client_id, redirect_uri, state, nonce, code_challenge, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + await sha256(id), + p.client_id, + p.redirect_uri, + p.state, + p.nonce, + p.code_challenge, + nowSeconds() + ttlSeconds + ) + .run(); +} + +export async function takePending( + db: D1Database, + id: string +): Promise { + const row = await db + .prepare(`DELETE FROM pending_authorizations WHERE id_hash = ? RETURNING *`) + .bind(await sha256(id)) + .first(); + if (!row || row.expires_at < nowSeconds()) return null; + return row; +} + +export async function saveCode( + db: D1Database, + code: string, + c: AuthorizationCode, + ttlSeconds: number +): Promise { + await db + .prepare( + `INSERT INTO authorization_codes + (code_hash, client_id, redirect_uri, nonce, code_challenge, claims, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + await sha256(code), + c.client_id, + c.redirect_uri, + c.nonce, + c.code_challenge, + JSON.stringify(c.claims), + nowSeconds() + ttlSeconds + ) + .run(); +} + +export async function takeCode(db: D1Database, code: string): Promise { + const row = await db + .prepare(`DELETE FROM authorization_codes WHERE code_hash = ? RETURNING *`) + .bind(await sha256(code)) + .first & { claims: string; expires_at: number }>(); + if (!row || row.expires_at < nowSeconds()) return null; + return { ...row, claims: JSON.parse(row.claims) as Record }; +} + +// --- Account page sessions ------------------------------------------------- + +export async function createSession( + db: D1Database, + id: string, + s: Session, + ttlSeconds: number +): Promise { + await db + .prepare( + `INSERT INTO sessions (id_hash, github_id, github_login, github_name, expires_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind(await sha256(id), s.github_id, s.github_login, s.github_name, nowSeconds() + ttlSeconds) + .run(); +} + +export async function getSession(db: D1Database, id: string | undefined): Promise { + if (!id || id.length > 100) return null; + return db + .prepare( + `SELECT github_id, github_login, github_name FROM sessions + WHERE id_hash = ? AND expires_at >= ?` + ) + .bind(await sha256(id), nowSeconds()) + .first(); +} + +export async function deleteSession(db: D1Database, id: string | undefined): Promise { + if (!id) return; + await db + .prepare(`DELETE FROM sessions WHERE id_hash = ?`) + .bind(await sha256(id)) + .run(); +} + +// --- Account cache ----------------------------------------------------------- + +export async function getAccount(db: D1Database, githubId: string): Promise { + const row = await db + .prepare(`SELECT github_id, email, suspended FROM accounts WHERE github_id = ?`) + .bind(githubId) + .first<{ github_id: string; email: string; suspended: number }>(); + return row ? { ...row, suspended: row.suspended === 1 } : null; +} + +export async function upsertAccount(db: D1Database, a: Account): Promise { + // An email can only belong to one GitHub ID; drop any stale row first. + await db.batch([ + db + .prepare(`DELETE FROM accounts WHERE email = ? AND github_id != ?`) + .bind(a.email, a.github_id), + db + .prepare( + `INSERT INTO accounts (github_id, email, suspended, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (github_id) DO UPDATE + SET email = excluded.email, suspended = excluded.suspended, updated_at = excluded.updated_at` + ) + .bind(a.github_id, a.email, a.suspended ? 1 : 0, nowSeconds()), + ]); +} + +/** Replaces the whole cache with a fresh read of the Directory. */ +export async function replaceAccounts(db: D1Database, accounts: Account[]): Promise { + const now = nowSeconds(); + await db.batch([ + db.prepare(`DELETE FROM accounts`), + ...accounts.map((a) => + db + .prepare( + `INSERT OR REPLACE INTO accounts (github_id, email, suspended, updated_at) VALUES (?, ?, ?, ?)` + ) + .bind(a.github_id, a.email, a.suspended ? 1 : 0, now) + ), + ]); +} + +// --- Locks, audit, cleanup --------------------------------------------------- + +/** Returns false if another creation for this GitHub user is in flight. */ +export async function acquireCreationLock(db: D1Database, githubId: string): Promise { + const now = nowSeconds(); + await db.prepare(`DELETE FROM creation_locks WHERE expires_at < ?`).bind(now).run(); + const res = await db + .prepare(`INSERT OR IGNORE INTO creation_locks (github_id, expires_at) VALUES (?, ?)`) + .bind(githubId, now + 120) + .run(); + return (res.meta.changes ?? 0) > 0; +} + +export async function releaseCreationLock(db: D1Database, githubId: string): Promise { + await db.prepare(`DELETE FROM creation_locks WHERE github_id = ?`).bind(githubId).run(); +} + +export async function audit( + db: D1Database, + actor: string, + action: string, + subject: string | null, + detail?: unknown +): Promise { + await db + .prepare(`INSERT INTO audit_log (at, actor, action, subject, detail) VALUES (?, ?, ?, ?, ?)`) + .bind( + nowSeconds(), + actor, + action, + subject, + detail === undefined ? null : JSON.stringify(detail) + ) + .run(); +} + +export async function deleteExpired(db: D1Database): Promise { + const now = nowSeconds(); + await db.batch([ + db.prepare(`DELETE FROM pending_authorizations WHERE expires_at < ?`).bind(now), + db.prepare(`DELETE FROM authorization_codes WHERE expires_at < ?`).bind(now), + db.prepare(`DELETE FROM sessions WHERE expires_at < ?`).bind(now), + db.prepare(`DELETE FROM creation_locks WHERE expires_at < ?`).bind(now), + ]); +} diff --git a/access-site/src/eligibility.ts b/access-site/src/eligibility.ts new file mode 100644 index 00000000..9f7300dd --- /dev/null +++ b/access-site/src/eligibility.ts @@ -0,0 +1,75 @@ +/** + * Eligibility and team membership, computed from the same config Pulumi uses + * to manage GitHub teams (src/config in this repo). The Worker is redeployed + * whenever that config changes, so it always agrees with the GitHub teams. + * + * Reading the config rather than calling the GitHub teams API also covers + * provisionUser roles that have no GitHub team (e.g. wg-ig-facilitators). + */ +import { MEMBERS } from '../../src/config/users'; +import { ROLES, buildRoleLookup, type Role } from '../../src/config/roles'; +import { githubTeamsFor, hasProvisionUserRole, type Member } from '../../src/config/utils'; +import type { RoleId } from '../../src/config/roleIds'; + +const roleLookup: Map = buildRoleLookup(); + +const membersByLogin = new Map(); +for (const member of MEMBERS) { + if (member.github) membersByLogin.set(member.github.toLowerCase(), member); +} + +export function findMember(githubLogin: string): Member | undefined { + return membersByLogin.get(githubLogin.toLowerCase()); +} + +/** Same rule Pulumi uses to decide who may have a Workspace account. */ +export function isEligible(member: Member | undefined): boolean { + if (!member || member.skipGoogleUserProvisioning) return false; + return hasProvisionUserRole(member.memberOf, roleLookup); +} + +/** GitHub team slugs the member belongs to, including parent teams. */ +export function teamsFor(member: Member | undefined): string[] { + return member ? githubTeamsFor(member.memberOf, roleLookup) : []; +} + +/** Email prefixes that must never be handed to a person. */ +export const RESERVED_PREFIXES: ReadonlySet = (() => { + const reserved = new Set([ + 'abuse', + 'admin', + 'administrator', + 'all', + 'billing', + 'everyone', + 'help', + 'hostmaster', + 'info', + 'legal', + 'mailer-daemon', + 'no-reply', + 'noreply', + 'postmaster', + 'press', + 'privacy', + 'root', + 'security', + 'support', + 'team', + 'webmaster', + ]); + for (const role of ROLES) { + reserved.add(role.id); + if (role.github) reserved.add(role.github.team); + if (role.google) reserved.add(role.google.group); + } + return reserved; +})(); + +/** + * Prefixes already assigned to someone in users.ts. A member may claim their + * own configured prefix; nobody else may. + */ +export function prefixOwner(prefix: string): Member | undefined { + return MEMBERS.find((m) => m.googleEmailPrefix?.toLowerCase() === prefix); +} diff --git a/access-site/src/env.ts b/access-site/src/env.ts new file mode 100644 index 00000000..3830221a --- /dev/null +++ b/access-site/src/env.ts @@ -0,0 +1,29 @@ +export interface Env { + DB: D1Database; + + ISSUER: string; + WORKSPACE_DOMAIN: string; + WORKSPACE_OU_PATH: string; + GITHUB_CLIENT_ID: string; + OIDC_CLIENT_ID: string; + /** Comma-separated list of exact redirect URIs accepted from the OIDC client. */ + OIDC_REDIRECT_URIS: string; + /** "report" (default) or "enforce". */ + OFFBOARDING_MODE: string; + OFFBOARDING_MAX_PER_RUN: string; + + OIDC_CLIENT_SECRET: string; + /** RSA private key, PKCS#8 PEM. */ + OIDC_SIGNING_KEY: string; + /** Optional public JWK (JSON) of the previous signing key, published during rotation. */ + OIDC_PREVIOUS_JWK?: string; + GITHUB_CLIENT_SECRET: string; + /** Google service account JSON key. */ + GOOGLE_SA_KEY: string; + /** Optional token for the nightly job's GitHub API lookups (higher rate limit). */ + GITHUB_API_TOKEN?: string; + /** Optional Discord webhook for account and offboarding notices. */ + DISCORD_WEBHOOK_URL?: string; +} + +export type AppEnv = { Bindings: Env }; diff --git a/access-site/src/github.ts b/access-site/src/github.ts new file mode 100644 index 00000000..04e7453c --- /dev/null +++ b/access-site/src/github.ts @@ -0,0 +1,32 @@ +/** + * The interactive GitHub sign-in is handled by @hono/oauth-providers. This + * file only covers the nightly job's lookup of a GitHub ID's current login. + */ + +export type GitHubLookup = + | { status: 'found'; login: string } + | { status: 'not_found' } + | { status: 'error'; detail: string }; + +/** Resolves a numeric GitHub user ID to its current login (logins can be renamed; IDs cannot). */ +export async function githubLoginForId(id: string, token?: string): Promise { + if (!/^\d+$/.test(id)) return { status: 'error', detail: 'non-numeric id' }; + try { + const res = await fetch(`https://api.github.com/user/${id}`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'access.modelcontextprotocol.io', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (res.status === 404) return { status: 'not_found' }; + if (!res.ok) return { status: 'error', detail: `HTTP ${res.status}` }; + const body = (await res.json()) as { login?: string }; + return body.login + ? { status: 'found', login: body.login } + : { status: 'error', detail: 'no login in response' }; + } catch (err) { + return { status: 'error', detail: String(err) }; + } +} diff --git a/access-site/src/google.ts b/access-site/src/google.ts new file mode 100644 index 00000000..1128b70f --- /dev/null +++ b/access-site/src/google.ts @@ -0,0 +1,191 @@ +/** + * Minimal Google Admin SDK Directory client. + * + * Authenticates as the service account itself (no domain-wide delegation). + * The service account holds a custom admin role with user create, read and + * update privileges scoped to the MCP organizational unit, so this Worker + * cannot touch admins or users in other OUs. + */ +import { SignJWT, importPKCS8 } from 'jose'; +import type { Env } from './env'; +import { nowSeconds, randomToken } from './crypto'; + +const DIRECTORY = 'https://admin.googleapis.com/admin/directory/v1'; +const SCOPE = 'https://www.googleapis.com/auth/admin.directory.user'; +export const GITHUB_EXTERNAL_ID_TYPE = 'github'; + +export interface ExternalId { + type: string; + customType?: string; + value: string; +} + +export interface DirectoryUser { + primaryEmail: string; + suspended?: boolean; + isAdmin?: boolean; + orgUnitPath?: string; + externalIds?: ExternalId[]; + name?: { givenName?: string; familyName?: string; fullName?: string }; +} + +let cachedToken: { key: string; token: string; exp: number } | undefined; + +async function accessToken(env: Env): Promise { + if (cachedToken?.key === env.GOOGLE_SA_KEY && cachedToken.exp > nowSeconds() + 60) { + return cachedToken.token; + } + const sa = JSON.parse(env.GOOGLE_SA_KEY) as { + client_email: string; + private_key: string; + token_uri?: string; + }; + const tokenUri = sa.token_uri ?? 'https://oauth2.googleapis.com/token'; + const assertion = await new SignJWT({ scope: SCOPE }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(sa.client_email) + .setAudience(tokenUri) + .setIssuedAt() + .setExpirationTime('1h') + .sign(await importPKCS8(sa.private_key, 'RS256')); + const res = await fetch(tokenUri, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }), + }); + if (!res.ok) throw new Error(`Google token exchange failed: ${res.status}`); + const body = (await res.json()) as { access_token: string; expires_in: number }; + cachedToken = { + key: env.GOOGLE_SA_KEY, + token: body.access_token, + exp: nowSeconds() + body.expires_in, + }; + return body.access_token; +} + +async function directory( + env: Env, + path: string, + init: { method?: string; body?: unknown } = {} +): Promise { + return fetch(`${DIRECTORY}${path}`, { + method: init.method ?? 'GET', + headers: { + Authorization: `Bearer ${await accessToken(env)}`, + ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); +} + +async function expectOk(res: Response, what: string): Promise { + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`${what} failed: ${res.status} ${detail.slice(0, 300)}`); + } +} + +function githubExternalId(githubId: string): ExternalId { + return { type: 'custom', customType: GITHUB_EXTERNAL_ID_TYPE, value: githubId }; +} + +export function githubIdOf(user: DirectoryUser): string | undefined { + return user.externalIds?.find( + (e) => e.type === 'custom' && e.customType === GITHUB_EXTERNAL_ID_TYPE + )?.value; +} + +export async function getUser(env: Env, email: string): Promise { + const res = await directory(env, `/users/${encodeURIComponent(email)}`); + if (res.status === 404) return null; + await expectOk(res, 'users.get'); + return (await res.json()) as DirectoryUser; +} + +/** Finds the Workspace user linked to a GitHub user ID, if any. */ +export async function findUserByGithubId( + env: Env, + githubId: string +): Promise { + const params = new URLSearchParams({ + customer: 'my_customer', + query: `externalId:${githubId}`, + maxResults: '10', + }); + const res = await directory(env, `/users?${params}`); + await expectOk(res, 'users.list'); + const body = (await res.json()) as { users?: DirectoryUser[] }; + // The search matches any external ID type, so keep only our "github" one. + return body.users?.find((u) => githubIdOf(u) === githubId) ?? null; +} + +export async function listUsersInOu(env: Env): Promise { + const users: DirectoryUser[] = []; + let pageToken: string | undefined; + do { + const params = new URLSearchParams({ + customer: 'my_customer', + query: `orgUnitPath='${env.WORKSPACE_OU_PATH}'`, + maxResults: '500', + }); + if (pageToken) params.set('pageToken', pageToken); + const res = await directory(env, `/users?${params}`); + await expectOk(res, 'users.list'); + const body = (await res.json()) as { users?: DirectoryUser[]; nextPageToken?: string }; + users.push(...(body.users ?? [])); + pageToken = body.nextPageToken; + } while (pageToken); + return users; +} + +export type CreateResult = { status: 'created' } | { status: 'conflict' }; + +export async function createUser( + env: Env, + input: { email: string; firstName: string; lastName: string; githubId: string } +): Promise { + const res = await directory(env, '/users', { + method: 'POST', + body: { + primaryEmail: input.email, + name: { givenName: input.firstName, familyName: input.lastName }, + // Sign-in is always SSO; this password is never shown to anyone. + password: randomToken(32), + changePasswordAtNextLogin: false, + orgUnitPath: env.WORKSPACE_OU_PATH, + externalIds: [githubExternalId(input.githubId)], + }, + }); + // 409: the address is already a user, alias or group. + if (res.status === 409) return { status: 'conflict' }; + await expectOk(res, 'users.insert'); + return { status: 'created' }; +} + +/** Records the GitHub ID on an existing user, keeping its other external IDs. */ +export async function linkGithubId(env: Env, user: DirectoryUser, githubId: string): Promise { + const others = (user.externalIds ?? []).filter( + (e) => !(e.type === 'custom' && e.customType === GITHUB_EXTERNAL_ID_TYPE) + ); + const res = await directory(env, `/users/${encodeURIComponent(user.primaryEmail)}`, { + method: 'PATCH', + body: { externalIds: [...others, githubExternalId(githubId)] }, + }); + await expectOk(res, 'users.patch (link)'); +} + +export async function suspendUser(env: Env, email: string): Promise { + const res = await directory(env, `/users/${encodeURIComponent(email)}`, { + method: 'PATCH', + body: { suspended: true }, + }); + await expectOk(res, 'users.patch (suspend)'); +} + +/** Test hook. */ +export function resetGoogleTokenCache(): void { + cachedToken = undefined; +} diff --git a/access-site/src/index.ts b/access-site/src/index.ts new file mode 100644 index 00000000..620f5447 --- /dev/null +++ b/access-site/src/index.ts @@ -0,0 +1,199 @@ +import { Hono } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import { csrf } from 'hono/csrf'; +import { HTTPException } from 'hono/http-exception'; +import { secureHeaders } from 'hono/secure-headers'; +import { githubAuth } from '@hono/oauth-providers/github'; +import type { AppEnv, Env } from './env'; +import { randomToken } from './crypto'; +import { createSession, deleteSession, getSession, takePending, type Session } from './db'; +import { accountRequestFor, ensureAccount, resolveAccount, type GitHubIdentity } from './accounts'; +import { + authorize, + completeAuthorization, + discovery, + jwks, + PENDING_COOKIE, + token, + userinfo, +} from './oidc'; +import * as pages from './pages'; +import { splitName, suggestPrefix } from './validation'; +import { runNightly } from './cron'; + +const SESSION_COOKIE = '__Host-mcp_session'; +const SESSION_TTL = 3600; + +const app = new Hono(); + +app.use( + '*', + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'none'"], + styleSrc: ["'unsafe-inline'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + baseUri: ["'none'"], + }, + referrerPolicy: 'no-referrer', + }) +); + +// --- OIDC provider (used by Google) ----------------------------------------- + +app.get('/.well-known/openid-configuration', discovery); +app.get('/jwks', jwks); +app.get('/authorize', authorize); +app.post('/token', token); +app.on(['GET', 'POST'], '/userinfo', userinfo); + +// --- GitHub sign-in ------------------------------------------------------------ + +app.get( + '/login', + (c, next) => + githubAuth({ + client_id: c.env.GITHUB_CLIENT_ID, + client_secret: c.env.GITHUB_CLIENT_SECRET, + redirect_uri: `${c.env.ISSUER}/login`, + })(c, next), + async (c) => { + const user = c.get('user-github'); + if (typeof user?.id !== 'number' || typeof user.login !== 'string') { + throw new HTTPException(502, { message: 'GitHub did not return a user.' }); + } + const identity: GitHubIdentity = { + id: String(user.id), + login: user.login, + name: user.name ?? null, + }; + + // A Google sign-in is waiting: hand back a code if this user can sign in. + const pendingId = getCookie(c, PENDING_COOKIE); + let signInBlocked = false; + if (pendingId) { + deleteCookie(c, PENDING_COOKIE, { path: '/', secure: true }); + const pending = await takePending(c.env.DB, pendingId); + if (pending) { + const location = await completeAuthorization(c.env, identity, pending); + if (location) return c.redirect(location); + signInBlocked = true; + } + } + + const sessionId = randomToken(); + await createSession( + c.env.DB, + sessionId, + { github_id: identity.id, github_login: identity.login, github_name: identity.name }, + SESSION_TTL + ); + setCookie(c, SESSION_COOKIE, sessionId, { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + maxAge: SESSION_TTL, + }); + return c.redirect(signInBlocked ? '/?signin=blocked' : '/'); + } +); + +// --- Account page ---------------------------------------------------------------- + +function identityOf(s: Session): GitHubIdentity { + return { id: s.github_id, login: s.github_login, name: s.github_name }; +} + +async function renderHome( + env: Env, + identity: GitHubIdentity, + opts: { notice?: string; error?: string; form?: Record } = {} +) { + const account = await resolveAccount(env, identity.id); + if (account?.suspended) + return pages.suspendedPage({ login: identity.login, email: account.email }); + if (account) { + return pages.accountReadyPage({ + login: identity.login, + email: account.email, + created: false, + domain: env.WORKSPACE_DOMAIN, + }); + } + const request = accountRequestFor(env, identity); + if (!request) return pages.notEligiblePage({ login: identity.login }); + const name = splitName(identity.name); + return pages.createAccountPage({ + login: identity.login, + domain: env.WORKSPACE_DOMAIN, + firstName: opts.form?.firstName ?? name.first, + lastName: opts.form?.lastName ?? name.last, + fixedEmail: request.kind === 'link' ? request.email : null, + prefix: opts.form?.prefix ?? suggestPrefix(identity.login), + notice: opts.notice, + error: opts.error, + }); +} + +app.get('/', async (c) => { + const session = await getSession(c.env.DB, getCookie(c, SESSION_COOKIE)); + if (!session) return c.html(pages.signedOutPage()); + const notice = + c.req.query('signin') === 'blocked' + ? 'You need an active @modelcontextprotocol.io account before you can sign in to Google.' + : undefined; + return c.html(await renderHome(c.env, identityOf(session), { notice })); +}); + +app.post('/account', csrf(), async (c) => { + const session = await getSession(c.env.DB, getCookie(c, SESSION_COOKIE)); + if (!session) return c.redirect('/'); + const identity = identityOf(session); + const body = await c.req.parseBody(); + const form = { + firstName: String(body.firstName ?? ''), + lastName: String(body.lastName ?? ''), + prefix: String(body.prefix ?? ''), + }; + const result = await ensureAccount(c.env, identity, form); + if (!result.ok) + return c.html(await renderHome(c.env, identity, { error: result.error, form }), 400); + return c.html( + pages.accountReadyPage({ + login: identity.login, + email: result.email, + created: result.created, + domain: c.env.WORKSPACE_DOMAIN, + }) + ); +}); + +app.post('/logout', csrf(), async (c) => { + await deleteSession(c.env.DB, getCookie(c, SESSION_COOKIE)); + deleteCookie(c, SESSION_COOKIE, { path: '/', secure: true }); + return c.redirect('/'); +}); + +app.get('/healthz', (c) => c.text('ok')); + +app.onError((err, c) => { + if (err instanceof HTTPException && err.status < 500) { + return c.html( + pages.errorPage('Sign-in expired or was interrupted. Please try again.'), + err.status + ); + } + console.error(err); + return c.html(pages.errorPage('Something went wrong. Please try again in a minute.'), 500); +}); + +export { app }; + +export default { + fetch: app.fetch, + async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext) { + ctx.waitUntil(runNightly(env)); + }, +} satisfies ExportedHandler; diff --git a/access-site/src/notify.ts b/access-site/src/notify.ts new file mode 100644 index 00000000..aef87e78 --- /dev/null +++ b/access-site/src/notify.ts @@ -0,0 +1,15 @@ +import type { Env } from './env'; + +/** Best-effort Discord notice; never fails the caller. */ +export async function notify(env: Env, content: string): Promise { + if (!env.DISCORD_WEBHOOK_URL) return; + try { + await fetch(env.DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content.slice(0, 1900), allowed_mentions: { parse: [] } }), + }); + } catch { + // ignore + } +} diff --git a/access-site/src/oidc.ts b/access-site/src/oidc.ts new file mode 100644 index 00000000..7e5fc27d --- /dev/null +++ b/access-site/src/oidc.ts @@ -0,0 +1,266 @@ +/** + * OpenID Connect provider for Google Workspace's custom OIDC SSO profile. + * + * Authorization code flow only, one confidential client (Google), RS256 ID + * tokens. The subject is the numeric GitHub user ID; the email claim is the + * user's Workspace address, which is what Google matches on. + */ +import type { Context } from 'hono'; +import { setCookie } from 'hono/cookie'; +import { SignJWT, createLocalJWKSet, jwtVerify, type JWK } from 'jose'; +import type { AppEnv, Env } from './env'; +import { loadSigningKey, randomToken, timingSafeEqual, verifyPkce } from './crypto'; +import { saveCode, savePending, takeCode, type PendingAuthorization } from './db'; +import { resolveAccount, type GitHubIdentity } from './accounts'; +import { findMember, isEligible, teamsFor } from './eligibility'; + +export const PENDING_COOKIE = '__Host-mcp_pending'; +const PENDING_TTL = 600; +const CODE_TTL = 120; +const TOKEN_TTL = 300; + +type Ctx = Context; + +function redirectUris(env: Env): string[] { + return env.OIDC_REDIRECT_URIS.split(',') + .map((u) => u.trim()) + .filter(Boolean); +} + +function oauthError(c: Ctx, error: string, description: string, status: 400 | 401 = 400) { + c.header('Cache-Control', 'no-store'); + return c.json({ error, error_description: description }, status); +} + +export function discovery(c: Ctx) { + const iss = c.env.ISSUER; + return c.json({ + issuer: iss, + authorization_endpoint: `${iss}/authorize`, + token_endpoint: `${iss}/token`, + userinfo_endpoint: `${iss}/userinfo`, + jwks_uri: `${iss}/jwks`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + scopes_supported: ['openid', 'email', 'profile'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + code_challenge_methods_supported: ['S256'], + claims_supported: ['sub', 'email', 'email_verified', 'name', 'preferred_username', 'groups'], + }); +} + +async function publicKeys(env: Env): Promise { + const keys: JWK[] = [(await loadSigningKey(env.OIDC_SIGNING_KEY)).publicJwk]; + if (env.OIDC_PREVIOUS_JWK) keys.push(JSON.parse(env.OIDC_PREVIOUS_JWK) as JWK); + return keys; +} + +export async function jwks(c: Ctx) { + c.header('Cache-Control', 'public, max-age=300'); + return c.json({ keys: await publicKeys(c.env) }); +} + +/** GET /authorize: validate Google's request, park it, and send the user to GitHub. */ +export async function authorize(c: Ctx) { + const q = c.req.query(); + const env = c.env; + + // Never redirect to an unknown client or URI; show an error instead. + if (q.client_id !== env.OIDC_CLIENT_ID) return c.text('Unknown client_id', 400); + const redirectUri = q.redirect_uri ?? ''; + if (!redirectUris(env).includes(redirectUri)) return c.text('Unregistered redirect_uri', 400); + + const fail = (error: string) => { + const url = new URL(redirectUri); + url.searchParams.set('error', error); + if (q.state) url.searchParams.set('state', q.state); + return c.redirect(url.toString()); + }; + if (q.response_type !== 'code') return fail('unsupported_response_type'); + if (!(q.scope ?? '').split(' ').includes('openid')) return fail('invalid_scope'); + if ((q.state?.length ?? 0) > 1024 || (q.nonce?.length ?? 0) > 1024) + return fail('invalid_request'); + + let codeChallenge: string | null = null; + if (q.code_challenge !== undefined) { + if (q.code_challenge_method !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(q.code_challenge)) { + return fail('invalid_request'); + } + codeChallenge = q.code_challenge; + } + + const pending: PendingAuthorization = { + client_id: q.client_id, + redirect_uri: redirectUri, + state: q.state ?? null, + nonce: q.nonce ?? null, + code_challenge: codeChallenge, + }; + const pendingId = randomToken(); + await savePending(env.DB, pendingId, pending, PENDING_TTL); + setCookie(c, PENDING_COOKIE, pendingId, { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + maxAge: PENDING_TTL, + }); + return c.redirect('/login'); +} + +/** + * Called after GitHub sign-in when a Google request is pending. Returns the + * redirect back to Google with a code, or null if this user cannot sign in + * (no eligible role, no account yet, or suspended): the caller then shows the + * account page instead. + */ +export async function completeAuthorization( + env: Env, + identity: GitHubIdentity, + pending: PendingAuthorization +): Promise { + const member = findMember(identity.login); + if (!isEligible(member)) return null; + const account = await resolveAccount(env, identity.id); + if (!account || account.suspended) return null; + + const claims = { + sub: identity.id, + email: account.email, + email_verified: true, + name: identity.name || identity.login, + preferred_username: identity.login, + groups: teamsFor(member), + }; + const code = randomToken(); + await saveCode( + env.DB, + code, + { + client_id: pending.client_id, + redirect_uri: pending.redirect_uri, + nonce: pending.nonce, + code_challenge: pending.code_challenge, + claims, + }, + CODE_TTL + ); + const url = new URL(pending.redirect_uri); + url.searchParams.set('code', code); + if (pending.state) url.searchParams.set('state', pending.state); + return url.toString(); +} + +function clientCredentials(c: Ctx, body: Record) { + const header = c.req.header('Authorization'); + if (header?.startsWith('Basic ')) { + try { + const [id, secret] = atob(header.slice(6)).split(':', 2); + return { + id: decodeURIComponent(id ?? ''), + secret: decodeURIComponent(secret ?? ''), + }; + } catch { + return { id: '', secret: '' }; + } + } + return { + id: typeof body.client_id === 'string' ? body.client_id : '', + secret: typeof body.client_secret === 'string' ? body.client_secret : '', + }; +} + +/** POST /token: exchange a single-use code for an ID token. */ +export async function token(c: Ctx) { + const env = c.env; + const body = (await c.req.parseBody()) as Record; + const client = clientCredentials(c, body); + if ( + !timingSafeEqual(client.id, env.OIDC_CLIENT_ID) || + !timingSafeEqual(client.secret, env.OIDC_CLIENT_SECRET) + ) { + c.header('WWW-Authenticate', 'Basic realm="token"'); + return oauthError(c, 'invalid_client', 'Client authentication failed', 401); + } + if (body.grant_type !== 'authorization_code') { + return oauthError(c, 'unsupported_grant_type', 'Only authorization_code is supported'); + } + if (typeof body.code !== 'string') return oauthError(c, 'invalid_request', 'Missing code'); + + const code = await takeCode(env.DB, body.code); + if (!code || code.client_id !== client.id || code.redirect_uri !== body.redirect_uri) { + return oauthError(c, 'invalid_grant', 'Invalid or expired code'); + } + if (code.code_challenge) { + const verifier = typeof body.code_verifier === 'string' ? body.code_verifier : ''; + if (!(await verifyPkce(verifier, code.code_challenge))) { + return oauthError(c, 'invalid_grant', 'PKCE verification failed'); + } + } + + const { privateKey, publicJwk } = await loadSigningKey(env.OIDC_SIGNING_KEY); + const sub = String(code.claims.sub); + const idToken = await new SignJWT({ + ...code.claims, + ...(code.nonce ? { nonce: code.nonce } : {}), + auth_time: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ alg: 'RS256', kid: publicJwk.kid, typ: 'JWT' }) + .setIssuer(env.ISSUER) + .setAudience(client.id) + .setSubject(sub) + .setIssuedAt() + .setExpirationTime(`${TOKEN_TTL}s`) + .sign(privateKey); + const accessToken = await new SignJWT({ + ...code.claims, + client_id: client.id, + scope: 'openid email profile', + }) + .setProtectedHeader({ alg: 'RS256', kid: publicJwk.kid, typ: 'at+jwt' }) + .setIssuer(env.ISSUER) + .setAudience(`${env.ISSUER}/userinfo`) + .setSubject(sub) + .setIssuedAt() + .setExpirationTime(`${TOKEN_TTL}s`) + .sign(privateKey); + + c.header('Cache-Control', 'no-store'); + return c.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: TOKEN_TTL, + id_token: idToken, + scope: 'openid email profile', + }); +} + +/** GET|POST /userinfo */ +export async function userinfo(c: Ctx) { + const header = c.req.header('Authorization') ?? ''; + if (!header.startsWith('Bearer ')) { + c.header('WWW-Authenticate', 'Bearer'); + return oauthError(c, 'invalid_token', 'Missing bearer token', 401); + } + try { + const { payload } = await jwtVerify( + header.slice(7), + createLocalJWKSet({ keys: await publicKeys(c.env) }), + { issuer: c.env.ISSUER, audience: `${c.env.ISSUER}/userinfo`, typ: 'at+jwt' } + ); + c.header('Cache-Control', 'no-store'); + return c.json({ + sub: payload.sub, + email: payload.email, + email_verified: payload.email_verified, + name: payload.name, + preferred_username: payload.preferred_username, + groups: payload.groups, + }); + } catch { + c.header('WWW-Authenticate', 'Bearer error="invalid_token"'); + return oauthError(c, 'invalid_token', 'Invalid or expired token', 401); + } +} diff --git a/access-site/src/pages.ts b/access-site/src/pages.ts new file mode 100644 index 00000000..59640ab9 --- /dev/null +++ b/access-site/src/pages.ts @@ -0,0 +1,241 @@ +import { html } from 'hono/html'; +import type { HtmlEscapedString } from 'hono/utils/html'; + +type Html = HtmlEscapedString | Promise; + +function layout(body: Html): Html { + return html` + + + + + MCP Access + + + +
+

Model Context Protocol access

+ ${body} +
+ + `; +} + +function signOut(): Html { + return html`
+ +
`; +} + +export function signedOutPage(): Html { + return layout( + html`
+

+ Sign in with GitHub to set up or check your + @modelcontextprotocol.io account. +

+ Sign in with GitHub +
` + ); +} + +export function accountReadyPage(opts: { + login: string; + email: string; + created: boolean; + domain: string; +}): Html { + return layout( + html`
+

+ ${opts.created ? 'Your account is ready:' : 'Your account:'} + ${opts.email} +

+

+ You sign in to Google with your GitHub account. There is no Google password. +

+ Open Gmail +
+

Signed in as @${opts.login}. ${signOut()}

` + ); +} + +export function suspendedPage(opts: { login: string; email: string }): Html { + return layout( + html`
+

Your account ${opts.email} is suspended.

+

+ This happens when you are no longer in a team that has Workspace accounts. Ask in the + Infrastructure WG channel if this looks wrong. +

+
+

Signed in as @${opts.login}. ${signOut()}

` + ); +} + +export function notEligiblePage(opts: { login: string }): Html { + return layout( + html`
+

@${opts.login} is not in a team that can have an @modelcontextprotocol.io account.

+

+ Accounts are available to maintainers and other roles listed in + modelcontextprotocol/access. +

+
+

${signOut()}

` + ); +} + +export function createAccountPage(opts: { + login: string; + domain: string; + firstName: string; + lastName: string; + /** Fixed address from users.ts, or null to let the user choose. */ + fixedEmail: string | null; + prefix: string; + error?: string; + notice?: string; +}): Html { + return layout( + html`${opts.notice ? html`

${opts.notice}

` : ''} +
+

+ ${opts.fixedEmail + ? html`Confirm your details to set up ${opts.fixedEmail}.` + : 'Create your @modelcontextprotocol.io account.'} +

+ ${opts.error ? html`

${opts.error}

` : ''} +
+ + + + + ${opts.fixedEmail + ? '' + : html` +
+ + @${opts.domain} +
`} + +
+
+

Signed in as @${opts.login}. ${signOut()}

` + ); +} + +export function errorPage(message: string): Html { + return layout( + html`
+

${message}

+ Back +
` + ); +} diff --git a/access-site/src/validation.ts b/access-site/src/validation.ts new file mode 100644 index 00000000..dee205c2 --- /dev/null +++ b/access-site/src/validation.ts @@ -0,0 +1,61 @@ +import { RESERVED_PREFIXES } from './eligibility'; + +export type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; + +const NAME_PATTERN = /^[\p{L}\p{M}][\p{L}\p{M} .'\-]*$/u; +// Lowercase letters, digits, hyphens; dots only between other characters. +const PREFIX_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*$/; + +export function validateName(raw: unknown, label: string): ValidationResult { + if (typeof raw !== 'string') return { ok: false, error: `${label} is required.` }; + const value = raw.normalize('NFC').trim().replace(/\s+/g, ' '); + if (value.length < 1 || value.length > 60) { + return { ok: false, error: `${label} must be 1 to 60 characters.` }; + } + if (!NAME_PATTERN.test(value)) { + return { + ok: false, + error: `${label} can contain letters, spaces, hyphens, apostrophes and dots.`, + }; + } + return { ok: true, value }; +} + +export function validatePrefix(raw: unknown): ValidationResult { + if (typeof raw !== 'string') return { ok: false, error: 'Email name is required.' }; + const value = raw.trim().toLowerCase(); + if (value.length < 2 || value.length > 30) { + return { ok: false, error: 'Email name must be 2 to 30 characters.' }; + } + if (!PREFIX_PATTERN.test(value) || value.endsWith('-')) { + return { + ok: false, + error: + 'Email name can use lowercase letters, digits, hyphens and single dots, and must start with a letter.', + }; + } + if (RESERVED_PREFIXES.has(value)) { + return { ok: false, error: `"${value}" is reserved. Please pick another.` }; + } + return { ok: true, value }; +} + +/** Best-effort default email name from a GitHub login. */ +export function suggestPrefix(login: string): string { + const cleaned = login + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^[^a-z]+/, '') + .replace(/-+$/, '') + .slice(0, 30); + return cleaned.length >= 2 ? cleaned : ''; +} + +/** Splits a GitHub display name into first and last name, best effort. */ +export function splitName(fullName: string | null | undefined): { first: string; last: string } { + const parts = (fullName ?? '').trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return { first: '', last: '' }; + if (parts.length === 1) return { first: parts[0]!, last: '' }; + return { first: parts.slice(0, -1).join(' '), last: parts[parts.length - 1]! }; +} diff --git a/access-site/test/account.test.ts b/access-site/test/account.test.ts new file mode 100644 index 00000000..92793248 --- /dev/null +++ b/access-site/test/account.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { app } from '../src/index'; +import type { Env } from '../src/env'; +import { + CookieJar, + type FakeWorld, + ISSUER, + createEnv, + ineligibleMember, + installFetchMock, + memberWithPrefix, + memberWithoutPrefix, +} from './helpers'; + +let env: Env; + +async function signedIn(login: string, id: number, world: FakeWorld) { + env = await createEnv(); + world.githubUser = { id, login, name: 'Ada Lovelace' }; + installFetchMock(world); + const jar = new CookieJar(); + const go = async (path: string, init: RequestInit = {}) => { + const res = await app.request( + `${ISSUER}${path}`, + { ...init, headers: { ...(init.headers as Record), Cookie: jar.header() } }, + env + ); + jar.store(res); + return res; + }; + const toGitHub = await go('/login'); + const state = new URL(toGitHub.headers.get('Location')!).searchParams.get('state'); + const back = await go(`/login?code=c&state=${state}`); + expect(back.headers.get('Location')).toBe('/'); + return go; +} + +function emptyWorld(): FakeWorld { + return { + githubUser: { id: 0, login: '', name: null }, + githubLogins: new Map(), + directory: new Map(), + calls: [], + }; +} + +function post(form: Record, origin = ISSUER): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin }, + body: new URLSearchParams(form).toString(), + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('account page', () => { + it('shows a sign-in button when signed out', async () => { + env = await createEnv(); + const res = await app.request(`${ISSUER}/`, {}, env); + expect(await res.text()).toContain('Sign in with GitHub'); + }); + + it('creates an account with a chosen address for an eligible member', async () => { + const world = emptyWorld(); + const member = memberWithoutPrefix(); + const go = await signedIn(member.github!, 777, world); + + const page = await (await go('/')).text(); + expect(page).toContain('Create account'); + + const res = await go( + '/account', + post({ firstName: 'Ada', lastName: 'Lovelace', prefix: 'ada.test' }) + ); + expect(res.status).toBe(200); + expect(await res.text()).toContain('ada.test@modelcontextprotocol.io'); + + const created = world.directory.get('ada.test@modelcontextprotocol.io')!; + expect(created.orgUnitPath).toBe('/Model Context Protocol'); + expect(created.externalIds).toEqual([{ type: 'custom', customType: 'github', value: '777' }]); + + const log = await env.DB.prepare(`SELECT action, subject FROM audit_log`).first<{ + action: string; + subject: string; + }>(); + expect(log).toEqual({ action: 'account.create', subject: 'ada.test@modelcontextprotocol.io' }); + + // Second visit shows the account instead of the form. + expect(await (await go('/')).text()).toContain('Open Gmail'); + }); + + it('links the address configured in users.ts instead of creating a new one', async () => { + const world = emptyWorld(); + const member = memberWithPrefix(); + const email = `${member.googleEmailPrefix}@modelcontextprotocol.io`; + world.directory.set(email, { primaryEmail: email, orgUnitPath: '/Model Context Protocol' }); + const go = await signedIn(member.github!, 888, world); + + const res = await go( + '/account', + post({ firstName: 'Ada', lastName: 'Lovelace', prefix: 'ignored' }) + ); + expect(res.status).toBe(200); + expect(world.directory.get(email)!.externalIds).toEqual([ + { type: 'custom', customType: 'github', value: '888' }, + ]); + expect(world.directory.has('ignored@modelcontextprotocol.io')).toBe(false); + }); + + it('refuses an address that is taken or reserved', async () => { + const world = emptyWorld(); + world.directory.set('taken@modelcontextprotocol.io', { + primaryEmail: 'taken@modelcontextprotocol.io', + }); + const go = await signedIn(memberWithoutPrefix().github!, 999, world); + + const taken = await go('/account', post({ firstName: 'Ada', lastName: 'L', prefix: 'taken' })); + expect(taken.status).toBe(400); + expect(await taken.text()).toContain('already taken'); + + const reserved = await go( + '/account', + post({ firstName: 'Ada', lastName: 'L', prefix: 'postmaster' }) + ); + expect(reserved.status).toBe(400); + expect(await reserved.text()).toContain('reserved'); + }); + + it('rejects cross-site form posts', async () => { + const world = emptyWorld(); + const go = await signedIn(memberWithoutPrefix().github!, 555, world); + const res = await go( + '/account', + post({ firstName: 'Ada', lastName: 'L', prefix: 'ada.x' }, 'https://evil.example') + ); + expect(res.status).toBe(403); + expect(world.directory.size).toBe(0); + }); + + it('tells ineligible users why, without a form', async () => { + const world = emptyWorld(); + const go = await signedIn(ineligibleMember().github!, 123, world); + const text = await (await go('/')).text(); + expect(text).toContain('is not in a team'); + expect(text).not.toContain('
'); + const res = await go('/account', post({ firstName: 'Ada', lastName: 'L', prefix: 'sneaky' })); + expect(res.status).toBe(400); + expect(world.directory.size).toBe(0); + }); +}); diff --git a/access-site/test/cron.test.ts b/access-site/test/cron.test.ts new file mode 100644 index 00000000..8d250961 --- /dev/null +++ b/access-site/test/cron.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runNightly } from '../src/cron'; +import { getAccount } from '../src/db'; +import { + createEnv, + type FakeWorld, + ineligibleMember, + installFetchMock, + memberWithPrefix, +} from './helpers'; + +const OU = '/Model Context Protocol'; + +function world(): FakeWorld { + const eligible = memberWithPrefix().github!; + const ineligible = ineligibleMember().github!; + return { + githubUser: { id: 0, login: '', name: null }, + githubLogins: new Map([ + ['1', eligible], + ['2', ineligible], + ['3', ineligible], + ]), + directory: new Map( + [ + { primaryEmail: 'keep@modelcontextprotocol.io', id: '1' }, + { primaryEmail: 'leaver@modelcontextprotocol.io', id: '2' }, + { primaryEmail: 'gone@modelcontextprotocol.io', id: '404' }, + { primaryEmail: 'admin@modelcontextprotocol.io', id: '3', isAdmin: true }, + { primaryEmail: 'unlinked@modelcontextprotocol.io' }, + ].map(({ id, ...u }) => [ + u.primaryEmail, + { + ...u, + orgUnitPath: OU, + externalIds: id ? [{ type: 'custom', customType: 'github', value: id }] : undefined, + }, + ]) + ), + calls: [], + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('nightly job', () => { + it('reports in report mode without suspending, and rebuilds the cache', async () => { + const env = await createEnv(); + const w = world(); + installFetchMock(w); + const report = await runNightly(env); + expect(report.toSuspend.map((s) => s.email).sort()).toEqual([ + 'gone@modelcontextprotocol.io', + 'leaver@modelcontextprotocol.io', + ]); + expect(report.suspended).toEqual([]); + expect(report.unlinked).toEqual(['unlinked@modelcontextprotocol.io']); + expect(w.directory.get('leaver@modelcontextprotocol.io')!.suspended).toBeFalsy(); + expect(await getAccount(env.DB, '1')).toMatchObject({ email: 'keep@modelcontextprotocol.io' }); + expect(await getAccount(env.DB, '3')).toBeNull(); // admins are never managed + }); + + it('suspends in enforce mode', async () => { + const env = await createEnv({ OFFBOARDING_MODE: 'enforce' }); + const w = world(); + installFetchMock(w); + const report = await runNightly(env); + expect(report.suspended.sort()).toEqual([ + 'gone@modelcontextprotocol.io', + 'leaver@modelcontextprotocol.io', + ]); + expect(w.directory.get('leaver@modelcontextprotocol.io')!.suspended).toBe(true); + expect(w.directory.get('keep@modelcontextprotocol.io')!.suspended).toBeFalsy(); + expect((await getAccount(env.DB, '2'))!.suspended).toBe(true); + }); + + it('aborts when too many accounts would be suspended', async () => { + const env = await createEnv({ OFFBOARDING_MODE: 'enforce', OFFBOARDING_MAX_PER_RUN: '1' }); + const w = world(); + installFetchMock(w); + const report = await runNightly(env); + expect(report.aborted).toBe(true); + expect(report.suspended).toEqual([]); + expect(w.directory.get('leaver@modelcontextprotocol.io')!.suspended).toBeFalsy(); + }); + + it('skips, rather than suspends, when the GitHub lookup fails', async () => { + const env = await createEnv({ OFFBOARDING_MODE: 'enforce' }); + const w = world(); + const mock = installFetchMock(w); + const original = mock.getMockImplementation()!; + mock.mockImplementation(async (input, init) => { + if (String(input).startsWith('https://api.github.com/user/2')) + return new Response('boom', { status: 502 }); + return original(input, init); + }); + const report = await runNightly(env); + expect(report.skipped.map((s) => s.email)).toEqual(['leaver@modelcontextprotocol.io']); + expect(w.directory.get('leaver@modelcontextprotocol.io')!.suspended).toBeFalsy(); + }); +}); diff --git a/access-site/test/helpers.ts b/access-site/test/helpers.ts new file mode 100644 index 00000000..a9c94e1c --- /dev/null +++ b/access-site/test/helpers.ts @@ -0,0 +1,242 @@ +/** + * Test harness: a D1 stand-in over node:sqlite (real SQL, including + * DELETE ... RETURNING), generated keys, and a fetch mock for GitHub and the + * Google Directory. Picks real members from src/config so tests follow the + * live eligibility rules. + */ +import { readFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { exportPKCS8, generateKeyPair } from 'jose'; +import { vi } from 'vitest'; +import type { Env } from '../src/env'; +import type { DirectoryUser } from '../src/google'; +import { resetGoogleTokenCache } from '../src/google'; +import { MEMBERS } from '../../src/config/users'; +import { buildRoleLookup } from '../../src/config/roles'; +import { hasProvisionUserRole } from '../../src/config/utils'; + +// --- D1 stand-in --------------------------------------------------------------- + +class Statement { + constructor( + private db: DatabaseSync, + private sql: string, + private params: unknown[] = [] + ) {} + bind(...params: unknown[]) { + return new Statement(this.db, this.sql, params); + } + private args() { + return this.params.map((p) => (typeof p === 'boolean' ? Number(p) : p)) as never[]; + } + async first(): Promise { + return (this.db.prepare(this.sql).get(...this.args()) as T | undefined) ?? null; + } + async all() { + return { + results: this.db.prepare(this.sql).all(...this.args()) as T[], + success: true, + meta: {}, + }; + } + async run() { + const r = this.db.prepare(this.sql).run(...this.args()); + return { success: true, results: [], meta: { changes: Number(r.changes) } }; + } +} + +export function createD1(): D1Database { + const db = new DatabaseSync(':memory:'); + db.exec(readFileSync(new URL('../migrations/0001_init.sql', import.meta.url), 'utf8')); + return { + prepare: (sql: string) => new Statement(db, sql), + batch: async (stmts: Statement[]) => { + db.exec('BEGIN'); + try { + const out = []; + for (const s of stmts) out.push(await s.run()); + db.exec('COMMIT'); + return out; + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } + }, + } as unknown as D1Database; +} + +// --- Env ------------------------------------------------------------------------- + +export const ISSUER = 'https://access.modelcontextprotocol.io'; +export const GOOGLE_REDIRECT = 'https://www.google.com/a/oidc/callback-test'; +export const CLIENT_SECRET = 'test-client-secret'; + +export async function createEnv(overrides: Partial = {}): Promise { + const signing = await generateKeyPair('RS256', { extractable: true }); + const sa = await generateKeyPair('RS256', { extractable: true }); + return { + DB: createD1(), + ISSUER, + WORKSPACE_DOMAIN: 'modelcontextprotocol.io', + WORKSPACE_OU_PATH: '/Model Context Protocol', + GITHUB_CLIENT_ID: 'gh-client', + GITHUB_CLIENT_SECRET: 'gh-secret', + OIDC_CLIENT_ID: 'google-workspace', + OIDC_REDIRECT_URIS: GOOGLE_REDIRECT, + OIDC_CLIENT_SECRET: CLIENT_SECRET, + OIDC_SIGNING_KEY: await exportPKCS8(signing.privateKey), + OFFBOARDING_MODE: 'report', + OFFBOARDING_MAX_PER_RUN: '3', + GOOGLE_SA_KEY: JSON.stringify({ + client_email: 'access-site@example.iam.gserviceaccount.com', + private_key: await exportPKCS8(sa.privateKey), + token_uri: 'https://oauth2.googleapis.com/token', + }), + ...overrides, + }; +} + +// --- Config-driven test identities --------------------------------------------- + +const roleLookup = buildRoleLookup(); + +/** An eligible member with a googleEmailPrefix in users.ts (link flow). */ +export function memberWithPrefix() { + const m = MEMBERS.find( + (m) => + m.github && + m.googleEmailPrefix && + !m.skipGoogleUserProvisioning && + hasProvisionUserRole(m.memberOf, roleLookup) + ); + if (!m) throw new Error('no eligible member with a prefix in config'); + return m; +} + +/** An eligible member without a googleEmailPrefix (create flow). */ +export function memberWithoutPrefix() { + const m = MEMBERS.find( + (m) => + m.github && + !m.googleEmailPrefix && + !m.skipGoogleUserProvisioning && + hasProvisionUserRole(m.memberOf, roleLookup) + ); + if (!m) throw new Error('no eligible member without a prefix in config'); + return m; +} + +/** A configured member who is not eligible. */ +export function ineligibleMember() { + const m = MEMBERS.find((m) => m.github && !hasProvisionUserRole(m.memberOf, roleLookup)); + if (!m) throw new Error('no ineligible member in config'); + return m; +} + +// --- Fetch mock ---------------------------------------------------------------------- + +export interface FakeWorld { + /** The GitHub user returned by the OAuth flow. */ + githubUser: { id: number; login: string; name: string | null }; + /** GitHub ID -> login for /user/{id}; missing means 404. */ + githubLogins: Map; + /** Directory users by primary email. */ + directory: Map; + calls: { method: string; url: string; body?: unknown }[]; +} + +export function installFetchMock(world: FakeWorld) { + resetGoogleTokenCache(); + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL( + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + ); + const method = init?.method ?? 'GET'; + let body: unknown; + if (typeof init?.body === 'string') { + try { + body = JSON.parse(init.body); + } catch { + body = init.body; + } + } + world.calls.push({ method, url: url.toString(), body }); + const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + + if (url.href === 'https://github.com/login/oauth/access_token') + return json({ access_token: 'gh-user-token', token_type: 'bearer', scope: '' }); + if (url.href === 'https://api.github.com/user') + return json({ ...world.githubUser, email: null }); + if (url.href === 'https://api.github.com/user/emails') + return json([ + { email: 'person@example.com', primary: true, verified: true, visibility: 'private' }, + ]); + const userById = url.pathname.match(/^\/user\/(\d+)$/); + if (url.hostname === 'api.github.com' && userById) { + const login = world.githubLogins.get(userById[1]!); + return login ? json({ id: Number(userById[1]), login }) : json({ message: 'Not Found' }, 404); + } + + if (url.href === 'https://oauth2.googleapis.com/token') + return json({ access_token: 'google-token', expires_in: 3600 }); + if (url.hostname === 'admin.googleapis.com') { + const path = url.pathname.replace('/admin/directory/v1', ''); + if (path === '/users' && method === 'GET') { + const query = url.searchParams.get('query') ?? ''; + const ext = query.match(/^externalId:(\d+)$/); + const users = [...world.directory.values()].filter((u) => + ext + ? u.externalIds?.some((e) => e.value === ext[1]) + : u.orgUnitPath === '/Model Context Protocol' + ); + return json({ users }); + } + if (path === '/users' && method === 'POST') { + const u = body as DirectoryUser; + if (world.directory.has(u.primaryEmail)) return json({ error: 'exists' }, 409); + world.directory.set(u.primaryEmail, { ...u, suspended: false }); + return json(u); + } + const one = path.match(/^\/users\/(.+)$/); + if (one) { + const email = decodeURIComponent(one[1]!); + const u = world.directory.get(email); + if (!u) return json({ error: 'not found' }, 404); + if (method === 'PATCH') { + Object.assign(u, body as object); + return json(u); + } + return json(u); + } + } + if (url.hostname === 'discord.com') return new Response(null, { status: 204 }); + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', mock); + return mock; +} + +// --- Cookie jar for multi-step flows ------------------------------------------------- + +export class CookieJar { + private cookies = new Map(); + store(res: Response) { + for (const header of res.headers.getSetCookie()) { + const [pair] = header.split(';'); + const [name, ...value] = pair!.split('='); + const v = value.join('='); + if (/Max-Age=0/i.test(header) || v === '') this.cookies.delete(name!); + else this.cookies.set(name!, v); + } + } + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; '); + } + get(name: string) { + return this.cookies.get(name); + } +} diff --git a/access-site/test/oidc.test.ts b/access-site/test/oidc.test.ts new file mode 100644 index 00000000..515d4153 --- /dev/null +++ b/access-site/test/oidc.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLocalJWKSet, jwtVerify } from 'jose'; +import { app } from '../src/index'; +import type { Env } from '../src/env'; +import { sha256 } from '../src/crypto'; +import { + CLIENT_SECRET, + CookieJar, + type FakeWorld, + GOOGLE_REDIRECT, + ISSUER, + createEnv, + installFetchMock, + memberWithPrefix, +} from './helpers'; + +const VERIFIER = 'v'.repeat(43) + '-verifier-for-tests'; + +let env: Env; +let world: FakeWorld; + +function worldFor(login: string, id = 4242): FakeWorld { + return { + githubUser: { id, login, name: 'Test Person' }, + githubLogins: new Map([[String(id), login]]), + directory: new Map(), + calls: [], + }; +} + +async function call(path: string, jar: CookieJar, init: RequestInit = {}) { + const res = await app.request( + `${ISSUER}${path}`, + { ...init, headers: { ...(init.headers as Record), Cookie: jar.header() } }, + env + ); + jar.store(res); + return res; +} + +/** Runs Google -> /authorize -> GitHub -> back to Google; returns the final redirect. */ +async function signIn(jar: CookieJar, params: Record = {}) { + const query = new URLSearchParams({ + client_id: 'google-workspace', + redirect_uri: GOOGLE_REDIRECT, + response_type: 'code', + scope: 'openid email profile', + state: 'google-state', + nonce: 'google-nonce', + code_challenge: await sha256(VERIFIER), + code_challenge_method: 'S256', + ...params, + }); + const authorize = await call(`/authorize?${query}`, jar); + expect(authorize.status).toBe(302); + expect(authorize.headers.get('Location')).toBe('/login'); + + const toGitHub = await call('/login', jar); + expect(toGitHub.status).toBe(302); + const githubUrl = new URL(toGitHub.headers.get('Location')!); + expect(githubUrl.origin).toBe('https://github.com'); + + return call(`/login?code=gh-code&state=${githubUrl.searchParams.get('state')}`, jar); +} + +async function exchange(code: string, extra: Record = {}) { + return app.request( + `${ISSUER}/token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa(`google-workspace:${CLIENT_SECRET}`)}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: GOOGLE_REDIRECT, + code_verifier: VERIFIER, + ...extra, + }), + }, + env + ); +} + +beforeEach(async () => { + env = await createEnv(); + const member = memberWithPrefix(); + world = worldFor(member.github!); + world.directory.set(`${member.googleEmailPrefix}@modelcontextprotocol.io`, { + primaryEmail: `${member.googleEmailPrefix}@modelcontextprotocol.io`, + orgUnitPath: '/Model Context Protocol', + externalIds: [{ type: 'custom', customType: 'github', value: '4242' }], + }); + installFetchMock(world); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe('discovery', () => { + it('publishes metadata and a JWKS', async () => { + const meta = (await ( + await app.request(`${ISSUER}/.well-known/openid-configuration`, {}, env) + ).json()) as Record; + expect(meta.issuer).toBe(ISSUER); + expect(meta.token_endpoint).toBe(`${ISSUER}/token`); + const jwks = (await (await app.request(`${ISSUER}/jwks`, {}, env)).json()) as { + keys: { kid: string; d?: string }[]; + }; + expect(jwks.keys).toHaveLength(1); + expect(jwks.keys[0]!.kid).toBeTruthy(); + expect(jwks.keys[0]!.d).toBeUndefined(); + }); +}); + +describe('authorization code flow', () => { + it('signs in a linked, eligible user with the GitHub ID as subject', async () => { + const jar = new CookieJar(); + const back = await signIn(jar); + expect(back.status).toBe(302); + const location = new URL(back.headers.get('Location')!); + expect(`${location.origin}${location.pathname}`).toBe(GOOGLE_REDIRECT); + expect(location.searchParams.get('state')).toBe('google-state'); + + const res = await exchange(location.searchParams.get('code')!); + expect(res.status).toBe(200); + const body = (await res.json()) as { id_token: string; access_token: string }; + + const jwks = (await (await app.request(`${ISSUER}/jwks`, {}, env)).json()) as { keys: never[] }; + const { payload } = await jwtVerify(body.id_token, createLocalJWKSet(jwks), { + issuer: ISSUER, + audience: 'google-workspace', + }); + const member = memberWithPrefix(); + expect(payload.sub).toBe('4242'); + expect(payload.email).toBe(`${member.googleEmailPrefix}@modelcontextprotocol.io`); + expect(payload.nonce).toBe('google-nonce'); + expect(payload.preferred_username).toBe(member.github); + + const info = await app.request( + `${ISSUER}/userinfo`, + { headers: { Authorization: `Bearer ${body.access_token}` } }, + env + ); + expect(((await info.json()) as { sub: string }).sub).toBe('4242'); + }); + + it('rejects code replay', async () => { + const back = await signIn(new CookieJar()); + const code = new URL(back.headers.get('Location')!).searchParams.get('code')!; + expect((await exchange(code)).status).toBe(200); + const replay = await exchange(code); + expect(replay.status).toBe(400); + expect(((await replay.json()) as { error: string }).error).toBe('invalid_grant'); + }); + + it('rejects a wrong PKCE verifier and a wrong client secret', async () => { + const back = await signIn(new CookieJar()); + const code = new URL(back.headers.get('Location')!).searchParams.get('code')!; + expect((await exchange(code, { code_verifier: 'x'.repeat(43) })).status).toBe(400); + + const back2 = await signIn(new CookieJar()); + const code2 = new URL(back2.headers.get('Location')!).searchParams.get('code')!; + const res = await app.request( + `${ISSUER}/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: code2, + redirect_uri: GOOGLE_REDIRECT, + code_verifier: VERIFIER, + client_id: 'google-workspace', + client_secret: 'wrong', + }), + }, + env + ); + expect(res.status).toBe(401); + }); + + it('never redirects to an unregistered redirect_uri', async () => { + const res = await app.request( + `${ISSUER}/authorize?client_id=google-workspace&redirect_uri=${encodeURIComponent('https://evil.example/cb')}&response_type=code&scope=openid`, + {}, + env + ); + expect(res.status).toBe(400); + expect(res.headers.get('Location')).toBeNull(); + }); + + it('sends users without an account to the account page instead of Google', async () => { + world.directory.clear(); + const jar = new CookieJar(); + const back = await signIn(jar); + expect(back.headers.get('Location')).toBe('/?signin=blocked'); + const home = await call('/?signin=blocked', jar); + const text = await home.text(); + expect(text).toContain('You need an active'); + expect(text).toContain('Set up account'); + }); + + it('refuses suspended accounts', async () => { + const [user] = [...world.directory.values()]; + user!.suspended = true; + const back = await signIn(new CookieJar()); + expect(back.headers.get('Location')).toBe('/?signin=blocked'); + }); +}); diff --git a/access-site/test/validation.test.ts b/access-site/test/validation.test.ts new file mode 100644 index 00000000..422f4b4f --- /dev/null +++ b/access-site/test/validation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { splitName, suggestPrefix, validateName, validatePrefix } from '../src/validation'; +import { findMember, isEligible, teamsFor } from '../src/eligibility'; +import { ineligibleMember, memberWithPrefix } from './helpers'; + +describe('validateName', () => { + it('accepts unicode names and normalises whitespace', () => { + expect(validateName(' José María ', 'First name')).toEqual({ ok: true, value: 'José María' }); + expect(validateName("O'Brien-Smith", 'Last name').ok).toBe(true); + }); + it('rejects empty, long and markup', () => { + expect(validateName('', 'First name').ok).toBe(false); + expect(validateName('a'.repeat(61), 'First name').ok).toBe(false); + expect(validateName('