diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..7212f19 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,6 @@ +# Copy to .dev.vars for local development (never commit .dev.vars). +# In production these are set as Cloudflare Pages secrets. + +# GitHub App OAuth credentials (github.com/apps/cletqui-petithub) +CLIENT_ID= +CLIENT_SECRET= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ce7b5da..48e0f3e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,18 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + dev-dependencies: + dependency-type: "development" + update-types: ["minor", "patch"] + production-dependencies: + dependency-type: "production" + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" - allow: - - dependency-type: "production" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5dc1ab7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index e3fa491..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy project to GitHub Pages - -on: - push: - branches: - - main - - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: true - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - name: Install dependencies - run: npm ci - - name: Build project - run: npm run build - - name: Setup Pages - uses: actions/configure-pages@v4 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # Upload dist folder - path: "./dist" - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 3e9c884..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Run tests - -on: - push: - branches: - - "**" - pull_request: - branches: - - "**" - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - - - name: Install dependencies - run: npm install - - - name: Run tests - run: npm run test diff --git a/.gitignore b/.gitignore index e319e06..802fc4a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ lerna-debug.log* # misc .DS_Store +CLAUDE.md diff --git a/README.md b/README.md index 1a2530a..394cf9b 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,71 @@ # PetitHub 🌌 -Explore the hidden gems of GitHub with [PetitHub](https://github.com/cletqui/petithub/)! +Explore the hidden gems of GitHub with [PetitHub](https://petithub.pages.dev/)! -This project is an adaptation of the [Petit Tube](https://en.wikipedia.org/wiki/Petit_Tube) concept to GitHub: it allows users to discover random, obscure GitHub repositories that have no stars. Whether you're looking to find an undiscovered masterpiece or just satisfy your curiosity, PetitHub is here to take you on a journey through the lesser-known corners of GitHub. +PetitHub adapts the [Petit Tube](https://en.wikipedia.org/wiki/Petit_Tube) concept to +GitHub: it shows you a random, obscure public repository that has **zero stars**. Reload +for another one. ![PetitHub Screenshot](.github/screenshot.png) -## Links πŸ”— - -- Cloudflare: [petithub.pages.dev](https://petithub.pages.dev/) -- GItHub: (coming soon) ⏳ - [![pages-build-deployment](https://github.com/cletqui/petithub/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/cletqui/petithub/actions/workflows/pages/pages-build-deployment) - -## Table of Contents πŸ“‹ - -- [PetitHub 🌌](#petithub-) - - [Links πŸ”—](#links-) - - [Table of Contents πŸ“‹](#table-of-contents-) - - [Features ✨](#features-) - - [How It Works πŸ”](#how-it-works-) - - [Endpoints πŸ“‘](#endpoints-) - - [Main Page](#main-page) - - [Custom API](#custom-api) - - [Getting Started πŸš€](#getting-started-) - - [Prerequisites](#prerequisites) - - [Installation](#installation) - - [Using the App](#using-the-app) - - [Authentication πŸ”](#authentication-) - - [Technologies Used πŸ› οΈ](#technologies-used-️) - - [Contributing 🀝](#contributing-) - - [License πŸ“„](#license-) - - [Contact πŸ“§](#contact-) - - [Acknowledgements πŸ™](#acknowledgements-) - - [Support β˜•](#support-) - - [Roadmap πŸ—ΊοΈ](#roadmap-️) - - [TODO](#todo) - - [Done](#done) - ## Features ✨ -- 🎲 **Random Repository Discovery**: Explore random GitHub repositories that have zero stars. -- 🎨 **Custom Repository Display**: View repository information (name, author, language, stars, watchers, forks...) in a fully integrated custom display. -- πŸ”— **Quick Access**: Easily navigate to the GitHub page of any displayed repository. -- πŸ‘€ **Enhanced Browsing with Authentication**: Connect your GitHub account to remove rate limits and access additional features like starring repositories. -- πŸ”§ **Custom API**: Search for niche repositories by name or ID using the integrated API. -- πŸš€ **Fast and Fun Interface**: Built with the [Hono](https://hono.dev/) framework for a smooth and enjoyable user experience. - -## How It Works πŸ” - -PetitHub uses the Octokit package to interact with the GitHub API. It browses the public repositories looking for unique, not starred repositories. You can use the app without a GitHub account, but signing in with [GitHub App](https://github.com/apps/cletqui-petithub) provides a more seamless experience without rate limiting and unlocks extra features. - -PetitHub is built with [Hono](https://hono.dev/) over a Vite server for a fast user and easy developer experience. It fully integrates most of the features from Hono. - -## Endpoints πŸ“‘ - -### Main Page - -The main page displays a random GitHub repository with all its relevant details. You can quickly star the repository or visit its GitHub page. - -### Custom API - -- **Browse by Name or ID**: Look up repositories by their name or ID using our custom API endpoints. -- **Recent Repository ID**: Retrieve the ID of the most recently created repository on GitHub. - -## Getting Started πŸš€ - -### Prerequisites - -- Node.js -- npm - -### Installation - -1. Clone the repository: +- 🎲 **Random discovery** – a fresh zero-star repository on every reload. +- 🎨 **GitHub-style display** – repository details rendered in a familiar layout. +- πŸ‘€ **Sign in to keep browsing** – anonymous requests share GitHub's + unauthenticated rate limit (60/hour). Connect your GitHub account via the + [PetitHub GitHub App](https://github.com/apps/cletqui-petithub) to browse with your + own token instead. +- πŸ”§ **JSON API** – `GET /api` for a random repository, `GET /api/{id}` for a specific one + (Bearer token required). Interactive docs at `/api/swagger`. - ```sh - git clone https://github.com/cletqui/petithub.git - cd petithub - ``` +## How it works πŸ” -2. Install dependencies: +PetitHub is a [Hono](https://hono.dev/) app rendered server-side (streaming JSX, no client +hydration) and deployed on **Cloudflare Pages**. It talks to the GitHub REST API through +[Octokit](https://github.com/octokit): it samples a random offset into the public +repository list and inspects a few candidates until it finds one with no stars, that isn't +a fork, and isn't empty. Requests use your OAuth token when you are signed in, otherwise +the shared anonymous limit applies. - ```sh - npm install - ``` +## Development πŸš€ -3. Start the development server: +Requires [Bun](https://bun.sh/). - ```sh - npm run dev - ``` +```sh +git clone https://github.com/cletqui/petithub.git +cd petithub +bun install +cp .dev.vars.example .dev.vars # fill in the values you have +bun run dev # http://localhost:5173 +``` -### Using the App +Scripts: `bun run dev`, `bun run build`, `bun run typecheck`, `bun run preview` +(wrangler), `bun run deploy`. -Open your browser and navigate to `http://localhost:5173` to start exploring obscure GitHub repositories. +### Environment -## Authentication πŸ” +| Variable | Purpose | +| --------------- | ------------------------------ | +| `CLIENT_ID` | GitHub App OAuth client id | +| `CLIENT_SECRET` | GitHub App OAuth client secret | -For an enhanced experience, connect your GitHub account. This will allow you to bypass rate limits and access special features like starring repositories directly from PetitHub. - -## Technologies Used πŸ› οΈ - -- **Hono JS Framework**: For a fast and clean frontend experience. -- **Octokit**: To interact with the GitHub API. -- **Node.js**: Backend runtime environment. -- **Vite**: Server framework. +Both are needed for the GitHub sign-in flow; without them PetitHub still runs in +anonymous mode. Locally they live in `.dev.vars`; in production they are Cloudflare +Pages secrets. ## Contributing 🀝 -Contributions are welcome! Feel free to open an issue or submit a pull request. - -1. Fork the repository. -2. Create your feature branch: - - ```sh - git checkout -b feature/AmazingFeature - ``` - -3. Commit your changes: - - ```sh - git commit -m 'Add some AmazingFeature' - ``` - -4. Push to the branch: - - ```sh - git push origin feature/AmazingFeature - ``` - -5. Open a pull request. +Issues and pull requests welcome. `bun run typecheck` and `bun run build` must pass (CI +runs both). ## License πŸ“„ -Distributed under the MIT License. See `LICENSE` for more information. - -## Contact πŸ“§ - -X / Twitter - [@cletqui](https://twitter.com/cletqui) - -Project Link: [https://github.com/cletqui/petithub](https://github.com/cletqui/petithub) +MIT β€” see [`LICENSE`](LICENSE). ## Acknowledgements πŸ™ -- Inspired by Petit Tube. -- Thanks to the developers of Octokit and Hono JS. - -## Support β˜• - -If you like this project, you can support me by buying my next coffee on [BuyMeACoffee](https://www.buymeacoffee.com/cletqui). - -Happy exploring! 🌟 - ---- - -## Roadmap πŸ—ΊοΈ - -### TODO - -- [ ] Improve GUI - - [x] add additional info about the repo - - [x] implement dark/light themes - - [x] mimic GitHub layout - - [x] set page title as `full_name` & use GitHub link in header like GitHub page - - [x] cache svg imports - - [x] add reload button - - [x] fix header hidden on small screens - - [ ] add colors for languages - - [ ] refactor code wit [shadcn](https://ui.shadcn.com/) UI - - [x] fix cookie storage/deletion (and interferences with GUI) - - [x] change the document title dynamically (with nested `Suspense`) - - [x] use nested renderer to render multiple components - - [x] use `useRequestContext` to have conditional render - - [x] implement Swagger with API - - [x] add profile name + icon when connected + fix UI - - [x] add possibility to logout - - [ ] allow starring repo when connected -- [ ] Handle Octokit errors (401 & 403) -- [ ] Define unit tests -- [x] Fix interfaces and type definitions -- [ ] Setup GitHub Actions - - [ ] to deploy the website to GitHub - - [x] to run tests automatically - - [x] fix GitHub security detection on `tests` files -- [ ] Make sure the project is compatible with multiple deployment types - - [x] Cloudflare - - [ ] GitHub - - [ ] Heroku -- [ ] Refactor saving `access_token` in c.var and access it through `octokit.auth` instead -- [ ] Sanitize all strings into `${string}` - -### Done - -- [x] Write `README.md` -- [x] Write GitHub App description -- [x] fix API by adding 404 not found if no repo by id is found -- [x] Add icons in footer -- [x] Find `MAX_ID` dynamically and store in Cookies -- [x] Require GitHub API (Bearer Auth) for `/api/*` requests -- [x] Display Template -- [x] Refactor code into smaller modules -- [x] Setup GitHub App - - [x] fix oauth workflow - - [x] define default demo/GitHub choice page - - [x] error handling when `access_token` is not defined (go back to authentication or use default token) - - [x] validate the token used before using them - - [x] fix `access_token`/`refresh_token` cookie storage issue - - [x] optimize performance - - [x] change GitHub App icon - - [x] handle `state` verification - - [x] handle `access_token` refresh with `refresh_token` -- [x] Use middlewares - - [x] to declare the octokit instance - - [x] to add auth header if access_token is valid (to allow API use when `access_token` is defined) +Inspired by Petit Tube. Built with [Hono](https://hono.dev/) and +[Octokit](https://github.com/octokit). + +If you enjoy it, you can [buy me a coffee](https://www.buymeacoffee.com/cletqui) β˜• diff --git a/bun.lock b/bun.lock index d161efd..bbc1902 100644 --- a/bun.lock +++ b/bun.lock @@ -5,18 +5,18 @@ "": { "name": "petithub", "dependencies": { - "@hono/swagger-ui": "latest", - "@hono/zod-openapi": "latest", - "hono": "latest", - "octokit": "latest", + "@hono/swagger-ui": "^0.6.1", + "@hono/zod-openapi": "^1.6.1", + "hono": "^4.13.5", + "octokit": "^5.0.5", }, "devDependencies": { - "@cloudflare/workers-types": "latest", - "@hono/vite-cloudflare-pages": "latest", - "@hono/vite-dev-server": "latest", - "vite": "latest", - "vitest": "latest", - "wrangler": "latest", + "@cloudflare/workers-types": "^5.20260902.1", + "@hono/vite-cloudflare-pages": "^0.4.3", + "@hono/vite-dev-server": "^0.26.1", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "wrangler": "^4.128.0", }, }, }, @@ -261,31 +261,47 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@types/aws-lambda": ["@types/aws-lambda@8.10.161", "", {}, "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -297,24 +313,14 @@ "brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -351,16 +357,12 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "miniflare": ["miniflare@5.20260831.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260831.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Hwgh1VDUiPCPGQKODQfUmy7hRAje1D55icB+9png3ueiM64rlSM87nSrtqpxAD+DlLWI4ehnYBuECaXV43zGmQ=="], "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - "octokit": ["octokit@5.0.5", "", { "dependencies": { "@octokit/app": "^16.1.2", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-graphql": "^6.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", "@octokit/plugin-retry": "^8.0.3", "@octokit/plugin-throttling": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw=="], "openapi3-ts": ["openapi3-ts@4.5.0", "", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="], @@ -381,28 +383,18 @@ "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - - "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -413,10 +405,6 @@ "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], - "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], - - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "workerd": ["workerd@1.20260831.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260831.1", "@cloudflare/workerd-darwin-arm64": "1.20260831.1", "@cloudflare/workerd-linux-64": "1.20260831.1", "@cloudflare/workerd-linux-arm64": "1.20260831.1", "@cloudflare/workerd-windows-64": "1.20260831.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ=="], "wrangler": ["wrangler@4.128.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260831.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260831.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260831.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jNXy9e8/pbx8iqTzXPiuflnitKJZoAfEUSUUDLW87bwyeMvJ7kb3yQMSbxEcfNdfHqJW38KRcKaLljOYV4N/4w=="], @@ -433,10 +421,6 @@ "@asteasolutions/zod-to-openapi/openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="], - "vitest/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "@asteasolutions/zod-to-openapi/openapi3-ts/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], } } diff --git a/package.json b/package.json index 5dd4e6b..1a4492e 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "scripts": { "dev": "vite", "build": "vite build", + "typecheck": "tsc --noEmit", "preview": "wrangler pages dev", - "test": "vitest", "deploy": "$npm_execpath run build && wrangler pages deploy" }, "dependencies": { @@ -39,8 +39,8 @@ "@cloudflare/workers-types": "^5.20260902.1", "@hono/vite-cloudflare-pages": "^0.4.3", "@hono/vite-dev-server": "^0.26.1", + "typescript": "^7.0.2", "vite": "^8.2.2", - "vitest": "^4.1.11", "wrangler": "^4.128.0" } } diff --git a/public/static/style.css b/public/static/style.css index b66eab1..3138c86 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -85,13 +85,18 @@ body { -moz-box-align: center; align-items: center; } +.refresh-link { + display: flex; + align-items: center; +} .header .refresh { -moz-box-align: center; -moz-box-pack: end; animation: 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0s 1 refresh; transition: transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); } -.header .refresh:hover { +.refresh-link:hover .refresh, +.refresh-link:focus-visible .refresh { transform: rotate(90deg); } @keyframes refresh { diff --git a/src/components/login.tsx b/src/components/login.tsx index 459ce59..d9a4f52 100644 --- a/src/components/login.tsx +++ b/src/components/login.tsx @@ -7,10 +7,10 @@ import { getAuthenticatedUser } from "../utils/octokit"; const LoginButton = () => { return ( - + + + {"Login with GitHub"} + ); }; @@ -27,12 +27,13 @@ const User = async ({ const { data } = await user; const { login, avatar_url } = data; return ( - + + + {login} + ); } catch (_) { return ; @@ -46,4 +47,4 @@ export const Login = async ({ octokit }: { octokit: Octokit }) => { ); -}; // TODO fix UI +}; diff --git a/src/components/repository.tsx b/src/components/repository.tsx index 2af537a..056404e 100644 --- a/src/components/repository.tsx +++ b/src/components/repository.tsx @@ -4,6 +4,21 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { timeAgo, dateOptions } from "../utils/time"; import { constructUrl } from "../utils/url"; +import { Loader } from "./loader"; + +type RepositoryData = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; + +const RepositoryError = (): JSX.Element => ( +
+

+ {"Couldn't load a repository right now β€” GitHub may be rate-limiting. "} + {"Try again"} + {" or "} + {"sign in"} + {" to lift the limit."} +

+
+); const LANGUAGE_COLORS: Record = { JavaScript: "#f1e05a", @@ -34,19 +49,29 @@ const LANGUAGE_COLORS: Record = { const getLanguageColor = (language: string | null): string => (language && LANGUAGE_COLORS[language]) || "#8b949e"; -export const Repository = async ({ +const ResolvedRepository = async ({ repository, }: { - repository: Promise< - RestEndpointMethodTypes["repos"]["get"]["response"]["data"] - >; + repository: Promise; +}) => { + try { + return ; + } catch (_) { + return ; + } +}; + +export const Repository = ({ + repository, +}: { + repository: Promise; }) => { return ( - {"error"}}> - + }> + ); -}; // TODO handle errors like https://docs.github.com/en/rest/guides/scripting-with-the-rest-api-and-javascript?apiVersion=2022-11-28#handling-rate-limit-errors +}; const Container = ({ repository, @@ -65,7 +90,6 @@ const Container = ({ pushed_at, homepage, stargazers_count, - watchers_count, language, forks_count, license, @@ -84,7 +108,7 @@ const Container = ({ href={owner_html_url} title={login} > - avatar + {login} @@ -101,41 +125,60 @@ const Container = ({ diff --git a/src/index.tsx b/src/index.tsx index be434ea..2d048c5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,6 @@ import { Context, Hono } from "hono"; import { logger } from "hono/logger"; +import { secureHeaders } from "hono/secure-headers"; import { Octokit } from "octokit"; import { renderer } from "./utils/renderer"; @@ -32,6 +33,12 @@ const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); /* MIDDLEWARES */ app.use(logger()); +app.use( + secureHeaders({ + xFrameOptions: "DENY", + referrerPolicy: "strict-origin-when-cross-origin", + }) +); app.use(renderer); app.use("/", handleMaxId); app.use("/", handleTokens); @@ -62,4 +69,10 @@ app.get( } ); +/* ERRORS */ +app.onError((err, c) => { + console.error(err); + return c.text("Something went wrong. Try reloading.", 500); +}); + export default app; diff --git a/src/routes/api.tsx b/src/routes/api.tsx index c7e8549..26befab 100644 --- a/src/routes/api.tsx +++ b/src/routes/api.tsx @@ -1,7 +1,6 @@ import { Context } from "hono"; -import { poweredBy } from "hono/powered-by"; import { prettyJSON } from "hono/pretty-json"; -import { trimTrailingSlash } from 'hono/trailing-slash' +import { trimTrailingSlash } from "hono/trailing-slash"; import { cors } from "hono/cors"; import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { swaggerUI } from "@hono/swagger-ui"; @@ -17,13 +16,9 @@ import { apiAuth, getRandomRepository, getRepository } from "../utils/octokit"; const app = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>(); /* MIDDLEWARES */ -app.use(poweredBy()); app.use(prettyJSON()); -app.use(trimTrailingSlash()) -app.use(cors({ origin: "*", allowMethods: ["GET"], credentials: true })); -app.use(handleMaxId); -app.use(handleTokens); -app.use(apiAuth); +app.use(trimTrailingSlash()); +app.use(cors({ origin: "*", allowMethods: ["GET"] })); /* SECURITY */ app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { @@ -31,7 +26,7 @@ app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { scheme: "bearer", }); -/* SWAGGER */ +/* SWAGGER (public β€” must be registered before apiAuth) */ app.get("/swagger", swaggerUI({ url: `/api/swagger.json`, version: "3.1" })); app.doc31( "/swagger.json", @@ -64,6 +59,11 @@ app.doc31( } ); +/* AUTH (applies only to the data endpoints below) */ +app.use(handleMaxId); +app.use(handleTokens); +app.use(apiAuth); + /* ROUTES */ const route = createRoute({ method: "get", @@ -114,11 +114,12 @@ app.openapi( const repository = id ? await getRepository(octokit, Number(id)) : await getRandomRepository(octokit, max_id.id); - if (id && repository.id != Number(id)) { + if (id && repository.id !== Number(id)) { return c.redirect(`/api/${repository.id}`, 302); } return c.json(repository, 200); - } catch (error: any) { + } catch (error) { + console.error(error); return c.json({ message: "Failed to fetch repository data" }, 500); } } diff --git a/src/routes/github.tsx b/src/routes/github.tsx index 90cf839..8558d76 100644 --- a/src/routes/github.tsx +++ b/src/routes/github.tsx @@ -2,15 +2,18 @@ import { Context, Hono } from "hono"; import { Bindings, Variables } from ".."; import { generateState, handleState } from "../utils/state"; -import { handleAccess, handleLogout } from "../utils/tokens"; +import { handleLogout } from "../utils/tokens"; /* APP */ const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); +/* HELPERS */ +const safePath = (path: string | undefined): string => + path && path.startsWith("/") && !path.startsWith("//") ? path : "/"; + /* MIDDLEWARES */ app.use("/login", generateState); app.use("/callback", handleState); -app.use("/access_token", handleAccess); app.use("/logout", handleLogout); /* ENDPOINTS */ @@ -21,13 +24,14 @@ app.get( ): Promise => { const { CLIENT_ID } = c.env; const { state } = c.var; - const { url } = c.req; - const redirect_url = new URL(url); + const redirect_url = new URL(c.req.url); redirect_url.pathname = "/github/callback"; - const searchParams = new URLSearchParams(); - searchParams.append("client_id", CLIENT_ID); - searchParams.append("redirect_uri", redirect_url.toString()); - searchParams.append("state", state); + redirect_url.search = ""; + const searchParams = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: redirect_url.toString(), + state, + }); return c.redirect( `https://github.com/login/oauth/authorize?${searchParams.toString()}`, 302 @@ -38,29 +42,14 @@ app.get( app.get( "/callback", async (c: Context<{ Bindings: Bindings; Variables: Variables }>) => { - const { refresh_token, access_token, expires_in } = c.var; - const searchParams = new URLSearchParams(); - refresh_token && searchParams.append("refresh_token", refresh_token); - access_token && searchParams.append("access_token", access_token); - expires_in && searchParams.append("expires_in", expires_in); - searchParams.append("callback_url", "/"); - return c.redirect(`/github/access_token?${searchParams.toString()}`, 302); - } -); - -app.get( - "/access_token", - async (c: Context<{ Bindings: Bindings; Variables: Variables }>) => { - const { callback_url } = c.req.query(); - return c.redirect(callback_url || "/", 302); + return c.redirect(safePath(c.req.query("callback_url")), 302); } ); app.get( "/logout", async (c: Context<{ Bindings: Bindings; Variables: Variables }>) => { - const { callback_url } = c.req.query(); - return c.redirect(callback_url || "/", 302); + return c.redirect(safePath(c.req.query("callback_url")), 302); } ); diff --git a/src/routes/id.tsx b/src/routes/id.tsx index 9b3ee00..711d8d6 100644 --- a/src/routes/id.tsx +++ b/src/routes/id.tsx @@ -23,17 +23,25 @@ app.get( access_token, octokit, } = c.var; - const update = access_token ? await getMaxId(octokit, id) : id; - const timestamp = access_token ? new Date().getTime() : old; - setCookie(c, "max_id", `{ "id": ${update}, "timestamp": ${timestamp} }`, { + let update = id; + let timestamp = old; + if (access_token) { + try { + update = await getMaxId(octokit, id); + timestamp = Date.now(); + } catch (error) { + console.error(error); + } + } + setCookie(c, "max_id", JSON.stringify({ id: update, timestamp }), { path: "/", secure: true, - httpOnly: false, // true + httpOnly: false, maxAge: 31557600, sameSite: "Strict", prefix: "secure", }); - return c.json({ id: update, timestamp: timestamp }); + return c.json({ id: update, timestamp }); } ); diff --git a/src/utils/octokit.tsx b/src/utils/octokit.tsx index 5d83569..8ea1d9a 100644 --- a/src/utils/octokit.tsx +++ b/src/utils/octokit.tsx @@ -8,7 +8,8 @@ import { version } from "../../package.json"; import { Bindings, Variables } from ".."; /** - * Asynchronously verifies the token by checking the status of fetching repositories. + * Asynchronously verifies a token by calling the rate-limit endpoint (which does + * not itself consume rate-limit budget). * @async @function verifyToken * @param {string} token The token to verify. * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c The Context object. @@ -18,9 +19,9 @@ const verifyToken = async ( token: string, c: Context<{ Bindings: Bindings; Variables: Variables }> ): Promise => { - const octokit = getOctokitInstance(c, token); try { - const { status } = await getRepos(octokit, "octocat", "Hello-World"); + const octokit = getOctokitInstance(c, token); + const { status } = await octokit.request("GET /rate_limit"); return status === 200; } catch (_) { return false; @@ -40,11 +41,12 @@ export const apiAuth = createMiddleware( next: Next ): Promise => { const { access_token } = c.var; - const accessToken = access_token || c.req.header("Authorization"); - if (accessToken && (await verifyToken(accessToken, c))) { - await next(); + const header = c.req.header("Authorization")?.replace(/^Bearer\s+/i, ""); + const token = access_token || header; + if (!token || !(await verifyToken(token, c))) { + return c.text("Unauthorized", 401); } - return c.text("Unauthorized", 401); + await next(); } ); @@ -68,7 +70,7 @@ export const handleOctokit = createMiddleware( /** * Creates and returns an instance of Octokit for GitHub API. - * @async @function getOctokitInstance + * @function getOctokitInstance * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The context object. * @param {string} [token] - Optional token for authentication. * @returns {Octokit} An instance of Octokit. @@ -90,7 +92,7 @@ export const getOctokitInstance = ( }; /** - * Asynchronously fetches repositories from a specified ID. + * Asynchronously fetches a page of public repositories starting from a given ID. * @async @function getRepositories * @param {Octokit} octokit - The Octokit instance for GitHub API. * @param {number} since - The ID to start fetching repositories from. @@ -100,11 +102,7 @@ const getRepositories = async ( octokit: Octokit, since: number ): Promise => { - try { - return octokit.rest.repos.listPublic({ since }); // octokit.request("GET /repositories", { since }); - } catch (error: any) { - throw error; - } + return octokit.rest.repos.listPublic({ since }); }; /** @@ -120,11 +118,7 @@ export const getRepos = async ( owner: string, repo: string ): Promise => { - try { - return octokit.rest.repos.get({ owner, repo }); // octokit.request("GET /repos/{owner}/{repo}", { owner, repo }); - } catch (error: any) { - throw error; - } + return octokit.rest.repos.get({ owner, repo }); }; /** @@ -138,33 +132,41 @@ export const getRepository = async ( octokit: Octokit, id: number ): Promise => { - try { - const { data, status, url } = await getRepositories( - octokit, - Number(id) - 1 - ); // (id - 1) because since starts from next id - if (status === 200) { - if (data.length === 0) { - throw new Error("Repository not found"); - } else { - const repo = data[0]; - const { - name, - owner: { login }, - } = repo; - const { data: repository } = await getRepos(octokit, login, name); - return repository; - } - } else { - throw new Error(`${status} error at ${url}`); - } - } catch (error: any) { - throw error; + // (id - 1) because `since` starts from the next id + const { data, status, url } = await getRepositories(octokit, Number(id) - 1); + if (status !== 200) { + throw new Error(`${status} error at ${url}`); } + if (data.length === 0) { + throw new Error("Repository not found"); + } + const { + name, + owner: { login }, + } = data[0]; + const { data: repository } = await getRepos(octokit, login, name); + return repository; }; /** - * Asynchronously retrieves a random repository that has no stars, is not a forked repo and is not empty. + * Returns a new array with the elements of `items` in random order (Fisher-Yates). + * @function shuffle + */ +function shuffle(items: T[]): T[] { + const result = [...items]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** + * Asynchronously retrieves a random repository that has no stars, is not a fork and is not empty. + * + * Picks a random `since` offset, then inspects a handful of random candidates from + * that page (one detailed request each) instead of scanning the whole page. If a + * page comes back empty the ceiling is halved, so a stale `maxId` self-corrects. * @async @function getRandomRepository * @param {Octokit} octokit - The Octokit instance for GitHub API. * @param {number} maxId - The maximum ID to consider for repository selection. @@ -174,35 +176,36 @@ export const getRandomRepository = async ( octokit: Octokit, maxId: number ): Promise => { - try { - const maxIterations = 10; // max iterations - for (let loop = 0; loop < maxIterations; loop++) { - const since = Math.floor(Math.random() * maxId); - const { data: repositories } = await getRepositories(octokit, since); - const originalRepositories = repositories.filter((repo) => !repo.fork); - for (const repo of originalRepositories) { - const { - name, - owner: { login }, - } = repo; - try { - const { data: repos } = await getRepos(octokit, login, name); - const { id, stargazers_count, size } = repos; - if (stargazers_count === 0 && size > 0) { - return repos; - } - console.log( - `${login}/${name} (id: ${id}, stars: ${stargazers_count}, size: ${size})` - ); - } catch (error: any) { - console.log(`${login}/${name} (${error})`); + const maxIterations = 15; + const candidatesPerPage = 3; + let ceiling = maxId; + for (let loop = 0; loop < maxIterations; loop++) { + const since = Math.floor(Math.random() * ceiling); + const { data: repositories } = await getRepositories(octokit, since); + if (repositories.length === 0) { + ceiling = Math.max(1, Math.floor(ceiling / 2)); + continue; + } + const candidates = shuffle(repositories.filter((repo) => !repo.fork)).slice( + 0, + candidatesPerPage + ); + for (const repo of candidates) { + try { + const { data: repos } = await getRepos( + octokit, + repo.owner.login, + repo.name + ); + if (repos.stargazers_count === 0 && repos.size > 0) { + return repos; } + } catch (_) { + /* repo went private / was renamed / deleted since listing β€” skip */ } } - throw new Error(`No repository found with ${maxIterations} iterations`); - } catch (error: any) { - throw error; } + throw new Error(`No repository found after ${maxIterations} iterations`); }; /** @@ -233,11 +236,7 @@ export const getAuthenticatedUser = async ( ): Promise< RestEndpointMethodTypes["users"]["getAuthenticated"]["response"] > => { - try { - return octokit.rest.users.getAuthenticated(); // octokit.request("GET /user"); - } catch (error: any) { - throw error; - } + return octokit.rest.users.getAuthenticated(); }; /** @@ -252,9 +251,20 @@ export const handleMaxId = createMiddleware( c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next ) => { - const max_id = getCookie(c, "max_id", "secure"); - const MAX_ID = 822080279; // TODO TBU - c.set("max_id", max_id ? JSON.parse(max_id) : { id: MAX_ID, timestamp: 0 }); + const MAX_ID = 1_000_000_000; + const cookie = getCookie(c, "max_id", "secure"); + let max_id = { id: MAX_ID, timestamp: 0 }; + if (cookie) { + try { + const parsed = JSON.parse(cookie); + if (typeof parsed?.id === "number" && parsed.id > 0) { + max_id = { id: parsed.id, timestamp: Number(parsed.timestamp) || 0 }; + } + } catch (_) { + /* malformed cookie β€” fall back to the default */ + } + } + c.set("max_id", max_id); await next(); } ); diff --git a/src/utils/renderer.tsx b/src/utils/renderer.tsx index 2ae2ecf..8b1a944 100644 --- a/src/utils/renderer.tsx +++ b/src/utils/renderer.tsx @@ -8,14 +8,16 @@ import { fetchRepositoryData } from "./octokit"; import { Loader } from "../components/loader"; import { Login } from "../components/login"; +type RepositoryData = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; + const Head = ({ repository, }: { - repository: Promise< - RestEndpointMethodTypes["repos"]["get"]["response"]["data"] - >; + repository?: Promise; }) => { - const full_name = fetchRepositoryData(repository, "full_name"); + const full_name = repository + ? fetchRepositoryData(repository, "full_name").catch(() => "") + : ""; return ( @@ -41,11 +43,9 @@ const Header = (): JSX.Element => { return (
- + + +

{"PetitHub"}

@@ -86,9 +86,7 @@ const Footer = (): JSX.Element => { ); }; -const Body = async ({ children }: PropsWithChildren) => { - const c = useRequestContext(); - const { octokit } = c.var; +const Body = ({ children }: PropsWithChildren) => { return (
@@ -104,9 +102,9 @@ export const renderer = jsxRenderer( ({ children, repository, - }: PropsWithChildren<{ repository?: any }>): JSX.Element => { + }: PropsWithChildren<{ repository?: Promise }>): JSX.Element => { return ( - + diff --git a/src/utils/state.tsx b/src/utils/state.tsx index 03e9646..f705dc0 100644 --- a/src/utils/state.tsx +++ b/src/utils/state.tsx @@ -45,7 +45,7 @@ export const handleState = createMiddleware( ): Promise => { const secret = getCookie(c, "state", "secure"); const { state } = c.req.query(); - if (secret === state) { + if (secret && state && secret === state) { await handleRefresh(c, next); } else { console.error( @@ -57,10 +57,11 @@ export const handleState = createMiddleware( ); /** - * Generates a random string using Math.random() and Date.now(). + * Generates a cryptographically random hex string for use as an OAuth state token. * @function generateRandomString * @returns {string} A randomly generated string. */ const generateRandomString = (): string => { - return Math.floor(Math.random() * Date.now()).toString(36); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); }; diff --git a/src/utils/tokens.tsx b/src/utils/tokens.tsx index cadacaf..b560c57 100644 --- a/src/utils/tokens.tsx +++ b/src/utils/tokens.tsx @@ -5,6 +5,17 @@ import { createMiddleware } from "hono/factory"; import { Bindings, Variables } from ".."; import { handleOctokit } from "./octokit"; +const DEFAULT_EXPIRES_IN = "28800"; + +type GitHubTokenResponse = { + error?: string; + error_description?: string; + access_token?: string; + expires_in?: string; + refresh_token?: string; + refresh_token_expires_in?: string; +}; + /** * Middleware function to handle tokens, refresh access_token if needed and handle the octokit. * @async @function handleTokens @@ -22,11 +33,18 @@ export const handleTokens = createMiddleware( c.set("access_token", accessToken); c.set("refresh_token", refreshToken); if (refreshToken && !accessToken) { - const { path } = c.req; - return c.redirect( - `/github/access_token?refresh_token=${refreshToken}&callback_url=${path}`, - 302 - ); + const { CLIENT_ID, CLIENT_SECRET } = c.env; + const { access_token, expires_in, error, error_description } = + await fetchGitHubToken(CLIENT_ID, CLIENT_SECRET, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + if (access_token) { + setToken(c, "access_token", access_token, expires_in); + } else { + console.error("token refresh failed", error, error_description); + unsetToken(c, "refresh_token"); + } } await handleOctokit(c, next); } @@ -38,21 +56,21 @@ export const handleTokens = createMiddleware( * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The Context object. * @param {keyof Variables} key - The key to set the token value in the context and cookie. * @param {string} value - The value of the token to be set. - * @param {string} expires - The expiration time of the token in seconds. + * @param {string} [expires] - The expiration time of the token in seconds. */ const setToken = ( c: Context<{ Bindings: Bindings; Variables: Variables }>, key: keyof Variables, value: string, - expires: string + expires?: string ): void => { c.set(key, value); setCookie(c, key, value, { path: "/", secure: true, httpOnly: true, - maxAge: Number(expires), - sameSite: "Lax", // Strict + maxAge: Number(expires) || Number(DEFAULT_EXPIRES_IN), + sameSite: "Lax", prefix: "secure", }); }; @@ -72,35 +90,51 @@ export const unsetToken = ( path: "/", secure: true, httpOnly: true, - sameSite: "Lax", // Strict + sameSite: "Lax", prefix: "secure", }); }; /** - * Asynchronously fetches a refresh token using a code. - * @async @function fetchRefreshToken + * Exchanges a code or refresh token with GitHub's OAuth token endpoint. Credentials + * and grant parameters are sent in the request body (never the URL) and the JSON + * response is requested explicitly. + * @async @function fetchGitHubToken * @param {string} clientId The GitHub App client ID. * @param {string} clientSecret The GitHub App client secret. - * @param {string} code The code for authentication. - * @returns {Promise} A promise that resolves to an object containing the refresh token information. + * @param {Record} params Grant-specific parameters (`code` or `refresh_token` + `grant_type`). + * @returns {Promise} A promise that resolves to the token response. */ -const fetchRefreshToken = async ( +const fetchGitHubToken = async ( clientId: string, clientSecret: string, - code: string -): Promise => { - const response = await fetch( - `https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}`, - { method: "POST" } - ); - const tokens = await response.text(); - const responseParams = new URLSearchParams(tokens); - return Object.fromEntries(responseParams); // TODO implement interface (any) + params: Record +): Promise => { + try { + const response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + ...params, + }), + } + ); + return (await response.json()) as GitHubTokenResponse; + } catch (error) { + return { error: "request_failed", error_description: String(error) }; + } }; /** - * Middleware function to handle token refresh by fetching new tokens. + * Middleware function to complete the OAuth code exchange and persist the resulting + * tokens as secure cookies. Runs after the state check on `/github/callback`. * @async @function handleRefresh * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The Context object. * @param {Next} next - The callback function to proceed to the next middleware. @@ -113,78 +147,23 @@ export const handleRefresh = createMiddleware( ): Promise => { const { CLIENT_ID, CLIENT_SECRET } = c.env; const { code } = c.req.query(); - const { - error, - error_description, - access_token, - expires_in, - refresh_token, - refresh_token_expires_in, - } = await fetchRefreshToken(CLIENT_ID, CLIENT_SECRET, code); - if (refresh_token) { - setToken(c, "refresh_token", refresh_token, refresh_token_expires_in); - } - if (access_token) { - c.set("access_token", access_token); - c.set("expires_in", expires_in || "28800"); - } else { - console.error(error, error_description); - } - await next(); - } -); - -/** - * Asynchronously fetches an access token using a refresh token. - * @async @function fetchAccessToken - * @param {string} clientId The GitHub App client ID. - * @param {string} clientSecret The GitHub App client secret. - * @param {string} refreshToken The refresh token to refresh. - * @returns {Promise} A promise that resolves to an object containing the access token information. - */ -const fetchAccessToken = async ( - clientId: string, - clientSecret: string, - refreshToken: string -): Promise => { - const response = await fetch( - `https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&refresh_token=${refreshToken}&grant_type=refresh_token`, - { method: "POST" } - ); - const tokens = await response.text(); - const responseParams = new URLSearchParams(tokens); - return Object.fromEntries(responseParams); // TODO implement interface (any) -}; - -/** - * Middleware function to handle access tokens based on the presence of access_token or refresh_token in the request query. - * @async @function handleAccess - * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The Context object. - * @param {Next} next - The callback function to proceed to the next middleware. - * @returns {Promise} A promise that resolves after handling access tokens and potentially redirecting. - */ -export const handleAccess = createMiddleware( - async ( - c: Context<{ Bindings: Bindings; Variables: Variables }>, - next: Next - ): Promise => { - const { CLIENT_ID, CLIENT_SECRET } = c.env; - const { refresh_token, access_token, expires_in } = c.req.query(); - if (access_token) { - setToken(c, "access_token", access_token, expires_in || "28800"); - } else if (refresh_token) { + if (code) { const { error, error_description, - access_token: update, - expires_in: expires, - } = await fetchAccessToken(CLIENT_ID, CLIENT_SECRET, refresh_token); - if (update) { - setToken(c, "access_token", update, expires); - c.set("expires_in", expires); + access_token, + expires_in, + refresh_token, + refresh_token_expires_in, + } = await fetchGitHubToken(CLIENT_ID, CLIENT_SECRET, { code }); + if (refresh_token) { + setToken(c, "refresh_token", refresh_token, refresh_token_expires_in); + } + if (access_token) { + setToken(c, "access_token", access_token, expires_in); + c.set("expires_in", expires_in || DEFAULT_EXPIRES_IN); } else { - console.error(error, error_description); - return c.redirect("/github/login", 302); + console.error("code exchange failed", error, error_description); } } await next(); diff --git a/tsconfig.json b/tsconfig.json index 3882768..d3b3bcc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,13 @@ "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, "lib": ["ESNext"], - "types": ["@cloudflare/workers-types", "vite/client", "vitest"], + "types": ["@cloudflare/workers-types", "vite/client"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx" - } + }, + "include": ["src", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 47c494b..1122bd7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,15 +12,4 @@ export default defineConfig({ }), ], optimizeDeps: { include: ["hono", "octokit"] }, - test: { - include: ["**/*.test.tsx"], - globals: true, - poolOptions: { - workers: { - wrangler: { - configPath: "./wrangler.toml", - }, - }, - }, - }, }); diff --git a/wrangler.toml b/wrangler.toml index 2073c4f..36fb505 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,3 @@ name = "petithub" pages_build_output_dir = "./dist" -compatibility_date = "2022-11-30" - -[vars] +compatibility_date = "2026-09-01"