diff --git a/.opencode/plans/delegated-release-service/evidence/w0.4-oauth-workerd.md b/.opencode/plans/delegated-release-service/evidence/w0.4-oauth-workerd.md new file mode 100644 index 0000000000..f6a6a33228 --- /dev/null +++ b/.opencode/plans/delegated-release-service/evidence/w0.4-oauth-workerd.md @@ -0,0 +1,82 @@ +# W0.4 Confidential OAuth Custody in workerd + +Outcome: proved for `@atcute/oauth-node-client` 2.0.0 with one documented key-removal limitation. + +## Verified contract + +- `new OAuthClient({ metadata, keyset, actorResolver, stores, requestLock, fetch })` builds a confidential client. `metadata.token_endpoint_auth_method` is `private_key_jwt`, `token_endpoint_auth_signing_alg` is `ES256`, and `dpop_bound_access_tokens` is `true`. +- `generateClientAssertionKey(kid)` creates the ES256 assertion key. `client.jwks` exposes only its public JWK. Supplying `jwks_uri` leaves the metadata pointed at the public endpoint while `client.jwks` supplies the response body. +- `authorize()` generates a different per-flow DPoP private key. Captured client-assertion JWTs use the assertion-key `kid`; captured DPoP JWTs contain a different public JWK. +- The exact scope used in metadata, PAR, token responses, stored sessions, and every fixture is `atproto repo:com.emdashcms.experimental.package.release?action=create`. +- Atcute directly handles an authorization-server DPoP nonce challenge. A `400 { "error": "use_dpop_nonce" }` plus `DPoP-Nonce` causes one PAR retry whose DPoP JWT contains the nonce. A reconstructed client then reads the nonce cache from D1 and sends that nonce at the token endpoint. +- D1 stores survive reconstruction between `authorize()`, `callback()`, and `restore()`. The test uses the real D1 binding supplied by `@cloudflare/vitest-pool-workers`, not Miniflare's Node storage API or a database mock. +- `requestLock` encloses atcute's load, refresh, and session-store update. The test lease uses D1 conditional updates, expiry, an owner token, and owner-checked session persistence. Two independent clients racing `restore(did)` against an expired token produce exactly one refresh request and persist the first rotating refresh token. The authorization-server fixture consumes that refresh token once and rejects reuse with `invalid_grant`. +- Session persistence fails closed if lease ownership expires before the write. A real D1 test expires the acquired lease, calls the same guarded `sessions.set()` path used by atcute, observes the ownership error, and verifies the previous parsed session remains semantically unchanged. +- Session deletion inside a session lock uses the same owner-and-expiry guard. A real atcute `restore(..., { refresh: true })` test delays `invalid_grant`, expires owner A, lets owner B persist a successor session, and proves A's subsequent atcute error cleanup cannot delete B's value. +- Lease ownership is operation-local, not shared mutable instance state. The workerd harness uses native `AsyncLocalStorage` from `node:async_hooks` under the narrow `nodejs_als` compatibility flag. A same-persistence overlap test expires A, runs B to completion, then proves resumed A can neither overwrite nor delete B's session. +- Ownerless session mutation is deliberately asymmetric. `sessions.set()` may insert an absent callback-created session but cannot overwrite an existing row. `sessions.delete()` is a no-op for an absent row and rejects if a row exists; `sessions.clear()` likewise rejects while any session exists. This restriction is session-only; authorization-state deletion and DPoP nonce replacement retain normal Store behavior. +- The ownerless deletion policy is exercised through real atcute `OAuthSession.signOut()`: atcute performs its token operation and then calls `deleteStored()` outside `requestLock`, where the harness rejects deletion and preserves the stored session. Direct Store coverage proves ownerless overwrite also fails. The harness does not claim atcute coordinates these paths itself. +- A live owner renews its lease periodically with a conditional update on the same owner and an unexpired lease. Configurable test timings hold an operation beyond its initial lease duration, observe renewal, persist the successor, and verify owner-checked release. Renewal stops in `finally`. A separate ownership-theft test observes renewal loss and proves later persistence still fails closed. +- `StoredSession.authMethod` retains `{ method: "private_key_jwt", kid }`. A keyset ordered `[new, old]` chooses `new` for a new authorization while restoring the old session by its recorded `kid`. +- Removing the old key from published JWKS does not revoke an already issued access token. An existing atcute `OAuthSession` continues a DPoP-authenticated resource request without a token-endpoint call. Authorization-server revocation or access-token expiry remains a separate operation. + +## Persisted atcute values + +`StoredState`, keyed by OAuth state ID: + +- `dpopKey`: private DPoP JWK. +- `authMethod`: `private_key_jwt` plus assertion-key `kid`. +- `pkceVerifier`. +- `issuer`, `redirectUri`. +- Optional expected `sub` and caller `userState`. +- `expiresAt`. + +`StoredSession`, keyed by publisher DID: + +- `dpopKey`: private DPoP JWK. +- `authMethod`: `private_key_jwt` plus the originating assertion-key `kid`. +- `tokenSet.iss`, `sub`, `aud`, and exact `scope`. +- `tokenSet.access_token`, rotating `refresh_token`, `token_type`, and optional `expires_at`. + +The DPoP nonce store is keyed by origin and contains the latest nonce string. Future production storage must application-encrypt state, sessions, DPoP keys/nonces, and tokens. The spike intentionally does not select the final schema or encryption envelope. + +## Exact library APIs + +- `generateClientAssertionKey(kid, "ES256")` +- `new OAuthClient(...)` +- `OAuthClient.jwks` +- `OAuthClient.authorize(...)` +- `OAuthClient.callback(params)` +- `OAuthClient.restore(did, { refresh })` +- `OAuthSession.handle(path, init)` +- `Store` for sessions, states, and DPoP nonces +- `LockFunction` through `OAuthClientOptions.requestLock` +- `StoredState`, `StoredSession`, and `TokenSet` + +## Direct proof versus simulation + +Directly proved in workerd: WebCrypto key generation and JWT signing, public-JWKS derivation, confidential metadata defaults, private-key JWT fields, distinct DPoP keys, nonce challenge/retry, D1 state/session/nonce round trips, callback deletion of one-time state, ownerless create-only session insertion, real-atcute ownerless sign-out rejection, D1 lease serialization and renewal, operation-local ownership across overlapping operations, owner-checked fail-closed persistence and deletion, rotating-token persistence, key selection by recorded `kid`, and DPoP resource requests after JWKS rotation. + +Simulated: OAuth discovery, PAR, token, and PDS HTTP responses are supplied by an injected in-isolate authorization-server fixture. The fixture enforces rotating refresh-token consumption and captures signed requests, but does not verify their cryptographic signatures or contact an external PDS. Cross-vendor create-only scope acceptance and revocation endpoint behavior belong to W0.3. + +## Limitation + +With only the new private key loaded, atcute 2.0.0 rejects `restore(oldDid, { refresh: false })` before making a resource request: `key "assertion-old" no longer available or compatible`. `OAuthServerAgent` eagerly constructs the client-assertion factory even when a valid access token needs no token-endpoint authentication. Therefore routine rotation must retain old private assertion keys, not only old public JWKS entries, for every restorable session using that `kid`. This is stricter than OAuth token validity: removing a key does not itself revoke an already issued access token, as the existing-session resource request proves. + +The operation-local lock adapter requires Workers' native `AsyncLocalStorage`. A production Worker using this coordination pattern must enable `nodejs_als` (or the broader `nodejs_compat`) at a compatible date. The feasibility harness proves the narrower flag in workerd; this does not decide the production coordinator or schema. + +Atcute's callback creation legitimately writes outside `requestLock`, while `OAuthClient.revoke()`, `OAuthSession.signOut()`, and second-invalid-resource cleanup can delete outside it. Under this harness policy, explicit revocation/sign-out cannot rely on raw atcute `Store.delete`; production service logic must acquire the publisher session lease, perform revocation, and delete conditionally under that owner. The spike directly tests `signOut()`, not every ownerless atcute deletion route. + +## Command evidence + +- Baseline: `pnpm lint:json | jq '.diagnostics | length'` -> `0`. +- Default package test: `pnpm --filter @emdash-cms/auth-atproto test` -> 27 Node tests and 9 workerd tests passed. The package-local Node config excludes the workerd-only file, and the normal package script then runs `test:workerd` explicitly. +- CI filter selection: root `test:unit` includes the exact `--filter @emdash-cms/auth-atproto`; its scoped equivalent `pnpm run --filter @emdash-cms/auth-atproto test` -> the same 27 Node and 9 workerd tests passed. +- Focused test: `pnpm --filter @emdash-cms/auth-atproto exec vitest run --config vitest.workerd.config.ts` -> 1 file, 9 tests passed. +- Harness types: `pnpm --filter @emdash-cms/auth-atproto exec tsgo --noEmit -p tests/support/confidential-oauth/tsconfig.json` -> passed. +- Repository build: `pnpm build` -> passed with existing virtual-import and direct-eval warnings. +- Package types: `pnpm --filter @emdash-cms/auth-atproto typecheck` -> passed after the full build. +- Full lint: `pnpm lint` -> 0 warnings and 0 errors. +- Diff integrity: `git diff --check` -> passed. + +The lockfile was regenerated only through `pnpm add`, `pnpm remove`, and `pnpm install`. The final direct additions are `@cloudflare/vitest-pool-workers`, `@cloudflare/workers-types`, and `@types/node`. Pnpm also reconciled pre-existing stale peer snapshots in this importer from TypeScript 6.0.0-beta to the workspace's catalog TypeScript 6.0.3 and deduplicated stale transitive snapshots; no root catalog or workspace file changed. diff --git a/package.json b/package.json index 0d62e3cbf6..687bc350c4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck:templates": "pnpm run --workspace-concurrency=1 --filter {./templates/*} typecheck", "check": "pnpm run typecheck && pnpm run --filter {./packages/*} check", "test": "pnpm run --filter {./packages/*} test", - "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", + "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/auth-atproto --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", "test:browser": "pnpm run --filter @emdash-cms/admin test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/auth-atproto/package.json b/packages/auth-atproto/package.json index a09e3533e2..509c1f009f 100644 --- a/packages/auth-atproto/package.json +++ b/packages/auth-atproto/package.json @@ -32,11 +32,16 @@ "devDependencies": { "@atcute/lexicons": "catalog:", "@cloudflare/kumo": "catalog:", + "@cloudflare/vitest-pool-workers": "catalog:", + "@cloudflare/workers-types": "catalog:", + "@types/node": "catalog:", "@types/react": "^19.0.0", "vitest": "catalog:" }, "scripts": { - "test": "vitest run", + "test": "pnpm test:node && pnpm test:workerd", + "test:node": "vitest run --config vitest.config.ts", + "test:workerd": "vitest run --config vitest.workerd.config.ts", "typecheck": "tsgo --noEmit" }, "repository": { diff --git a/packages/auth-atproto/tests/confidential-oauth-workerd.test.ts b/packages/auth-atproto/tests/confidential-oauth-workerd.test.ts new file mode 100644 index 0000000000..de3d7d8dec --- /dev/null +++ b/packages/auth-atproto/tests/confidential-oauth-workerd.test.ts @@ -0,0 +1,454 @@ +import { generateClientAssertionKey } from "@atcute/oauth-node-client"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { D1OAuthPersistence } from "./support/confidential-oauth/d1-persistence.js"; +import { + AS_ORIGIN, + CLIENT_ID, + JWKS_URI, + NEW_DID, + OLD_DID, + OAuthServerFixture, + RELEASE_SCOPE, + authorizeAndCallback, + createConfidentialClient, + decodeJwtPart, + readStoredSession, + readStoredState, +} from "./support/confidential-oauth/oauth-fixture.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +beforeEach(async () => { + await testEnv.DB.batch([ + testEnv.DB.prepare("DELETE FROM oauth_values"), + testEnv.DB.prepare("DELETE FROM oauth_session_leases"), + ]); +}); + +describe("confidential atproto OAuth custody in workerd", () => { + it("uses private_key_jwt, distinct DPoP keys, public JWKS, and D1-restorable state/session data", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + const authorizingClient = createConfidentialClient( + [assertionKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + + expect(authorizingClient.metadata).toMatchObject({ + client_id: CLIENT_ID, + scope: RELEASE_SCOPE, + token_endpoint_auth_method: "private_key_jwt", + token_endpoint_auth_signing_alg: "ES256", + dpop_bound_access_tokens: true, + jwks_uri: JWKS_URI, + }); + expect(authorizingClient.jwks?.keys).toHaveLength(1); + expect(authorizingClient.jwks?.keys[0]).toMatchObject({ kid: "assertion-old", alg: "ES256" }); + expect(authorizingClient.jwks?.keys[0]).not.toHaveProperty("d"); + + const { stateId } = await authorizingClient.authorize({ + target: { type: "pds", serviceUrl: "https://pds.emdashcms.com" }, + }); + expect(server.parRequests).toHaveLength(2); + expect(server.parRequests[0]?.body.get("scope")).toBe(RELEASE_SCOPE); + expect(decodeJwtPart(server.parRequests[1]!.dpop, 1)).toMatchObject({ nonce: "par-nonce" }); + + const assertion = server.parRequests[1]?.body.get("client_assertion"); + expect(assertion).toBeTruthy(); + expect(decodeJwtPart(assertion!, 0)).toMatchObject({ alg: "ES256", kid: "assertion-old" }); + expect(decodeJwtPart(assertion!, 1)).toMatchObject({ + iss: CLIENT_ID, + sub: CLIENT_ID, + aud: AS_ORIGIN, + }); + + const dpopPublicKey = decodeJwtPart(server.parRequests[1]!.dpop, 0).jwk; + expect(dpopPublicKey).not.toEqual(authorizingClient.jwks?.keys[0]); + expect(dpopPublicKey).not.toHaveProperty("kid", "assertion-old"); + + const storedState = await readStoredState(testEnv.DB, stateId); + expect(storedState).toMatchObject({ + authMethod: { method: "private_key_jwt", kid: "assertion-old" }, + issuer: AS_ORIGIN, + redirectUri: "https://release.emdashcms.com/oauth/callback", + }); + expect(storedState?.dpopKey).toHaveProperty("d"); + expect(storedState?.pkceVerifier).toEqual(expect.any(String)); + + const callbackClient = createConfidentialClient( + [assertionKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + await callbackClient.callback( + new URLSearchParams({ state: stateId, code: "old-code", iss: AS_ORIGIN }), + ); + expect(await readStoredState(testEnv.DB, stateId)).toBeUndefined(); + expect(server.tokenRequests[0]?.body.get("client_assertion_type")).toBe( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ); + expect(decodeJwtPart(server.tokenRequests[0]!.dpop, 1)).toMatchObject({ nonce: "par-nonce" }); + + const storedSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(storedSession).toMatchObject({ + authMethod: { method: "private_key_jwt", kid: "assertion-old" }, + tokenSet: { + iss: AS_ORIGIN, + sub: OLD_DID, + aud: "https://pds.emdashcms.com", + scope: RELEASE_SCOPE, + access_token: `access-${OLD_DID}`, + refresh_token: `refresh-${OLD_DID}-0`, + token_type: "DPoP", + }, + }); + expect(storedSession?.dpopKey).toEqual(storedState?.dpopKey); + + const restored = await createConfidentialClient( + [assertionKey], + new D1OAuthPersistence(testEnv.DB), + server, + ).restore(OLD_DID, { refresh: false }); + expect((await restored.getTokenInfo(false)).scope).toBe(RELEASE_SCOPE); + }); + + it("allows ownerless session creation but rejects ownerless overwrite and real atcute sign-out deletion", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + const { session } = await authorizeAndCallback(testEnv.DB, [assertionKey], server); + const originalSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(originalSession).toBeDefined(); + + const persistence = new D1OAuthPersistence(testEnv.DB); + const overwrite = structuredClone(originalSession!); + overwrite.tokenSet.access_token = "ownerless-overwrite"; + await expect(persistence.sessions.set(OLD_DID, overwrite)).rejects.toThrow( + "Ownerless OAuth session creation cannot replace an existing session", + ); + await expect(session.signOut()).rejects.toThrow( + "Ownerless OAuth session deletion requires coordinated service logic", + ); + expect(await readStoredSession(testEnv.DB, OLD_DID)).toEqual(originalSession); + + await persistence.dpopNonces.set(AS_ORIGIN, "replacement-nonce"); + expect(await persistence.dpopNonces.get(AS_ORIGIN)).toBe("replacement-nonce"); + }); + + it("serializes concurrent rotating-token refresh through a D1 lease and owner-checked persist", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + server.codeExpiresIn = 0; + server.refreshDelayMs = 50; + await authorizeAndCallback(testEnv.DB, [assertionKey], server); + + const first = createConfidentialClient( + [assertionKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + const second = createConfidentialClient( + [assertionKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + await Promise.all([first.restore(OLD_DID), second.restore(OLD_DID)]); + + expect(server.refreshCount).toBe(1); + expect(server.consumedRefreshTokens).toEqual(new Set([`refresh-${OLD_DID}-0`])); + const stored = await readStoredSession(testEnv.DB, OLD_DID); + expect(stored?.tokenSet).toMatchObject({ + access_token: `access-${OLD_DID}-1`, + refresh_token: `refresh-${OLD_DID}-1`, + }); + const redelivery = await server.fetch(`${AS_ORIGIN}/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: "fixture-redelivery", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: `refresh-${OLD_DID}-0`, + }), + }); + expect(redelivery.status).toBe(400); + expect(await redelivery.json()).toEqual({ error: "invalid_grant" }); + const lease = await testEnv.DB.prepare("SELECT owner FROM oauth_session_leases WHERE name = ?") + .bind(`oauth-session-${OLD_DID}`) + .first<{ owner: string | null }>(); + expect(lease?.owner).toBeNull(); + }); + + it("fails closed without replacing the stored session when its lease expires before persist", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + await authorizeAndCallback(testEnv.DB, [assertionKey], server); + const staleSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(staleSession).toBeDefined(); + + const attemptedSession = structuredClone(staleSession!); + attemptedSession.tokenSet.access_token = "must-not-persist"; + attemptedSession.tokenSet.refresh_token = "must-not-persist"; + const persistence = new D1OAuthPersistence(testEnv.DB); + const lockName = `oauth-session-${OLD_DID}`; + + await expect( + persistence.requestLock(lockName, async () => { + await testEnv.DB.prepare("UPDATE oauth_session_leases SET expires_at = 0 WHERE name = ?") + .bind(lockName) + .run(); + await persistence.sessions.set(OLD_DID, attemptedSession); + }), + ).rejects.toThrow("OAuth session lease was lost before the rotated token was persisted"); + + expect(await readStoredSession(testEnv.DB, OLD_DID)).toEqual(staleSession); + }); + + it("does not delete a successor session after delayed invalid_grant from a stale owner", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + server.codeExpiresIn = 0; + await authorizeAndCallback(testEnv.DB, [assertionKey], server); + const previousSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(previousSession).toBeDefined(); + + server.consumedRefreshTokens.add(`refresh-${OLD_DID}-0`); + server.refreshDelayMs = 100; + const staleClient = createConfidentialClient( + [assertionKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + const staleRestore = staleClient.restore(OLD_DID, { refresh: true }); + const staleRejection = expect(staleRestore).rejects.toThrow( + "error while deleting stored value", + ); + await waitFor(() => server.refreshCount === 1); + + const lockName = `oauth-session-${OLD_DID}`; + await testEnv.DB.prepare("UPDATE oauth_session_leases SET expires_at = 0 WHERE name = ?") + .bind(lockName) + .run(); + const successorSession = structuredClone(previousSession!); + successorSession.tokenSet.access_token = "successor-access"; + successorSession.tokenSet.refresh_token = "successor-refresh"; + successorSession.tokenSet.expires_at = Date.now() + 3_600_000; + const successorPersistence = new D1OAuthPersistence(testEnv.DB); + await successorPersistence.requestLock(lockName, async () => { + await successorPersistence.sessions.set(OLD_DID, successorSession); + }); + + await staleRejection; + expect(await readStoredSession(testEnv.DB, OLD_DID)).toEqual(successorSession); + }); + + it("keeps overlapping same-instance lease owners operation-local", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + await authorizeAndCallback(testEnv.DB, [assertionKey], server); + const originalSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(originalSession).toBeDefined(); + + const persistence = new D1OAuthPersistence(testEnv.DB); + const lockName = `oauth-session-${OLD_DID}`; + const ownerAStarted = deferred(); + const resumeOwnerA = deferred(); + let overwriteError: unknown; + let deleteError: unknown; + const ownerA = persistence.requestLock(lockName, async () => { + ownerAStarted.resolve(); + await resumeOwnerA.promise; + const staleWrite = structuredClone(originalSession!); + staleWrite.tokenSet.access_token = "stale-owner-access"; + try { + await persistence.sessions.set(OLD_DID, staleWrite); + } catch (error) { + overwriteError = error; + } + try { + await persistence.sessions.delete(OLD_DID); + } catch (error) { + deleteError = error; + } + }); + + await ownerAStarted.promise; + await testEnv.DB.prepare("UPDATE oauth_session_leases SET expires_at = 0 WHERE name = ?") + .bind(lockName) + .run(); + const ownerBSession = structuredClone(originalSession!); + ownerBSession.tokenSet.access_token = "owner-b-access"; + ownerBSession.tokenSet.refresh_token = "owner-b-refresh"; + await persistence.requestLock(lockName, async () => { + await persistence.sessions.set(OLD_DID, ownerBSession); + }); + + resumeOwnerA.resolve(); + await ownerA; + expect(overwriteError).toEqual( + expect.objectContaining({ + message: "OAuth session lease was lost before the rotated token was persisted", + }), + ); + expect(deleteError).toEqual( + expect.objectContaining({ + message: "OAuth session lease was lost before the stored session was deleted", + }), + ); + expect(await readStoredSession(testEnv.DB, OLD_DID)).toEqual(ownerBSession); + }); + + it("renews a long-running owner lease through successor persistence and safe release", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + await authorizeAndCallback(testEnv.DB, [assertionKey], server); + const originalSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(originalSession).toBeDefined(); + + const persistence = new D1OAuthPersistence(testEnv.DB, { + leaseDurationMs: 60, + leaseRenewIntervalMs: 10, + leaseAcquireTimeoutMs: 500, + }); + const lockName = `oauth-session-${OLD_DID}`; + const successorSession = structuredClone(originalSession!); + successorSession.tokenSet.access_token = "renewed-owner-access"; + successorSession.tokenSet.refresh_token = "renewed-owner-refresh"; + await persistence.requestLock(lockName, async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + await persistence.sessions.set(OLD_DID, successorSession); + }); + + expect(persistence.leaseRenewalCount).toBeGreaterThanOrEqual(1); + expect(await readStoredSession(testEnv.DB, OLD_DID)).toEqual(successorSession); + const released = await testEnv.DB.prepare( + "SELECT owner, expires_at FROM oauth_session_leases WHERE name = ?", + ) + .bind(lockName) + .first<{ owner: string | null; expires_at: number }>(); + expect(released).toEqual({ owner: null, expires_at: 0 }); + }); + + it("marks renewal ownership loss and keeps later persistence fail-closed", async () => { + const assertionKey = await generateClientAssertionKey("assertion-old"); + const server = new OAuthServerFixture(); + await authorizeAndCallback(testEnv.DB, [assertionKey], server); + const originalSession = await readStoredSession(testEnv.DB, OLD_DID); + expect(originalSession).toBeDefined(); + + const persistence = new D1OAuthPersistence(testEnv.DB, { + leaseDurationMs: 100, + leaseRenewIntervalMs: 10, + leaseAcquireTimeoutMs: 500, + }); + const lockName = `oauth-session-${OLD_DID}`; + const ownerStarted = deferred(); + const ownershipStolen = deferred(); + let persistenceError: unknown; + const operation = persistence.requestLock(lockName, async () => { + ownerStarted.resolve(); + await ownershipStolen.promise; + await waitFor(() => persistence.leaseRenewalLossCount === 1); + try { + await persistence.sessions.set(OLD_DID, structuredClone(originalSession!)); + } catch (error) { + persistenceError = error; + } + }); + + await ownerStarted.promise; + await testEnv.DB.prepare( + "UPDATE oauth_session_leases SET owner = ?, expires_at = ? WHERE name = ?", + ) + .bind("successor-owner", Date.now() + 1_000, lockName) + .run(); + ownershipStolen.resolve(); + await operation; + + expect(persistenceError).toEqual( + expect.objectContaining({ message: "OAuth session lease renewal lost ownership" }), + ); + expect(await readStoredSession(testEnv.DB, OLD_DID)).toEqual(originalSession); + }); + + it("pins originating assertion kids across rotation without treating JWKS removal as token revocation", async () => { + const oldKey = await generateClientAssertionKey("assertion-old"); + const newKey = await generateClientAssertionKey("assertion-new"); + const server = new OAuthServerFixture(); + const { session: existingSession } = await authorizeAndCallback(testEnv.DB, [oldKey], server); + + const rotatingClient = createConfidentialClient( + [newKey, oldKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + await rotatingClient.restore(OLD_DID, { refresh: false }); + const { stateId } = await rotatingClient.authorize({ + target: { type: "pds", serviceUrl: "https://pds.emdashcms.com" }, + }); + expect((await readStoredSession(testEnv.DB, OLD_DID))?.authMethod).toEqual({ + method: "private_key_jwt", + kid: "assertion-old", + }); + expect((await readStoredState(testEnv.DB, stateId))?.authMethod).toEqual({ + method: "private_key_jwt", + kid: "assertion-new", + }); + + const newSession = await rotatingClient.callback( + new URLSearchParams({ state: stateId, code: "new-code", iss: AS_ORIGIN }), + ); + expect(newSession.session.did).toBe(NEW_DID); + expect((await readStoredSession(testEnv.DB, NEW_DID))?.authMethod).toEqual({ + method: "private_key_jwt", + kid: "assertion-new", + }); + + const newOnlyClient = createConfidentialClient( + [newKey], + new D1OAuthPersistence(testEnv.DB), + server, + ); + expect(newOnlyClient.jwks?.keys.map((key) => key.kid)).toEqual(["assertion-new"]); + const tokenRequestCount = server.tokenRequests.length; + const response = await existingSession.handle( + "/xrpc/com.atproto.repo.createRecord?repo=test&collection=com.emdashcms.experimental.package.release", + { method: "POST", body: "{}" }, + ); + expect(response.ok).toBe(true); + expect(server.tokenRequests).toHaveLength(tokenRequestCount); + expect(server.resourceRequests).toHaveLength(1); + + await expect(newOnlyClient.restore(OLD_DID, { refresh: false })).rejects.toThrow( + 'key "assertion-old" no longer available or compatible', + ); + }); +}); + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for OAuth fixture event"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/packages/auth-atproto/tests/support/confidential-oauth/d1-persistence.ts b/packages/auth-atproto/tests/support/confidential-oauth/d1-persistence.ts new file mode 100644 index 0000000000..ce628c79d6 --- /dev/null +++ b/packages/auth-atproto/tests/support/confidential-oauth/d1-persistence.ts @@ -0,0 +1,260 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { LockFunction, StoredSession, StoredState, Store } from "@atcute/oauth-node-client"; + +const SESSION_NAMESPACE = "sessions"; +const LEASE_DURATION_MS = 35_000; +const LEASE_ACQUIRE_TIMEOUT_MS = 30_000; +const LEASE_RENEW_INTERVAL_MS = 10_000; + +export interface D1OAuthPersistenceOptions { + leaseDurationMs?: number; + leaseAcquireTimeoutMs?: number; + leaseRenewIntervalMs?: number; +} + +interface LeaseContext { + name: string; + owner: string; + lost: boolean; +} + +export class D1OAuthPersistence { + readonly sessions: Store; + readonly states: Store; + readonly dpopNonces: Store; + readonly requestLock: LockFunction; + leaseRenewalCount = 0; + leaseRenewalLossCount = 0; + + readonly #db: D1Database; + readonly #leaseContext = new AsyncLocalStorage(); + readonly #leaseDurationMs: number; + readonly #leaseAcquireTimeoutMs: number; + readonly #leaseRenewIntervalMs: number; + + constructor(db: D1Database, options: D1OAuthPersistenceOptions = {}) { + this.#db = db; + this.#leaseDurationMs = options.leaseDurationMs ?? LEASE_DURATION_MS; + this.#leaseAcquireTimeoutMs = options.leaseAcquireTimeoutMs ?? LEASE_ACQUIRE_TIMEOUT_MS; + this.#leaseRenewIntervalMs = options.leaseRenewIntervalMs ?? LEASE_RENEW_INTERVAL_MS; + this.sessions = this.#store(SESSION_NAMESPACE); + this.states = this.#store("states"); + this.dpopNonces = this.#store("dpop-nonces"); + this.requestLock = async (name: string, operation: () => Promise): Promise => { + const owner = crypto.randomUUID(); + await this.#acquireLease(name, owner); + const context: LeaseContext = { name, owner, lost: false }; + const renewalStop = deferred(); + const renewal = this.#renewLease(context, renewalStop.promise); + try { + return await this.#leaseContext.run(context, operation); + } finally { + renewalStop.resolve(); + try { + await renewal; + } finally { + await this.#db + .prepare( + "UPDATE oauth_session_leases SET owner = NULL, expires_at = 0 WHERE name = ? AND owner = ?", + ) + .bind(name, owner) + .run(); + } + } + }; + } + + #store(namespace: string): Store { + return { + get: async (key): Promise => { + const row = await this.#db + .prepare("SELECT value FROM oauth_values WHERE namespace = ? AND key = ?") + .bind(namespace, key) + .first<{ value: string }>(); + return row ? (JSON.parse(row.value) as T) : undefined; + }, + set: async (key, value): Promise => { + const serialized = JSON.stringify(value); + const lockName = `oauth-session-${key}`; + const owner = this.#sessionLeaseOwner(namespace, lockName); + + if (owner) { + const result = await this.#db + .prepare( + `INSERT INTO oauth_values (namespace, key, value) + SELECT ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM oauth_session_leases + WHERE name = ? AND owner = ? AND expires_at > ? + ) + ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value`, + ) + .bind(namespace, key, serialized, lockName, owner, Date.now()) + .run(); + if (result.meta.changes !== 1) { + throw new Error("OAuth session lease was lost before the rotated token was persisted"); + } + return; + } + + if (namespace === SESSION_NAMESPACE) { + const result = await this.#db + .prepare( + "INSERT INTO oauth_values (namespace, key, value) VALUES (?, ?, ?) ON CONFLICT(namespace, key) DO NOTHING", + ) + .bind(namespace, key, serialized) + .run(); + if (result.meta.changes !== 1) { + throw new Error("Ownerless OAuth session creation cannot replace an existing session"); + } + return; + } + + await this.#db + .prepare( + `INSERT INTO oauth_values (namespace, key, value) VALUES (?, ?, ?) + ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value`, + ) + .bind(namespace, key, serialized) + .run(); + }, + delete: async (key): Promise => { + const lockName = `oauth-session-${key}`; + const owner = this.#sessionLeaseOwner(namespace, lockName); + if (owner) { + const now = Date.now(); + const result = await this.#db + .prepare( + `DELETE FROM oauth_values + WHERE namespace = ? AND key = ? + AND EXISTS ( + SELECT 1 FROM oauth_session_leases + WHERE name = ? AND owner = ? AND expires_at > ? + )`, + ) + .bind(namespace, key, lockName, owner, now) + .run(); + if (result.meta.changes === 0 && !(await this.#ownsLease(lockName, owner, now))) { + throw new Error("OAuth session lease was lost before the stored session was deleted"); + } + return; + } + + if (namespace === SESSION_NAMESPACE) { + const existing = await this.#db + .prepare("SELECT 1 AS present FROM oauth_values WHERE namespace = ? AND key = ?") + .bind(namespace, key) + .first<{ present: number }>(); + if (existing) { + throw new Error("Ownerless OAuth session deletion requires coordinated service logic"); + } + return; + } + + await this.#db + .prepare("DELETE FROM oauth_values WHERE namespace = ? AND key = ?") + .bind(namespace, key) + .run(); + }, + clear: async (): Promise => { + if (namespace === SESSION_NAMESPACE) { + const existing = await this.#db + .prepare("SELECT 1 AS present FROM oauth_values WHERE namespace = ? LIMIT 1") + .bind(namespace) + .first<{ present: number }>(); + if (existing) { + throw new Error("OAuth session clearing requires coordinated service logic"); + } + return; + } + + await this.#db + .prepare("DELETE FROM oauth_values WHERE namespace = ?") + .bind(namespace) + .run(); + }, + }; + } + + #sessionLeaseOwner(namespace: string, expectedLockName: string): string | undefined { + if (namespace !== SESSION_NAMESPACE) return undefined; + const context = this.#leaseContext.getStore(); + if (!context) return undefined; + if (context.name !== expectedLockName) { + throw new Error(`OAuth session mutation does not match active lease: ${context.name}`); + } + if (context.lost) { + throw new Error("OAuth session lease renewal lost ownership"); + } + return context.owner; + } + + async #renewLease(context: LeaseContext, stop: Promise): Promise { + while (true) { + const stopped = await Promise.race([ + stop.then(() => true), + new Promise((resolve) => setTimeout(resolve, this.#leaseRenewIntervalMs, false)), + ]); + if (stopped) return; + + const now = Date.now(); + const result = await this.#db + .prepare( + `UPDATE oauth_session_leases + SET expires_at = ? + WHERE name = ? AND owner = ? AND expires_at > ?`, + ) + .bind(now + this.#leaseDurationMs, context.name, context.owner, now) + .run(); + if (result.meta.changes !== 1) { + context.lost = true; + this.leaseRenewalLossCount++; + return; + } + this.leaseRenewalCount++; + } + } + + async #ownsLease(name: string, owner: string, now: number): Promise { + const row = await this.#db + .prepare( + "SELECT 1 AS owned FROM oauth_session_leases WHERE name = ? AND owner = ? AND expires_at > ?", + ) + .bind(name, owner, now) + .first<{ owned: number }>(); + return row?.owned === 1; + } + + async #acquireLease(name: string, owner: string): Promise { + await this.#db + .prepare("INSERT OR IGNORE INTO oauth_session_leases (name) VALUES (?)") + .bind(name) + .run(); + + const deadline = Date.now() + this.#leaseAcquireTimeoutMs; + while (Date.now() < deadline) { + const now = Date.now(); + const result = await this.#db + .prepare( + `UPDATE oauth_session_leases + SET owner = ?, expires_at = ? + WHERE name = ? AND (owner IS NULL OR expires_at <= ?)`, + ) + .bind(owner, now + this.#leaseDurationMs, name, now) + .run(); + if (result.meta.changes === 1) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + throw new Error(`Timed out acquiring OAuth session lease: ${name}`); + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} diff --git a/packages/auth-atproto/tests/support/confidential-oauth/migrations/0001_oauth_harness.sql b/packages/auth-atproto/tests/support/confidential-oauth/migrations/0001_oauth_harness.sql new file mode 100644 index 0000000000..fa98ef8e92 --- /dev/null +++ b/packages/auth-atproto/tests/support/confidential-oauth/migrations/0001_oauth_harness.sql @@ -0,0 +1,12 @@ +CREATE TABLE oauth_values ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) +); + +CREATE TABLE oauth_session_leases ( + name TEXT PRIMARY KEY, + owner TEXT, + expires_at INTEGER NOT NULL DEFAULT 0 +); diff --git a/packages/auth-atproto/tests/support/confidential-oauth/oauth-fixture.ts b/packages/auth-atproto/tests/support/confidential-oauth/oauth-fixture.ts new file mode 100644 index 0000000000..a42c09fc19 --- /dev/null +++ b/packages/auth-atproto/tests/support/confidential-oauth/oauth-fixture.ts @@ -0,0 +1,221 @@ +import type { ActorResolver } from "@atcute/identity-resolver"; +import { + OAuthClient, + type ClientAssertionPrivateJwk, + type StoredSession, + type StoredState, +} from "@atcute/oauth-node-client"; + +import { D1OAuthPersistence } from "./d1-persistence.js"; + +export const RELEASE_SCOPE = + "atproto repo:com.emdashcms.experimental.package.release?action=create"; +export const CLIENT_ID = "https://release.emdashcms.com/oauth/client-metadata.json"; +export const JWKS_URI = "https://release.emdashcms.com/oauth/jwks.json"; +export const REDIRECT_URI = "https://release.emdashcms.com/oauth/callback"; +export const PDS_ORIGIN = "https://pds.emdashcms.com"; +export const AS_ORIGIN = "https://auth.emdashcms.com"; +export const OLD_DID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; +export const NEW_DID = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; + +interface CapturedRequest { + url: string; + body: URLSearchParams; + dpop: string; +} + +export class OAuthServerFixture { + readonly parRequests: CapturedRequest[] = []; + readonly tokenRequests: CapturedRequest[] = []; + readonly resourceRequests: Request[] = []; + readonly consumedRefreshTokens = new Set(); + refreshCount = 0; + refreshDelayMs = 0; + codeExpiresIn = 3_600; + + readonly fetch: typeof globalThis.fetch = async (input, init) => { + const request = + input instanceof Request && init === undefined ? input : new Request(input, init); + const url = new URL(request.url); + + if (url.href === `${PDS_ORIGIN}/.well-known/oauth-protected-resource`) { + return jsonResponse({ resource: PDS_ORIGIN, authorization_servers: [AS_ORIGIN] }); + } + if (url.href === `${AS_ORIGIN}/.well-known/oauth-authorization-server`) { + return jsonResponse({ + issuer: AS_ORIGIN, + authorization_endpoint: `${AS_ORIGIN}/authorize`, + token_endpoint: `${AS_ORIGIN}/token`, + pushed_authorization_request_endpoint: `${AS_ORIGIN}/par`, + token_endpoint_auth_methods_supported: ["private_key_jwt"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + dpop_signing_alg_values_supported: ["ES256"], + client_id_metadata_document_supported: true, + protected_resources: [PDS_ORIGIN], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + }); + } + + if (url.href === `${AS_ORIGIN}/par`) { + const captured = await captureRequest(request); + this.parRequests.push(captured); + if (this.parRequests.length === 1) { + return jsonResponse( + { error: "use_dpop_nonce" }, + { status: 400, headers: { "DPoP-Nonce": "par-nonce" } }, + ); + } + return jsonResponse({ + request_uri: "urn:ietf:params:oauth:request_uri:test", + expires_in: 60, + }); + } + + if (url.href === `${AS_ORIGIN}/token`) { + const captured = await captureRequest(request); + this.tokenRequests.push(captured); + const grantType = captured.body.get("grant_type"); + if (grantType === "authorization_code") { + const sub = captured.body.get("code") === "new-code" ? NEW_DID : OLD_DID; + return jsonResponse({ + access_token: `access-${sub}`, + refresh_token: `refresh-${sub}-0`, + token_type: "DPoP", + sub, + scope: RELEASE_SCOPE, + expires_in: this.codeExpiresIn, + }); + } + if (grantType === "refresh_token") { + this.refreshCount++; + const refreshToken = captured.body.get("refresh_token"); + if ( + refreshToken !== `refresh-${OLD_DID}-0` || + this.consumedRefreshTokens.has(refreshToken) + ) { + await new Promise((resolve) => setTimeout(resolve, this.refreshDelayMs)); + return jsonResponse({ error: "invalid_grant" }, { status: 400 }); + } + this.consumedRefreshTokens.add(refreshToken); + await new Promise((resolve) => setTimeout(resolve, this.refreshDelayMs)); + return jsonResponse({ + access_token: `access-${OLD_DID}-1`, + refresh_token: `refresh-${OLD_DID}-1`, + token_type: "DPoP", + sub: OLD_DID, + scope: RELEASE_SCOPE, + expires_in: 3_600, + }); + } + } + + if (url.origin === PDS_ORIGIN) { + this.resourceRequests.push(request); + return jsonResponse({ + uri: `at://${OLD_DID}/com.emdashcms.experimental.package.release/demo:1.0.0`, + }); + } + + return new Response("not found", { status: 404 }); + }; +} + +const actorResolver: ActorResolver = { + async resolve(actor) { + return { + did: actor as typeof OLD_DID, + handle: "publisher.emdashcms.com", + pds: PDS_ORIGIN, + }; + }, +}; + +export function createConfidentialClient( + keys: ClientAssertionPrivateJwk[], + persistence: D1OAuthPersistence, + server: OAuthServerFixture, +): OAuthClient { + return new OAuthClient({ + metadata: { + client_id: CLIENT_ID, + redirect_uris: [REDIRECT_URI], + scope: RELEASE_SCOPE, + jwks_uri: JWKS_URI, + }, + keyset: keys, + actorResolver, + stores: { + sessions: persistence.sessions, + states: persistence.states, + dpopNonces: persistence.dpopNonces, + }, + requestLock: persistence.requestLock, + fetch: server.fetch, + }); +} + +export async function authorizeAndCallback( + db: D1Database, + keys: ClientAssertionPrivateJwk[], + server: OAuthServerFixture, + code = "old-code", +): Promise<{ + client: OAuthClient; + session: Awaited>; + stateId: string; +}> { + const authorizingClient = createConfidentialClient(keys, new D1OAuthPersistence(db), server); + const { stateId } = await authorizingClient.authorize({ + target: { type: "pds", serviceUrl: PDS_ORIGIN }, + }); + + const callbackClient = createConfidentialClient(keys, new D1OAuthPersistence(db), server); + const { session } = await callbackClient.callback( + new URLSearchParams({ state: stateId, code, iss: AS_ORIGIN }), + ); + return { client: callbackClient, session, stateId }; +} + +export async function readStoredState( + db: D1Database, + stateId: string, +): Promise { + return new D1OAuthPersistence(db).states.get(stateId); +} + +export async function readStoredSession( + db: D1Database, + did: string, +): Promise { + return new D1OAuthPersistence(db).sessions.get(did); +} + +export function decodeJwtPart(value: string, index: 0 | 1): Record { + const encoded = value.split(".")[index]; + if (!encoded) throw new Error("Invalid JWT fixture value"); + return JSON.parse( + new TextDecoder().decode(Uint8Array.fromBase64(encoded, { alphabet: "base64url" })), + ) as Record; +} + +async function captureRequest(request: Request): Promise { + return { + url: request.url, + body: new URLSearchParams(new TextDecoder().decode(await request.arrayBuffer())), + dpop: requiredHeader(request.headers, "DPoP"), + }; +} + +function requiredHeader(headers: Headers, name: string): string { + const value = headers.get(name); + if (!value) throw new Error(`Missing ${name} header`); + return value; +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + const headers = new Headers(init?.headers); + headers.set("Content-Type", "application/json"); + return new Response(JSON.stringify(body), { ...init, headers }); +} diff --git a/packages/auth-atproto/tests/support/confidential-oauth/tsconfig.json b/packages/auth-atproto/tests/support/confidential-oauth/tsconfig.json new file mode 100644 index 0000000000..3bba8dd82b --- /dev/null +++ b/packages/auth-atproto/tests/support/confidential-oauth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers/types", "@cloudflare/workers-types", "node"], + "lib": ["es2023", "DOM", "DOM.Iterable", "esnext.typedarrays"], + "noEmit": true + }, + "include": [ + "./**/*.ts", + "../../confidential-oauth-workerd.test.ts", + "../../../vitest.config.ts", + "../../../vitest.workerd.config.ts" + ] +} diff --git a/packages/auth-atproto/tests/support/confidential-oauth/worker.ts b/packages/auth-atproto/tests/support/confidential-oauth/worker.ts new file mode 100644 index 0000000000..765d6ce9b1 --- /dev/null +++ b/packages/auth-atproto/tests/support/confidential-oauth/worker.ts @@ -0,0 +1,5 @@ +export default { + fetch(): Response { + return new Response("confidential OAuth workerd fixture"); + }, +}; diff --git a/packages/auth-atproto/tests/support/confidential-oauth/wrangler.jsonc b/packages/auth-atproto/tests/support/confidential-oauth/wrangler.jsonc new file mode 100644 index 0000000000..5ce930ee7f --- /dev/null +++ b/packages/auth-atproto/tests/support/confidential-oauth/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "name": "auth-atproto-confidential-oauth-test", + "main": "./worker.ts", + "compatibility_date": "2026-02-24", + "compatibility_flags": ["nodejs_als"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "auth-atproto-confidential-oauth-test", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "./migrations", + }, + ], +} diff --git a/packages/auth-atproto/vitest.config.ts b/packages/auth-atproto/vitest.config.ts new file mode 100644 index 0000000000..cfb66a63ef --- /dev/null +++ b/packages/auth-atproto/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + exclude: ["tests/confidential-oauth-workerd.test.ts"], + }, +}); diff --git a/packages/auth-atproto/vitest.workerd.config.ts b/packages/auth-atproto/vitest.workerd.config.ts new file mode 100644 index 0000000000..cccdc53a91 --- /dev/null +++ b/packages/auth-atproto/vitest.workerd.config.ts @@ -0,0 +1,24 @@ +import { fileURLToPath } from "node:url"; + +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +const migrations = await readD1Migrations( + fileURLToPath(new URL("./tests/support/confidential-oauth/migrations", import.meta.url)), +); + +export default defineConfig({ + test: { + include: ["tests/confidential-oauth-workerd.test.ts"], + }, + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./tests/support/confidential-oauth/wrangler.jsonc", + }, + miniflare: { + bindings: { TEST_MIGRATIONS: migrations }, + }, + }), + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af45cfb8a9..b26e9abc87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1362,10 +1362,10 @@ importers: dependencies: '@atcute/identity-resolver': specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) + version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/oauth-node-client': specifier: 'catalog:' - version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) + version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@emdash-cms/auth': specifier: workspace:* version: link:../auth @@ -1388,12 +1388,21 @@ importers: '@cloudflare/kumo': specifier: 'catalog:' version: 2.6.0(@date-fns/tz@1.4.1)(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) + '@cloudflare/vitest-pool-workers': + specifier: 'catalog:' + version: 0.16.3(@cloudflare/workers-types@4.20260305.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260305.1 + '@types/node': + specifier: 'catalog:' + version: 24.10.13 '@types/react': specifier: ^19.0.0 version: 19.2.14 vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/blocks: dependencies: @@ -4931,12 +4940,6 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -7922,9 +7925,6 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - cjs-module-lexer@1.4.0: - resolution: {integrity: sha512-N1NGmowPlGBLsOZLPvm48StN04V4YvQRL0i6b7ctrVY3epjP/ct7hFLOItz6pDIvRjwpfPxi52a2UWV2ziir8g==} - cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -8370,6 +8370,7 @@ packages: esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} + hasBin: true esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -9584,6 +9585,7 @@ packages: miniflare@4.20260415.0: resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==} engines: {node: '>=18.0.0'} + hasBin: true miniflare@4.20260507.1: resolution: {integrity: sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==} @@ -9592,10 +9594,12 @@ packages: miniflare@4.20260609.0: resolution: {integrity: sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA==} engines: {node: '>=22.0.0'} + hasBin: true miniflare@4.20260611.0: resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} engines: {node: '>=22.0.0'} + hasBin: true minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} @@ -9697,10 +9701,6 @@ packages: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10163,14 +10163,6 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -10579,6 +10571,7 @@ packages: rolldown@1.0.0-rc.18: resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true rolldown@1.0.0-rc.3: resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} @@ -11125,6 +11118,7 @@ packages: typescript@5.6.1-rc: resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} + hasBin: true typescript@6.0.0-beta: resolution: {integrity: sha512-CldZdztDpQRLM1HC6WDQjQkQN5Ub5zRau737a1diGh3lPmb9oRsaWHk1y5iqK0o7+1bNJ0oXfEGRkAogFZBL+Q==} @@ -11819,6 +11813,7 @@ packages: workerd@1.20260415.1: resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==} engines: {node: '>=16'} + hasBin: true workerd@1.20260507.1: resolution: {integrity: sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==} @@ -11827,10 +11822,12 @@ packages: workerd@1.20260609.1: resolution: {integrity: sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA==} engines: {node: '>=16'} + hasBin: true workerd@1.20260611.1: resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} engines: {node: '>=16'} + hasBin: true wrangler@4.100.0: resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} @@ -12663,13 +12660,6 @@ snapshots: '@atcute/multibase': 1.2.0 '@atcute/uint8array': 1.1.1 - '@atcute/client@5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': - dependencies: - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/lexicons': 2.0.0 - transitivePeerDependencies: - - typescript - '@atcute/client@5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3)': dependencies: '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) @@ -12703,15 +12693,6 @@ snapshots: '@atcute/util-fetch': 1.0.5 '@badrap/valita': 0.4.6 - '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': - dependencies: - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/lexicons': 2.0.0 - '@atcute/util-fetch': 2.0.0(typescript@6.0.0-beta) - valibot: 1.4.0(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': dependencies: '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) @@ -12726,17 +12707,10 @@ snapshots: '@atcute/lexicons': 1.3.0 '@badrap/valita': 0.4.6 - '@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': - dependencies: - '@atcute/lexicons': 2.0.0 - valibot: 1.4.0(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3)': dependencies: '@atcute/lexicons': 2.0.0 - valibot: 1.4.0(typescript@6.0.3) + valibot: 1.4.1(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -12814,15 +12788,6 @@ snapshots: dependencies: '@atcute/uint8array': 1.1.1 - '@atcute/oauth-crypto@1.0.0(typescript@6.0.0-beta)': - dependencies: - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - nanoid: 5.1.11 - valibot: 1.4.1(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/oauth-crypto@1.0.0(typescript@6.0.3)': dependencies: '@atcute/multibase': 1.2.0 @@ -12832,33 +12797,12 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/oauth-keyset@0.1.1(typescript@6.0.0-beta)': - dependencies: - '@atcute/oauth-crypto': 1.0.0(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/oauth-keyset@0.1.1(typescript@6.0.3)': dependencies: '@atcute/oauth-crypto': 1.0.0(typescript@6.0.3) transitivePeerDependencies: - typescript - '@atcute/oauth-node-client@2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': - dependencies: - '@atcute/client': 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/lexicons': 2.0.0 - '@atcute/oauth-crypto': 1.0.0(typescript@6.0.0-beta) - '@atcute/oauth-keyset': 0.1.1(typescript@6.0.0-beta) - '@atcute/oauth-types': 1.0.0(typescript@6.0.0-beta) - '@atcute/util-fetch': 2.0.0(typescript@6.0.0-beta) - nanoid: 5.1.11 - valibot: 1.4.1(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/oauth-node-client@2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': dependencies: '@atcute/client': 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) @@ -12874,15 +12818,6 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/oauth-types@1.0.0(typescript@6.0.0-beta)': - dependencies: - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/lexicons': 2.0.0 - '@atcute/oauth-keyset': 0.1.1(typescript@6.0.0-beta) - valibot: 1.4.1(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/oauth-types@1.0.0(typescript@6.0.3)': dependencies: '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) @@ -12918,12 +12853,6 @@ snapshots: dependencies: '@badrap/valita': 0.4.6 - '@atcute/util-fetch@2.0.0(typescript@6.0.0-beta)': - dependencies: - valibot: 1.4.1(typescript@6.0.0-beta) - transitivePeerDependencies: - - typescript - '@atcute/util-fetch@2.0.0(typescript@6.0.3)': dependencies: valibot: 1.4.1(typescript@6.0.3) @@ -13965,15 +13894,30 @@ snapshots: - utf-8-validate - workerd + '@cloudflare/vitest-pool-workers@0.16.3(@cloudflare/workers-types@4.20260305.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': + dependencies: + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + cjs-module-lexer: 1.4.3 + esbuild: 0.27.3 + miniflare: 4.20260507.1 + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + wrangler: 4.90.0(@cloudflare/workers-types@4.20260305.1) + zod: 3.25.76 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + '@cloudflare/vitest-pool-workers@0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 - cjs-module-lexer: 1.4.0 + cjs-module-lexer: 1.4.3 esbuild: 0.27.3 miniflare: 4.20260507.1 vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - wrangler: 4.90.0 + wrangler: 4.90.0(@cloudflare/workers-types@4.20260305.1) zod: 3.25.76 transitivePeerDependencies: - '@cloudflare/workers-types' @@ -14478,8 +14422,8 @@ snapshots: hast-util-to-html: 9.0.5 hast-util-to-text: 4.0.2 hastscript: 9.0.1 - postcss: 8.5.15 - postcss-nested: 6.2.0(postcss@8.5.15) + postcss: 8.5.16 + postcss-nested: 6.2.0(postcss@8.5.16) unist-util-visit: 5.1.0 unist-util-visit-parents: 6.0.2 @@ -15144,13 +15088,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -15737,7 +15674,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': @@ -17040,7 +16977,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 24.10.13 + '@types/node': 25.9.1 '@types/chai@5.2.2': dependencies: @@ -17131,7 +17068,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.10.13 + '@types/node': 25.9.1 '@types/semver@7.7.1': {} @@ -18350,8 +18287,6 @@ snapshots: dependencies: consola: 3.4.2 - cjs-module-lexer@1.4.0: {} - cjs-module-lexer@1.4.3: {} class-variance-authority@0.7.1: @@ -20600,8 +20535,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@3.3.12: {} - nanoid@3.3.15: {} nanoid@5.1.11: {} @@ -21084,9 +21017,9 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-nested@6.2.0(postcss@8.5.15): + postcss-nested@6.2.0(postcss@8.5.16): dependencies: - postcss: 8.5.15 + postcss: 8.5.16 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.0.10: @@ -21099,18 +21032,6 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.14: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.16: dependencies: nanoid: 3.3.15 @@ -22528,18 +22449,10 @@ snapshots: util-deprecate@1.0.2: {} - valibot@1.4.0(typescript@6.0.0-beta): - optionalDependencies: - typescript: 6.0.0-beta - valibot@1.4.0(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 - valibot@1.4.1(typescript@6.0.0-beta): - optionalDependencies: - typescript: 6.0.0-beta - valibot@1.4.1(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -22616,9 +22529,9 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.14 + postcss: 8.5.16 rolldown: 1.0.0-rc.18 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.10.13 esbuild: 0.28.1 @@ -23050,7 +22963,7 @@ snapshots: - bufferutil - utf-8-validate - wrangler@4.90.0: + wrangler@4.90.0(@cloudflare/workers-types@4.20260305.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1) @@ -23061,6 +22974,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260507.1 optionalDependencies: + '@cloudflare/workers-types': 4.20260305.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil