The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .github
    └── workflows
    │   └── tests.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── LICENSE.txt
├── README.md
├── SECURITY.md
├── __tests__
    ├── mocks
    │   └── cloudflare-workers.ts
    ├── oauth-provider.test.ts
    └── setup.ts
├── package.json
├── pnpm-lock.yaml
├── src
    └── oauth-provider.ts
├── storage-schema.md
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts


/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
 1 | name: Tests
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [ main ]
 6 |   pull_request:
 7 |     branches: [ main ]
 8 | 
 9 | jobs:
10 |   test:
11 |     runs-on: ubuntu-latest
12 |     
13 |     steps:
14 |     - uses: actions/checkout@v4
15 |     - name: Install pnpm
16 |       uses: pnpm/action-setup@v4
17 |       with:
18 |         version: 10
19 |     - name: Use Node.js ${{ matrix.node-version }}
20 |       uses: actions/setup-node@v4
21 |       with:
22 |         node-version: ${{ matrix.node-version }}
23 |         cache: 'pnpm'
24 |     - name: Install dependencies
25 |       run: pnpm install
26 |     
27 |     - name: Run tests
28 |       run: npm test


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /dist
3 | 


--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.md
2 | *.yml
3 | *.yaml
4 | 


--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 |   "trailingComma": "es5",
3 |   "tabWidth": 2,
4 |   "semi": true,
5 |   "singleQuote": true,
6 |   "printWidth": 120
7 | }
8 | 


--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2025 Cloudflare, Inc.
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # OAuth 2.1 Provider Framework for Cloudflare Workers
  2 | 
  3 | This is a TypeScript library that implements the provider side of the OAuth 2.1 protocol with PKCE support. The library is intended to be used on Cloudflare Workers.
  4 | 
  5 | ## Beta
  6 | 
  7 | As of March, 2025, this library is very new, prerelease software. The API is still subject to change.
  8 | 
  9 | ## Benefits of this library
 10 | 
 11 | * The library acts as a wrapper around your Worker code, which adds authorization for your API endpoints.
 12 | * All token management is handled automatically.
 13 | * Your API handler is written like a regular fetch handler, but receives the already-authenticated user details as a parameter. No need to perform any checks of your own.
 14 | * The library is agnostic to how you manage and authenticate users.
 15 | * The library is agnostic to how you build your UI. Your authorization flow can be implemented using whatever UI framework you use for everything else.
 16 | * The library's storage does not store any secrets, only hashes of them.
 17 | 
 18 | ## Usage
 19 | 
 20 | A Worker that uses the library might look like this:
 21 | 
 22 | ```ts
 23 | import { OAuthProvider } from "my-oauth";
 24 | import { WorkerEntrypoint } from "cloudflare:workers";
 25 | 
 26 | // We export the OAuthProvider instance as the entrypoint to our Worker. This means it
 27 | // implements the `fetch()` handler, receiving all HTTP requests.
 28 | export default new OAuthProvider({
 29 |   // Configure API routes. Any requests whose URL starts with any of these prefixes will be
 30 |   // considered API requests. The OAuth provider will check the access token on these requests,
 31 |   // and then, if the token is valid, send the request to the API handler.
 32 |   // You can provide:
 33 |   // - A single route (string) or multiple routes (array)
 34 |   // - Full URLs (which will match the hostname) or just paths (which will match any hostname)
 35 |   apiRoute: [
 36 |     "/api/", // Path only - will match any hostname
 37 |     "https://api.example.com/" // Full URL - will check hostname
 38 |   ],
 39 | 
 40 |   // When the OAuth system receives an API request with a valid access token, it passes the request
 41 |   // to this handler object's fetch method.
 42 |   // You can provide either an object with a fetch method (ExportedHandler)
 43 |   // or a class extending WorkerEntrypoint.
 44 |   apiHandler: ApiHandler, // Using a WorkerEntrypoint class
 45 |   
 46 |   // For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler.
 47 |   // This allows you to use different handlers for different API routes.
 48 |   // Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both.
 49 |   // Example:
 50 |   // apiHandlers: {
 51 |   //   "/api/users/": UsersApiHandler,
 52 |   //   "/api/documents/": DocumentsApiHandler,
 53 |   //   "https://api.example.com/": ExternalApiHandler,
 54 |   // },
 55 | 
 56 |   // Any requests which aren't API request will be passed to the default handler instead.
 57 |   // Again, this can be either an object or a WorkerEntrypoint.
 58 |   defaultHandler: defaultHandler, // Using an object with a fetch method
 59 | 
 60 |   // This specifies the URL of the OAuth authorization flow UI. This UI is NOT implemented by
 61 |   // the OAuthProvider. It is up to the application to implement a UI here. The only reason why
 62 |   // this URL is given to the OAuthProvider is so that it can implement the RFC-8414 metadata
 63 |   // discovery endpoint, i.e. `.well-known/oauth-authorization-server`.
 64 |   // Can also be specified as just a path (e.g., "/authorize").
 65 |   authorizeEndpoint: "https://example.com/authorize",
 66 | 
 67 |   // This specifies the OAuth 2 token exchange endpoint. The OAuthProvider will implement this
 68 |   // endpoint (by directly responding to requests with a matching URL).
 69 |   // Can also be specified as just a path (e.g., "/oauth/token").
 70 |   tokenEndpoint: "https://example.com/oauth/token",
 71 | 
 72 |   // This specifies the RFC-7591 dynamic client registration endpoint. This setting is optional,
 73 |   // but if provided, the OAuthProvider will implement this endpoint to allow dynamic client
 74 |   // registration.
 75 |   // Can also be specified as just a path (e.g., "/oauth/register").
 76 |   clientRegistrationEndpoint: "https://example.com/oauth/register",
 77 | 
 78 |   // Optional list of scopes supported by this OAuth provider.
 79 |   // If provided, this will be included in the RFC 8414 metadata as 'scopes_supported'.
 80 |   // If not provided, the 'scopes_supported' field will be omitted from the metadata.
 81 |   scopesSupported: ["document.read", "document.write", "profile"],
 82 | 
 83 |   // Optional: Controls whether the OAuth implicit flow is allowed.
 84 |   // The implicit flow is discouraged in OAuth 2.1 but may be needed for some clients.
 85 |   // Defaults to false.
 86 |   allowImplicitFlow: false,
 87 | 
 88 |   // Optional: Controls whether public clients (clients without a secret, like SPAs)
 89 |   // can register via the dynamic client registration endpoint.
 90 |   // When true, only confidential clients can register.
 91 |   // Note: Creating public clients via the OAuthHelpers.createClient() method
 92 |   // is always allowed regardless of this setting.
 93 |   // Defaults to false.
 94 |   disallowPublicClientRegistration: false
 95 | });
 96 | 
 97 | // The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
 98 | // if they aren't API requests or do not have a valid access token
 99 | const defaultHandler = {
100 |   // This fetch method works just like a standard Cloudflare Workers fetch handler
101 |   //
102 |   // The `request`, `env`, and `ctx` parameters are the same as for a normal Cloudflare Workers fetch
103 |   // handler, and are exactly the objects that the `OAuthProvider` itself received from the Workers
104 |   // runtime.
105 |   //
106 |   // The `env.OAUTH_PROVIDER` provides an API by which the application can call back to the
107 |   // OAuthProvider.
108 |   async fetch(request: Request, env, ctx) {
109 |     let url = new URL(request.url);
110 | 
111 |     if (url.pathname == "/authorize") {
112 |       // This is a request for our OAuth authorization flow UI. It is up to the application to
113 |       // implement this. However, the OAuthProvider library provides some helpers to assist.
114 | 
115 |       // `env.OAUTH_PROVIDER.parseAuthRequest()` parses the OAuth authorization request to extract the parameters
116 |       // required by the OAuth 2 standard, namely response_type, client_id, redirect_uri, scope, and
117 |       // state. It returns an object containing all these (using idiomatic camelCase naming).
118 |       let oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request);
119 | 
120 |       // `env.OAUTH_PROVIDER.lookupClient()` looks up metadata about the client, as definetd by RFC-7591. This
121 |       // includes things like redirect_uris, client_name, logo_uri, etc.
122 |       let clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId);
123 | 
124 |       // At this point, the application should use `oauthReqInfo` and `clientInfo` to render an
125 |       // authorization consent UI to the user. The details of this are up to the app so are not
126 |       // shown here.
127 | 
128 |       // After the user has granted consent, the application calls `env.OAUTH_PROVIDER.completeAuthorization()` to
129 |       // grant the authorization.
130 |       let {redirectTo} = await env.OAUTH_PROVIDER.completeAuthorization({
131 |         // The application passes back the original OAuth request info that was returned by
132 |         // `parseAuthRequest()` earlier.
133 |         request: oauthReqInfo,
134 | 
135 |         // The application must specify the user's ID, which is some sort of string. This is needed
136 |         // so that the application can later query the OAuthProvider to enumerate all grants
137 |         // belonging to a particular user, e.g. to implement an audit and revocation UI.
138 |         userId: "1234",
139 | 
140 |         // The application can specify some arbitary metadata which describes this grant. The
141 |         // metadata can contain any JSON-serializable content. This metadata is not used by the
142 |         // OAuthProvider, but the application can read back the metadata attached to specific
143 |         // grants when enumerating them later, again e.g. to implement an udit and revocation UI.
144 |         metadata: {label: "foo"},
145 | 
146 |         // The application specifies the list of OAuth scope identifiers that were granted. This
147 |         // may or may not be the same as was requested in `oauthReqInfo.scope`.
148 |         scope: ["document.read", "document.write"],
149 | 
150 |         // `props` is an arbitrary JSON-serializable object which will be passed back to the API
151 |         // handler for every request authorized by this grant.
152 |         props: {
153 |           userId: 1234,
154 |           username: "Bob"
155 |         }
156 |       });
157 | 
158 |       // `completeAuthorization()` will have returned the URL to which the user should be redirected
159 |       // in order to complete the authorization flow. This is the requesting client's OAuth
160 |       // redirect_uri with the appropriate query parameters added to complete the flow and obtain
161 |       // tokens.
162 |       return Response.redirect(redirectTo, 302);
163 |     }
164 | 
165 |     // ... the application can implement other non-API HTTP endpoints here ...
166 | 
167 |     return new Response("Not found", {status: 404});
168 |   }
169 | };
170 | 
171 | // The API handler object - the OAuthProivder will pass authorized API requests to this object's fetch method
172 | // (because we provided it as the `apiHandler` setting, above). This is ONLY called for API requests
173 | // that had a valid access token.
174 | class ApiHandler extends WorkerEntrypoint {
175 |   // This fetch method works just like any other WorkerEntrypoint fetch method. The `request` is
176 |   // passed as a parameter, while `env` and `ctx` are available as `this.env` and `this.ctx`.
177 |   //
178 |   // The `this.env.OAUTH_PROVIDER` is available just like in the default handler.
179 |   //
180 |   // The `this.ctx.props` property contains the `props` value that was passed to
181 |   // `env.OAUTH_PROVIDER.completeAuthorization()` during the authorization flow that authorized this client.
182 |   fetch(request: Request) {
183 |     // The application can implement its API endpoints like normal. This app implements a single
184 |     // endpoint, `/api/whoami`, which returns the user's authenticated identity.
185 | 
186 |     let url = new URL(request.url);
187 |     if (url.pathname == "/api/whoami") {
188 |       // Since the username is embedded in `ctx.props`, which came from the access token that the
189 |       // OAuthProivder already verified, we don't need to do any other authentication steps.
190 |       return new Response(`You are authenticated as: ${this.ctx.props.username}`);
191 |     }
192 | 
193 |     return new Response("Not found", {status: 404});
194 |   }
195 | };
196 | ```
197 | 
198 | This implementation requires that your worker is configured with a Workers KV namespace binding called `OAUTH_KV`, which is used to store token information. See the file `storage-schema.md` for details on the schema of this namespace.
199 | 
200 | The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some methods to query the storage, including:
201 | 
202 | * Create, list, modify, and delete client_id registrations (in addition to `lookupClient()`, already shown in the example code).
203 | * List all active authorization grants for a particular user.
204 | * Revoke (delete) an authorization grant.
205 | 
206 | See the `OAuthHelpers` interface definition for full API details.
207 | 
208 | ## Token Exchange Callback
209 | 
210 | This library allows you to update the `props` value during token exchanges by configuring a callback function. This is useful for scenarios where the application needs to perform additional processing when tokens are issued or refreshed.
211 | 
212 | For example, if your application is also a client to some other OAuth API, you might want to perform an equivalent upstream token exchange and store the result in the `props`. The callback can be used to update the props for both the grant record and specific access tokens.
213 | 
214 | To use this feature, provide a `tokenExchangeCallback` in your OAuthProvider options:
215 | 
216 | ```ts
217 | new OAuthProvider({
218 |   // ... other options ...
219 |   tokenExchangeCallback: async (options) => {
220 |     // options.grantType is either 'authorization_code' or 'refresh_token'
221 |     // options.props contains the current props
222 |     // options.clientId, options.userId, and options.scope are also available
223 | 
224 |     if (options.grantType === 'authorization_code') {
225 |       // For authorization code exchange, might want to obtain upstream tokens
226 |       const upstreamTokens = await exchangeUpstreamToken(options.props.someCode);
227 | 
228 |       return {
229 |         // Update the props stored in the access token
230 |         accessTokenProps: {
231 |           ...options.props,
232 |           upstreamAccessToken: upstreamTokens.access_token
233 |         },
234 |         // Update the props stored in the grant (for future token refreshes)
235 |         newProps: {
236 |           ...options.props,
237 |           upstreamRefreshToken: upstreamTokens.refresh_token
238 |         }
239 |       };
240 |     }
241 | 
242 |     if (options.grantType === 'refresh_token') {
243 |       // For refresh token exchanges, might want to refresh upstream tokens too
244 |       const upstreamTokens = await refreshUpstreamToken(options.props.upstreamRefreshToken);
245 | 
246 |       return {
247 |         accessTokenProps: {
248 |           ...options.props,
249 |           upstreamAccessToken: upstreamTokens.access_token
250 |         },
251 |         newProps: {
252 |           ...options.props,
253 |           upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken
254 |         },
255 |         // Optionally override the default access token TTL to match the upstream token
256 |         accessTokenTTL: upstreamTokens.expires_in
257 |       };
258 |     }
259 |   }
260 | });
261 | ```
262 | 
263 | The callback can:
264 | - Return both `accessTokenProps` and `newProps` to update both
265 | - Return only `accessTokenProps` to update just the current access token
266 | - Return only `newProps` to update both the grant and access token (the access token inherits these props)
267 | - Return `accessTokenTTL` to override the default TTL for this specific access token
268 | - Return nothing to keep the original props unchanged
269 | 
270 | The `accessTokenTTL` override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired.
271 | 
272 | The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
273 | 
274 | ## Custom Error Responses
275 | 
276 | By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
277 | 
278 | ```ts
279 | new OAuthProvider({
280 |   // ... other options ...
281 |   onError({ code, description, status, headers }) {
282 |     Sentry.captureMessage(/* ... */)
283 |   }
284 | })
285 | ```
286 | 
287 | By returning a `Response` you can also override what the OAuthProvider returns to your users:
288 | 
289 | ```ts
290 | new OAuthProvider({
291 |   // ... other options ...
292 |   onError({ code, description, status, headers }) {
293 |     if (code === 'unsupported_grant_type') {
294 |       return new Response('...', { status, headers })
295 |     }
296 |     // returning undefined (i.e. void) uses the default Response generation
297 |   }
298 | })
299 | ```
300 | 
301 | By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
302 | 
303 | ## Implementation Notes
304 | 
305 | ### End-to-end encryption
306 | 
307 | This library stores records about authorization tokens in KV. The storage schema is carefully designed such that a complete leak of the storage only reveals mundane metadata about what has been granted. In particular:
308 | 
309 | * Secrets (including access tokens, refresh tokens, authorization codes, and client secrets) are stored only by hash. Hence, such secrets cannot be derived from the storage alone.
310 | * The `props` associated with a grant (which are passed back to the application when API requests are performed) are stored encrypted with the secret token as key material. Hence, the contents of `props` are impossible to derive from storage unless a valid token is provided.
311 | 
312 | Note that the `userId` and the `metadata` associated with each grant are not encrypted, because the purpose of these values is to allow grants to be enumerated for audit and revocation purposes. However, these values are completely opaque to the library. An application is free to omit them or apply its own encryption to them before passing them into the library, if it desires.
313 | 
314 | ### Single-use refresh tokens?
315 | 
316 | OAuth 2.1 requires that refresh tokens are either "cryptographically bound" to the client, or are single-use. This library currently does not implement any cryptographic binding, thus seemingly requiring single-use tokens. Under this requirement, every token refresh request invalidates the old refresh token and issues a new one.
317 | 
318 | This requirement is seemingly fundamentally flawed as it assumes that every refresh request will complete with no errors. In the real world, a transient network error, machine failure, or software fault could mean that the client fails to store the new refresh token after a refresh request. In this case, the client would be permanently unable to make any further requests, as the only token it has is no longer valid.
319 | 
320 | This library implements a compromise: At any particular time, a grant may have two valid refresh tokens. When the client uses one of them, the other one is invalidated, and a new one is generated and returned. Thus, if the client correctly uses the new refresh token each time, then older refresh tokens are continuously invalidated. But if a transient failure prevents the client from updating its token, it can always retry the request with the token it used previously.
321 | 
322 | ## Written using Claude
323 | 
324 | This library (including the schema documentation) was largely written with the help of [Claude](https://claude.ai), the AI model by Anthropic. Claude's output was thoroughly reviewed by Cloudflare engineers with careful attention paid to security and compliance with standards. Many improvements were made on the initial output, mostly again by prompting Claude (and reviewing the results). Check out the commit history to see how Claude was prompted and what code it produced.
325 | 
326 | **"NOOOOOOOO!!!! You can't just use an LLM to write an auth library!"**
327 | 
328 | "haha gpus go brrr"
329 | 
330 | In all seriousness, two months ago (January 2025), I ([@kentonv](https://github.com/kentonv)) would have agreed. I was an AI skeptic. I thought LLMs were glorified Markov chain generators that didn't actually understand code and couldn't produce anything novel. I started this project on a lark, fully expecting the AI to produce terrible code for me to laugh at. And then, uh... the code actually looked pretty good. Not perfect, but I just told the AI to fix things, and it did. I was shocked.
331 | 
332 | To emphasize, **this is not "vibe coded"**. Every line was thoroughly reviewed and cross-referenced with relevant RFCs, by security experts with previous experience with those RFCs. I was *trying* to validate my skepticism. I ended up proving myself wrong.
333 | 
334 | Again, please check out the commit history -- especially early commits -- to understand how this went.
335 | 


--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
 1 | # Security Policy
 2 | 
 3 | https://www.cloudflare.com/disclosure
 4 | 
 5 | ## Reporting a Vulnerability
 6 | 
 7 | * https://hackerone.com/cloudflare
 8 |   * All Cloudflare products are in scope for reporting. If you submit a valid report on bounty-eligible assets through our disclosure program, we will transfer your report to our private bug bounty program and invite you as a participant.
 9 | * `mailto:security@cloudflare.com`
10 |   * If you'd like to encrypt your message, please do so within the the body of the message. Our email system doesn't handle PGP-MIME well.
11 |   * https://www.cloudflare.com/gpg/security-at-cloudflare-pubkey-06A67236.txt
12 | 
13 | All abuse reports should be submitted to our Trust & Safety team through our dedicated page: https://www.cloudflare.com/abuse/
14 | 
15 | 


--------------------------------------------------------------------------------
/__tests__/mocks/cloudflare-workers.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * Mock for cloudflare:workers module
 3 |  * Provides a minimal implementation of WorkerEntrypoint for testing
 4 |  */
 5 | 
 6 | export class WorkerEntrypoint {
 7 |   ctx: any;
 8 |   env: any;
 9 | 
10 |   constructor(ctx: any, env: any) {
11 |     this.ctx = ctx;
12 |     this.env = env;
13 |   }
14 | 
15 |   fetch(request: Request): Response | Promise<Response> {
16 |     throw new Error('Method not implemented. This should be overridden by subclasses.');
17 |   }
18 | }
19 | 


--------------------------------------------------------------------------------
/__tests__/oauth-provider.test.ts:
--------------------------------------------------------------------------------
   1 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
   2 | import { OAuthProvider, ClientInfo, AuthRequest, CompleteAuthorizationOptions } from '../src/oauth-provider';
   3 | import { ExecutionContext } from '@cloudflare/workers-types';
   4 | // We're importing WorkerEntrypoint from our mock implementation
   5 | // The actual import is mocked in setup.ts
   6 | import { WorkerEntrypoint } from 'cloudflare:workers';
   7 | 
   8 | /**
   9 |  * Mock KV namespace implementation that stores data in memory
  10 |  */
  11 | class MockKV {
  12 |   private storage: Map<string, { value: any; expiration?: number }> = new Map();
  13 | 
  14 |   async put(key: string, value: string | ArrayBuffer, options?: { expirationTtl?: number }): Promise<void> {
  15 |     let expirationTime: number | undefined = undefined;
  16 | 
  17 |     if (options?.expirationTtl) {
  18 |       expirationTime = Date.now() + options.expirationTtl * 1000;
  19 |     }
  20 | 
  21 |     this.storage.set(key, { value, expiration: expirationTime });
  22 |   }
  23 | 
  24 |   async get(key: string, options?: { type: 'text' | 'json' | 'arrayBuffer' | 'stream' }): Promise<any> {
  25 |     const item = this.storage.get(key);
  26 | 
  27 |     if (!item) {
  28 |       return null;
  29 |     }
  30 | 
  31 |     if (item.expiration && item.expiration < Date.now()) {
  32 |       this.storage.delete(key);
  33 |       return null;
  34 |     }
  35 | 
  36 |     if (options?.type === 'json' && typeof item.value === 'string') {
  37 |       return JSON.parse(item.value);
  38 |     }
  39 | 
  40 |     return item.value;
  41 |   }
  42 | 
  43 |   async delete(key: string): Promise<void> {
  44 |     this.storage.delete(key);
  45 |   }
  46 | 
  47 |   async list(options: { prefix: string; limit?: number; cursor?: string }): Promise<{
  48 |     keys: { name: string }[];
  49 |     list_complete: boolean;
  50 |     cursor?: string;
  51 |   }> {
  52 |     const { prefix, limit = 1000 } = options;
  53 |     let keys: { name: string }[] = [];
  54 | 
  55 |     for (const key of this.storage.keys()) {
  56 |       if (key.startsWith(prefix)) {
  57 |         const item = this.storage.get(key);
  58 |         if (item && (!item.expiration || item.expiration >= Date.now())) {
  59 |           keys.push({ name: key });
  60 |         }
  61 |       }
  62 | 
  63 |       if (keys.length >= limit) {
  64 |         break;
  65 |       }
  66 |     }
  67 | 
  68 |     return {
  69 |       keys,
  70 |       list_complete: true,
  71 |     };
  72 |   }
  73 | 
  74 |   clear() {
  75 |     this.storage.clear();
  76 |   }
  77 | }
  78 | 
  79 | /**
  80 |  * Mock execution context for Cloudflare Workers
  81 |  */
  82 | class MockExecutionContext implements ExecutionContext {
  83 |   props: any = {};
  84 | 
  85 |   waitUntil(promise: Promise<any>): void {
  86 |     // In tests, we can just ignore waitUntil
  87 |   }
  88 | 
  89 |   passThroughOnException(): void {
  90 |     // No-op for tests
  91 |   }
  92 | }
  93 | 
  94 | // Simple API handler for testing
  95 | class TestApiHandler extends WorkerEntrypoint {
  96 |   fetch(request: Request) {
  97 |     const url = new URL(request.url);
  98 | 
  99 |     if (url.pathname === '/api/test') {
 100 |       // Return authenticated user info from ctx.props
 101 |       return new Response(
 102 |         JSON.stringify({
 103 |           success: true,
 104 |           user: this.ctx.props,
 105 |         }),
 106 |         {
 107 |           headers: { 'Content-Type': 'application/json' },
 108 |         }
 109 |       );
 110 |     }
 111 | 
 112 |     return new Response('Not found', { status: 404 });
 113 |   }
 114 | }
 115 | 
 116 | // Simple default handler for testing
 117 | const testDefaultHandler = {
 118 |   async fetch(request: Request, env: any, ctx: ExecutionContext) {
 119 |     const url = new URL(request.url);
 120 | 
 121 |     if (url.pathname === '/authorize') {
 122 |       // Mock authorize endpoint
 123 |       const oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request);
 124 |       const clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId);
 125 | 
 126 |       // Mock user consent flow - automatically grant consent
 127 |       const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
 128 |         request: oauthReqInfo,
 129 |         userId: 'test-user-123',
 130 |         metadata: { testConsent: true },
 131 |         scope: oauthReqInfo.scope,
 132 |         props: { userId: 'test-user-123', username: 'TestUser' },
 133 |       });
 134 | 
 135 |       return Response.redirect(redirectTo, 302);
 136 |     }
 137 | 
 138 |     return new Response('Default handler', { status: 200 });
 139 |   },
 140 | };
 141 | 
 142 | // Helper function to create mock requests
 143 | function createMockRequest(
 144 |   url: string,
 145 |   method: string = 'GET',
 146 |   headers: Record<string, string> = {},
 147 |   body?: string | FormData
 148 | ): Request {
 149 |   const requestInit: RequestInit = {
 150 |     method,
 151 |     headers,
 152 |   };
 153 | 
 154 |   if (body) {
 155 |     requestInit.body = body;
 156 |   }
 157 | 
 158 |   return new Request(url, requestInit);
 159 | }
 160 | 
 161 | // Create a configured mock environment
 162 | function createMockEnv() {
 163 |   return {
 164 |     OAUTH_KV: new MockKV(),
 165 |     OAUTH_PROVIDER: null, // Will be populated by the OAuthProvider
 166 |   };
 167 | }
 168 | 
 169 | describe('OAuthProvider', () => {
 170 |   let oauthProvider: OAuthProvider;
 171 |   let mockEnv: ReturnType<typeof createMockEnv>;
 172 |   let mockCtx: MockExecutionContext;
 173 | 
 174 |   beforeEach(() => {
 175 |     // Reset mocks before each test
 176 |     vi.resetAllMocks();
 177 | 
 178 |     // Create fresh instances for each test
 179 |     mockEnv = createMockEnv();
 180 |     mockCtx = new MockExecutionContext();
 181 | 
 182 |     // Create OAuth provider with test configuration
 183 |     oauthProvider = new OAuthProvider({
 184 |       apiRoute: ['/api/', 'https://api.example.com/'],
 185 |       apiHandler: TestApiHandler,
 186 |       defaultHandler: testDefaultHandler,
 187 |       authorizeEndpoint: '/authorize',
 188 |       tokenEndpoint: '/oauth/token',
 189 |       clientRegistrationEndpoint: '/oauth/register',
 190 |       scopesSupported: ['read', 'write', 'profile'],
 191 |       accessTokenTTL: 3600,
 192 |       allowImplicitFlow: true, // Enable implicit flow for tests
 193 |     });
 194 |   });
 195 | 
 196 |   afterEach(() => {
 197 |     // Clean up KV storage after each test
 198 |     mockEnv.OAUTH_KV.clear();
 199 |   });
 200 | 
 201 |   describe('API Route Configuration', () => {
 202 |     it('should support multi-handler configuration with apiHandlers', async () => {
 203 |       // Create handler classes for different API routes
 204 |       class UsersApiHandler extends WorkerEntrypoint {
 205 |         fetch(request: Request) {
 206 |           return new Response('Users API response', { status: 200 });
 207 |         }
 208 |       }
 209 | 
 210 |       class DocumentsApiHandler extends WorkerEntrypoint {
 211 |         fetch(request: Request) {
 212 |           return new Response('Documents API response', { status: 200 });
 213 |         }
 214 |       }
 215 | 
 216 |       // Create provider with multi-handler configuration
 217 |       const providerWithMultiHandler = new OAuthProvider({
 218 |         apiHandlers: {
 219 |           '/api/users/': UsersApiHandler,
 220 |           '/api/documents/': DocumentsApiHandler,
 221 |         },
 222 |         defaultHandler: testDefaultHandler,
 223 |         authorizeEndpoint: '/authorize',
 224 |         tokenEndpoint: '/oauth/token',
 225 |         clientRegistrationEndpoint: '/oauth/register', // Important for registering clients in the test
 226 |         scopesSupported: ['read', 'write'],
 227 |       });
 228 | 
 229 |       // Create a client and get an access token
 230 |       const clientData = {
 231 |         redirect_uris: ['https://client.example.com/callback'],
 232 |         client_name: 'Test Client',
 233 |         token_endpoint_auth_method: 'client_secret_basic',
 234 |       };
 235 | 
 236 |       const registerRequest = createMockRequest(
 237 |         'https://example.com/oauth/register',
 238 |         'POST',
 239 |         { 'Content-Type': 'application/json' },
 240 |         JSON.stringify(clientData)
 241 |       );
 242 | 
 243 |       const registerResponse = await providerWithMultiHandler.fetch(registerRequest, mockEnv, mockCtx);
 244 |       const client = await registerResponse.json();
 245 |       const clientId = client.client_id;
 246 |       const clientSecret = client.client_secret;
 247 |       const redirectUri = 'https://client.example.com/callback';
 248 | 
 249 |       // Get an auth code
 250 |       const authRequest = createMockRequest(
 251 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 252 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 253 |           `&scope=read%20write&state=xyz123`
 254 |       );
 255 | 
 256 |       const authResponse = await providerWithMultiHandler.fetch(authRequest, mockEnv, mockCtx);
 257 |       const location = authResponse.headers.get('Location')!;
 258 |       const code = new URL(location).searchParams.get('code')!;
 259 | 
 260 |       // Exchange for tokens
 261 |       const params = new URLSearchParams();
 262 |       params.append('grant_type', 'authorization_code');
 263 |       params.append('code', code);
 264 |       params.append('redirect_uri', redirectUri);
 265 |       params.append('client_id', clientId);
 266 |       params.append('client_secret', clientSecret);
 267 | 
 268 |       const tokenRequest = createMockRequest(
 269 |         'https://example.com/oauth/token',
 270 |         'POST',
 271 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 272 |         params.toString()
 273 |       );
 274 | 
 275 |       const tokenResponse = await providerWithMultiHandler.fetch(tokenRequest, mockEnv, mockCtx);
 276 |       const tokens = await tokenResponse.json();
 277 |       const accessToken = tokens.access_token;
 278 | 
 279 |       // Make requests to different API routes
 280 |       const usersApiRequest = createMockRequest('https://example.com/api/users/profile', 'GET', {
 281 |         Authorization: `Bearer ${accessToken}`,
 282 |       });
 283 | 
 284 |       const documentsApiRequest = createMockRequest('https://example.com/api/documents/list', 'GET', {
 285 |         Authorization: `Bearer ${accessToken}`,
 286 |       });
 287 | 
 288 |       // Request to Users API should be handled by UsersApiHandler
 289 |       const usersResponse = await providerWithMultiHandler.fetch(usersApiRequest, mockEnv, mockCtx);
 290 |       expect(usersResponse.status).toBe(200);
 291 |       expect(await usersResponse.text()).toBe('Users API response');
 292 | 
 293 |       // Request to Documents API should be handled by DocumentsApiHandler
 294 |       const documentsResponse = await providerWithMultiHandler.fetch(documentsApiRequest, mockEnv, mockCtx);
 295 |       expect(documentsResponse.status).toBe(200);
 296 |       expect(await documentsResponse.text()).toBe('Documents API response');
 297 |     });
 298 | 
 299 |     it('should throw an error when both single-handler and multi-handler configs are provided', () => {
 300 |       expect(() => {
 301 |         new OAuthProvider({
 302 |           apiRoute: '/api/',
 303 |           apiHandler: {
 304 |             fetch: () => Promise.resolve(new Response()),
 305 |           },
 306 |           apiHandlers: {
 307 |             '/api/users/': {
 308 |               fetch: () => Promise.resolve(new Response()),
 309 |             },
 310 |           },
 311 |           defaultHandler: testDefaultHandler,
 312 |           authorizeEndpoint: '/authorize',
 313 |           tokenEndpoint: '/oauth/token',
 314 |         });
 315 |       }).toThrow('Cannot use both apiRoute/apiHandler and apiHandlers');
 316 |     });
 317 | 
 318 |     it('should throw an error when neither single-handler nor multi-handler config is provided', () => {
 319 |       expect(() => {
 320 |         new OAuthProvider({
 321 |           // Intentionally omitting apiRoute and apiHandler and apiHandlers
 322 |           defaultHandler: testDefaultHandler,
 323 |           authorizeEndpoint: '/authorize',
 324 |           tokenEndpoint: '/oauth/token',
 325 |         });
 326 |       }).toThrow('Must provide either apiRoute + apiHandler OR apiHandlers');
 327 |     });
 328 |   });
 329 | 
 330 |   describe('OAuth Metadata Discovery', () => {
 331 |     it('should return correct metadata at .well-known/oauth-authorization-server', async () => {
 332 |       const request = createMockRequest('https://example.com/.well-known/oauth-authorization-server');
 333 |       const response = await oauthProvider.fetch(request, mockEnv, mockCtx);
 334 | 
 335 |       expect(response.status).toBe(200);
 336 | 
 337 |       const metadata = await response.json();
 338 |       expect(metadata.issuer).toBe('https://example.com');
 339 |       expect(metadata.authorization_endpoint).toBe('https://example.com/authorize');
 340 |       expect(metadata.token_endpoint).toBe('https://example.com/oauth/token');
 341 |       expect(metadata.registration_endpoint).toBe('https://example.com/oauth/register');
 342 |       expect(metadata.scopes_supported).toEqual(['read', 'write', 'profile']);
 343 |       expect(metadata.response_types_supported).toContain('code');
 344 |       expect(metadata.response_types_supported).toContain('token'); // Implicit flow enabled
 345 |       expect(metadata.grant_types_supported).toContain('authorization_code');
 346 |       expect(metadata.code_challenge_methods_supported).toContain('S256');
 347 |     });
 348 | 
 349 |     it('should not include token response type when implicit flow is disabled', async () => {
 350 |       // Create a provider with implicit flow disabled
 351 |       const providerWithoutImplicit = new OAuthProvider({
 352 |         apiRoute: ['/api/'],
 353 |         apiHandler: TestApiHandler,
 354 |         defaultHandler: testDefaultHandler,
 355 |         authorizeEndpoint: '/authorize',
 356 |         tokenEndpoint: '/oauth/token',
 357 |         scopesSupported: ['read', 'write'],
 358 |         allowImplicitFlow: false, // Explicitly disable
 359 |       });
 360 | 
 361 |       const request = createMockRequest('https://example.com/.well-known/oauth-authorization-server');
 362 |       const response = await providerWithoutImplicit.fetch(request, mockEnv, mockCtx);
 363 | 
 364 |       expect(response.status).toBe(200);
 365 | 
 366 |       const metadata = await response.json();
 367 |       expect(metadata.response_types_supported).toContain('code');
 368 |       expect(metadata.response_types_supported).not.toContain('token');
 369 |     });
 370 |   });
 371 | 
 372 |   describe('Client Registration', () => {
 373 |     it('should register a new client', async () => {
 374 |       const clientData = {
 375 |         redirect_uris: ['https://client.example.com/callback'],
 376 |         client_name: 'Test Client',
 377 |         token_endpoint_auth_method: 'client_secret_basic',
 378 |       };
 379 | 
 380 |       const request = createMockRequest(
 381 |         'https://example.com/oauth/register',
 382 |         'POST',
 383 |         { 'Content-Type': 'application/json' },
 384 |         JSON.stringify(clientData)
 385 |       );
 386 | 
 387 |       const response = await oauthProvider.fetch(request, mockEnv, mockCtx);
 388 | 
 389 |       expect(response.status).toBe(201);
 390 | 
 391 |       const registeredClient = await response.json();
 392 |       expect(registeredClient.client_id).toBeDefined();
 393 |       expect(registeredClient.client_secret).toBeDefined();
 394 |       expect(registeredClient.redirect_uris).toEqual(['https://client.example.com/callback']);
 395 |       expect(registeredClient.client_name).toBe('Test Client');
 396 | 
 397 |       // Verify the client was saved to KV
 398 |       const savedClient = await mockEnv.OAUTH_KV.get(`client:${registeredClient.client_id}`, { type: 'json' });
 399 |       expect(savedClient).not.toBeNull();
 400 |       expect(savedClient.clientId).toBe(registeredClient.client_id);
 401 |       // Secret should be stored as a hash
 402 |       expect(savedClient.clientSecret).not.toBe(registeredClient.client_secret);
 403 |     });
 404 | 
 405 |     it('should register a public client', async () => {
 406 |       const clientData = {
 407 |         redirect_uris: ['https://spa.example.com/callback'],
 408 |         client_name: 'SPA Client',
 409 |         token_endpoint_auth_method: 'none',
 410 |       };
 411 | 
 412 |       const request = createMockRequest(
 413 |         'https://example.com/oauth/register',
 414 |         'POST',
 415 |         { 'Content-Type': 'application/json' },
 416 |         JSON.stringify(clientData)
 417 |       );
 418 | 
 419 |       const response = await oauthProvider.fetch(request, mockEnv, mockCtx);
 420 | 
 421 |       expect(response.status).toBe(201);
 422 | 
 423 |       const registeredClient = await response.json();
 424 |       expect(registeredClient.client_id).toBeDefined();
 425 |       expect(registeredClient.client_secret).toBeUndefined(); // Public client should not have a secret
 426 |       expect(registeredClient.token_endpoint_auth_method).toBe('none');
 427 | 
 428 |       // Verify the client was saved to KV
 429 |       const savedClient = await mockEnv.OAUTH_KV.get(`client:${registeredClient.client_id}`, { type: 'json' });
 430 |       expect(savedClient).not.toBeNull();
 431 |       expect(savedClient.clientSecret).toBeUndefined(); // No secret stored
 432 |     });
 433 |   });
 434 | 
 435 |   describe('Authorization Code Flow', () => {
 436 |     let clientId: string;
 437 |     let clientSecret: string;
 438 |     let redirectUri: string;
 439 | 
 440 |     // Helper to create a test client before authorization tests
 441 |     async function createTestClient() {
 442 |       const clientData = {
 443 |         redirect_uris: ['https://client.example.com/callback'],
 444 |         client_name: 'Test Client',
 445 |         token_endpoint_auth_method: 'client_secret_basic',
 446 |       };
 447 | 
 448 |       const request = createMockRequest(
 449 |         'https://example.com/oauth/register',
 450 |         'POST',
 451 |         { 'Content-Type': 'application/json' },
 452 |         JSON.stringify(clientData)
 453 |       );
 454 | 
 455 |       const response = await oauthProvider.fetch(request, mockEnv, mockCtx);
 456 |       const client = await response.json();
 457 | 
 458 |       clientId = client.client_id;
 459 |       clientSecret = client.client_secret;
 460 |       redirectUri = 'https://client.example.com/callback';
 461 |     }
 462 | 
 463 |     beforeEach(async () => {
 464 |       await createTestClient();
 465 |     });
 466 | 
 467 |     it('should handle the authorization request and redirect', async () => {
 468 |       // Create an authorization request
 469 |       const authRequest = createMockRequest(
 470 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 471 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 472 |           `&scope=read%20write&state=xyz123`
 473 |       );
 474 | 
 475 |       // The default handler will process this request and generate a redirect
 476 |       const response = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 477 | 
 478 |       expect(response.status).toBe(302);
 479 | 
 480 |       // Check that we're redirected to the client's redirect_uri with a code
 481 |       const location = response.headers.get('Location');
 482 |       expect(location).toBeDefined();
 483 |       expect(location).toContain(redirectUri);
 484 |       expect(location).toContain('code=');
 485 |       expect(location).toContain('state=xyz123');
 486 | 
 487 |       // Extract the authorization code from the redirect URL
 488 |       const url = new URL(location!);
 489 |       const code = url.searchParams.get('code');
 490 |       expect(code).toBeDefined();
 491 | 
 492 |       // Verify a grant was created in KV
 493 |       const grants = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' });
 494 |       expect(grants.keys.length).toBe(1);
 495 |     });
 496 | 
 497 |     it('should reject authorization request with invalid redirect URI', async () => {
 498 |       // Create an authorization request with an invalid redirect URI
 499 |       const invalidRedirectUri = 'https://attacker.example.com/callback';
 500 |       const authRequest = createMockRequest(
 501 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 502 |           `&redirect_uri=${encodeURIComponent(invalidRedirectUri)}` +
 503 |           `&scope=read%20write&state=xyz123`
 504 |       );
 505 | 
 506 |       // Expect the request to be rejected
 507 |       await expect(oauthProvider.fetch(authRequest, mockEnv, mockCtx)).rejects.toThrow('Invalid redirect URI');
 508 | 
 509 |       // Verify no grant was created
 510 |       const grants = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' });
 511 |       expect(grants.keys.length).toBe(0);
 512 |     });
 513 | 
 514 |     // Add more tests for auth code flow...
 515 |   });
 516 | 
 517 |   describe('Implicit Flow', () => {
 518 |     let clientId: string;
 519 |     let redirectUri: string;
 520 | 
 521 |     // Helper to create a test client before authorization tests
 522 |     async function createPublicClient() {
 523 |       const clientData = {
 524 |         redirect_uris: ['https://spa-client.example.com/callback'],
 525 |         client_name: 'SPA Test Client',
 526 |         token_endpoint_auth_method: 'none', // Public client
 527 |       };
 528 | 
 529 |       const request = createMockRequest(
 530 |         'https://example.com/oauth/register',
 531 |         'POST',
 532 |         { 'Content-Type': 'application/json' },
 533 |         JSON.stringify(clientData)
 534 |       );
 535 | 
 536 |       const response = await oauthProvider.fetch(request, mockEnv, mockCtx);
 537 |       const client = await response.json();
 538 | 
 539 |       clientId = client.client_id;
 540 |       redirectUri = 'https://spa-client.example.com/callback';
 541 |     }
 542 | 
 543 |     beforeEach(async () => {
 544 |       await createPublicClient();
 545 |     });
 546 | 
 547 |     it('should handle implicit flow request and redirect with token in fragment', async () => {
 548 |       // Create an implicit flow authorization request
 549 |       const authRequest = createMockRequest(
 550 |         `https://example.com/authorize?response_type=token&client_id=${clientId}` +
 551 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 552 |           `&scope=read%20write&state=xyz123`
 553 |       );
 554 | 
 555 |       // The default handler will process this request and generate a redirect
 556 |       const response = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 557 | 
 558 |       expect(response.status).toBe(302);
 559 | 
 560 |       // Check that we're redirected to the client's redirect_uri with token in fragment
 561 |       const location = response.headers.get('Location');
 562 |       expect(location).toBeDefined();
 563 |       expect(location).toContain(redirectUri);
 564 | 
 565 |       const url = new URL(location!);
 566 | 
 567 |       // Check that there's no code parameter in the query string
 568 |       expect(url.searchParams.has('code')).toBe(false);
 569 | 
 570 |       // Check that we have a hash/fragment with token parameters
 571 |       expect(url.hash).toBeTruthy();
 572 | 
 573 |       // Parse the fragment
 574 |       const fragment = new URLSearchParams(url.hash.substring(1)); // Remove the # character
 575 | 
 576 |       // Verify token parameters
 577 |       expect(fragment.get('access_token')).toBeTruthy();
 578 |       expect(fragment.get('token_type')).toBe('bearer');
 579 |       expect(fragment.get('expires_in')).toBe('3600');
 580 |       expect(fragment.get('scope')).toBe('read write');
 581 |       expect(fragment.get('state')).toBe('xyz123');
 582 | 
 583 |       // Verify a grant was created in KV
 584 |       const grants = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' });
 585 |       expect(grants.keys.length).toBe(1);
 586 | 
 587 |       // Verify access token was stored in KV
 588 |       const tokenEntries = await mockEnv.OAUTH_KV.list({ prefix: 'token:' });
 589 |       expect(tokenEntries.keys.length).toBe(1);
 590 |     });
 591 | 
 592 |     it('should reject implicit flow when allowImplicitFlow is disabled', async () => {
 593 |       // Create a provider with implicit flow disabled
 594 |       const providerWithoutImplicit = new OAuthProvider({
 595 |         apiRoute: ['/api/'],
 596 |         apiHandler: TestApiHandler,
 597 |         defaultHandler: testDefaultHandler,
 598 |         authorizeEndpoint: '/authorize',
 599 |         tokenEndpoint: '/oauth/token',
 600 |         scopesSupported: ['read', 'write'],
 601 |         allowImplicitFlow: false, // Explicitly disable
 602 |       });
 603 | 
 604 |       // Create an implicit flow authorization request
 605 |       const authRequest = createMockRequest(
 606 |         `https://example.com/authorize?response_type=token&client_id=${clientId}` +
 607 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 608 |           `&scope=read%20write&state=xyz123`
 609 |       );
 610 | 
 611 |       // Mock parseAuthRequest to test error handling
 612 |       vi.spyOn(authRequest, 'formData').mockImplementation(() => {
 613 |         throw new Error('The implicit grant flow is not enabled for this provider');
 614 |       });
 615 | 
 616 |       // Expect an error response
 617 |       await expect(providerWithoutImplicit.fetch(authRequest, mockEnv, mockCtx)).rejects.toThrow(
 618 |         'The implicit grant flow is not enabled for this provider'
 619 |       );
 620 |     });
 621 | 
 622 |     it('should use the access token to access API directly', async () => {
 623 |       // Create an implicit flow authorization request
 624 |       const authRequest = createMockRequest(
 625 |         `https://example.com/authorize?response_type=token&client_id=${clientId}` +
 626 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 627 |           `&scope=read%20write&state=xyz123`
 628 |       );
 629 | 
 630 |       // The default handler will process this request and generate a redirect
 631 |       const response = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 632 |       const location = response.headers.get('Location')!;
 633 | 
 634 |       // Parse the fragment to get the access token
 635 |       const url = new URL(location);
 636 |       const fragment = new URLSearchParams(url.hash.substring(1));
 637 |       const accessToken = fragment.get('access_token')!;
 638 | 
 639 |       // Now use the access token for an API request
 640 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
 641 |         Authorization: `Bearer ${accessToken}`,
 642 |       });
 643 | 
 644 |       const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx);
 645 | 
 646 |       expect(apiResponse.status).toBe(200);
 647 | 
 648 |       const apiData = await apiResponse.json();
 649 |       expect(apiData.success).toBe(true);
 650 |       expect(apiData.user).toEqual({ userId: 'test-user-123', username: 'TestUser' });
 651 |     });
 652 |   });
 653 | 
 654 |   describe('Authorization Code Flow Exchange', () => {
 655 |     let clientId: string;
 656 |     let clientSecret: string;
 657 |     let redirectUri: string;
 658 | 
 659 |     // Helper to create a test client before authorization tests
 660 |     async function createTestClient() {
 661 |       const clientData = {
 662 |         redirect_uris: ['https://client.example.com/callback'],
 663 |         client_name: 'Test Client',
 664 |         token_endpoint_auth_method: 'client_secret_basic',
 665 |       };
 666 | 
 667 |       const request = createMockRequest(
 668 |         'https://example.com/oauth/register',
 669 |         'POST',
 670 |         { 'Content-Type': 'application/json' },
 671 |         JSON.stringify(clientData)
 672 |       );
 673 | 
 674 |       const response = await oauthProvider.fetch(request, mockEnv, mockCtx);
 675 |       const client = await response.json();
 676 | 
 677 |       clientId = client.client_id;
 678 |       clientSecret = client.client_secret;
 679 |       redirectUri = 'https://client.example.com/callback';
 680 |     }
 681 | 
 682 |     beforeEach(async () => {
 683 |       await createTestClient();
 684 |     });
 685 | 
 686 |     it('should exchange auth code for tokens', async () => {
 687 |       // First get an auth code
 688 |       const authRequest = createMockRequest(
 689 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 690 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 691 |           `&scope=read%20write&state=xyz123`
 692 |       );
 693 | 
 694 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 695 |       const location = authResponse.headers.get('Location')!;
 696 |       const url = new URL(location);
 697 |       const code = url.searchParams.get('code')!;
 698 | 
 699 |       // Now exchange the code for tokens
 700 |       // Use URLSearchParams which is proper for application/x-www-form-urlencoded
 701 |       const params = new URLSearchParams();
 702 |       params.append('grant_type', 'authorization_code');
 703 |       params.append('code', code);
 704 |       params.append('redirect_uri', redirectUri);
 705 |       params.append('client_id', clientId);
 706 |       params.append('client_secret', clientSecret);
 707 | 
 708 |       // Use the URLSearchParams object as the body - correctly encoded for Content-Type: application/x-www-form-urlencoded
 709 |       const tokenRequest = createMockRequest(
 710 |         'https://example.com/oauth/token',
 711 |         'POST',
 712 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 713 |         params.toString()
 714 |       );
 715 | 
 716 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
 717 | 
 718 |       expect(tokenResponse.status).toBe(200);
 719 | 
 720 |       const tokens = await tokenResponse.json();
 721 |       expect(tokens.access_token).toBeDefined();
 722 |       expect(tokens.refresh_token).toBeDefined();
 723 |       expect(tokens.token_type).toBe('bearer');
 724 |       expect(tokens.expires_in).toBe(3600);
 725 | 
 726 |       // Verify token was stored in KV
 727 |       const tokenEntries = await mockEnv.OAUTH_KV.list({ prefix: 'token:' });
 728 |       expect(tokenEntries.keys.length).toBe(1);
 729 | 
 730 |       // Verify grant was updated (auth code removed, refresh token added)
 731 |       const grantEntries = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' });
 732 |       const grantKey = grantEntries.keys[0].name;
 733 |       const grant = await mockEnv.OAUTH_KV.get(grantKey, { type: 'json' });
 734 | 
 735 |       expect(grant.authCodeId).toBeUndefined(); // Auth code should be removed
 736 |       expect(grant.refreshTokenId).toBeDefined(); // Refresh token should be added
 737 |     });
 738 | 
 739 |     it('should reject token exchange without redirect_uri when not using PKCE', async () => {
 740 |       // First get an auth code
 741 |       const authRequest = createMockRequest(
 742 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 743 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 744 |           `&scope=read%20write&state=xyz123`
 745 |       );
 746 | 
 747 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 748 |       const location = authResponse.headers.get('Location')!;
 749 |       const url = new URL(location);
 750 |       const code = url.searchParams.get('code')!;
 751 | 
 752 |       // Now exchange the code without providing redirect_uri
 753 |       const params = new URLSearchParams();
 754 |       params.append('grant_type', 'authorization_code');
 755 |       params.append('code', code);
 756 |       // redirect_uri intentionally omitted
 757 |       params.append('client_id', clientId);
 758 |       params.append('client_secret', clientSecret);
 759 | 
 760 |       const tokenRequest = createMockRequest(
 761 |         'https://example.com/oauth/token',
 762 |         'POST',
 763 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 764 |         params.toString()
 765 |       );
 766 | 
 767 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
 768 | 
 769 |       // Should fail because redirect_uri is required when not using PKCE
 770 |       expect(tokenResponse.status).toBe(400);
 771 |       const error = await tokenResponse.json();
 772 |       expect(error.error).toBe('invalid_request');
 773 |       expect(error.error_description).toBe('redirect_uri is required when not using PKCE');
 774 |     });
 775 | 
 776 |     it('should reject token exchange with code_verifier when PKCE was not used in authorization', async () => {
 777 |       // First get an auth code WITHOUT using PKCE
 778 |       const authRequest = createMockRequest(
 779 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 780 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 781 |           `&scope=read%20write&state=xyz123`
 782 |       );
 783 | 
 784 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 785 |       const location = authResponse.headers.get('Location')!;
 786 |       const url = new URL(location);
 787 |       const code = url.searchParams.get('code')!;
 788 | 
 789 |       // Now exchange the code and incorrectly provide a code_verifier
 790 |       const params = new URLSearchParams();
 791 |       params.append('grant_type', 'authorization_code');
 792 |       params.append('code', code);
 793 |       params.append('redirect_uri', redirectUri);
 794 |       params.append('client_id', clientId);
 795 |       params.append('client_secret', clientSecret);
 796 |       params.append('code_verifier', 'some_random_verifier_that_wasnt_used_in_auth');
 797 | 
 798 |       const tokenRequest = createMockRequest(
 799 |         'https://example.com/oauth/token',
 800 |         'POST',
 801 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 802 |         params.toString()
 803 |       );
 804 | 
 805 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
 806 | 
 807 |       // Should fail because code_verifier is provided but PKCE wasn't used in authorization
 808 |       expect(tokenResponse.status).toBe(400);
 809 |       const error = await tokenResponse.json();
 810 |       expect(error.error).toBe('invalid_request');
 811 |       expect(error.error_description).toBe('code_verifier provided for a flow that did not use PKCE');
 812 |     });
 813 | 
 814 |     // Helper function for PKCE tests
 815 |     function generateRandomString(length: number): string {
 816 |       const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
 817 |       let result = '';
 818 |       const values = new Uint8Array(length);
 819 |       crypto.getRandomValues(values);
 820 |       for (let i = 0; i < length; i++) {
 821 |         result += characters.charAt(values[i] % characters.length);
 822 |       }
 823 |       return result;
 824 |     }
 825 | 
 826 |     // Helper function for PKCE tests
 827 |     function base64UrlEncode(str: string): string {
 828 |       return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
 829 |     }
 830 | 
 831 |     it('should accept token exchange without redirect_uri when using PKCE', async () => {
 832 |       // Generate PKCE code verifier and challenge
 833 |       const codeVerifier = generateRandomString(43); // Recommended length
 834 |       const encoder = new TextEncoder();
 835 |       const data = encoder.encode(codeVerifier);
 836 |       const hashBuffer = await crypto.subtle.digest('SHA-256', data);
 837 |       const hashArray = Array.from(new Uint8Array(hashBuffer));
 838 |       const codeChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
 839 | 
 840 |       // First get an auth code with PKCE
 841 |       const authRequest = createMockRequest(
 842 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 843 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 844 |           `&scope=read%20write&state=xyz123` +
 845 |           `&code_challenge=${codeChallenge}&code_challenge_method=S256`
 846 |       );
 847 | 
 848 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 849 |       const location = authResponse.headers.get('Location')!;
 850 |       const url = new URL(location);
 851 |       const code = url.searchParams.get('code')!;
 852 | 
 853 |       // Now exchange the code without providing redirect_uri
 854 |       const params = new URLSearchParams();
 855 |       params.append('grant_type', 'authorization_code');
 856 |       params.append('code', code);
 857 |       // redirect_uri intentionally omitted
 858 |       params.append('client_id', clientId);
 859 |       params.append('client_secret', clientSecret);
 860 |       params.append('code_verifier', codeVerifier);
 861 | 
 862 |       const tokenRequest = createMockRequest(
 863 |         'https://example.com/oauth/token',
 864 |         'POST',
 865 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 866 |         params.toString()
 867 |       );
 868 | 
 869 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
 870 | 
 871 |       // Should succeed because redirect_uri is optional when using PKCE
 872 |       expect(tokenResponse.status).toBe(200);
 873 | 
 874 |       const tokens = await tokenResponse.json();
 875 |       expect(tokens.access_token).toBeDefined();
 876 |       expect(tokens.refresh_token).toBeDefined();
 877 |       expect(tokens.token_type).toBe('bearer');
 878 |       expect(tokens.expires_in).toBe(3600);
 879 |     });
 880 | 
 881 |     it('should accept the access token for API requests', async () => {
 882 |       // Get an auth code
 883 |       const authRequest = createMockRequest(
 884 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 885 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 886 |           `&scope=read%20write&state=xyz123`
 887 |       );
 888 | 
 889 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 890 |       const location = authResponse.headers.get('Location')!;
 891 |       const code = new URL(location).searchParams.get('code')!;
 892 | 
 893 |       // Exchange for tokens
 894 |       const params = new URLSearchParams();
 895 |       params.append('grant_type', 'authorization_code');
 896 |       params.append('code', code);
 897 |       params.append('redirect_uri', redirectUri);
 898 |       params.append('client_id', clientId);
 899 |       params.append('client_secret', clientSecret);
 900 | 
 901 |       const tokenRequest = createMockRequest(
 902 |         'https://example.com/oauth/token',
 903 |         'POST',
 904 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 905 |         params.toString()
 906 |       );
 907 | 
 908 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
 909 |       const tokens = await tokenResponse.json();
 910 | 
 911 |       // Now use the access token for an API request
 912 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
 913 |         Authorization: `Bearer ${tokens.access_token}`,
 914 |       });
 915 | 
 916 |       const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx);
 917 | 
 918 |       expect(apiResponse.status).toBe(200);
 919 | 
 920 |       const apiData = await apiResponse.json();
 921 |       expect(apiData.success).toBe(true);
 922 |       expect(apiData.user).toEqual({ userId: 'test-user-123', username: 'TestUser' });
 923 |     });
 924 |   });
 925 | 
 926 |   describe('Refresh Token Flow', () => {
 927 |     let clientId: string;
 928 |     let clientSecret: string;
 929 |     let refreshToken: string;
 930 | 
 931 |     // Helper to get through authorization and token exchange to get a refresh token
 932 |     async function getRefreshToken() {
 933 |       // Create a client
 934 |       const clientData = {
 935 |         redirect_uris: ['https://client.example.com/callback'],
 936 |         client_name: 'Test Client',
 937 |         token_endpoint_auth_method: 'client_secret_basic',
 938 |       };
 939 | 
 940 |       const registerRequest = createMockRequest(
 941 |         'https://example.com/oauth/register',
 942 |         'POST',
 943 |         { 'Content-Type': 'application/json' },
 944 |         JSON.stringify(clientData)
 945 |       );
 946 | 
 947 |       const registerResponse = await oauthProvider.fetch(registerRequest, mockEnv, mockCtx);
 948 |       const client = await registerResponse.json();
 949 |       clientId = client.client_id;
 950 |       clientSecret = client.client_secret;
 951 |       const redirectUri = 'https://client.example.com/callback';
 952 | 
 953 |       // Get an auth code
 954 |       const authRequest = createMockRequest(
 955 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
 956 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
 957 |           `&scope=read%20write&state=xyz123`
 958 |       );
 959 | 
 960 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
 961 |       const location = authResponse.headers.get('Location')!;
 962 |       const code = new URL(location).searchParams.get('code')!;
 963 | 
 964 |       // Exchange for tokens
 965 |       const params = new URLSearchParams();
 966 |       params.append('grant_type', 'authorization_code');
 967 |       params.append('code', code);
 968 |       params.append('redirect_uri', redirectUri);
 969 |       params.append('client_id', clientId);
 970 |       params.append('client_secret', clientSecret);
 971 | 
 972 |       const tokenRequest = createMockRequest(
 973 |         'https://example.com/oauth/token',
 974 |         'POST',
 975 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
 976 |         params.toString()
 977 |       );
 978 | 
 979 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
 980 |       const tokens = await tokenResponse.json();
 981 |       refreshToken = tokens.refresh_token;
 982 |     }
 983 | 
 984 |     beforeEach(async () => {
 985 |       await getRefreshToken();
 986 |     });
 987 | 
 988 |     it('should issue new tokens with refresh token', async () => {
 989 |       // Use the refresh token to get a new access token
 990 |       const params = new URLSearchParams();
 991 |       params.append('grant_type', 'refresh_token');
 992 |       params.append('refresh_token', refreshToken);
 993 |       params.append('client_id', clientId);
 994 |       params.append('client_secret', clientSecret);
 995 | 
 996 |       const refreshRequest = createMockRequest(
 997 |         'https://example.com/oauth/token',
 998 |         'POST',
 999 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1000 |         params.toString()
1001 |       );
1002 | 
1003 |       const refreshResponse = await oauthProvider.fetch(refreshRequest, mockEnv, mockCtx);
1004 | 
1005 |       expect(refreshResponse.status).toBe(200);
1006 | 
1007 |       const newTokens = await refreshResponse.json();
1008 |       expect(newTokens.access_token).toBeDefined();
1009 |       expect(newTokens.refresh_token).toBeDefined();
1010 |       expect(newTokens.refresh_token).not.toBe(refreshToken); // Should get a new refresh token
1011 | 
1012 |       // Verify we now have a new token in storage
1013 |       const tokenEntries = await mockEnv.OAUTH_KV.list({ prefix: 'token:' });
1014 |       expect(tokenEntries.keys.length).toBe(2); // The old one and the new one
1015 | 
1016 |       // Verify the grant was updated
1017 |       const grantEntries = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' });
1018 |       const grantKey = grantEntries.keys[0].name;
1019 |       const grant = await mockEnv.OAUTH_KV.get(grantKey, { type: 'json' });
1020 | 
1021 |       expect(grant.previousRefreshTokenId).toBeDefined(); // Old refresh token should be tracked
1022 |       expect(grant.refreshTokenId).toBeDefined(); // New refresh token should be set
1023 |     });
1024 | 
1025 |     it('should allow using the previous refresh token once', async () => {
1026 |       // Use the refresh token to get a new access token (first refresh)
1027 |       const params1 = new URLSearchParams();
1028 |       params1.append('grant_type', 'refresh_token');
1029 |       params1.append('refresh_token', refreshToken);
1030 |       params1.append('client_id', clientId);
1031 |       params1.append('client_secret', clientSecret);
1032 | 
1033 |       const refreshRequest1 = createMockRequest(
1034 |         'https://example.com/oauth/token',
1035 |         'POST',
1036 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1037 |         params1.toString()
1038 |       );
1039 | 
1040 |       const refreshResponse1 = await oauthProvider.fetch(refreshRequest1, mockEnv, mockCtx);
1041 |       const newTokens1 = await refreshResponse1.json();
1042 |       const newRefreshToken = newTokens1.refresh_token;
1043 | 
1044 |       // Now try to use the original refresh token again (simulating a retry after failure)
1045 |       const params2 = new URLSearchParams();
1046 |       params2.append('grant_type', 'refresh_token');
1047 |       params2.append('refresh_token', refreshToken); // Original token
1048 |       params2.append('client_id', clientId);
1049 |       params2.append('client_secret', clientSecret);
1050 | 
1051 |       const refreshRequest2 = createMockRequest(
1052 |         'https://example.com/oauth/token',
1053 |         'POST',
1054 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1055 |         params2.toString()
1056 |       );
1057 | 
1058 |       const refreshResponse2 = await oauthProvider.fetch(refreshRequest2, mockEnv, mockCtx);
1059 | 
1060 |       // The request should succeed
1061 |       expect(refreshResponse2.status).toBe(200);
1062 | 
1063 |       const newTokens2 = await refreshResponse2.json();
1064 |       expect(newTokens2.access_token).toBeDefined();
1065 |       expect(newTokens2.refresh_token).toBeDefined();
1066 | 
1067 |       // Now the grant should have the newest refresh token and the token from the first refresh
1068 |       // as the previous token
1069 |       const grantEntries = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' });
1070 |       const grantKey = grantEntries.keys[0].name;
1071 |       const grant = await mockEnv.OAUTH_KV.get(grantKey, { type: 'json' });
1072 | 
1073 |       // The previousRefreshTokenId should now be from the first refresh, not the original
1074 |       expect(grant.previousRefreshTokenId).toBeDefined();
1075 |     });
1076 |   });
1077 | 
1078 |   describe('Token Validation and API Access', () => {
1079 |     let accessToken: string;
1080 | 
1081 |     // Helper to get through authorization and token exchange to get an access token
1082 |     async function getAccessToken() {
1083 |       // Create a client
1084 |       const clientData = {
1085 |         redirect_uris: ['https://client.example.com/callback'],
1086 |         client_name: 'Test Client',
1087 |         token_endpoint_auth_method: 'client_secret_basic',
1088 |       };
1089 | 
1090 |       const registerRequest = createMockRequest(
1091 |         'https://example.com/oauth/register',
1092 |         'POST',
1093 |         { 'Content-Type': 'application/json' },
1094 |         JSON.stringify(clientData)
1095 |       );
1096 | 
1097 |       const registerResponse = await oauthProvider.fetch(registerRequest, mockEnv, mockCtx);
1098 |       const client = await registerResponse.json();
1099 |       const clientId = client.client_id;
1100 |       const clientSecret = client.client_secret;
1101 |       const redirectUri = 'https://client.example.com/callback';
1102 | 
1103 |       // Get an auth code
1104 |       const authRequest = createMockRequest(
1105 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
1106 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
1107 |           `&scope=read%20write&state=xyz123`
1108 |       );
1109 | 
1110 |       const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
1111 |       const location = authResponse.headers.get('Location')!;
1112 |       const code = new URL(location).searchParams.get('code')!;
1113 | 
1114 |       // Exchange for tokens
1115 |       const params = new URLSearchParams();
1116 |       params.append('grant_type', 'authorization_code');
1117 |       params.append('code', code);
1118 |       params.append('redirect_uri', redirectUri);
1119 |       params.append('client_id', clientId);
1120 |       params.append('client_secret', clientSecret);
1121 | 
1122 |       const tokenRequest = createMockRequest(
1123 |         'https://example.com/oauth/token',
1124 |         'POST',
1125 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1126 |         params.toString()
1127 |       );
1128 | 
1129 |       const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx);
1130 |       const tokens = await tokenResponse.json();
1131 |       accessToken = tokens.access_token;
1132 |     }
1133 | 
1134 |     beforeEach(async () => {
1135 |       await getAccessToken();
1136 |     });
1137 | 
1138 |     it('should reject API requests without a token', async () => {
1139 |       const apiRequest = createMockRequest('https://example.com/api/test');
1140 | 
1141 |       const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx);
1142 | 
1143 |       expect(apiResponse.status).toBe(401);
1144 | 
1145 |       const error = await apiResponse.json();
1146 |       expect(error.error).toBe('invalid_token');
1147 |     });
1148 | 
1149 |     it('should reject API requests with an invalid token', async () => {
1150 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1151 |         Authorization: 'Bearer invalid-token',
1152 |       });
1153 | 
1154 |       const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx);
1155 | 
1156 |       expect(apiResponse.status).toBe(401);
1157 | 
1158 |       const error = await apiResponse.json();
1159 |       expect(error.error).toBe('invalid_token');
1160 |     });
1161 | 
1162 |     it('should accept valid token and pass props to API handler', async () => {
1163 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1164 |         Authorization: `Bearer ${accessToken}`,
1165 |       });
1166 | 
1167 |       const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx);
1168 | 
1169 |       expect(apiResponse.status).toBe(200);
1170 | 
1171 |       const data = await apiResponse.json();
1172 |       expect(data.success).toBe(true);
1173 |       expect(data.user).toEqual({ userId: 'test-user-123', username: 'TestUser' });
1174 |     });
1175 | 
1176 |     it('should handle CORS preflight for API requests', async () => {
1177 |       const preflightRequest = createMockRequest('https://example.com/api/test', 'OPTIONS', {
1178 |         Origin: 'https://client.example.com',
1179 |         'Access-Control-Request-Method': 'GET',
1180 |         'Access-Control-Request-Headers': 'Authorization',
1181 |       });
1182 | 
1183 |       const preflightResponse = await oauthProvider.fetch(preflightRequest, mockEnv, mockCtx);
1184 | 
1185 |       expect(preflightResponse.status).toBe(204);
1186 |       expect(preflightResponse.headers.get('Access-Control-Allow-Origin')).toBe('https://client.example.com');
1187 |       expect(preflightResponse.headers.get('Access-Control-Allow-Methods')).toBe('*');
1188 |       expect(preflightResponse.headers.get('Access-Control-Allow-Headers')).toContain('Authorization');
1189 |     });
1190 |   });
1191 | 
1192 |   describe('Token Exchange Callback', () => {
1193 |     // Test with provider that has token exchange callback
1194 |     let oauthProviderWithCallback: OAuthProvider;
1195 |     let callbackInvocations: any[] = [];
1196 |     let mockEnv: ReturnType<typeof createMockEnv>;
1197 |     let mockCtx: MockExecutionContext;
1198 | 
1199 |     // Helper function to create a test OAuth provider with a token exchange callback
1200 |     function createProviderWithCallback() {
1201 |       callbackInvocations = [];
1202 | 
1203 |       const tokenExchangeCallback = async (options: any) => {
1204 |         // Record that the callback was called and with what arguments
1205 |         callbackInvocations.push({ ...options });
1206 | 
1207 |         // Return different props based on the grant type
1208 |         if (options.grantType === 'authorization_code') {
1209 |           return {
1210 |             accessTokenProps: {
1211 |               ...options.props,
1212 |               tokenSpecific: true,
1213 |               tokenUpdatedAt: 'auth_code_flow',
1214 |             },
1215 |             newProps: {
1216 |               ...options.props,
1217 |               grantUpdated: true,
1218 |             },
1219 |           };
1220 |         } else if (options.grantType === 'refresh_token') {
1221 |           return {
1222 |             accessTokenProps: {
1223 |               ...options.props,
1224 |               tokenSpecific: true,
1225 |               tokenUpdatedAt: 'refresh_token_flow',
1226 |             },
1227 |             newProps: {
1228 |               ...options.props,
1229 |               grantUpdated: true,
1230 |               refreshCount: (options.props.refreshCount || 0) + 1,
1231 |             },
1232 |           };
1233 |         }
1234 |       };
1235 | 
1236 |       return new OAuthProvider({
1237 |         apiRoute: ['/api/', 'https://api.example.com/'],
1238 |         apiHandler: TestApiHandler,
1239 |         defaultHandler: testDefaultHandler,
1240 |         authorizeEndpoint: '/authorize',
1241 |         tokenEndpoint: '/oauth/token',
1242 |         clientRegistrationEndpoint: '/oauth/register',
1243 |         scopesSupported: ['read', 'write', 'profile'],
1244 |         accessTokenTTL: 3600,
1245 |         allowImplicitFlow: true,
1246 |         tokenExchangeCallback,
1247 |       });
1248 |     }
1249 | 
1250 |     let clientId: string;
1251 |     let clientSecret: string;
1252 |     let redirectUri: string;
1253 | 
1254 |     // Helper to create a test client
1255 |     async function createTestClient() {
1256 |       const clientData = {
1257 |         redirect_uris: ['https://client.example.com/callback'],
1258 |         client_name: 'Test Client',
1259 |         token_endpoint_auth_method: 'client_secret_basic',
1260 |       };
1261 | 
1262 |       const request = createMockRequest(
1263 |         'https://example.com/oauth/register',
1264 |         'POST',
1265 |         { 'Content-Type': 'application/json' },
1266 |         JSON.stringify(clientData)
1267 |       );
1268 | 
1269 |       const response = await oauthProviderWithCallback.fetch(request, mockEnv, mockCtx);
1270 |       const client = await response.json();
1271 | 
1272 |       clientId = client.client_id;
1273 |       clientSecret = client.client_secret;
1274 |       redirectUri = 'https://client.example.com/callback';
1275 |     }
1276 | 
1277 |     beforeEach(async () => {
1278 |       // Reset mocks before each test
1279 |       vi.resetAllMocks();
1280 | 
1281 |       // Create fresh instances for each test
1282 |       mockEnv = createMockEnv();
1283 |       mockCtx = new MockExecutionContext();
1284 | 
1285 |       // Create OAuth provider with test configuration and callback
1286 |       oauthProviderWithCallback = createProviderWithCallback();
1287 | 
1288 |       // Create a test client
1289 |       await createTestClient();
1290 |     });
1291 | 
1292 |     afterEach(() => {
1293 |       // Clean up KV storage after each test
1294 |       mockEnv.OAUTH_KV.clear();
1295 |     });
1296 | 
1297 |     it('should call the callback during authorization code flow', async () => {
1298 |       // First get an auth code
1299 |       const authRequest = createMockRequest(
1300 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
1301 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
1302 |           `&scope=read%20write&state=xyz123`
1303 |       );
1304 | 
1305 |       const authResponse = await oauthProviderWithCallback.fetch(authRequest, mockEnv, mockCtx);
1306 |       const location = authResponse.headers.get('Location')!;
1307 |       const code = new URL(location).searchParams.get('code')!;
1308 | 
1309 |       // Reset callback invocations tracking before token exchange
1310 |       callbackInvocations = [];
1311 | 
1312 |       // Exchange code for tokens
1313 |       const params = new URLSearchParams();
1314 |       params.append('grant_type', 'authorization_code');
1315 |       params.append('code', code);
1316 |       params.append('redirect_uri', redirectUri);
1317 |       params.append('client_id', clientId);
1318 |       params.append('client_secret', clientSecret);
1319 | 
1320 |       const tokenRequest = createMockRequest(
1321 |         'https://example.com/oauth/token',
1322 |         'POST',
1323 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1324 |         params.toString()
1325 |       );
1326 | 
1327 |       const tokenResponse = await oauthProviderWithCallback.fetch(tokenRequest, mockEnv, mockCtx);
1328 | 
1329 |       // Check that the token exchange was successful
1330 |       expect(tokenResponse.status).toBe(200);
1331 |       const tokens = await tokenResponse.json();
1332 |       expect(tokens.access_token).toBeDefined();
1333 | 
1334 |       // Check that the callback was called once
1335 |       expect(callbackInvocations.length).toBe(1);
1336 | 
1337 |       // Check that callback was called with correct arguments
1338 |       const callbackArgs = callbackInvocations[0];
1339 |       expect(callbackArgs.grantType).toBe('authorization_code');
1340 |       expect(callbackArgs.clientId).toBe(clientId);
1341 |       expect(callbackArgs.props).toEqual({ userId: 'test-user-123', username: 'TestUser' });
1342 | 
1343 |       // Use the token to access API
1344 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1345 |         Authorization: `Bearer ${tokens.access_token}`,
1346 |       });
1347 | 
1348 |       const apiResponse = await oauthProviderWithCallback.fetch(apiRequest, mockEnv, mockCtx);
1349 |       expect(apiResponse.status).toBe(200);
1350 | 
1351 |       // Check that the API received the token-specific props from the callback
1352 |       const apiData = await apiResponse.json();
1353 |       expect(apiData.user).toEqual({
1354 |         userId: 'test-user-123',
1355 |         username: 'TestUser',
1356 |         tokenSpecific: true,
1357 |         tokenUpdatedAt: 'auth_code_flow',
1358 |       });
1359 |     });
1360 | 
1361 |     it('should call the callback during refresh token flow', async () => {
1362 |       // First get an auth code and exchange it for tokens
1363 |       const authRequest = createMockRequest(
1364 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
1365 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
1366 |           `&scope=read%20write&state=xyz123`
1367 |       );
1368 | 
1369 |       const authResponse = await oauthProviderWithCallback.fetch(authRequest, mockEnv, mockCtx);
1370 |       const location = authResponse.headers.get('Location')!;
1371 |       const code = new URL(location).searchParams.get('code')!;
1372 | 
1373 |       // Exchange code for tokens
1374 |       const codeParams = new URLSearchParams();
1375 |       codeParams.append('grant_type', 'authorization_code');
1376 |       codeParams.append('code', code);
1377 |       codeParams.append('redirect_uri', redirectUri);
1378 |       codeParams.append('client_id', clientId);
1379 |       codeParams.append('client_secret', clientSecret);
1380 | 
1381 |       const tokenRequest = createMockRequest(
1382 |         'https://example.com/oauth/token',
1383 |         'POST',
1384 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1385 |         codeParams.toString()
1386 |       );
1387 | 
1388 |       const tokenResponse = await oauthProviderWithCallback.fetch(tokenRequest, mockEnv, mockCtx);
1389 |       const tokens = await tokenResponse.json();
1390 | 
1391 |       // Reset the callback invocations tracking before refresh
1392 |       callbackInvocations = [];
1393 | 
1394 |       // Now use the refresh token
1395 |       const refreshParams = new URLSearchParams();
1396 |       refreshParams.append('grant_type', 'refresh_token');
1397 |       refreshParams.append('refresh_token', tokens.refresh_token);
1398 |       refreshParams.append('client_id', clientId);
1399 |       refreshParams.append('client_secret', clientSecret);
1400 | 
1401 |       const refreshRequest = createMockRequest(
1402 |         'https://example.com/oauth/token',
1403 |         'POST',
1404 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1405 |         refreshParams.toString()
1406 |       );
1407 | 
1408 |       const refreshResponse = await oauthProviderWithCallback.fetch(refreshRequest, mockEnv, mockCtx);
1409 | 
1410 |       // Check that the refresh was successful
1411 |       expect(refreshResponse.status).toBe(200);
1412 |       const newTokens = await refreshResponse.json();
1413 |       expect(newTokens.access_token).toBeDefined();
1414 | 
1415 |       // Check that the callback was called once
1416 |       expect(callbackInvocations.length).toBe(1);
1417 | 
1418 |       // Check that callback was called with correct arguments
1419 |       const callbackArgs = callbackInvocations[0];
1420 |       expect(callbackArgs.grantType).toBe('refresh_token');
1421 |       expect(callbackArgs.clientId).toBe(clientId);
1422 | 
1423 |       // The props are from the updated grant during auth code flow
1424 |       expect(callbackArgs.props).toEqual({
1425 |         userId: 'test-user-123',
1426 |         username: 'TestUser',
1427 |         grantUpdated: true,
1428 |       });
1429 | 
1430 |       // Use the new token to access API
1431 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1432 |         Authorization: `Bearer ${newTokens.access_token}`,
1433 |       });
1434 | 
1435 |       const apiResponse = await oauthProviderWithCallback.fetch(apiRequest, mockEnv, mockCtx);
1436 |       expect(apiResponse.status).toBe(200);
1437 | 
1438 |       // Check that the API received the token-specific props from the refresh callback
1439 |       const apiData = await apiResponse.json();
1440 |       expect(apiData.user).toEqual({
1441 |         userId: 'test-user-123',
1442 |         username: 'TestUser',
1443 |         grantUpdated: true,
1444 |         tokenSpecific: true,
1445 |         tokenUpdatedAt: 'refresh_token_flow',
1446 |       });
1447 | 
1448 |       // Do a second refresh to verify that grant props are properly updated
1449 |       const refresh2Params = new URLSearchParams();
1450 |       refresh2Params.append('grant_type', 'refresh_token');
1451 |       refresh2Params.append('refresh_token', newTokens.refresh_token);
1452 |       refresh2Params.append('client_id', clientId);
1453 |       refresh2Params.append('client_secret', clientSecret);
1454 | 
1455 |       // Reset the callback invocations before second refresh
1456 |       callbackInvocations = [];
1457 | 
1458 |       const refresh2Request = createMockRequest(
1459 |         'https://example.com/oauth/token',
1460 |         'POST',
1461 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1462 |         refresh2Params.toString()
1463 |       );
1464 | 
1465 |       const refresh2Response = await oauthProviderWithCallback.fetch(refresh2Request, mockEnv, mockCtx);
1466 |       const newerTokens = await refresh2Response.json();
1467 | 
1468 |       // Check that the refresh count was incremented in the grant props
1469 |       expect(callbackInvocations.length).toBe(1);
1470 |       expect(callbackInvocations[0].props.refreshCount).toBe(1);
1471 |     });
1472 | 
1473 |     it('should update token props during refresh when explicitly provided', async () => {
1474 |       // Create a provider with a callback that returns both accessTokenProps and newProps
1475 |       // but with different values for each
1476 |       const differentPropsCallback = async (options: any) => {
1477 |         if (options.grantType === 'refresh_token') {
1478 |           return {
1479 |             accessTokenProps: {
1480 |               ...options.props,
1481 |               refreshed: true,
1482 |               tokenOnly: true,
1483 |             },
1484 |             newProps: {
1485 |               ...options.props,
1486 |               grantUpdated: true,
1487 |             },
1488 |           };
1489 |         }
1490 |         return undefined;
1491 |       };
1492 | 
1493 |       const refreshPropsProvider = new OAuthProvider({
1494 |         apiRoute: ['/api/'],
1495 |         apiHandler: TestApiHandler,
1496 |         defaultHandler: testDefaultHandler,
1497 |         authorizeEndpoint: '/authorize',
1498 |         tokenEndpoint: '/oauth/token',
1499 |         clientRegistrationEndpoint: '/oauth/register',
1500 |         scopesSupported: ['read', 'write'],
1501 |         tokenExchangeCallback: differentPropsCallback,
1502 |       });
1503 | 
1504 |       // Create a client
1505 |       const clientData = {
1506 |         redirect_uris: ['https://client.example.com/callback'],
1507 |         client_name: 'Refresh Props Test',
1508 |         token_endpoint_auth_method: 'client_secret_basic',
1509 |       };
1510 | 
1511 |       const registerRequest = createMockRequest(
1512 |         'https://example.com/oauth/register',
1513 |         'POST',
1514 |         { 'Content-Type': 'application/json' },
1515 |         JSON.stringify(clientData)
1516 |       );
1517 | 
1518 |       const registerResponse = await refreshPropsProvider.fetch(registerRequest, mockEnv, mockCtx);
1519 |       const client = await registerResponse.json();
1520 |       const testClientId = client.client_id;
1521 |       const testClientSecret = client.client_secret;
1522 |       const testRedirectUri = 'https://client.example.com/callback';
1523 | 
1524 |       // Get an auth code and exchange it for tokens
1525 |       const authRequest = createMockRequest(
1526 |         `https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1527 |           `&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1528 |           `&scope=read%20write&state=xyz123`
1529 |       );
1530 | 
1531 |       const authResponse = await refreshPropsProvider.fetch(authRequest, mockEnv, mockCtx);
1532 |       const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1533 | 
1534 |       // Exchange for tokens
1535 |       const params = new URLSearchParams();
1536 |       params.append('grant_type', 'authorization_code');
1537 |       params.append('code', code);
1538 |       params.append('redirect_uri', testRedirectUri);
1539 |       params.append('client_id', testClientId);
1540 |       params.append('client_secret', testClientSecret);
1541 | 
1542 |       const tokenRequest = createMockRequest(
1543 |         'https://example.com/oauth/token',
1544 |         'POST',
1545 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1546 |         params.toString()
1547 |       );
1548 | 
1549 |       const tokenResponse = await refreshPropsProvider.fetch(tokenRequest, mockEnv, mockCtx);
1550 |       const tokens = await tokenResponse.json();
1551 | 
1552 |       // Now do a refresh token exchange
1553 |       const refreshParams = new URLSearchParams();
1554 |       refreshParams.append('grant_type', 'refresh_token');
1555 |       refreshParams.append('refresh_token', tokens.refresh_token);
1556 |       refreshParams.append('client_id', testClientId);
1557 |       refreshParams.append('client_secret', testClientSecret);
1558 | 
1559 |       const refreshRequest = createMockRequest(
1560 |         'https://example.com/oauth/token',
1561 |         'POST',
1562 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1563 |         refreshParams.toString()
1564 |       );
1565 | 
1566 |       const refreshResponse = await refreshPropsProvider.fetch(refreshRequest, mockEnv, mockCtx);
1567 |       const newTokens = await refreshResponse.json();
1568 | 
1569 |       // Use the new token to access API
1570 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1571 |         Authorization: `Bearer ${newTokens.access_token}`,
1572 |       });
1573 | 
1574 |       const apiResponse = await refreshPropsProvider.fetch(apiRequest, mockEnv, mockCtx);
1575 |       const apiData = await apiResponse.json();
1576 | 
1577 |       // The access token should contain the token-specific props from the refresh callback
1578 |       expect(apiData.user).toHaveProperty('refreshed', true);
1579 |       expect(apiData.user).toHaveProperty('tokenOnly', true);
1580 |       expect(apiData.user).not.toHaveProperty('grantUpdated');
1581 |     });
1582 | 
1583 |     it('should handle callback that returns only accessTokenProps or only newProps', async () => {
1584 |       // Create a provider with a callback that returns only accessTokenProps for auth code
1585 |       // and only newProps for refresh token
1586 |       // Note: With the enhanced implementation, when only newProps is returned
1587 |       // without accessTokenProps, the token props will inherit from newProps
1588 |       const propsCallback = async (options: any) => {
1589 |         if (options.grantType === 'authorization_code') {
1590 |           return {
1591 |             accessTokenProps: { ...options.props, tokenOnly: true },
1592 |           };
1593 |         } else if (options.grantType === 'refresh_token') {
1594 |           return {
1595 |             newProps: { ...options.props, grantOnly: true },
1596 |           };
1597 |         }
1598 |       };
1599 | 
1600 |       const specialProvider = new OAuthProvider({
1601 |         apiRoute: ['/api/'],
1602 |         apiHandler: TestApiHandler,
1603 |         defaultHandler: testDefaultHandler,
1604 |         authorizeEndpoint: '/authorize',
1605 |         tokenEndpoint: '/oauth/token',
1606 |         clientRegistrationEndpoint: '/oauth/register',
1607 |         scopesSupported: ['read', 'write'],
1608 |         tokenExchangeCallback: propsCallback,
1609 |       });
1610 | 
1611 |       // Create a client
1612 |       const clientData = {
1613 |         redirect_uris: ['https://client.example.com/callback'],
1614 |         client_name: 'Token Props Only Test',
1615 |         token_endpoint_auth_method: 'client_secret_basic',
1616 |       };
1617 | 
1618 |       const registerRequest = createMockRequest(
1619 |         'https://example.com/oauth/register',
1620 |         'POST',
1621 |         { 'Content-Type': 'application/json' },
1622 |         JSON.stringify(clientData)
1623 |       );
1624 | 
1625 |       const registerResponse = await specialProvider.fetch(registerRequest, mockEnv, mockCtx);
1626 |       const client = await registerResponse.json();
1627 |       const testClientId = client.client_id;
1628 |       const testClientSecret = client.client_secret;
1629 |       const testRedirectUri = 'https://client.example.com/callback';
1630 | 
1631 |       // Get an auth code
1632 |       const authRequest = createMockRequest(
1633 |         `https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1634 |           `&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1635 |           `&scope=read%20write&state=xyz123`
1636 |       );
1637 | 
1638 |       const authResponse = await specialProvider.fetch(authRequest, mockEnv, mockCtx);
1639 |       const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1640 | 
1641 |       // Exchange code for tokens
1642 |       const params = new URLSearchParams();
1643 |       params.append('grant_type', 'authorization_code');
1644 |       params.append('code', code);
1645 |       params.append('redirect_uri', testRedirectUri);
1646 |       params.append('client_id', testClientId);
1647 |       params.append('client_secret', testClientSecret);
1648 | 
1649 |       const tokenRequest = createMockRequest(
1650 |         'https://example.com/oauth/token',
1651 |         'POST',
1652 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1653 |         params.toString()
1654 |       );
1655 | 
1656 |       const tokenResponse = await specialProvider.fetch(tokenRequest, mockEnv, mockCtx);
1657 |       const tokens = await tokenResponse.json();
1658 | 
1659 |       // Verify the token has the tokenOnly property when used for API access
1660 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1661 |         Authorization: `Bearer ${tokens.access_token}`,
1662 |       });
1663 | 
1664 |       const apiResponse = await specialProvider.fetch(apiRequest, mockEnv, mockCtx);
1665 |       const apiData = await apiResponse.json();
1666 |       expect(apiData.user.tokenOnly).toBe(true);
1667 | 
1668 |       // Now do a refresh token exchange
1669 |       const refreshParams = new URLSearchParams();
1670 |       refreshParams.append('grant_type', 'refresh_token');
1671 |       refreshParams.append('refresh_token', tokens.refresh_token);
1672 |       refreshParams.append('client_id', testClientId);
1673 |       refreshParams.append('client_secret', testClientSecret);
1674 | 
1675 |       const refreshRequest = createMockRequest(
1676 |         'https://example.com/oauth/token',
1677 |         'POST',
1678 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1679 |         refreshParams.toString()
1680 |       );
1681 | 
1682 |       const refreshResponse = await specialProvider.fetch(refreshRequest, mockEnv, mockCtx);
1683 |       const newTokens = await refreshResponse.json();
1684 | 
1685 |       // Use the new token to access API
1686 |       const api2Request = createMockRequest('https://example.com/api/test', 'GET', {
1687 |         Authorization: `Bearer ${newTokens.access_token}`,
1688 |       });
1689 | 
1690 |       const api2Response = await specialProvider.fetch(api2Request, mockEnv, mockCtx);
1691 |       const api2Data = await api2Response.json();
1692 | 
1693 |       // With the enhanced implementation, the token props now inherit from grant props
1694 |       // when only newProps is returned but accessTokenProps is not specified
1695 |       expect(api2Data.user).toEqual({
1696 |         userId: 'test-user-123',
1697 |         username: 'TestUser',
1698 |         grantOnly: true, // This is now included in the token props
1699 |       });
1700 |     });
1701 | 
1702 |     it('should allow customizing access token TTL via callback', async () => {
1703 |       // Create a provider with a callback that customizes TTL
1704 |       const customTtlCallback = async (options: any) => {
1705 |         if (options.grantType === 'refresh_token') {
1706 |           // Return custom TTL for the access token
1707 |           return {
1708 |             accessTokenProps: { ...options.props, customTtl: true },
1709 |             accessTokenTTL: 7200, // 2 hours instead of default
1710 |           };
1711 |         }
1712 |         return undefined;
1713 |       };
1714 | 
1715 |       const customTtlProvider = new OAuthProvider({
1716 |         apiRoute: ['/api/'],
1717 |         apiHandler: TestApiHandler,
1718 |         defaultHandler: testDefaultHandler,
1719 |         authorizeEndpoint: '/authorize',
1720 |         tokenEndpoint: '/oauth/token',
1721 |         clientRegistrationEndpoint: '/oauth/register',
1722 |         scopesSupported: ['read', 'write'],
1723 |         accessTokenTTL: 3600, // Default 1 hour
1724 |         tokenExchangeCallback: customTtlCallback,
1725 |       });
1726 | 
1727 |       // Create a client
1728 |       const clientData = {
1729 |         redirect_uris: ['https://client.example.com/callback'],
1730 |         client_name: 'Custom TTL Test',
1731 |         token_endpoint_auth_method: 'client_secret_basic',
1732 |       };
1733 | 
1734 |       const registerRequest = createMockRequest(
1735 |         'https://example.com/oauth/register',
1736 |         'POST',
1737 |         { 'Content-Type': 'application/json' },
1738 |         JSON.stringify(clientData)
1739 |       );
1740 | 
1741 |       const registerResponse = await customTtlProvider.fetch(registerRequest, mockEnv, mockCtx);
1742 |       const client = await registerResponse.json();
1743 |       const testClientId = client.client_id;
1744 |       const testClientSecret = client.client_secret;
1745 |       const testRedirectUri = 'https://client.example.com/callback';
1746 | 
1747 |       // Get an auth code
1748 |       const authRequest = createMockRequest(
1749 |         `https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1750 |           `&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1751 |           `&scope=read%20write&state=xyz123`
1752 |       );
1753 | 
1754 |       const authResponse = await customTtlProvider.fetch(authRequest, mockEnv, mockCtx);
1755 |       const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1756 | 
1757 |       // Exchange code for tokens
1758 |       const params = new URLSearchParams();
1759 |       params.append('grant_type', 'authorization_code');
1760 |       params.append('code', code);
1761 |       params.append('redirect_uri', testRedirectUri);
1762 |       params.append('client_id', testClientId);
1763 |       params.append('client_secret', testClientSecret);
1764 | 
1765 |       const tokenRequest = createMockRequest(
1766 |         'https://example.com/oauth/token',
1767 |         'POST',
1768 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1769 |         params.toString()
1770 |       );
1771 | 
1772 |       const tokenResponse = await customTtlProvider.fetch(tokenRequest, mockEnv, mockCtx);
1773 |       const tokens = await tokenResponse.json();
1774 | 
1775 |       // Now do a refresh
1776 |       const refreshParams = new URLSearchParams();
1777 |       refreshParams.append('grant_type', 'refresh_token');
1778 |       refreshParams.append('refresh_token', tokens.refresh_token);
1779 |       refreshParams.append('client_id', testClientId);
1780 |       refreshParams.append('client_secret', testClientSecret);
1781 | 
1782 |       const refreshRequest = createMockRequest(
1783 |         'https://example.com/oauth/token',
1784 |         'POST',
1785 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1786 |         refreshParams.toString()
1787 |       );
1788 | 
1789 |       const refreshResponse = await customTtlProvider.fetch(refreshRequest, mockEnv, mockCtx);
1790 |       const newTokens = await refreshResponse.json();
1791 | 
1792 |       // Verify that the TTL is from the callback, not the default
1793 |       expect(newTokens.expires_in).toBe(7200);
1794 | 
1795 |       // Verify the token contains our custom property
1796 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1797 |         Authorization: `Bearer ${newTokens.access_token}`,
1798 |       });
1799 | 
1800 |       const apiResponse = await customTtlProvider.fetch(apiRequest, mockEnv, mockCtx);
1801 |       const apiData = await apiResponse.json();
1802 |       expect(apiData.user.customTtl).toBe(true);
1803 |     });
1804 | 
1805 |     it('should handle callback that returns undefined (keeping original props)', async () => {
1806 |       // Create a provider with a callback that returns undefined
1807 |       const noopCallback = async (options: any) => {
1808 |         // Don't return anything, which should keep the original props
1809 |         return undefined;
1810 |       };
1811 | 
1812 |       const noopProvider = new OAuthProvider({
1813 |         apiRoute: ['/api/'],
1814 |         apiHandler: TestApiHandler,
1815 |         defaultHandler: testDefaultHandler,
1816 |         authorizeEndpoint: '/authorize',
1817 |         tokenEndpoint: '/oauth/token',
1818 |         clientRegistrationEndpoint: '/oauth/register',
1819 |         scopesSupported: ['read', 'write'],
1820 |         tokenExchangeCallback: noopCallback,
1821 |       });
1822 | 
1823 |       // Create a client
1824 |       const clientData = {
1825 |         redirect_uris: ['https://client.example.com/callback'],
1826 |         client_name: 'Noop Callback Test',
1827 |         token_endpoint_auth_method: 'client_secret_basic',
1828 |       };
1829 | 
1830 |       const registerRequest = createMockRequest(
1831 |         'https://example.com/oauth/register',
1832 |         'POST',
1833 |         { 'Content-Type': 'application/json' },
1834 |         JSON.stringify(clientData)
1835 |       );
1836 | 
1837 |       const registerResponse = await noopProvider.fetch(registerRequest, mockEnv, mockCtx);
1838 |       const client = await registerResponse.json();
1839 |       const testClientId = client.client_id;
1840 |       const testClientSecret = client.client_secret;
1841 |       const testRedirectUri = 'https://client.example.com/callback';
1842 | 
1843 |       // Get an auth code
1844 |       const authRequest = createMockRequest(
1845 |         `https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1846 |           `&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1847 |           `&scope=read%20write&state=xyz123`
1848 |       );
1849 | 
1850 |       const authResponse = await noopProvider.fetch(authRequest, mockEnv, mockCtx);
1851 |       const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1852 | 
1853 |       // Exchange code for tokens
1854 |       const params = new URLSearchParams();
1855 |       params.append('grant_type', 'authorization_code');
1856 |       params.append('code', code);
1857 |       params.append('redirect_uri', testRedirectUri);
1858 |       params.append('client_id', testClientId);
1859 |       params.append('client_secret', testClientSecret);
1860 | 
1861 |       const tokenRequest = createMockRequest(
1862 |         'https://example.com/oauth/token',
1863 |         'POST',
1864 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1865 |         params.toString()
1866 |       );
1867 | 
1868 |       const tokenResponse = await noopProvider.fetch(tokenRequest, mockEnv, mockCtx);
1869 |       const tokens = await tokenResponse.json();
1870 | 
1871 |       // Verify the token has the original props when used for API access
1872 |       const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
1873 |         Authorization: `Bearer ${tokens.access_token}`,
1874 |       });
1875 | 
1876 |       const apiResponse = await noopProvider.fetch(apiRequest, mockEnv, mockCtx);
1877 |       const apiData = await apiResponse.json();
1878 | 
1879 |       // The props should be the original ones (no change)
1880 |       expect(apiData.user).toEqual({ userId: 'test-user-123', username: 'TestUser' });
1881 |     });
1882 | 
1883 |     it('should correctly handle the previous refresh token when callback updates grant props', async () => {
1884 |       // This test verifies fixes for two bugs:
1885 |       // 1. previousRefreshTokenWrappedKey not being re-wrapped when grant props change
1886 |       // 2. accessTokenProps not inheriting from newProps when only newProps is returned
1887 |       let callCount = 0;
1888 |       const propUpdatingCallback = async (options: any) => {
1889 |         callCount++;
1890 |         if (options.grantType === 'refresh_token') {
1891 |           const updatedProps = {
1892 |             ...options.props,
1893 |             updatedCount: (options.props.updatedCount || 0) + 1,
1894 |           };
1895 | 
1896 |           // Only return newProps to test that accessTokenProps will inherit from it
1897 |           return {
1898 |             // Return new props to trigger the re-encryption with a new key
1899 |             newProps: updatedProps,
1900 |             // Intentionally not setting accessTokenProps to verify inheritance works
1901 |           };
1902 |         }
1903 |         return undefined;
1904 |       };
1905 | 
1906 |       const testProvider = new OAuthProvider({
1907 |         apiRoute: ['/api/'],
1908 |         apiHandler: TestApiHandler,
1909 |         defaultHandler: testDefaultHandler,
1910 |         authorizeEndpoint: '/authorize',
1911 |         tokenEndpoint: '/oauth/token',
1912 |         clientRegistrationEndpoint: '/oauth/register',
1913 |         scopesSupported: ['read', 'write'],
1914 |         tokenExchangeCallback: propUpdatingCallback,
1915 |       });
1916 | 
1917 |       // Create a client
1918 |       const clientData = {
1919 |         redirect_uris: ['https://client.example.com/callback'],
1920 |         client_name: 'Key-Rewrapping Test',
1921 |         token_endpoint_auth_method: 'client_secret_basic',
1922 |       };
1923 | 
1924 |       const registerRequest = createMockRequest(
1925 |         'https://example.com/oauth/register',
1926 |         'POST',
1927 |         { 'Content-Type': 'application/json' },
1928 |         JSON.stringify(clientData)
1929 |       );
1930 | 
1931 |       const registerResponse = await testProvider.fetch(registerRequest, mockEnv, mockCtx);
1932 |       const client = await registerResponse.json();
1933 |       const testClientId = client.client_id;
1934 |       const testClientSecret = client.client_secret;
1935 |       const testRedirectUri = 'https://client.example.com/callback';
1936 | 
1937 |       // Get an auth code
1938 |       const authRequest = createMockRequest(
1939 |         `https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1940 |           `&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1941 |           `&scope=read%20write&state=xyz123`
1942 |       );
1943 | 
1944 |       const authResponse = await testProvider.fetch(authRequest, mockEnv, mockCtx);
1945 |       const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1946 | 
1947 |       // Exchange code for tokens
1948 |       const params = new URLSearchParams();
1949 |       params.append('grant_type', 'authorization_code');
1950 |       params.append('code', code);
1951 |       params.append('redirect_uri', testRedirectUri);
1952 |       params.append('client_id', testClientId);
1953 |       params.append('client_secret', testClientSecret);
1954 | 
1955 |       const tokenRequest = createMockRequest(
1956 |         'https://example.com/oauth/token',
1957 |         'POST',
1958 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1959 |         params.toString()
1960 |       );
1961 | 
1962 |       const tokenResponse = await testProvider.fetch(tokenRequest, mockEnv, mockCtx);
1963 |       const tokens = await tokenResponse.json();
1964 |       const refreshToken = tokens.refresh_token;
1965 | 
1966 |       // Reset the callback invocations before refresh
1967 |       callCount = 0;
1968 | 
1969 |       // First refresh - this will update the grant props and re-encrypt them with a new key
1970 |       const refreshParams = new URLSearchParams();
1971 |       refreshParams.append('grant_type', 'refresh_token');
1972 |       refreshParams.append('refresh_token', refreshToken);
1973 |       refreshParams.append('client_id', testClientId);
1974 |       refreshParams.append('client_secret', testClientSecret);
1975 | 
1976 |       const refreshRequest = createMockRequest(
1977 |         'https://example.com/oauth/token',
1978 |         'POST',
1979 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
1980 |         refreshParams.toString()
1981 |       );
1982 | 
1983 |       const refreshResponse = await testProvider.fetch(refreshRequest, mockEnv, mockCtx);
1984 |       expect(refreshResponse.status).toBe(200);
1985 | 
1986 |       // The callback should have been called once for the refresh
1987 |       expect(callCount).toBe(1);
1988 | 
1989 |       // Get the new tokens from the first refresh
1990 |       const newTokens = await refreshResponse.json();
1991 | 
1992 |       // Get the refresh token's corresponding token data to verify it has the updated props
1993 |       const apiRequest1 = createMockRequest('https://example.com/api/test', 'GET', {
1994 |         Authorization: `Bearer ${newTokens.access_token}`,
1995 |       });
1996 | 
1997 |       const apiResponse1 = await testProvider.fetch(apiRequest1, mockEnv, mockCtx);
1998 |       const apiData1 = await apiResponse1.json();
1999 | 
2000 |       // Print the actual API response to debug
2001 |       console.log('First API response:', JSON.stringify(apiData1));
2002 | 
2003 |       // Verify that the token has the updated props (updatedCount should be 1)
2004 |       expect(apiData1.user.updatedCount).toBe(1);
2005 | 
2006 |       // Reset callCount before the second refresh
2007 |       callCount = 0;
2008 | 
2009 |       // Now try to use the SAME refresh token again (which should work once due to token rotation)
2010 |       // With the bug, this would fail because previousRefreshTokenWrappedKey wasn't re-wrapped with the new key
2011 |       const secondRefreshRequest = createMockRequest(
2012 |         'https://example.com/oauth/token',
2013 |         'POST',
2014 |         { 'Content-Type': 'application/x-www-form-urlencoded' },
2015 |         refreshParams.toString() // Using same params with the same refresh token
2016 |       );
2017 | 
2018 |       const secondRefreshResponse = await testProvider.fetch(secondRefreshRequest, mockEnv, mockCtx);
2019 | 
2020 |       // With the bug, this would fail with an error.
2021 |       // When fixed, it should succeed because the previous refresh token is still valid once.
2022 |       expect(secondRefreshResponse.status).toBe(200);
2023 | 
2024 |       const secondTokens = await secondRefreshResponse.json();
2025 |       expect(secondTokens.access_token).toBeDefined();
2026 | 
2027 |       // The callback should have been called again
2028 |       expect(callCount).toBe(1);
2029 | 
2030 |       // Use the token to access API and verify it has the updated props
2031 |       const apiRequest2 = createMockRequest('https://example.com/api/test', 'GET', {
2032 |         Authorization: `Bearer ${secondTokens.access_token}`,
2033 |       });
2034 | 
2035 |       const apiResponse2 = await testProvider.fetch(apiRequest2, mockEnv, mockCtx);
2036 |       const apiData2 = await apiResponse2.json();
2037 | 
2038 |       // The updatedCount should be 2 now (incremented again during the second refresh)
2039 |       expect(apiData2.user.updatedCount).toBe(2);
2040 |     });
2041 |   });
2042 | 
2043 |   describe('Error Handling with onError Callback', () => {
2044 |     it('should use the default onError callback that logs a warning', async () => {
2045 |       // Spy on console.warn to check default behavior
2046 |       const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
2047 | 
2048 |       // Create a request that will trigger an error
2049 |       const invalidTokenRequest = createMockRequest('https://example.com/api/test', 'GET', {
2050 |         Authorization: 'Bearer invalid-token',
2051 |       });
2052 | 
2053 |       const response = await oauthProvider.fetch(invalidTokenRequest, mockEnv, mockCtx);
2054 | 
2055 |       // Verify the error response
2056 |       expect(response.status).toBe(401);
2057 |       const error = await response.json();
2058 |       expect(error.error).toBe('invalid_token');
2059 | 
2060 |       // Verify the default onError callback was triggered and logged a warning
2061 |       expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('OAuth error response: 401 invalid_token'));
2062 | 
2063 |       // Restore the spy
2064 |       consoleWarnSpy.mockRestore();
2065 |     });
2066 | 
2067 |     it('should allow custom onError callback to modify the error response', async () => {
2068 |       // Create a provider with custom onError callback
2069 |       const customErrorProvider = new OAuthProvider({
2070 |         apiRoute: ['/api/'],
2071 |         apiHandler: TestApiHandler,
2072 |         defaultHandler: testDefaultHandler,
2073 |         authorizeEndpoint: '/authorize',
2074 |         tokenEndpoint: '/oauth/token',
2075 |         scopesSupported: ['read', 'write'],
2076 |         onError: ({ code, description, status }) => {
2077 |           // Return a completely different response
2078 |           return new Response(
2079 |             JSON.stringify({
2080 |               custom_error: true,
2081 |               original_code: code,
2082 |               custom_message: `Custom error handler: ${description}`,
2083 |             }),
2084 |             {
2085 |               status,
2086 |               headers: {
2087 |                 'Content-Type': 'application/json',
2088 |                 'X-Custom-Error': 'true',
2089 |               },
2090 |             }
2091 |           );
2092 |         },
2093 |       });
2094 | 
2095 |       // Create a request that will trigger an error
2096 |       const invalidTokenRequest = createMockRequest('https://example.com/api/test', 'GET', {
2097 |         Authorization: 'Bearer invalid-token',
2098 |       });
2099 | 
2100 |       const response = await customErrorProvider.fetch(invalidTokenRequest, mockEnv, mockCtx);
2101 | 
2102 |       // Verify the custom error response
2103 |       expect(response.status).toBe(401); // Status should be preserved
2104 |       expect(response.headers.get('X-Custom-Error')).toBe('true');
2105 | 
2106 |       const error = await response.json();
2107 |       expect(error.custom_error).toBe(true);
2108 |       expect(error.original_code).toBe('invalid_token');
2109 |       expect(error.custom_message).toContain('Custom error handler');
2110 |     });
2111 | 
2112 |     it('should use standard error response when onError returns void', async () => {
2113 |       // Create a provider with a callback that performs a side effect but doesn't return a response
2114 |       let callbackInvoked = false;
2115 |       const sideEffectProvider = new OAuthProvider({
2116 |         apiRoute: ['/api/'],
2117 |         apiHandler: TestApiHandler,
2118 |         defaultHandler: testDefaultHandler,
2119 |         authorizeEndpoint: '/authorize',
2120 |         tokenEndpoint: '/oauth/token',
2121 |         scopesSupported: ['read', 'write'],
2122 |         onError: () => {
2123 |           callbackInvoked = true;
2124 |           // No return - should use standard error response
2125 |         },
2126 |       });
2127 | 
2128 |       // Create a request that will trigger an error
2129 |       const invalidRequest = createMockRequest('https://example.com/oauth/token', 'POST', {
2130 |         'Content-Type': 'application/x-www-form-urlencoded',
2131 |       });
2132 | 
2133 |       const response = await sideEffectProvider.fetch(invalidRequest, mockEnv, mockCtx);
2134 | 
2135 |       // Verify the standard error response
2136 |       expect(response.status).toBe(401);
2137 |       const error = await response.json();
2138 |       expect(error.error).toBe('invalid_client');
2139 | 
2140 |       // Verify callback was invoked
2141 |       expect(callbackInvoked).toBe(true);
2142 |     });
2143 |   });
2144 | 
2145 |   describe('OAuthHelpers', () => {
2146 |     it('should allow listing and revoking grants', async () => {
2147 |       // Create a client
2148 |       const clientData = {
2149 |         redirect_uris: ['https://client.example.com/callback'],
2150 |         client_name: 'Test Client',
2151 |         token_endpoint_auth_method: 'client_secret_basic',
2152 |       };
2153 | 
2154 |       const registerRequest = createMockRequest(
2155 |         'https://example.com/oauth/register',
2156 |         'POST',
2157 |         { 'Content-Type': 'application/json' },
2158 |         JSON.stringify(clientData)
2159 |       );
2160 | 
2161 |       await oauthProvider.fetch(registerRequest, mockEnv, mockCtx);
2162 | 
2163 |       // Create a grant by going through auth flow
2164 |       const clientId = (await mockEnv.OAUTH_KV.list({ prefix: 'client:' })).keys[0].name.substring(7);
2165 |       const redirectUri = 'https://client.example.com/callback';
2166 | 
2167 |       const authRequest = createMockRequest(
2168 |         `https://example.com/authorize?response_type=code&client_id=${clientId}` +
2169 |           `&redirect_uri=${encodeURIComponent(redirectUri)}` +
2170 |           `&scope=read%20write&state=xyz123`
2171 |       );
2172 | 
2173 |       await oauthProvider.fetch(authRequest, mockEnv, mockCtx);
2174 | 
2175 |       // Ensure OAUTH_PROVIDER was injected
2176 |       expect(mockEnv.OAUTH_PROVIDER).not.toBeNull();
2177 | 
2178 |       // List grants for the user
2179 |       const grants = await mockEnv.OAUTH_PROVIDER.listUserGrants('test-user-123');
2180 | 
2181 |       expect(grants.items.length).toBe(1);
2182 |       expect(grants.items[0].clientId).toBe(clientId);
2183 |       expect(grants.items[0].userId).toBe('test-user-123');
2184 |       expect(grants.items[0].metadata).toEqual({ testConsent: true });
2185 | 
2186 |       // Revoke the grant
2187 |       await mockEnv.OAUTH_PROVIDER.revokeGrant(grants.items[0].id, 'test-user-123');
2188 | 
2189 |       // Verify grant was deleted
2190 |       const grantsAfterRevoke = await mockEnv.OAUTH_PROVIDER.listUserGrants('test-user-123');
2191 |       expect(grantsAfterRevoke.items.length).toBe(0);
2192 |     });
2193 | 
2194 |     it('should allow listing, updating, and deleting clients', async () => {
2195 |       // First make a simple request to initialize the OAUTH_PROVIDER in the environment
2196 |       const initRequest = createMockRequest('https://example.com/');
2197 |       await oauthProvider.fetch(initRequest, mockEnv, mockCtx);
2198 | 
2199 |       // Now OAUTH_PROVIDER should be initialized
2200 |       expect(mockEnv.OAUTH_PROVIDER).not.toBeNull();
2201 | 
2202 |       // Create a client
2203 |       const client = await mockEnv.OAUTH_PROVIDER.createClient({
2204 |         redirectUris: ['https://client.example.com/callback'],
2205 |         clientName: 'Test Client',
2206 |         tokenEndpointAuthMethod: 'client_secret_basic',
2207 |       });
2208 | 
2209 |       expect(client.clientId).toBeDefined();
2210 |       expect(client.clientSecret).toBeDefined();
2211 | 
2212 |       // List clients
2213 |       const clients = await mockEnv.OAUTH_PROVIDER.listClients();
2214 |       expect(clients.items.length).toBe(1);
2215 |       expect(clients.items[0].clientId).toBe(client.clientId);
2216 | 
2217 |       // Update client
2218 |       const updatedClient = await mockEnv.OAUTH_PROVIDER.updateClient(client.clientId, {
2219 |         clientName: 'Updated Client Name',
2220 |       });
2221 | 
2222 |       expect(updatedClient).not.toBeNull();
2223 |       expect(updatedClient!.clientName).toBe('Updated Client Name');
2224 | 
2225 |       // Delete client
2226 |       await mockEnv.OAUTH_PROVIDER.deleteClient(client.clientId);
2227 | 
2228 |       // Verify client was deleted
2229 |       const clientsAfterDelete = await mockEnv.OAUTH_PROVIDER.listClients();
2230 |       expect(clientsAfterDelete.items.length).toBe(0);
2231 |     });
2232 |   });
2233 | });
2234 | 


--------------------------------------------------------------------------------
/__tests__/setup.ts:
--------------------------------------------------------------------------------
 1 | import { vi } from 'vitest';
 2 | import { WorkerEntrypoint } from './mocks/cloudflare-workers';
 3 | 
 4 | // Mock the 'cloudflare:workers' module
 5 | vi.mock('cloudflare:workers', () => {
 6 |   return {
 7 |     WorkerEntrypoint,
 8 |   };
 9 | });
10 | 
11 | // Add any other global setup needed for the tests
12 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "@cloudflare/workers-oauth-provider",
 3 |   "version": "0.0.5",
 4 |   "description": "OAuth provider for Cloudflare Workers",
 5 |   "main": "dist/oauth-provider.js",
 6 |   "types": "dist/oauth-provider.d.ts",
 7 |   "author": "Kenton Varda <kenton@cloudflare.com>",
 8 |   "license": "MIT",
 9 |   "files": [
10 |     "dist"
11 |   ],
12 |   "type": "module",
13 |   "publishConfig": {
14 |     "access": "public"
15 |   },
16 |   "scripts": {
17 |     "build": "tsup",
18 |     "build:watch": "tsup --watch",
19 |     "test": "vitest run",
20 |     "test:watch": "vitest",
21 |     "prepublishOnly": "npm run build",
22 |     "prettier": "prettier -w ."
23 |   },
24 |   "devDependencies": {
25 |     "@cloudflare/workers-types": "^4.20250311.0",
26 |     "prettier": "^3.5.3",
27 |     "tsup": "^8.4.0",
28 |     "typescript": "^5.8.2",
29 |     "vitest": "^3.0.8"
30 |   },
31 |   "repository": {
32 |     "type": "git",
33 |     "url": "https://github.com/cloudflare/workers-oauth-provider"
34 |   },
35 |   "bugs": {
36 |     "url": "https://github.com/cloudflare/workers-oauth-provider/issues"
37 |   },
38 |   "homepage": "https://github.com/cloudflare/workers-oauth-provider#readme"
39 | }
40 | 


--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
   1 | lockfileVersion: '9.0'
   2 | 
   3 | settings:
   4 |   autoInstallPeers: true
   5 |   excludeLinksFromLockfile: false
   6 | 
   7 | importers:
   8 | 
   9 |   .:
  10 |     devDependencies:
  11 |       '@cloudflare/workers-types':
  12 |         specifier: ^4.20250311.0
  13 |         version: 4.20250311.0
  14 |       prettier:
  15 |         specifier: ^3.5.3
  16 |         version: 3.5.3
  17 |       tsup:
  18 |         specifier: ^8.4.0
  19 |         version: 8.4.0(postcss@8.5.3)(typescript@5.8.2)
  20 |       typescript:
  21 |         specifier: ^5.8.2
  22 |         version: 5.8.2
  23 |       vitest:
  24 |         specifier: ^3.0.8
  25 |         version: 3.0.9(@types/node@22.13.10)
  26 | 
  27 | packages:
  28 | 
  29 |   '@cloudflare/workers-types@4.20250311.0':
  30 |     resolution: {integrity: sha512-5ftSdP1vEdKM6in4p3DZ5SIgaJtRh6LqVeitQtFFsHCyHSPES0KX5HaqTYai+T/5UwmZrB2a3fBUKpGmfDOXBg==}
  31 | 
  32 |   '@esbuild/aix-ppc64@0.21.5':
  33 |     resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
  34 |     engines: {node: '>=12'}
  35 |     cpu: [ppc64]
  36 |     os: [aix]
  37 | 
  38 |   '@esbuild/aix-ppc64@0.25.1':
  39 |     resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==}
  40 |     engines: {node: '>=18'}
  41 |     cpu: [ppc64]
  42 |     os: [aix]
  43 | 
  44 |   '@esbuild/android-arm64@0.21.5':
  45 |     resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
  46 |     engines: {node: '>=12'}
  47 |     cpu: [arm64]
  48 |     os: [android]
  49 | 
  50 |   '@esbuild/android-arm64@0.25.1':
  51 |     resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==}
  52 |     engines: {node: '>=18'}
  53 |     cpu: [arm64]
  54 |     os: [android]
  55 | 
  56 |   '@esbuild/android-arm@0.21.5':
  57 |     resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
  58 |     engines: {node: '>=12'}
  59 |     cpu: [arm]
  60 |     os: [android]
  61 | 
  62 |   '@esbuild/android-arm@0.25.1':
  63 |     resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==}
  64 |     engines: {node: '>=18'}
  65 |     cpu: [arm]
  66 |     os: [android]
  67 | 
  68 |   '@esbuild/android-x64@0.21.5':
  69 |     resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
  70 |     engines: {node: '>=12'}
  71 |     cpu: [x64]
  72 |     os: [android]
  73 | 
  74 |   '@esbuild/android-x64@0.25.1':
  75 |     resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==}
  76 |     engines: {node: '>=18'}
  77 |     cpu: [x64]
  78 |     os: [android]
  79 | 
  80 |   '@esbuild/darwin-arm64@0.21.5':
  81 |     resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
  82 |     engines: {node: '>=12'}
  83 |     cpu: [arm64]
  84 |     os: [darwin]
  85 | 
  86 |   '@esbuild/darwin-arm64@0.25.1':
  87 |     resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==}
  88 |     engines: {node: '>=18'}
  89 |     cpu: [arm64]
  90 |     os: [darwin]
  91 | 
  92 |   '@esbuild/darwin-x64@0.21.5':
  93 |     resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
  94 |     engines: {node: '>=12'}
  95 |     cpu: [x64]
  96 |     os: [darwin]
  97 | 
  98 |   '@esbuild/darwin-x64@0.25.1':
  99 |     resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==}
 100 |     engines: {node: '>=18'}
 101 |     cpu: [x64]
 102 |     os: [darwin]
 103 | 
 104 |   '@esbuild/freebsd-arm64@0.21.5':
 105 |     resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
 106 |     engines: {node: '>=12'}
 107 |     cpu: [arm64]
 108 |     os: [freebsd]
 109 | 
 110 |   '@esbuild/freebsd-arm64@0.25.1':
 111 |     resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==}
 112 |     engines: {node: '>=18'}
 113 |     cpu: [arm64]
 114 |     os: [freebsd]
 115 | 
 116 |   '@esbuild/freebsd-x64@0.21.5':
 117 |     resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
 118 |     engines: {node: '>=12'}
 119 |     cpu: [x64]
 120 |     os: [freebsd]
 121 | 
 122 |   '@esbuild/freebsd-x64@0.25.1':
 123 |     resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==}
 124 |     engines: {node: '>=18'}
 125 |     cpu: [x64]
 126 |     os: [freebsd]
 127 | 
 128 |   '@esbuild/linux-arm64@0.21.5':
 129 |     resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
 130 |     engines: {node: '>=12'}
 131 |     cpu: [arm64]
 132 |     os: [linux]
 133 | 
 134 |   '@esbuild/linux-arm64@0.25.1':
 135 |     resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==}
 136 |     engines: {node: '>=18'}
 137 |     cpu: [arm64]
 138 |     os: [linux]
 139 | 
 140 |   '@esbuild/linux-arm@0.21.5':
 141 |     resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
 142 |     engines: {node: '>=12'}
 143 |     cpu: [arm]
 144 |     os: [linux]
 145 | 
 146 |   '@esbuild/linux-arm@0.25.1':
 147 |     resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==}
 148 |     engines: {node: '>=18'}
 149 |     cpu: [arm]
 150 |     os: [linux]
 151 | 
 152 |   '@esbuild/linux-ia32@0.21.5':
 153 |     resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
 154 |     engines: {node: '>=12'}
 155 |     cpu: [ia32]
 156 |     os: [linux]
 157 | 
 158 |   '@esbuild/linux-ia32@0.25.1':
 159 |     resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==}
 160 |     engines: {node: '>=18'}
 161 |     cpu: [ia32]
 162 |     os: [linux]
 163 | 
 164 |   '@esbuild/linux-loong64@0.21.5':
 165 |     resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
 166 |     engines: {node: '>=12'}
 167 |     cpu: [loong64]
 168 |     os: [linux]
 169 | 
 170 |   '@esbuild/linux-loong64@0.25.1':
 171 |     resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==}
 172 |     engines: {node: '>=18'}
 173 |     cpu: [loong64]
 174 |     os: [linux]
 175 | 
 176 |   '@esbuild/linux-mips64el@0.21.5':
 177 |     resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
 178 |     engines: {node: '>=12'}
 179 |     cpu: [mips64el]
 180 |     os: [linux]
 181 | 
 182 |   '@esbuild/linux-mips64el@0.25.1':
 183 |     resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==}
 184 |     engines: {node: '>=18'}
 185 |     cpu: [mips64el]
 186 |     os: [linux]
 187 | 
 188 |   '@esbuild/linux-ppc64@0.21.5':
 189 |     resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
 190 |     engines: {node: '>=12'}
 191 |     cpu: [ppc64]
 192 |     os: [linux]
 193 | 
 194 |   '@esbuild/linux-ppc64@0.25.1':
 195 |     resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==}
 196 |     engines: {node: '>=18'}
 197 |     cpu: [ppc64]
 198 |     os: [linux]
 199 | 
 200 |   '@esbuild/linux-riscv64@0.21.5':
 201 |     resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
 202 |     engines: {node: '>=12'}
 203 |     cpu: [riscv64]
 204 |     os: [linux]
 205 | 
 206 |   '@esbuild/linux-riscv64@0.25.1':
 207 |     resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==}
 208 |     engines: {node: '>=18'}
 209 |     cpu: [riscv64]
 210 |     os: [linux]
 211 | 
 212 |   '@esbuild/linux-s390x@0.21.5':
 213 |     resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
 214 |     engines: {node: '>=12'}
 215 |     cpu: [s390x]
 216 |     os: [linux]
 217 | 
 218 |   '@esbuild/linux-s390x@0.25.1':
 219 |     resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==}
 220 |     engines: {node: '>=18'}
 221 |     cpu: [s390x]
 222 |     os: [linux]
 223 | 
 224 |   '@esbuild/linux-x64@0.21.5':
 225 |     resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
 226 |     engines: {node: '>=12'}
 227 |     cpu: [x64]
 228 |     os: [linux]
 229 | 
 230 |   '@esbuild/linux-x64@0.25.1':
 231 |     resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==}
 232 |     engines: {node: '>=18'}
 233 |     cpu: [x64]
 234 |     os: [linux]
 235 | 
 236 |   '@esbuild/netbsd-arm64@0.25.1':
 237 |     resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==}
 238 |     engines: {node: '>=18'}
 239 |     cpu: [arm64]
 240 |     os: [netbsd]
 241 | 
 242 |   '@esbuild/netbsd-x64@0.21.5':
 243 |     resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
 244 |     engines: {node: '>=12'}
 245 |     cpu: [x64]
 246 |     os: [netbsd]
 247 | 
 248 |   '@esbuild/netbsd-x64@0.25.1':
 249 |     resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==}
 250 |     engines: {node: '>=18'}
 251 |     cpu: [x64]
 252 |     os: [netbsd]
 253 | 
 254 |   '@esbuild/openbsd-arm64@0.25.1':
 255 |     resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==}
 256 |     engines: {node: '>=18'}
 257 |     cpu: [arm64]
 258 |     os: [openbsd]
 259 | 
 260 |   '@esbuild/openbsd-x64@0.21.5':
 261 |     resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
 262 |     engines: {node: '>=12'}
 263 |     cpu: [x64]
 264 |     os: [openbsd]
 265 | 
 266 |   '@esbuild/openbsd-x64@0.25.1':
 267 |     resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==}
 268 |     engines: {node: '>=18'}
 269 |     cpu: [x64]
 270 |     os: [openbsd]
 271 | 
 272 |   '@esbuild/sunos-x64@0.21.5':
 273 |     resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
 274 |     engines: {node: '>=12'}
 275 |     cpu: [x64]
 276 |     os: [sunos]
 277 | 
 278 |   '@esbuild/sunos-x64@0.25.1':
 279 |     resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==}
 280 |     engines: {node: '>=18'}
 281 |     cpu: [x64]
 282 |     os: [sunos]
 283 | 
 284 |   '@esbuild/win32-arm64@0.21.5':
 285 |     resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
 286 |     engines: {node: '>=12'}
 287 |     cpu: [arm64]
 288 |     os: [win32]
 289 | 
 290 |   '@esbuild/win32-arm64@0.25.1':
 291 |     resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==}
 292 |     engines: {node: '>=18'}
 293 |     cpu: [arm64]
 294 |     os: [win32]
 295 | 
 296 |   '@esbuild/win32-ia32@0.21.5':
 297 |     resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
 298 |     engines: {node: '>=12'}
 299 |     cpu: [ia32]
 300 |     os: [win32]
 301 | 
 302 |   '@esbuild/win32-ia32@0.25.1':
 303 |     resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==}
 304 |     engines: {node: '>=18'}
 305 |     cpu: [ia32]
 306 |     os: [win32]
 307 | 
 308 |   '@esbuild/win32-x64@0.21.5':
 309 |     resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
 310 |     engines: {node: '>=12'}
 311 |     cpu: [x64]
 312 |     os: [win32]
 313 | 
 314 |   '@esbuild/win32-x64@0.25.1':
 315 |     resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==}
 316 |     engines: {node: '>=18'}
 317 |     cpu: [x64]
 318 |     os: [win32]
 319 | 
 320 |   '@isaacs/cliui@8.0.2':
 321 |     resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
 322 |     engines: {node: '>=12'}
 323 | 
 324 |   '@jridgewell/gen-mapping@0.3.8':
 325 |     resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
 326 |     engines: {node: '>=6.0.0'}
 327 | 
 328 |   '@jridgewell/resolve-uri@3.1.2':
 329 |     resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
 330 |     engines: {node: '>=6.0.0'}
 331 | 
 332 |   '@jridgewell/set-array@1.2.1':
 333 |     resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
 334 |     engines: {node: '>=6.0.0'}
 335 | 
 336 |   '@jridgewell/sourcemap-codec@1.5.0':
 337 |     resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
 338 | 
 339 |   '@jridgewell/trace-mapping@0.3.25':
 340 |     resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
 341 | 
 342 |   '@pkgjs/parseargs@0.11.0':
 343 |     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
 344 |     engines: {node: '>=14'}
 345 | 
 346 |   '@rollup/rollup-android-arm-eabi@4.35.0':
 347 |     resolution: {integrity: sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==}
 348 |     cpu: [arm]
 349 |     os: [android]
 350 | 
 351 |   '@rollup/rollup-android-arm64@4.35.0':
 352 |     resolution: {integrity: sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA==}
 353 |     cpu: [arm64]
 354 |     os: [android]
 355 | 
 356 |   '@rollup/rollup-darwin-arm64@4.35.0':
 357 |     resolution: {integrity: sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q==}
 358 |     cpu: [arm64]
 359 |     os: [darwin]
 360 | 
 361 |   '@rollup/rollup-darwin-x64@4.35.0':
 362 |     resolution: {integrity: sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q==}
 363 |     cpu: [x64]
 364 |     os: [darwin]
 365 | 
 366 |   '@rollup/rollup-freebsd-arm64@4.35.0':
 367 |     resolution: {integrity: sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ==}
 368 |     cpu: [arm64]
 369 |     os: [freebsd]
 370 | 
 371 |   '@rollup/rollup-freebsd-x64@4.35.0':
 372 |     resolution: {integrity: sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw==}
 373 |     cpu: [x64]
 374 |     os: [freebsd]
 375 | 
 376 |   '@rollup/rollup-linux-arm-gnueabihf@4.35.0':
 377 |     resolution: {integrity: sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==}
 378 |     cpu: [arm]
 379 |     os: [linux]
 380 | 
 381 |   '@rollup/rollup-linux-arm-musleabihf@4.35.0':
 382 |     resolution: {integrity: sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==}
 383 |     cpu: [arm]
 384 |     os: [linux]
 385 | 
 386 |   '@rollup/rollup-linux-arm64-gnu@4.35.0':
 387 |     resolution: {integrity: sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==}
 388 |     cpu: [arm64]
 389 |     os: [linux]
 390 | 
 391 |   '@rollup/rollup-linux-arm64-musl@4.35.0':
 392 |     resolution: {integrity: sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==}
 393 |     cpu: [arm64]
 394 |     os: [linux]
 395 | 
 396 |   '@rollup/rollup-linux-loongarch64-gnu@4.35.0':
 397 |     resolution: {integrity: sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==}
 398 |     cpu: [loong64]
 399 |     os: [linux]
 400 | 
 401 |   '@rollup/rollup-linux-powerpc64le-gnu@4.35.0':
 402 |     resolution: {integrity: sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==}
 403 |     cpu: [ppc64]
 404 |     os: [linux]
 405 | 
 406 |   '@rollup/rollup-linux-riscv64-gnu@4.35.0':
 407 |     resolution: {integrity: sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==}
 408 |     cpu: [riscv64]
 409 |     os: [linux]
 410 | 
 411 |   '@rollup/rollup-linux-s390x-gnu@4.35.0':
 412 |     resolution: {integrity: sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==}
 413 |     cpu: [s390x]
 414 |     os: [linux]
 415 | 
 416 |   '@rollup/rollup-linux-x64-gnu@4.35.0':
 417 |     resolution: {integrity: sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA==}
 418 |     cpu: [x64]
 419 |     os: [linux]
 420 | 
 421 |   '@rollup/rollup-linux-x64-musl@4.35.0':
 422 |     resolution: {integrity: sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg==}
 423 |     cpu: [x64]
 424 |     os: [linux]
 425 | 
 426 |   '@rollup/rollup-win32-arm64-msvc@4.35.0':
 427 |     resolution: {integrity: sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==}
 428 |     cpu: [arm64]
 429 |     os: [win32]
 430 | 
 431 |   '@rollup/rollup-win32-ia32-msvc@4.35.0':
 432 |     resolution: {integrity: sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw==}
 433 |     cpu: [ia32]
 434 |     os: [win32]
 435 | 
 436 |   '@rollup/rollup-win32-x64-msvc@4.35.0':
 437 |     resolution: {integrity: sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==}
 438 |     cpu: [x64]
 439 |     os: [win32]
 440 | 
 441 |   '@types/estree@1.0.6':
 442 |     resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
 443 | 
 444 |   '@types/node@22.13.10':
 445 |     resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==}
 446 | 
 447 |   '@vitest/expect@3.0.9':
 448 |     resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==}
 449 | 
 450 |   '@vitest/mocker@3.0.9':
 451 |     resolution: {integrity: sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==}
 452 |     peerDependencies:
 453 |       msw: ^2.4.9
 454 |       vite: ^5.0.0 || ^6.0.0
 455 |     peerDependenciesMeta:
 456 |       msw:
 457 |         optional: true
 458 |       vite:
 459 |         optional: true
 460 | 
 461 |   '@vitest/pretty-format@3.0.9':
 462 |     resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==}
 463 | 
 464 |   '@vitest/runner@3.0.9':
 465 |     resolution: {integrity: sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==}
 466 | 
 467 |   '@vitest/snapshot@3.0.9':
 468 |     resolution: {integrity: sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==}
 469 | 
 470 |   '@vitest/spy@3.0.9':
 471 |     resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==}
 472 | 
 473 |   '@vitest/utils@3.0.9':
 474 |     resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==}
 475 | 
 476 |   ansi-regex@5.0.1:
 477 |     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
 478 |     engines: {node: '>=8'}
 479 | 
 480 |   ansi-regex@6.1.0:
 481 |     resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==}
 482 |     engines: {node: '>=12'}
 483 | 
 484 |   ansi-styles@4.3.0:
 485 |     resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
 486 |     engines: {node: '>=8'}
 487 | 
 488 |   ansi-styles@6.2.1:
 489 |     resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
 490 |     engines: {node: '>=12'}
 491 | 
 492 |   any-promise@1.3.0:
 493 |     resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
 494 | 
 495 |   assertion-error@2.0.1:
 496 |     resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
 497 |     engines: {node: '>=12'}
 498 | 
 499 |   balanced-match@1.0.2:
 500 |     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
 501 | 
 502 |   brace-expansion@2.0.1:
 503 |     resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
 504 | 
 505 |   bundle-require@5.1.0:
 506 |     resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
 507 |     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
 508 |     peerDependencies:
 509 |       esbuild: '>=0.18'
 510 | 
 511 |   cac@6.7.14:
 512 |     resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
 513 |     engines: {node: '>=8'}
 514 | 
 515 |   chai@5.2.0:
 516 |     resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
 517 |     engines: {node: '>=12'}
 518 | 
 519 |   check-error@2.1.1:
 520 |     resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
 521 |     engines: {node: '>= 16'}
 522 | 
 523 |   chokidar@4.0.3:
 524 |     resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
 525 |     engines: {node: '>= 14.16.0'}
 526 | 
 527 |   color-convert@2.0.1:
 528 |     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
 529 |     engines: {node: '>=7.0.0'}
 530 | 
 531 |   color-name@1.1.4:
 532 |     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
 533 | 
 534 |   commander@4.1.1:
 535 |     resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
 536 |     engines: {node: '>= 6'}
 537 | 
 538 |   consola@3.4.0:
 539 |     resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==}
 540 |     engines: {node: ^14.18.0 || >=16.10.0}
 541 | 
 542 |   cross-spawn@7.0.6:
 543 |     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
 544 |     engines: {node: '>= 8'}
 545 | 
 546 |   debug@4.4.0:
 547 |     resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
 548 |     engines: {node: '>=6.0'}
 549 |     peerDependencies:
 550 |       supports-color: '*'
 551 |     peerDependenciesMeta:
 552 |       supports-color:
 553 |         optional: true
 554 | 
 555 |   deep-eql@5.0.2:
 556 |     resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
 557 |     engines: {node: '>=6'}
 558 | 
 559 |   eastasianwidth@0.2.0:
 560 |     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
 561 | 
 562 |   emoji-regex@8.0.0:
 563 |     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
 564 | 
 565 |   emoji-regex@9.2.2:
 566 |     resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
 567 | 
 568 |   es-module-lexer@1.6.0:
 569 |     resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==}
 570 | 
 571 |   esbuild@0.21.5:
 572 |     resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
 573 |     engines: {node: '>=12'}
 574 |     hasBin: true
 575 | 
 576 |   esbuild@0.25.1:
 577 |     resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==}
 578 |     engines: {node: '>=18'}
 579 |     hasBin: true
 580 | 
 581 |   estree-walker@3.0.3:
 582 |     resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
 583 | 
 584 |   expect-type@1.2.0:
 585 |     resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==}
 586 |     engines: {node: '>=12.0.0'}
 587 | 
 588 |   fdir@6.4.3:
 589 |     resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==}
 590 |     peerDependencies:
 591 |       picomatch: ^3 || ^4
 592 |     peerDependenciesMeta:
 593 |       picomatch:
 594 |         optional: true
 595 | 
 596 |   foreground-child@3.3.1:
 597 |     resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
 598 |     engines: {node: '>=14'}
 599 | 
 600 |   fsevents@2.3.3:
 601 |     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
 602 |     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
 603 |     os: [darwin]
 604 | 
 605 |   glob@10.4.5:
 606 |     resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
 607 |     hasBin: true
 608 | 
 609 |   is-fullwidth-code-point@3.0.0:
 610 |     resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
 611 |     engines: {node: '>=8'}
 612 | 
 613 |   isexe@2.0.0:
 614 |     resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
 615 | 
 616 |   jackspeak@3.4.3:
 617 |     resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
 618 | 
 619 |   joycon@3.1.1:
 620 |     resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
 621 |     engines: {node: '>=10'}
 622 | 
 623 |   lilconfig@3.1.3:
 624 |     resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
 625 |     engines: {node: '>=14'}
 626 | 
 627 |   lines-and-columns@1.2.4:
 628 |     resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
 629 | 
 630 |   load-tsconfig@0.2.5:
 631 |     resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
 632 |     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
 633 | 
 634 |   lodash.sortby@4.7.0:
 635 |     resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
 636 | 
 637 |   loupe@3.1.3:
 638 |     resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
 639 | 
 640 |   lru-cache@10.4.3:
 641 |     resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
 642 | 
 643 |   magic-string@0.30.17:
 644 |     resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
 645 | 
 646 |   minimatch@9.0.5:
 647 |     resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
 648 |     engines: {node: '>=16 || 14 >=14.17'}
 649 | 
 650 |   minipass@7.1.2:
 651 |     resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
 652 |     engines: {node: '>=16 || 14 >=14.17'}
 653 | 
 654 |   ms@2.1.3:
 655 |     resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
 656 | 
 657 |   mz@2.7.0:
 658 |     resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
 659 | 
 660 |   nanoid@3.3.9:
 661 |     resolution: {integrity: sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==}
 662 |     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
 663 |     hasBin: true
 664 | 
 665 |   object-assign@4.1.1:
 666 |     resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
 667 |     engines: {node: '>=0.10.0'}
 668 | 
 669 |   package-json-from-dist@1.0.1:
 670 |     resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
 671 | 
 672 |   path-key@3.1.1:
 673 |     resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
 674 |     engines: {node: '>=8'}
 675 | 
 676 |   path-scurry@1.11.1:
 677 |     resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
 678 |     engines: {node: '>=16 || 14 >=14.18'}
 679 | 
 680 |   pathe@2.0.3:
 681 |     resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
 682 | 
 683 |   pathval@2.0.0:
 684 |     resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
 685 |     engines: {node: '>= 14.16'}
 686 | 
 687 |   picocolors@1.1.1:
 688 |     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 689 | 
 690 |   picomatch@4.0.2:
 691 |     resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
 692 |     engines: {node: '>=12'}
 693 | 
 694 |   pirates@4.0.6:
 695 |     resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
 696 |     engines: {node: '>= 6'}
 697 | 
 698 |   postcss-load-config@6.0.1:
 699 |     resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
 700 |     engines: {node: '>= 18'}
 701 |     peerDependencies:
 702 |       jiti: '>=1.21.0'
 703 |       postcss: '>=8.0.9'
 704 |       tsx: ^4.8.1
 705 |       yaml: ^2.4.2
 706 |     peerDependenciesMeta:
 707 |       jiti:
 708 |         optional: true
 709 |       postcss:
 710 |         optional: true
 711 |       tsx:
 712 |         optional: true
 713 |       yaml:
 714 |         optional: true
 715 | 
 716 |   postcss@8.5.3:
 717 |     resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
 718 |     engines: {node: ^10 || ^12 || >=14}
 719 | 
 720 |   prettier@3.5.3:
 721 |     resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
 722 |     engines: {node: '>=14'}
 723 |     hasBin: true
 724 | 
 725 |   punycode@2.3.1:
 726 |     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
 727 |     engines: {node: '>=6'}
 728 | 
 729 |   readdirp@4.1.2:
 730 |     resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
 731 |     engines: {node: '>= 14.18.0'}
 732 | 
 733 |   resolve-from@5.0.0:
 734 |     resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
 735 |     engines: {node: '>=8'}
 736 | 
 737 |   rollup@4.35.0:
 738 |     resolution: {integrity: sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==}
 739 |     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
 740 |     hasBin: true
 741 | 
 742 |   shebang-command@2.0.0:
 743 |     resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
 744 |     engines: {node: '>=8'}
 745 | 
 746 |   shebang-regex@3.0.0:
 747 |     resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
 748 |     engines: {node: '>=8'}
 749 | 
 750 |   siginfo@2.0.0:
 751 |     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 752 | 
 753 |   signal-exit@4.1.0:
 754 |     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
 755 |     engines: {node: '>=14'}
 756 | 
 757 |   source-map-js@1.2.1:
 758 |     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
 759 |     engines: {node: '>=0.10.0'}
 760 | 
 761 |   source-map@0.8.0-beta.0:
 762 |     resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
 763 |     engines: {node: '>= 8'}
 764 | 
 765 |   stackback@0.0.2:
 766 |     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 767 | 
 768 |   std-env@3.8.1:
 769 |     resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==}
 770 | 
 771 |   string-width@4.2.3:
 772 |     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
 773 |     engines: {node: '>=8'}
 774 | 
 775 |   string-width@5.1.2:
 776 |     resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
 777 |     engines: {node: '>=12'}
 778 | 
 779 |   strip-ansi@6.0.1:
 780 |     resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
 781 |     engines: {node: '>=8'}
 782 | 
 783 |   strip-ansi@7.1.0:
 784 |     resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
 785 |     engines: {node: '>=12'}
 786 | 
 787 |   sucrase@3.35.0:
 788 |     resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
 789 |     engines: {node: '>=16 || 14 >=14.17'}
 790 |     hasBin: true
 791 | 
 792 |   thenify-all@1.6.0:
 793 |     resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
 794 |     engines: {node: '>=0.8'}
 795 | 
 796 |   thenify@3.3.1:
 797 |     resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
 798 | 
 799 |   tinybench@2.9.0:
 800 |     resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
 801 | 
 802 |   tinyexec@0.3.2:
 803 |     resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
 804 | 
 805 |   tinyglobby@0.2.12:
 806 |     resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==}
 807 |     engines: {node: '>=12.0.0'}
 808 | 
 809 |   tinypool@1.0.2:
 810 |     resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==}
 811 |     engines: {node: ^18.0.0 || >=20.0.0}
 812 | 
 813 |   tinyrainbow@2.0.0:
 814 |     resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
 815 |     engines: {node: '>=14.0.0'}
 816 | 
 817 |   tinyspy@3.0.2:
 818 |     resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
 819 |     engines: {node: '>=14.0.0'}
 820 | 
 821 |   tr46@1.0.1:
 822 |     resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
 823 | 
 824 |   tree-kill@1.2.2:
 825 |     resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
 826 |     hasBin: true
 827 | 
 828 |   ts-interface-checker@0.1.13:
 829 |     resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
 830 | 
 831 |   tsup@8.4.0:
 832 |     resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==}
 833 |     engines: {node: '>=18'}
 834 |     hasBin: true
 835 |     peerDependencies:
 836 |       '@microsoft/api-extractor': ^7.36.0
 837 |       '@swc/core': ^1
 838 |       postcss: ^8.4.12
 839 |       typescript: '>=4.5.0'
 840 |     peerDependenciesMeta:
 841 |       '@microsoft/api-extractor':
 842 |         optional: true
 843 |       '@swc/core':
 844 |         optional: true
 845 |       postcss:
 846 |         optional: true
 847 |       typescript:
 848 |         optional: true
 849 | 
 850 |   typescript@5.8.2:
 851 |     resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==}
 852 |     engines: {node: '>=14.17'}
 853 |     hasBin: true
 854 | 
 855 |   undici-types@6.20.0:
 856 |     resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
 857 | 
 858 |   vite-node@3.0.9:
 859 |     resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==}
 860 |     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
 861 |     hasBin: true
 862 | 
 863 |   vite@5.4.14:
 864 |     resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
 865 |     engines: {node: ^18.0.0 || >=20.0.0}
 866 |     hasBin: true
 867 |     peerDependencies:
 868 |       '@types/node': ^18.0.0 || >=20.0.0
 869 |       less: '*'
 870 |       lightningcss: ^1.21.0
 871 |       sass: '*'
 872 |       sass-embedded: '*'
 873 |       stylus: '*'
 874 |       sugarss: '*'
 875 |       terser: ^5.4.0
 876 |     peerDependenciesMeta:
 877 |       '@types/node':
 878 |         optional: true
 879 |       less:
 880 |         optional: true
 881 |       lightningcss:
 882 |         optional: true
 883 |       sass:
 884 |         optional: true
 885 |       sass-embedded:
 886 |         optional: true
 887 |       stylus:
 888 |         optional: true
 889 |       sugarss:
 890 |         optional: true
 891 |       terser:
 892 |         optional: true
 893 | 
 894 |   vitest@3.0.9:
 895 |     resolution: {integrity: sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==}
 896 |     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
 897 |     hasBin: true
 898 |     peerDependencies:
 899 |       '@edge-runtime/vm': '*'
 900 |       '@types/debug': ^4.1.12
 901 |       '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
 902 |       '@vitest/browser': 3.0.9
 903 |       '@vitest/ui': 3.0.9
 904 |       happy-dom: '*'
 905 |       jsdom: '*'
 906 |     peerDependenciesMeta:
 907 |       '@edge-runtime/vm':
 908 |         optional: true
 909 |       '@types/debug':
 910 |         optional: true
 911 |       '@types/node':
 912 |         optional: true
 913 |       '@vitest/browser':
 914 |         optional: true
 915 |       '@vitest/ui':
 916 |         optional: true
 917 |       happy-dom:
 918 |         optional: true
 919 |       jsdom:
 920 |         optional: true
 921 | 
 922 |   webidl-conversions@4.0.2:
 923 |     resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
 924 | 
 925 |   whatwg-url@7.1.0:
 926 |     resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
 927 | 
 928 |   which@2.0.2:
 929 |     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
 930 |     engines: {node: '>= 8'}
 931 |     hasBin: true
 932 | 
 933 |   why-is-node-running@2.3.0:
 934 |     resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
 935 |     engines: {node: '>=8'}
 936 |     hasBin: true
 937 | 
 938 |   wrap-ansi@7.0.0:
 939 |     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
 940 |     engines: {node: '>=10'}
 941 | 
 942 |   wrap-ansi@8.1.0:
 943 |     resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
 944 |     engines: {node: '>=12'}
 945 | 
 946 | snapshots:
 947 | 
 948 |   '@cloudflare/workers-types@4.20250311.0': {}
 949 | 
 950 |   '@esbuild/aix-ppc64@0.21.5':
 951 |     optional: true
 952 | 
 953 |   '@esbuild/aix-ppc64@0.25.1':
 954 |     optional: true
 955 | 
 956 |   '@esbuild/android-arm64@0.21.5':
 957 |     optional: true
 958 | 
 959 |   '@esbuild/android-arm64@0.25.1':
 960 |     optional: true
 961 | 
 962 |   '@esbuild/android-arm@0.21.5':
 963 |     optional: true
 964 | 
 965 |   '@esbuild/android-arm@0.25.1':
 966 |     optional: true
 967 | 
 968 |   '@esbuild/android-x64@0.21.5':
 969 |     optional: true
 970 | 
 971 |   '@esbuild/android-x64@0.25.1':
 972 |     optional: true
 973 | 
 974 |   '@esbuild/darwin-arm64@0.21.5':
 975 |     optional: true
 976 | 
 977 |   '@esbuild/darwin-arm64@0.25.1':
 978 |     optional: true
 979 | 
 980 |   '@esbuild/darwin-x64@0.21.5':
 981 |     optional: true
 982 | 
 983 |   '@esbuild/darwin-x64@0.25.1':
 984 |     optional: true
 985 | 
 986 |   '@esbuild/freebsd-arm64@0.21.5':
 987 |     optional: true
 988 | 
 989 |   '@esbuild/freebsd-arm64@0.25.1':
 990 |     optional: true
 991 | 
 992 |   '@esbuild/freebsd-x64@0.21.5':
 993 |     optional: true
 994 | 
 995 |   '@esbuild/freebsd-x64@0.25.1':
 996 |     optional: true
 997 | 
 998 |   '@esbuild/linux-arm64@0.21.5':
 999 |     optional: true
1000 | 
1001 |   '@esbuild/linux-arm64@0.25.1':
1002 |     optional: true
1003 | 
1004 |   '@esbuild/linux-arm@0.21.5':
1005 |     optional: true
1006 | 
1007 |   '@esbuild/linux-arm@0.25.1':
1008 |     optional: true
1009 | 
1010 |   '@esbuild/linux-ia32@0.21.5':
1011 |     optional: true
1012 | 
1013 |   '@esbuild/linux-ia32@0.25.1':
1014 |     optional: true
1015 | 
1016 |   '@esbuild/linux-loong64@0.21.5':
1017 |     optional: true
1018 | 
1019 |   '@esbuild/linux-loong64@0.25.1':
1020 |     optional: true
1021 | 
1022 |   '@esbuild/linux-mips64el@0.21.5':
1023 |     optional: true
1024 | 
1025 |   '@esbuild/linux-mips64el@0.25.1':
1026 |     optional: true
1027 | 
1028 |   '@esbuild/linux-ppc64@0.21.5':
1029 |     optional: true
1030 | 
1031 |   '@esbuild/linux-ppc64@0.25.1':
1032 |     optional: true
1033 | 
1034 |   '@esbuild/linux-riscv64@0.21.5':
1035 |     optional: true
1036 | 
1037 |   '@esbuild/linux-riscv64@0.25.1':
1038 |     optional: true
1039 | 
1040 |   '@esbuild/linux-s390x@0.21.5':
1041 |     optional: true
1042 | 
1043 |   '@esbuild/linux-s390x@0.25.1':
1044 |     optional: true
1045 | 
1046 |   '@esbuild/linux-x64@0.21.5':
1047 |     optional: true
1048 | 
1049 |   '@esbuild/linux-x64@0.25.1':
1050 |     optional: true
1051 | 
1052 |   '@esbuild/netbsd-arm64@0.25.1':
1053 |     optional: true
1054 | 
1055 |   '@esbuild/netbsd-x64@0.21.5':
1056 |     optional: true
1057 | 
1058 |   '@esbuild/netbsd-x64@0.25.1':
1059 |     optional: true
1060 | 
1061 |   '@esbuild/openbsd-arm64@0.25.1':
1062 |     optional: true
1063 | 
1064 |   '@esbuild/openbsd-x64@0.21.5':
1065 |     optional: true
1066 | 
1067 |   '@esbuild/openbsd-x64@0.25.1':
1068 |     optional: true
1069 | 
1070 |   '@esbuild/sunos-x64@0.21.5':
1071 |     optional: true
1072 | 
1073 |   '@esbuild/sunos-x64@0.25.1':
1074 |     optional: true
1075 | 
1076 |   '@esbuild/win32-arm64@0.21.5':
1077 |     optional: true
1078 | 
1079 |   '@esbuild/win32-arm64@0.25.1':
1080 |     optional: true
1081 | 
1082 |   '@esbuild/win32-ia32@0.21.5':
1083 |     optional: true
1084 | 
1085 |   '@esbuild/win32-ia32@0.25.1':
1086 |     optional: true
1087 | 
1088 |   '@esbuild/win32-x64@0.21.5':
1089 |     optional: true
1090 | 
1091 |   '@esbuild/win32-x64@0.25.1':
1092 |     optional: true
1093 | 
1094 |   '@isaacs/cliui@8.0.2':
1095 |     dependencies:
1096 |       string-width: 5.1.2
1097 |       string-width-cjs: string-width@4.2.3
1098 |       strip-ansi: 7.1.0
1099 |       strip-ansi-cjs: strip-ansi@6.0.1
1100 |       wrap-ansi: 8.1.0
1101 |       wrap-ansi-cjs: wrap-ansi@7.0.0
1102 | 
1103 |   '@jridgewell/gen-mapping@0.3.8':
1104 |     dependencies:
1105 |       '@jridgewell/set-array': 1.2.1
1106 |       '@jridgewell/sourcemap-codec': 1.5.0
1107 |       '@jridgewell/trace-mapping': 0.3.25
1108 | 
1109 |   '@jridgewell/resolve-uri@3.1.2': {}
1110 | 
1111 |   '@jridgewell/set-array@1.2.1': {}
1112 | 
1113 |   '@jridgewell/sourcemap-codec@1.5.0': {}
1114 | 
1115 |   '@jridgewell/trace-mapping@0.3.25':
1116 |     dependencies:
1117 |       '@jridgewell/resolve-uri': 3.1.2
1118 |       '@jridgewell/sourcemap-codec': 1.5.0
1119 | 
1120 |   '@pkgjs/parseargs@0.11.0':
1121 |     optional: true
1122 | 
1123 |   '@rollup/rollup-android-arm-eabi@4.35.0':
1124 |     optional: true
1125 | 
1126 |   '@rollup/rollup-android-arm64@4.35.0':
1127 |     optional: true
1128 | 
1129 |   '@rollup/rollup-darwin-arm64@4.35.0':
1130 |     optional: true
1131 | 
1132 |   '@rollup/rollup-darwin-x64@4.35.0':
1133 |     optional: true
1134 | 
1135 |   '@rollup/rollup-freebsd-arm64@4.35.0':
1136 |     optional: true
1137 | 
1138 |   '@rollup/rollup-freebsd-x64@4.35.0':
1139 |     optional: true
1140 | 
1141 |   '@rollup/rollup-linux-arm-gnueabihf@4.35.0':
1142 |     optional: true
1143 | 
1144 |   '@rollup/rollup-linux-arm-musleabihf@4.35.0':
1145 |     optional: true
1146 | 
1147 |   '@rollup/rollup-linux-arm64-gnu@4.35.0':
1148 |     optional: true
1149 | 
1150 |   '@rollup/rollup-linux-arm64-musl@4.35.0':
1151 |     optional: true
1152 | 
1153 |   '@rollup/rollup-linux-loongarch64-gnu@4.35.0':
1154 |     optional: true
1155 | 
1156 |   '@rollup/rollup-linux-powerpc64le-gnu@4.35.0':
1157 |     optional: true
1158 | 
1159 |   '@rollup/rollup-linux-riscv64-gnu@4.35.0':
1160 |     optional: true
1161 | 
1162 |   '@rollup/rollup-linux-s390x-gnu@4.35.0':
1163 |     optional: true
1164 | 
1165 |   '@rollup/rollup-linux-x64-gnu@4.35.0':
1166 |     optional: true
1167 | 
1168 |   '@rollup/rollup-linux-x64-musl@4.35.0':
1169 |     optional: true
1170 | 
1171 |   '@rollup/rollup-win32-arm64-msvc@4.35.0':
1172 |     optional: true
1173 | 
1174 |   '@rollup/rollup-win32-ia32-msvc@4.35.0':
1175 |     optional: true
1176 | 
1177 |   '@rollup/rollup-win32-x64-msvc@4.35.0':
1178 |     optional: true
1179 | 
1180 |   '@types/estree@1.0.6': {}
1181 | 
1182 |   '@types/node@22.13.10':
1183 |     dependencies:
1184 |       undici-types: 6.20.0
1185 |     optional: true
1186 | 
1187 |   '@vitest/expect@3.0.9':
1188 |     dependencies:
1189 |       '@vitest/spy': 3.0.9
1190 |       '@vitest/utils': 3.0.9
1191 |       chai: 5.2.0
1192 |       tinyrainbow: 2.0.0
1193 | 
1194 |   '@vitest/mocker@3.0.9(vite@5.4.14(@types/node@22.13.10))':
1195 |     dependencies:
1196 |       '@vitest/spy': 3.0.9
1197 |       estree-walker: 3.0.3
1198 |       magic-string: 0.30.17
1199 |     optionalDependencies:
1200 |       vite: 5.4.14(@types/node@22.13.10)
1201 | 
1202 |   '@vitest/pretty-format@3.0.9':
1203 |     dependencies:
1204 |       tinyrainbow: 2.0.0
1205 | 
1206 |   '@vitest/runner@3.0.9':
1207 |     dependencies:
1208 |       '@vitest/utils': 3.0.9
1209 |       pathe: 2.0.3
1210 | 
1211 |   '@vitest/snapshot@3.0.9':
1212 |     dependencies:
1213 |       '@vitest/pretty-format': 3.0.9
1214 |       magic-string: 0.30.17
1215 |       pathe: 2.0.3
1216 | 
1217 |   '@vitest/spy@3.0.9':
1218 |     dependencies:
1219 |       tinyspy: 3.0.2
1220 | 
1221 |   '@vitest/utils@3.0.9':
1222 |     dependencies:
1223 |       '@vitest/pretty-format': 3.0.9
1224 |       loupe: 3.1.3
1225 |       tinyrainbow: 2.0.0
1226 | 
1227 |   ansi-regex@5.0.1: {}
1228 | 
1229 |   ansi-regex@6.1.0: {}
1230 | 
1231 |   ansi-styles@4.3.0:
1232 |     dependencies:
1233 |       color-convert: 2.0.1
1234 | 
1235 |   ansi-styles@6.2.1: {}
1236 | 
1237 |   any-promise@1.3.0: {}
1238 | 
1239 |   assertion-error@2.0.1: {}
1240 | 
1241 |   balanced-match@1.0.2: {}
1242 | 
1243 |   brace-expansion@2.0.1:
1244 |     dependencies:
1245 |       balanced-match: 1.0.2
1246 | 
1247 |   bundle-require@5.1.0(esbuild@0.25.1):
1248 |     dependencies:
1249 |       esbuild: 0.25.1
1250 |       load-tsconfig: 0.2.5
1251 | 
1252 |   cac@6.7.14: {}
1253 | 
1254 |   chai@5.2.0:
1255 |     dependencies:
1256 |       assertion-error: 2.0.1
1257 |       check-error: 2.1.1
1258 |       deep-eql: 5.0.2
1259 |       loupe: 3.1.3
1260 |       pathval: 2.0.0
1261 | 
1262 |   check-error@2.1.1: {}
1263 | 
1264 |   chokidar@4.0.3:
1265 |     dependencies:
1266 |       readdirp: 4.1.2
1267 | 
1268 |   color-convert@2.0.1:
1269 |     dependencies:
1270 |       color-name: 1.1.4
1271 | 
1272 |   color-name@1.1.4: {}
1273 | 
1274 |   commander@4.1.1: {}
1275 | 
1276 |   consola@3.4.0: {}
1277 | 
1278 |   cross-spawn@7.0.6:
1279 |     dependencies:
1280 |       path-key: 3.1.1
1281 |       shebang-command: 2.0.0
1282 |       which: 2.0.2
1283 | 
1284 |   debug@4.4.0:
1285 |     dependencies:
1286 |       ms: 2.1.3
1287 | 
1288 |   deep-eql@5.0.2: {}
1289 | 
1290 |   eastasianwidth@0.2.0: {}
1291 | 
1292 |   emoji-regex@8.0.0: {}
1293 | 
1294 |   emoji-regex@9.2.2: {}
1295 | 
1296 |   es-module-lexer@1.6.0: {}
1297 | 
1298 |   esbuild@0.21.5:
1299 |     optionalDependencies:
1300 |       '@esbuild/aix-ppc64': 0.21.5
1301 |       '@esbuild/android-arm': 0.21.5
1302 |       '@esbuild/android-arm64': 0.21.5
1303 |       '@esbuild/android-x64': 0.21.5
1304 |       '@esbuild/darwin-arm64': 0.21.5
1305 |       '@esbuild/darwin-x64': 0.21.5
1306 |       '@esbuild/freebsd-arm64': 0.21.5
1307 |       '@esbuild/freebsd-x64': 0.21.5
1308 |       '@esbuild/linux-arm': 0.21.5
1309 |       '@esbuild/linux-arm64': 0.21.5
1310 |       '@esbuild/linux-ia32': 0.21.5
1311 |       '@esbuild/linux-loong64': 0.21.5
1312 |       '@esbuild/linux-mips64el': 0.21.5
1313 |       '@esbuild/linux-ppc64': 0.21.5
1314 |       '@esbuild/linux-riscv64': 0.21.5
1315 |       '@esbuild/linux-s390x': 0.21.5
1316 |       '@esbuild/linux-x64': 0.21.5
1317 |       '@esbuild/netbsd-x64': 0.21.5
1318 |       '@esbuild/openbsd-x64': 0.21.5
1319 |       '@esbuild/sunos-x64': 0.21.5
1320 |       '@esbuild/win32-arm64': 0.21.5
1321 |       '@esbuild/win32-ia32': 0.21.5
1322 |       '@esbuild/win32-x64': 0.21.5
1323 | 
1324 |   esbuild@0.25.1:
1325 |     optionalDependencies:
1326 |       '@esbuild/aix-ppc64': 0.25.1
1327 |       '@esbuild/android-arm': 0.25.1
1328 |       '@esbuild/android-arm64': 0.25.1
1329 |       '@esbuild/android-x64': 0.25.1
1330 |       '@esbuild/darwin-arm64': 0.25.1
1331 |       '@esbuild/darwin-x64': 0.25.1
1332 |       '@esbuild/freebsd-arm64': 0.25.1
1333 |       '@esbuild/freebsd-x64': 0.25.1
1334 |       '@esbuild/linux-arm': 0.25.1
1335 |       '@esbuild/linux-arm64': 0.25.1
1336 |       '@esbuild/linux-ia32': 0.25.1
1337 |       '@esbuild/linux-loong64': 0.25.1
1338 |       '@esbuild/linux-mips64el': 0.25.1
1339 |       '@esbuild/linux-ppc64': 0.25.1
1340 |       '@esbuild/linux-riscv64': 0.25.1
1341 |       '@esbuild/linux-s390x': 0.25.1
1342 |       '@esbuild/linux-x64': 0.25.1
1343 |       '@esbuild/netbsd-arm64': 0.25.1
1344 |       '@esbuild/netbsd-x64': 0.25.1
1345 |       '@esbuild/openbsd-arm64': 0.25.1
1346 |       '@esbuild/openbsd-x64': 0.25.1
1347 |       '@esbuild/sunos-x64': 0.25.1
1348 |       '@esbuild/win32-arm64': 0.25.1
1349 |       '@esbuild/win32-ia32': 0.25.1
1350 |       '@esbuild/win32-x64': 0.25.1
1351 | 
1352 |   estree-walker@3.0.3:
1353 |     dependencies:
1354 |       '@types/estree': 1.0.6
1355 | 
1356 |   expect-type@1.2.0: {}
1357 | 
1358 |   fdir@6.4.3(picomatch@4.0.2):
1359 |     optionalDependencies:
1360 |       picomatch: 4.0.2
1361 | 
1362 |   foreground-child@3.3.1:
1363 |     dependencies:
1364 |       cross-spawn: 7.0.6
1365 |       signal-exit: 4.1.0
1366 | 
1367 |   fsevents@2.3.3:
1368 |     optional: true
1369 | 
1370 |   glob@10.4.5:
1371 |     dependencies:
1372 |       foreground-child: 3.3.1
1373 |       jackspeak: 3.4.3
1374 |       minimatch: 9.0.5
1375 |       minipass: 7.1.2
1376 |       package-json-from-dist: 1.0.1
1377 |       path-scurry: 1.11.1
1378 | 
1379 |   is-fullwidth-code-point@3.0.0: {}
1380 | 
1381 |   isexe@2.0.0: {}
1382 | 
1383 |   jackspeak@3.4.3:
1384 |     dependencies:
1385 |       '@isaacs/cliui': 8.0.2
1386 |     optionalDependencies:
1387 |       '@pkgjs/parseargs': 0.11.0
1388 | 
1389 |   joycon@3.1.1: {}
1390 | 
1391 |   lilconfig@3.1.3: {}
1392 | 
1393 |   lines-and-columns@1.2.4: {}
1394 | 
1395 |   load-tsconfig@0.2.5: {}
1396 | 
1397 |   lodash.sortby@4.7.0: {}
1398 | 
1399 |   loupe@3.1.3: {}
1400 | 
1401 |   lru-cache@10.4.3: {}
1402 | 
1403 |   magic-string@0.30.17:
1404 |     dependencies:
1405 |       '@jridgewell/sourcemap-codec': 1.5.0
1406 | 
1407 |   minimatch@9.0.5:
1408 |     dependencies:
1409 |       brace-expansion: 2.0.1
1410 | 
1411 |   minipass@7.1.2: {}
1412 | 
1413 |   ms@2.1.3: {}
1414 | 
1415 |   mz@2.7.0:
1416 |     dependencies:
1417 |       any-promise: 1.3.0
1418 |       object-assign: 4.1.1
1419 |       thenify-all: 1.6.0
1420 | 
1421 |   nanoid@3.3.9: {}
1422 | 
1423 |   object-assign@4.1.1: {}
1424 | 
1425 |   package-json-from-dist@1.0.1: {}
1426 | 
1427 |   path-key@3.1.1: {}
1428 | 
1429 |   path-scurry@1.11.1:
1430 |     dependencies:
1431 |       lru-cache: 10.4.3
1432 |       minipass: 7.1.2
1433 | 
1434 |   pathe@2.0.3: {}
1435 | 
1436 |   pathval@2.0.0: {}
1437 | 
1438 |   picocolors@1.1.1: {}
1439 | 
1440 |   picomatch@4.0.2: {}
1441 | 
1442 |   pirates@4.0.6: {}
1443 | 
1444 |   postcss-load-config@6.0.1(postcss@8.5.3):
1445 |     dependencies:
1446 |       lilconfig: 3.1.3
1447 |     optionalDependencies:
1448 |       postcss: 8.5.3
1449 | 
1450 |   postcss@8.5.3:
1451 |     dependencies:
1452 |       nanoid: 3.3.9
1453 |       picocolors: 1.1.1
1454 |       source-map-js: 1.2.1
1455 | 
1456 |   prettier@3.5.3: {}
1457 | 
1458 |   punycode@2.3.1: {}
1459 | 
1460 |   readdirp@4.1.2: {}
1461 | 
1462 |   resolve-from@5.0.0: {}
1463 | 
1464 |   rollup@4.35.0:
1465 |     dependencies:
1466 |       '@types/estree': 1.0.6
1467 |     optionalDependencies:
1468 |       '@rollup/rollup-android-arm-eabi': 4.35.0
1469 |       '@rollup/rollup-android-arm64': 4.35.0
1470 |       '@rollup/rollup-darwin-arm64': 4.35.0
1471 |       '@rollup/rollup-darwin-x64': 4.35.0
1472 |       '@rollup/rollup-freebsd-arm64': 4.35.0
1473 |       '@rollup/rollup-freebsd-x64': 4.35.0
1474 |       '@rollup/rollup-linux-arm-gnueabihf': 4.35.0
1475 |       '@rollup/rollup-linux-arm-musleabihf': 4.35.0
1476 |       '@rollup/rollup-linux-arm64-gnu': 4.35.0
1477 |       '@rollup/rollup-linux-arm64-musl': 4.35.0
1478 |       '@rollup/rollup-linux-loongarch64-gnu': 4.35.0
1479 |       '@rollup/rollup-linux-powerpc64le-gnu': 4.35.0
1480 |       '@rollup/rollup-linux-riscv64-gnu': 4.35.0
1481 |       '@rollup/rollup-linux-s390x-gnu': 4.35.0
1482 |       '@rollup/rollup-linux-x64-gnu': 4.35.0
1483 |       '@rollup/rollup-linux-x64-musl': 4.35.0
1484 |       '@rollup/rollup-win32-arm64-msvc': 4.35.0
1485 |       '@rollup/rollup-win32-ia32-msvc': 4.35.0
1486 |       '@rollup/rollup-win32-x64-msvc': 4.35.0
1487 |       fsevents: 2.3.3
1488 | 
1489 |   shebang-command@2.0.0:
1490 |     dependencies:
1491 |       shebang-regex: 3.0.0
1492 | 
1493 |   shebang-regex@3.0.0: {}
1494 | 
1495 |   siginfo@2.0.0: {}
1496 | 
1497 |   signal-exit@4.1.0: {}
1498 | 
1499 |   source-map-js@1.2.1: {}
1500 | 
1501 |   source-map@0.8.0-beta.0:
1502 |     dependencies:
1503 |       whatwg-url: 7.1.0
1504 | 
1505 |   stackback@0.0.2: {}
1506 | 
1507 |   std-env@3.8.1: {}
1508 | 
1509 |   string-width@4.2.3:
1510 |     dependencies:
1511 |       emoji-regex: 8.0.0
1512 |       is-fullwidth-code-point: 3.0.0
1513 |       strip-ansi: 6.0.1
1514 | 
1515 |   string-width@5.1.2:
1516 |     dependencies:
1517 |       eastasianwidth: 0.2.0
1518 |       emoji-regex: 9.2.2
1519 |       strip-ansi: 7.1.0
1520 | 
1521 |   strip-ansi@6.0.1:
1522 |     dependencies:
1523 |       ansi-regex: 5.0.1
1524 | 
1525 |   strip-ansi@7.1.0:
1526 |     dependencies:
1527 |       ansi-regex: 6.1.0
1528 | 
1529 |   sucrase@3.35.0:
1530 |     dependencies:
1531 |       '@jridgewell/gen-mapping': 0.3.8
1532 |       commander: 4.1.1
1533 |       glob: 10.4.5
1534 |       lines-and-columns: 1.2.4
1535 |       mz: 2.7.0
1536 |       pirates: 4.0.6
1537 |       ts-interface-checker: 0.1.13
1538 | 
1539 |   thenify-all@1.6.0:
1540 |     dependencies:
1541 |       thenify: 3.3.1
1542 | 
1543 |   thenify@3.3.1:
1544 |     dependencies:
1545 |       any-promise: 1.3.0
1546 | 
1547 |   tinybench@2.9.0: {}
1548 | 
1549 |   tinyexec@0.3.2: {}
1550 | 
1551 |   tinyglobby@0.2.12:
1552 |     dependencies:
1553 |       fdir: 6.4.3(picomatch@4.0.2)
1554 |       picomatch: 4.0.2
1555 | 
1556 |   tinypool@1.0.2: {}
1557 | 
1558 |   tinyrainbow@2.0.0: {}
1559 | 
1560 |   tinyspy@3.0.2: {}
1561 | 
1562 |   tr46@1.0.1:
1563 |     dependencies:
1564 |       punycode: 2.3.1
1565 | 
1566 |   tree-kill@1.2.2: {}
1567 | 
1568 |   ts-interface-checker@0.1.13: {}
1569 | 
1570 |   tsup@8.4.0(postcss@8.5.3)(typescript@5.8.2):
1571 |     dependencies:
1572 |       bundle-require: 5.1.0(esbuild@0.25.1)
1573 |       cac: 6.7.14
1574 |       chokidar: 4.0.3
1575 |       consola: 3.4.0
1576 |       debug: 4.4.0
1577 |       esbuild: 0.25.1
1578 |       joycon: 3.1.1
1579 |       picocolors: 1.1.1
1580 |       postcss-load-config: 6.0.1(postcss@8.5.3)
1581 |       resolve-from: 5.0.0
1582 |       rollup: 4.35.0
1583 |       source-map: 0.8.0-beta.0
1584 |       sucrase: 3.35.0
1585 |       tinyexec: 0.3.2
1586 |       tinyglobby: 0.2.12
1587 |       tree-kill: 1.2.2
1588 |     optionalDependencies:
1589 |       postcss: 8.5.3
1590 |       typescript: 5.8.2
1591 |     transitivePeerDependencies:
1592 |       - jiti
1593 |       - supports-color
1594 |       - tsx
1595 |       - yaml
1596 | 
1597 |   typescript@5.8.2: {}
1598 | 
1599 |   undici-types@6.20.0:
1600 |     optional: true
1601 | 
1602 |   vite-node@3.0.9(@types/node@22.13.10):
1603 |     dependencies:
1604 |       cac: 6.7.14
1605 |       debug: 4.4.0
1606 |       es-module-lexer: 1.6.0
1607 |       pathe: 2.0.3
1608 |       vite: 5.4.14(@types/node@22.13.10)
1609 |     transitivePeerDependencies:
1610 |       - '@types/node'
1611 |       - less
1612 |       - lightningcss
1613 |       - sass
1614 |       - sass-embedded
1615 |       - stylus
1616 |       - sugarss
1617 |       - supports-color
1618 |       - terser
1619 | 
1620 |   vite@5.4.14(@types/node@22.13.10):
1621 |     dependencies:
1622 |       esbuild: 0.21.5
1623 |       postcss: 8.5.3
1624 |       rollup: 4.35.0
1625 |     optionalDependencies:
1626 |       '@types/node': 22.13.10
1627 |       fsevents: 2.3.3
1628 | 
1629 |   vitest@3.0.9(@types/node@22.13.10):
1630 |     dependencies:
1631 |       '@vitest/expect': 3.0.9
1632 |       '@vitest/mocker': 3.0.9(vite@5.4.14(@types/node@22.13.10))
1633 |       '@vitest/pretty-format': 3.0.9
1634 |       '@vitest/runner': 3.0.9
1635 |       '@vitest/snapshot': 3.0.9
1636 |       '@vitest/spy': 3.0.9
1637 |       '@vitest/utils': 3.0.9
1638 |       chai: 5.2.0
1639 |       debug: 4.4.0
1640 |       expect-type: 1.2.0
1641 |       magic-string: 0.30.17
1642 |       pathe: 2.0.3
1643 |       std-env: 3.8.1
1644 |       tinybench: 2.9.0
1645 |       tinyexec: 0.3.2
1646 |       tinypool: 1.0.2
1647 |       tinyrainbow: 2.0.0
1648 |       vite: 5.4.14(@types/node@22.13.10)
1649 |       vite-node: 3.0.9(@types/node@22.13.10)
1650 |       why-is-node-running: 2.3.0
1651 |     optionalDependencies:
1652 |       '@types/node': 22.13.10
1653 |     transitivePeerDependencies:
1654 |       - less
1655 |       - lightningcss
1656 |       - msw
1657 |       - sass
1658 |       - sass-embedded
1659 |       - stylus
1660 |       - sugarss
1661 |       - supports-color
1662 |       - terser
1663 | 
1664 |   webidl-conversions@4.0.2: {}
1665 | 
1666 |   whatwg-url@7.1.0:
1667 |     dependencies:
1668 |       lodash.sortby: 4.7.0
1669 |       tr46: 1.0.1
1670 |       webidl-conversions: 4.0.2
1671 | 
1672 |   which@2.0.2:
1673 |     dependencies:
1674 |       isexe: 2.0.0
1675 | 
1676 |   why-is-node-running@2.3.0:
1677 |     dependencies:
1678 |       siginfo: 2.0.0
1679 |       stackback: 0.0.2
1680 | 
1681 |   wrap-ansi@7.0.0:
1682 |     dependencies:
1683 |       ansi-styles: 4.3.0
1684 |       string-width: 4.2.3
1685 |       strip-ansi: 6.0.1
1686 | 
1687 |   wrap-ansi@8.1.0:
1688 |     dependencies:
1689 |       ansi-styles: 6.2.1
1690 |       string-width: 5.1.2
1691 |       strip-ansi: 7.1.0
1692 | 


--------------------------------------------------------------------------------
/storage-schema.md:
--------------------------------------------------------------------------------
  1 | # OAuth KV Storage Schema
  2 | 
  3 | This document describes the schema used in the OAUTH_KV storage for the OAuth 2.0 provider library. The library uses Cloudflare Workers KV to store all OAuth-related data, including client registrations, authorization grants, and tokens.
  4 | 
  5 | ## Overview
  6 | 
  7 | The OAUTH_KV namespace stores several types of objects, each with a distinct key prefix to identify the type of data. The storage leverages KV's built-in TTL (Time-To-Live) functionality for automatic expiration of short-lived data like tokens and authorization codes.
  8 | 
  9 | The system implements end-to-end encryption for sensitive application-specific properties (`props`) to ensure that only holders of valid tokens can access this data.
 10 | 
 11 | ## Key Naming Conventions
 12 | 
 13 | All keys in the KV namespace follow a consistent pattern to make them easily identifiable:
 14 | 
 15 | | Prefix | Purpose | Example |
 16 | |--------|---------|---------|
 17 | | `client:` | Client registration data | `client:abc123` |
 18 | | `grant:{userId}:` | Authorization grant data | `grant:user123:xyz789` |
 19 | | `token:` | Access and refresh tokens | `token:ghi789` |
 20 | 
 21 | ## Data Structures
 22 | 
 23 | ### Clients
 24 | 
 25 | Client records store OAuth client application information.
 26 | 
 27 | **Key format:** `client:{clientId}`
 28 | 
 29 | **Content Example:**
 30 | ```json
 31 | {
 32 |   "clientId": "abc123",
 33 |   "clientSecret": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
 34 |   "redirectUris": ["https://app.example.com/callback"],
 35 |   "clientName": "Example App",
 36 |   "logoUri": "https://app.example.com/logo.png",
 37 |   "clientUri": "https://app.example.com",
 38 |   "policyUri": "https://app.example.com/privacy",
 39 |   "tosUri": "https://app.example.com/terms",
 40 |   "jwksUri": null,
 41 |   "contacts": ["dev@example.com"],
 42 |   "grantTypes": ["authorization_code", "refresh_token"],
 43 |   "responseTypes": ["code"],
 44 |   "registrationDate": 1644256123
 45 | }
 46 | ```
 47 | 
 48 | > **Note:** The `clientSecret` is stored as a SHA-256 hash, not in plaintext. The actual secret is only returned to the client when initially created or updated, and never stored.
 49 | 
 50 | **TTL:** No expiration (persistent storage)
 51 | 
 52 | ### Authorization Grants
 53 | 
 54 | Grant records store information about permissions a user has granted to an application, along with the authorization code (initially) and refresh token (after code exchange).
 55 | 
 56 | **Key format:** `grant:{userId}:{grantId}`
 57 | 
 58 | **Content Example (during authorization):**
 59 | ```json
 60 | {
 61 |   "id": "xyz789",
 62 |   "clientId": "abc123",
 63 |   "userId": "user123",
 64 |   "scope": ["document.read", "document.write"],
 65 |   "metadata": {
 66 |     "label": "My Files Access",
 67 |     "deviceInfo": "Chrome on Windows"
 68 |   },
 69 |   "encryptedProps": "AES-GCM encrypted base64-encoded string",
 70 |   "createdAt": 1644256123,
 71 |   "authCodeId": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
 72 |   "authCodeWrappedKey": "base64-encoded wrapped encryption key",
 73 |   "codeChallenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
 74 |   "codeChallengeMethod": "S256"
 75 | }
 76 | ```
 77 | 
 78 | **Content Example (after code exchange):**
 79 | ```json
 80 | {
 81 |   "id": "xyz789",
 82 |   "clientId": "abc123",
 83 |   "userId": "user123",
 84 |   "scope": ["document.read", "document.write"],
 85 |   "metadata": {
 86 |     "label": "My Files Access",
 87 |     "deviceInfo": "Chrome on Windows"
 88 |   },
 89 |   "encryptedProps": "AES-GCM encrypted base64-encoded string",
 90 |   "createdAt": 1644256123,
 91 |   "refreshTokenId": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
 92 |   "refreshTokenWrappedKey": "base64-encoded wrapped encryption key"
 93 | }
 94 | ```
 95 | 
 96 | **Content Example (after refresh token rotation):**
 97 | ```json
 98 | {
 99 |   "id": "xyz789",
100 |   "clientId": "abc123",
101 |   "userId": "user123",
102 |   "scope": ["document.read", "document.write"],
103 |   "metadata": {
104 |     "label": "My Files Access",
105 |     "deviceInfo": "Chrome on Windows"
106 |   },
107 |   "encryptedProps": "AES-GCM encrypted base64-encoded string",
108 |   "createdAt": 1644256123,
109 |   "refreshTokenId": "7f2ab876c546a9e9f988ba7645af78239cfe980a4231ab38fcb895cb244a0a12",
110 |   "refreshTokenWrappedKey": "base64-encoded wrapped encryption key",
111 |   "previousRefreshTokenId": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
112 |   "previousRefreshTokenWrappedKey": "base64-encoded wrapped encryption key for previous token"
113 | }
114 | ```
115 | 
116 | **TTL:**
117 | - Initially 10 minutes (during authorization process)
118 | - No expiration after the authorization code is exchanged for tokens
119 | 
120 | > **Note:** The grant record includes the hash of the authorization code initially, which is replaced by the hash of the refresh token after the code is exchanged. The record also has a TTL during the authorization process, which is removed when the code is exchanged for tokens to make the grant permanent.
121 | 
122 | ### Tokens
123 | 
124 | Token records store metadata about issued access tokens, including denormalized grant information for faster access.
125 | 
126 | **Key format:** `token:{userId}:{grantId}:{tokenId}`
127 | 
128 | **Content Example:**
129 | ```json
130 | {
131 |   "id": "ghi789",
132 |   "grantId": "xyz789",
133 |   "userId": "user123",
134 |   "createdAt": 1644256123,
135 |   "expiresAt": 1644259723,
136 |   "wrappedEncryptionKey": "base64-encoded wrapped encryption key",
137 |   "grant": {
138 |     "clientId": "abc123",
139 |     "scope": ["document.read", "document.write"],
140 |     "encryptedProps": "AES-GCM encrypted base64-encoded string"
141 |   }
142 | }
143 | ```
144 | 
145 | > **Note:** The token format is `{userId}:{grantId}:{random-secret}` which embeds the identifiers needed for efficient lookups. The token key format includes the user ID and grant ID to enable efficient revocation of all tokens for a specific grant. The token record contains denormalized grant information to eliminate the need for a separate grant lookup during token validation. The token also carries a wrapped encryption key that can only be unwrapped using the actual token string, allowing decryption of the encrypted props.
146 | 
147 | **TTL:** Access tokens typically have a 1 hour (3600 seconds) TTL by default
148 | 
149 | ## Security Considerations
150 | 
151 | 1. **Sensitive Value Storage**: No sensitive values are stored in plaintext in KV storage:
152 |    - Access tokens and refresh tokens are stored as SHA-256 hashes
153 |    - Client secrets are stored as SHA-256 hashes
154 |    - Authorization codes are stored as SHA-256 hashes
155 |    - For PKCE, only the code challenge is stored, never the code verifier
156 |    - Application-specific properties (`props`) are encrypted using AES-GCM
157 | 
158 |    This ensures that even if the KV data is compromised, the actual sensitive values cannot be retrieved.
159 | 
160 | 2. **End-to-End Encryption for Props**:
161 |    - Each grant has its own unique AES-256 key for encrypting props
162 |    - A constant all-zero initialization vector (IV) is used with AES-GCM encryption
163 |    - This is cryptographically secure because each key is only used exactly once
164 |    - Using unique keys eliminates the IV randomization requirement of AES-GCM
165 |    - The encryption key is wrapped (encrypted) using each token as key material
166 |    - The wrapped key can only be unwrapped by someone with the actual token
167 |    - No backup of the encryption key is stored anywhere
168 |    - Even system administrators cannot decrypt the props without a valid token
169 | 
170 | 3. **Key Wrapping Security**:
171 |    - Token wrapping keys are derived using HMAC-SHA256 with a static key
172 |    - The derivation method is different from token ID generation for security separation
173 |    - Each token type (authorization code, refresh token, access token) has its own wrapped key
174 |    - The wrapping algorithm used is AES-KW (AES Key Wrap)
175 | 
176 | 4. **Token Format**: Tokens use the format `{userId}:{grantId}:{random-secret}` which allows:
177 |    - Direct access to token records without needing to look up grants separately
178 |    - Verification that the token was issued for the specific grant and user
179 |    - Enhanced security through proper validation checks
180 | 
181 | 5. **TTL-based Expiration**: Access tokens automatically expire using KV's TTL feature, reducing the need for manual cleanup.
182 | 
183 | 6. **Efficient Storage**:
184 |    - Refresh tokens are stored within the grant records, eliminating redundant storage
185 |    - Grant data is denormalized into token records for faster validation
186 |    - Token keys include user ID and grant ID to enable efficient revocation
187 | 
188 | 7. **Structured Key Design**: The key format `token:{userId}:{grantId}:{tokenId}` enables:
189 |    - Efficient revocation of all tokens for a specific grant
190 |    - Easy lookup of all tokens issued to a specific user
191 |    - Clean organization of the key-value namespace
192 | 
193 | 8. **Cryptographic Hash Verification**: When validating credentials, the system hashes the provided value and compares it with the stored hash, rather than comparing plaintext values.
194 | 
195 | ## Example Workflow with Encrypted Props
196 | 
197 | 1. A client is registered, creating a `client:{clientId}` entry with a hashed client secret.
198 | 
199 | 2. A user authorizes the client, creating a `grant:{userId}:{grantId}` entry that includes:
200 |    - The hashed authorization code in the `authCodeId` field
201 |    - PKCE code challenge and method (if PKCE is used)
202 |    - A new AES-256 encryption key is generated specifically for this grant
203 |    - The `props` data is encrypted using this key with AES-GCM and a constant zero IV
204 |    - The encryption key is wrapped using the authorization code
205 |    - The wrapped key is stored in `authCodeWrappedKey`
206 |    - A 10-minute TTL on the grant record
207 | 
208 | 3. The client exchanges the authorization code for tokens:
209 |    - The code is validated by comparing its hash to the one stored in the grant
210 |    - If PKCE was used, the code_verifier is validated against the stored code_challenge
211 |    - The encryption key is unwrapped using the authorization code
212 |    - The key is re-wrapped for both the access token and refresh token
213 |    - The `authCodeId`, `authCodeWrappedKey`, and PKCE fields are removed from the grant
214 |    - A refresh token is generated and its hash is stored in the grant's `refreshTokenId` field
215 |    - The wrapped key for the refresh token is stored in `refreshTokenWrappedKey`
216 |    - The grant's TTL is removed, making it permanent
217 |    - A new access token is generated and stored as `token:{userId}:{grantId}:{accessTokenId}` 
218 |    - The access token record includes the encrypted props, IV, and wrapped key
219 |    - Both tokens are returned to the client
220 | 
221 | 4. When the client makes API requests with the access token:
222 |    - The system looks up the token directly using the structured key format
223 |    - The wrapped encryption key is unwrapped using the access token
224 |    - The props are decrypted using the unwrapped key
225 |    - The decrypted props are made available to the API handler
226 | 
227 | 5. Access tokens expire automatically after their TTL.
228 | 
229 | 6. Refresh tokens do not expire and are stored directly in the grant; they remain valid until the grant is revoked.
230 |    - For security, the provider issues a new refresh token with each refresh operation
231 |    - It keeps track of both the current and previous tokens, along with their wrapped keys
232 |    - When the new token is used, the previous token is invalidated, but can still be used until replaced
233 | 
234 | 7. When a grant is revoked:
235 |    - All associated access tokens are found using the key prefix `token:{userId}:{grantId}:` and deleted
236 |    - The grant record is deleted, which also effectively revokes the refresh token and all encrypted data
237 | 
238 | ## Implementation Notes
239 | 
240 | - For high-traffic applications, consider using a caching layer in front of KV to reduce read operations on frequently accessed data.
241 | - Monitor KV usage metrics to ensure you stay within Cloudflare's limits for your plan.
242 | - The design uses KV's `list()` capability with key prefixes to efficiently query related data like all grants for a user, eliminating the need for separate list indexes.
243 | - If a grant is revoked, associated tokens are not immediately deleted from KV, but rely on TTL expiration. Add a cleanup process if immediate revocation is required.


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "es2021",
 4 |     "module": "es2022",
 5 |     "lib": ["es2021"],
 6 |     "moduleResolution": "node",
 7 |     "types": ["@cloudflare/workers-types"],
 8 |     "isolatedModules": true,
 9 |     "esModuleInterop": true,
10 |     "strict": true,
11 |     "skipLibCheck": true
12 |   },
13 |   "include": ["src/**/*.ts"],
14 |   "exclude": ["node_modules", "dist"]
15 | }
16 | 


--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineConfig } from 'tsup';
 2 | 
 3 | export default defineConfig({
 4 |   entry: ['src/oauth-provider.ts'],
 5 |   format: ['esm'],
 6 |   dts: true,
 7 |   clean: true,
 8 |   outDir: 'dist',
 9 |   external: ['cloudflare:workers'],
10 | });
11 | 


--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineConfig } from 'vitest/config';
 2 | 
 3 | export default defineConfig({
 4 |   test: {
 5 |     globals: true,
 6 |     environment: 'node',
 7 |     setupFiles: ['./__tests__/setup.ts'],
 8 |   },
 9 | });
10 | 


--------------------------------------------------------------------------------