diff --git a/.changeset/itchy-walls-smell.md b/.changeset/itchy-walls-smell.md new file mode 100644 index 000000000..865fa0888 --- /dev/null +++ b/.changeset/itchy-walls-smell.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Adds AT Protocol OAuth login for signing into the CMS with an Atmosphere handle diff --git a/docs/src/content/docs/guides/authentication.mdx b/docs/src/content/docs/guides/authentication.mdx index 12b77e4fe..50255c236 100644 --- a/docs/src/content/docs/guides/authentication.mdx +++ b/docs/src/content/docs/guides/authentication.mdx @@ -93,6 +93,64 @@ EmDash supports OAuth login with GitHub and Google when configured. Users can li See the [Configuration guide](/reference/configuration#oauth) for setup instructions. +## ATProto Login + +EmDash supports login via the [AT Protocol](https://atproto.com/) (the protocol behind Bluesky). Users can sign in with any AT Protocol handle — whether on `bsky.social` or a self-hosted PDS. + +### How It Works + + + +1. Enable ATProto in your config: + + ```js title="astro.config.mjs" + import emdash from "emdash/astro"; + + emdash({ + atproto: true, + }) + ``` + +2. The login page shows a "Sign in via the Atmosphere" section with a handle input + +3. The user enters their handle (e.g., `alice.bsky.social`) and clicks **Sign in** + +4. They're redirected to their PDS to authorize the login + +5. After authorization, they're redirected back to EmDash + + + +### Email Verification + +ATProto doesn't provide email addresses during OAuth. After authorizing on their PDS, users are asked to enter an email address and verify it via a confirmation link. This email is used for: + +- Matching with existing EmDash accounts +- Checking against [allowed domains](#self-signup) for self-signup +- Account communication (invites, magic links) + + + +### Access Control + +ATProto login follows the same access rules as GitHub/Google OAuth: + +- **Existing users** — If the email matches an existing user, the ATProto identity is linked to that account +- **Allowed domains** — New users can sign up if their email domain is in the allowed domains list (with the domain's default role) +- **No match** — Users without a matching account or allowed domain are rejected + + + +### Identity Storage + +When a user signs in via ATProto, their DID (Decentralized Identifier) is stored on their user profile. This is separate from the existing [ATProto plugin](/plugins/overview) which handles content syndication to Bluesky using app passwords. + ## User Roles EmDash uses role-based access control with five levels: diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index e1d537604..19a3ad0e9 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -173,6 +173,22 @@ oauth: { } ``` +### `atproto` + +**Optional.** Enable AT Protocol OAuth login. When `true`, the login page shows a handle input for signing in with a Bluesky handle or any PDS-hosted identity. + +```js +emdash({ + atproto: true, +}) +``` + +ATProto login is additive — it works alongside passkeys, OAuth, and magic links. It does not replace or disable any existing auth method. + +Users who sign in via ATProto must verify their email address before their account is created. Access is gated by the same [allowed domains](#authselfsignup) rules as other auth methods. + +See the [Authentication guide](/guides/authentication#atproto-login) for details. + #### `auth.session` Session configuration. diff --git a/packages/admin/src/components/LoginPage.tsx b/packages/admin/src/components/LoginPage.tsx index 6d028a71c..8379f599a 100644 --- a/packages/admin/src/components/LoginPage.tsx +++ b/packages/admin/src/components/LoginPage.tsx @@ -206,6 +206,71 @@ function MagicLinkForm({ onBack }: MagicLinkFormProps) { ); } +// ============================================================================ +// ATProto Login Form +// ============================================================================ + +function AtprotoLoginForm() { + const [handle, setHandle] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const response = await apiFetch("/_emdash/api/auth/atproto/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle: handle.trim() }), + }); + + if (!response.ok) { + const body: { error?: { message?: string } } = await response.json().catch(() => ({})); + throw new Error(body?.error?.message || "Failed to start login"); + } + + const result: { data?: { redirectUrl?: string } } = await response.json(); + if (result.data?.redirectUrl) { + window.location.href = result.data.redirectUrl; + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start login"); + setIsLoading(false); + } + }; + + return ( +
+
+ setHandle(e.target.value)} + placeholder="alice.bsky.social" + disabled={isLoading} + autoComplete="username" + className="flex-1" + /> + +
+ {error && ( +
{error}
+ )} +
+ ); +} + // ============================================================================ // Main Component // ============================================================================ @@ -321,6 +386,24 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { ))} + {/* ATProto Login */} + {manifest?.atprotoEnabled && ( + <> +
+
+ +
+
+ + Sign in via the Atmosphere + +
+
+ + + + )} + {/* Magic Link Option */} + + +

+ + Back to login + +

+ + + ); + } + + return ( +
+
+ {/* Header */} +
+ +

Almost there

+

+ Enter your email to complete sign-in. We'll send a verification link. +

+
+ + {/* Form */} +
+
+ setEmail(e.target.value)} + placeholder="you@example.com" + className={error ? "border-kumo-danger" : ""} + disabled={isLoading} + autoComplete="email" + autoFocus + required + /> + + {error && ( +
+ {error} +
+ )} + + +
+
+ + {/* Back link */} +

+ + Back to login + +

+
+
+ ); +} diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index b46a84964..5807ce216 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -123,6 +123,11 @@ export interface AdminManifest { * Used by the login page to conditionally show the "Sign up" link. */ signupEnabled?: boolean; + /** + * Whether ATProto OAuth login is enabled. + * When true, the login page shows a handle input for ATProto auth. + */ + atprotoEnabled?: boolean; /** * i18n configuration. Present when multiple locales are configured. */ diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 89b6e75da..872499773 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -19,6 +19,7 @@ import { } from "@tanstack/react-router"; import * as React from "react"; +import { CompleteProfilePage } from "./components/auth/CompleteProfilePage"; import { CommentInbox } from "./components/comments/CommentInbox"; import { ContentEditor } from "./components/ContentEditor"; import { ContentList } from "./components/ContentList"; @@ -151,6 +152,13 @@ const signupRoute = createRoute({ component: SignupPage, }); +// Complete profile route (standalone, no Shell) - ATProto email collection +const completeProfileRoute = createRoute({ + getParentRoute: () => baseRootRoute, + path: "/complete-profile", + component: CompleteProfilePage, +}); + // Device authorization route (standalone, no Shell) const deviceRoute = createRoute({ getParentRoute: () => baseRootRoute, @@ -1561,6 +1569,7 @@ const routeTree = baseRootRoute.addChildren([ setupRoute, loginRoute, signupRoute, + completeProfileRoute, deviceRoute, adminRoutes, ]); diff --git a/packages/core/package.json b/packages/core/package.json index 660facd0a..976be0bd9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -154,6 +154,7 @@ "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { + "@atproto/oauth-client-node": "^0.3.17", "@emdash-cms/admin": "workspace:*", "@emdash-cms/auth": "workspace:*", "@emdash-cms/gutenberg-to-portable-text": "workspace:*", diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 3ed69a2d2..b70a1269d 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -153,6 +153,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { auth: resolvedConfig.auth, marketplace: resolvedConfig.marketplace, passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin, + atproto: resolvedConfig.atproto, }; // Determine auth mode for route injection diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index a3eb49d53..edf5c2dcc 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -805,6 +805,32 @@ export function injectBuiltinAuthRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/auth/oauth/[provider]/callback.ts"), }); + // ATProto OAuth routes + injectRoute({ + pattern: "/_emdash/api/auth/atproto/client-metadata.json", + entrypoint: resolveRoute("api/auth/atproto/client-metadata.json.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/login", + entrypoint: resolveRoute("api/auth/atproto/login.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/callback", + entrypoint: resolveRoute("api/auth/atproto/callback.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/complete-profile", + entrypoint: resolveRoute("api/auth/atproto/complete-profile.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/verify-email", + entrypoint: resolveRoute("api/auth/atproto/verify-email.ts"), + }); + // Self-signup routes injectRoute({ pattern: "/_emdash/api/auth/signup/request", diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 7b000d738..52d8dcb1c 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -222,6 +222,23 @@ export interface EmDashConfig { */ auth?: AuthDescriptor; + /** + * Enable ATProto OAuth login (Bluesky / AT Protocol handles). + * + * When true, the admin login page shows a handle input for signing in + * with a Bluesky handle or any AT Protocol PDS-hosted identity. + * + * @default false + * + * @example + * ```ts + * emdash({ + * atproto: true, + * }) + * ``` + */ + atproto?: boolean; + /** * Enable the MCP (Model Context Protocol) server endpoint. * diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 76228d1c6..7c7e709a0 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -95,6 +95,7 @@ const PUBLIC_API_PREFIXES = [ "/_emdash/api/auth/invite/accept", "/_emdash/api/auth/invite/complete", "/_emdash/api/auth/oauth/", + "/_emdash/api/auth/atproto/", "/_emdash/api/oauth/device/token", "/_emdash/api/oauth/device/code", "/_emdash/api/oauth/token", @@ -108,6 +109,7 @@ const PUBLIC_API_EXACT = new Set([ "/_emdash/api/auth/passkey/verify", "/_emdash/api/oauth/token", "/_emdash/api/snapshot", + "/_emdash/api/manifest", ]); function isPublicEmDashRoute(pathname: string): boolean { @@ -268,6 +270,7 @@ async function handleEmDashAuth( const { emdash } = locals; const isLoginRoute = url.pathname.startsWith("/_emdash/admin/login"); + const isCompleteProfileRoute = url.pathname.startsWith("/_emdash/admin/complete-profile"); const isApiRoute = url.pathname.startsWith("/_emdash/api"); if (!emdash?.db) { @@ -293,7 +296,7 @@ async function handleEmDashAuth( } // Passkey authentication (default) - if (isLoginRoute) { + if (isLoginRoute || isCompleteProfileRoute) { return next(); } diff --git a/packages/core/src/astro/routes/api/auth/atproto/callback.ts b/packages/core/src/astro/routes/api/auth/atproto/callback.ts new file mode 100644 index 000000000..94ee79c68 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/callback.ts @@ -0,0 +1,258 @@ +/** + * GET /_emdash/api/auth/atproto/callback + * + * Handles the redirect from the PDS authorization server after the user + * approves the ATProto OAuth request. Exchanges the code for tokens, + * provisions or links the user, and creates an EmDash session. + */ + +import type { APIRoute } from "astro"; +import { ulid } from "ulidx"; + +import { createAtprotoClient, cleanupAtprotoEntries } from "#auth/atproto/client.js"; + +export const prerender = false; + +const PENDING_TTL_MS = 15 * 60 * 1000; // 15 minutes for email collection + +/** Create a redirect response with mutable headers (unlike Response.redirect) */ +function redirectTo(url: string, status = 302): Response { + return new Response(null, { + status, + headers: { Location: url }, + }); +} + +export const GET: APIRoute = async ({ request, locals, session }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return redirectTo( + `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, + ); + } + + if (!emdash.config?.atproto) { + return redirectTo( + `/_emdash/admin/login?error=not_configured&message=${encodeURIComponent("ATProto authentication is not enabled")}`, + ); + } + + const url = new URL(request.url); + const params = url.searchParams; + + // In loopback mode, the PDS redirects to 127.0.0.1 but the user browses + // on localhost. We use two origins: + // - localhostOrigin: for UI redirects (login page, complete-profile) where + // the SPA runs and existing cookies live + // - url.origin: for session-setting redirects (successful login) so the + // session cookie is set on the same host as the response + const hostname = url.hostname; + const isLoopback = hostname === "127.0.0.1" || hostname === "[::1]"; + const localhostOrigin = isLoopback ? `http://localhost:${url.port}` : url.origin; + // For successful auth, stay on the callback host so session cookie is valid + const sessionOrigin = url.origin; + + // Handle errors from the PDS + const error = params.get("error"); + if (error) { + const desc = params.get("error_description") || error; + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=oauth_denied&message=${encodeURIComponent(desc)}`, + ); + } + + try { + const client = createAtprotoClient({ + publicUrl: localhostOrigin, + db: emdash.db, + allowHttp: import.meta.env.DEV, + }); + + // Exchange code for tokens via SDK + const { session: oauthSession } = await client.callback(params); + + const did = oauthSession.did; + + // Resolve handle from DID + let handle: string | undefined; + try { + const identity = await client.identityResolver.resolve(did); + handle = identity.handle !== "handle.invalid" ? identity.handle : undefined; + } catch { + // Handle resolution failure is not fatal — we still have the DID + } + + // Try to get email from the token info + // ATProto doesn't standardize email in token responses, + // so this may not be available + let email: string | undefined; + try { + const tokenInfo = await oauthSession.getTokenInfo(); + // Check if email is available in token metadata + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const info = tokenInfo as any; + if (typeof info.email === "string" && info.email) { + email = info.email; + } + } catch { + // Email not available from token — will need interstitial + } + + // === User provisioning === + + // Step 1: Look up by DID in oauth_accounts + const existingAccount = await emdash.db + .selectFrom("oauth_accounts") + .selectAll() + .where("provider", "=", "atproto") + .where("provider_account_id", "=", did) + .executeTakeFirst(); + + if (existingAccount) { + const user = await emdash.db + .selectFrom("users") + .selectAll() + .where("id", "=", existingAccount.user_id) + .executeTakeFirst(); + + if (!user) { + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=user_not_found&message=${encodeURIComponent("Linked user account not found")}`, + ); + } + + if (user.disabled) { + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Your account has been disabled")}`, + ); + } + + // Update handle if it changed + if (handle && user.atproto_did === did) { + // User already has DID set, just update if needed + } + + if (session) { + session.set("user", { id: user.id }); + } + + await cleanupAtprotoEntries(emdash.db); + return redirectTo(`${sessionOrigin}/_emdash/admin`); + } + + // Step 2: If we have email, try to link by email + if (email) { + const existingUser = await emdash.db + .selectFrom("users") + .selectAll() + .where("email", "=", email.toLowerCase()) + .executeTakeFirst(); + + if (existingUser) { + if (existingUser.disabled) { + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Your account has been disabled")}`, + ); + } + + // Link DID to existing user + await emdash.db + .updateTable("users") + .set({ atproto_did: did }) + .where("id", "=", existingUser.id) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: existingUser.id, + }) + .execute(); + + if (session) { + session.set("user", { id: existingUser.id }); + } + + await cleanupAtprotoEntries(emdash.db); + return redirectTo(`${sessionOrigin}/_emdash/admin`); + } + + // Step 3: Check allowed_domains for self-signup + const domain = email.split("@")[1]?.toLowerCase(); + if (domain) { + const allowedDomain = await emdash.db + .selectFrom("allowed_domains") + .selectAll() + .where("domain", "=", domain) + .where("enabled", "=", 1) + .executeTakeFirst(); + + if (allowedDomain) { + const userId = ulid(); + await emdash.db + .insertInto("users") + .values({ + id: userId, + email: email.toLowerCase(), + name: handle || null, + avatar_url: null, + role: allowedDomain.default_role, + email_verified: 1, + data: null, + atproto_did: did, + }) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: userId, + }) + .execute(); + + if (session) { + session.set("user", { id: userId }); + } + + await cleanupAtprotoEntries(emdash.db); + return redirectTo(`${sessionOrigin}/_emdash/admin`); + } + } + + // No allowed domain — signup not permitted + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=signup_not_allowed&message=${encodeURIComponent("Self-signup is not allowed for your email domain. Please contact an administrator.")}`, + ); + } + + // Step 4: No email available — redirect to complete-profile interstitial + const pendingKey = ulid(); + const expiresAt = new Date(Date.now() + PENDING_TTL_MS).toISOString(); + + await emdash.db + .insertInto("auth_challenges") + .values({ + challenge: pendingKey, + type: "atproto_pending", + user_id: null, + data: JSON.stringify({ did, handle }), + expires_at: expiresAt, + }) + .execute(); + + return redirectTo( + `${localhostOrigin}/_emdash/admin/complete-profile?state=${encodeURIComponent(pendingKey)}`, + ); + } catch (callbackError) { + console.error("ATProto callback error:", callbackError); + + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=atproto_error&message=${encodeURIComponent("Authentication failed. Please try again.")}`, + ); + } +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts b/packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts new file mode 100644 index 000000000..285481025 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts @@ -0,0 +1,38 @@ +/** + * GET /_emdash/api/auth/atproto/client-metadata.json + * + * Serves the OAuth client metadata document for ATProto OAuth. + * This URL IS the client_id (ATProto convention for public clients). + * + * Must be publicly accessible — PDS authorization servers fetch this + * to validate the client during the OAuth flow. + */ + +import type { APIRoute } from "astro"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const url = new URL(request.url); + const origin = url.origin; + + const metadata = { + client_id: `${origin}/_emdash/api/auth/atproto/client-metadata.json`, + client_name: "EmDash CMS", + client_uri: origin, + redirect_uris: [`${origin}/_emdash/api/auth/atproto/callback`], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "atproto", + dpop_bound_access_tokens: true, + application_type: "web", + }; + + return Response.json(metadata, { + headers: { + "Cache-Control": "public, max-age=3600", + "Content-Type": "application/json", + }, + }); +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/complete-profile.ts b/packages/core/src/astro/routes/api/auth/atproto/complete-profile.ts new file mode 100644 index 000000000..4037d3aad --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/complete-profile.ts @@ -0,0 +1,132 @@ +/** + * POST /_emdash/api/auth/atproto/complete-profile + * + * Collects email when the PDS authorization server didn't return one. + * Sends a verification email instead of immediately creating the user. + * The user must click the link in the email to complete sign-in. + */ + +import { generateTokenWithHash } from "@emdash-cms/auth"; +import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import type { APIRoute } from "astro"; + +import { apiError, handleError } from "#api/error.js"; + +export const prerender = false; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + let email: string; + let state: string; + try { + const body = (await request.json()) as { email?: string; state?: string }; + email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + state = typeof body.state === "string" ? body.state.trim() : ""; + } catch { + return apiError("VALIDATION_ERROR", "Invalid request body", 400); + } + + if (!state) { + return apiError("VALIDATION_ERROR", "Missing state parameter", 400); + } + + if (!email || !EMAIL_RE.test(email)) { + return apiError("VALIDATION_ERROR", "Please enter a valid email address", 400); + } + + try { + // Retrieve pending ATProto state + const pending = await emdash.db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", state) + .where("type", "=", "atproto_pending") + .executeTakeFirst(); + + if (!pending?.data) { + return apiError( + "INVALID_STATE", + "Session expired or invalid. Please try logging in again.", + 400, + ); + } + + // Check expiration + if (new Date(pending.expires_at).getTime() < Date.now()) { + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + return apiError("EXPIRED", "Session expired. Please try logging in again.", 400); + } + + // Check if email pipeline is available + if (!emdash.email?.isAvailable()) { + return apiError( + "EMAIL_NOT_CONFIGURED", + "Email is not configured. Please contact an administrator.", + 500, + ); + } + + // Generate verification token + const { token, hash } = generateTokenWithHash(); + const adapter = createKyselyAdapter(emdash.db); + + await adapter.createToken({ + hash, + userId: null, // No user yet + email, + type: "email_verify", + expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS), + }); + + // Build verification URL with both the token and the atproto state + const url = new URL(request.url); + const verifyUrl = new URL("/_emdash/api/auth/atproto/verify-email", url.origin); + verifyUrl.searchParams.set("token", token); + verifyUrl.searchParams.set("state", state); + + // Log full verification URL in dev mode + if (import.meta.env.DEV) { + console.log(`[atproto] Verification URL: ${verifyUrl.toString()}`); + } + + // Send verification email + const siteName = "EmDash"; + await emdash.email.send( + { + to: email, + subject: `Verify your email for ${siteName}`, + text: `Click this link to verify your email and complete sign-in:\n\n${verifyUrl.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`, + html: ` + + + + + + + +

Verify your email

+

Click the button below to verify your email and complete sign-in:

+

+ Verify email +

+

This link expires in 15 minutes.

+

If you didn't request this, you can safely ignore this email.

+ +`, + }, + "system", + ); + + return Response.json({ data: { emailSent: true } }); + } catch (error) { + return handleError(error, "Failed to send verification email", "COMPLETE_PROFILE_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/login.ts b/packages/core/src/astro/routes/api/auth/atproto/login.ts new file mode 100644 index 000000000..8f6e77681 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/login.ts @@ -0,0 +1,59 @@ +/** + * POST /_emdash/api/auth/atproto/login + * + * Initiates ATProto OAuth login flow. + * Accepts a Bluesky handle, resolves it to a PDS, generates PKCE state, + * and returns the authorization URL for the user to be redirected to. + */ + +import type { APIRoute } from "astro"; + +import { apiError, handleError } from "#api/error.js"; +import { createAtprotoClient } from "#auth/atproto/client.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + if (!emdash.config?.atproto) { + return apiError("NOT_CONFIGURED", "ATProto authentication is not enabled", 403); + } + + let handle: string; + try { + const body = (await request.json()) as { handle?: string }; + handle = typeof body.handle === "string" ? body.handle.trim().toLowerCase() : ""; + } catch { + return apiError("VALIDATION_ERROR", "Invalid request body", 400); + } + + if (!handle || !handle.includes(".")) { + return apiError( + "VALIDATION_ERROR", + "Please enter a valid handle (e.g., alice.bsky.social)", + 400, + ); + } + + try { + const url = new URL(request.url); + const client = createAtprotoClient({ + publicUrl: url.origin, + db: emdash.db, + allowHttp: import.meta.env.DEV, + }); + + const authUrl = await client.authorize(handle); + + return Response.json({ data: { redirectUrl: authUrl.toString() } }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("[ATPROTO_LOGIN_ERROR]", message); + return apiError("ATPROTO_LOGIN_ERROR", "Failed to initiate ATProto login", 500); + } +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/verify-email.ts b/packages/core/src/astro/routes/api/auth/atproto/verify-email.ts new file mode 100644 index 000000000..555f6c330 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/verify-email.ts @@ -0,0 +1,211 @@ +/** + * GET /_emdash/api/auth/atproto/verify-email + * + * Handles the email verification link clicked from the verification email. + * Verifies the token, creates or links the user, and establishes a session. + */ + +import { hashToken } from "@emdash-cms/auth"; +import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import type { APIRoute } from "astro"; +import { ulid } from "ulidx"; + +import { cleanupAtprotoEntries } from "#auth/atproto/client.js"; + +export const prerender = false; + +/** Create a redirect response with mutable headers */ +function redirectTo(url: string, status = 302): Response { + return new Response(null, { + status, + headers: { Location: url }, + }); +} + +export const GET: APIRoute = async ({ request, locals, session }) => { + const { emdash } = locals; + const url = new URL(request.url); + const token = url.searchParams.get("token"); + const state = url.searchParams.get("state"); + + if (!emdash?.db) { + return redirectTo( + `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, + ); + } + + if (!token || !state) { + return redirectTo( + `/_emdash/admin/login?error=invalid_link&message=${encodeURIComponent("Invalid or incomplete verification link")}`, + ); + } + + try { + const adapter = createKyselyAdapter(emdash.db); + + // Verify the email token + const hash = hashToken(token); + const authToken = await adapter.getToken(hash, "email_verify"); + + if (!authToken) { + return redirectTo( + `/_emdash/admin/login?error=invalid_token&message=${encodeURIComponent("Invalid or expired verification link")}`, + ); + } + + if (authToken.expiresAt < new Date()) { + await adapter.deleteToken(hash); + return redirectTo( + `/_emdash/admin/login?error=token_expired&message=${encodeURIComponent("Verification link has expired. Please try again.")}`, + ); + } + + const email = authToken.email; + if (!email) { + await adapter.deleteToken(hash); + return redirectTo( + `/_emdash/admin/login?error=invalid_token&message=${encodeURIComponent("Invalid verification token")}`, + ); + } + + // Delete token (single-use) + await adapter.deleteToken(hash); + + // Retrieve pending ATProto state + const pending = await emdash.db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", state) + .where("type", "=", "atproto_pending") + .executeTakeFirst(); + + if (!pending?.data) { + return redirectTo( + `/_emdash/admin/login?error=invalid_state&message=${encodeURIComponent("ATProto session expired. Please try logging in again.")}`, + ); + } + + if (new Date(pending.expires_at).getTime() < Date.now()) { + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + return redirectTo( + `/_emdash/admin/login?error=expired&message=${encodeURIComponent("ATProto session expired. Please try logging in again.")}`, + ); + } + + const { did, handle } = JSON.parse(pending.data) as { + did: string; + handle?: string; + }; + + // === User provisioning (same logic as before, but email is now verified) === + + // Check if DID already linked (race condition guard) + const existingByDid = await emdash.db + .selectFrom("oauth_accounts") + .selectAll() + .where("provider", "=", "atproto") + .where("provider_account_id", "=", did) + .executeTakeFirst(); + + if (existingByDid) { + if (session) { + session.set("user", { id: existingByDid.user_id }); + } + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + await cleanupAtprotoEntries(emdash.db); + return redirectTo("/_emdash/admin"); + } + + // Check if user with this email exists — link DID + const existingUser = await emdash.db + .selectFrom("users") + .selectAll() + .where("email", "=", email) + .executeTakeFirst(); + + if (existingUser) { + if (existingUser.disabled) { + return redirectTo( + `/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Your account has been disabled")}`, + ); + } + + await emdash.db + .updateTable("users") + .set({ atproto_did: did }) + .where("id", "=", existingUser.id) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: existingUser.id, + }) + .execute(); + + if (session) { + session.set("user", { id: existingUser.id }); + } + + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + await cleanupAtprotoEntries(emdash.db); + return redirectTo("/_emdash/admin"); + } + + // Check allowed_domains for self-signup + const domain = email.split("@")[1]?.toLowerCase(); + if (domain) { + const allowedDomain = await emdash.db + .selectFrom("allowed_domains") + .selectAll() + .where("domain", "=", domain) + .where("enabled", "=", 1) + .executeTakeFirst(); + + if (allowedDomain) { + const userId = ulid(); + await emdash.db + .insertInto("users") + .values({ + id: userId, + email, + name: handle || null, + avatar_url: null, + role: allowedDomain.default_role, + email_verified: 1, // Verified by clicking the email link + data: null, + atproto_did: did, + }) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: userId, + }) + .execute(); + + if (session) { + session.set("user", { id: userId }); + } + + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + await cleanupAtprotoEntries(emdash.db); + return redirectTo("/_emdash/admin"); + } + } + + return redirectTo( + `/_emdash/admin/login?error=signup_not_allowed&message=${encodeURIComponent("Self-signup is not allowed for your email domain. Please contact an administrator.")}`, + ); + } catch (error) { + console.error("[VERIFY_EMAIL_ERROR]", error instanceof Error ? error.message : error); + return redirectTo( + `/_emdash/admin/login?error=verify_error&message=${encodeURIComponent("Verification failed. Please try again.")}`, + ); + } +}; diff --git a/packages/core/src/astro/routes/api/manifest.ts b/packages/core/src/astro/routes/api/manifest.ts index da9cd77be..45de127b6 100644 --- a/packages/core/src/astro/routes/api/manifest.ts +++ b/packages/core/src/astro/routes/api/manifest.ts @@ -36,11 +36,14 @@ export const GET: APIRoute = async ({ locals }) => { } } + const atprotoEnabled = !!emdash?.config?.atproto; + const manifest: EmDashManifest = emdashManifest ? { ...emdashManifest, authMode: authMode.type === "external" ? authMode.providerType : "passkey", signupEnabled, + atprotoEnabled, } : { version: "0.1.0", @@ -49,6 +52,7 @@ export const GET: APIRoute = async ({ locals }) => { plugins: {}, authMode: "passkey", signupEnabled, + atprotoEnabled, }; return Response.json( diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index e5b8b6f7c..a5dd4a2d3 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -111,6 +111,11 @@ export interface EmDashManifest { * Used by the login page to conditionally show the "Sign up" link. */ signupEnabled?: boolean; + /** + * Whether ATProto OAuth login is enabled. + * When true, the login page shows a handle input for ATProto auth. + */ + atprotoEnabled?: boolean; /** * i18n configuration from Astro config. * Only present when i18n is enabled (multiple locales configured). diff --git a/packages/core/src/auth/atproto/client.ts b/packages/core/src/auth/atproto/client.ts new file mode 100644 index 000000000..aa81d6d97 --- /dev/null +++ b/packages/core/src/auth/atproto/client.ts @@ -0,0 +1,128 @@ +/** + * ATProto OAuth client + * + * Creates and configures a NodeOAuthClient for AT Protocol OAuth login. + * Uses PKCE (public client) — no JWKS endpoint or key management needed. + * DPoP keys are managed by the SDK internally. + */ + +import { NodeOAuthClient } from "@atproto/oauth-client-node"; +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { createAtprotoSessionStore, createAtprotoStateStore } from "./stores.js"; + +export interface AtprotoClientOptions { + /** Public URL of the site (e.g., "https://example.com") */ + publicUrl: string; + /** Database instance for state/session stores */ + db: Kysely; + /** Allow HTTP for development (default: false) */ + allowHttp?: boolean; +} + +/** + * Check if a URL is a loopback/localhost address. + */ +function isLoopback(url: string): boolean { + try { + const { hostname } = new URL(url); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + } catch { + return false; + } +} + +/** Cached client instances keyed by publicUrl */ +const clientCache = new Map(); + +/** + * Get or create an ATProto OAuth client for the given options. + * + * The client is cached per publicUrl since the configuration (stores, metadata) + * doesn't change between requests. The db reference is stable (single runtime + * connection), so stores created from it are safe to reuse. + * + * For production (HTTPS), the client ID is the URL of the client-metadata.json + * endpoint, which is an ATProto convention for public clients. + * + * For localhost development, ATProto uses a special loopback client ID format: + * `http://localhost?redirect_uri=...` with redirect URIs using + * `http://127.0.0.1` (not localhost) per RFC 8252 §7.3. + */ +export function createAtprotoClient(options: AtprotoClientOptions): NodeOAuthClient { + const { publicUrl, db, allowHttp = false } = options; + + const cached = clientCache.get(publicUrl); + if (cached) return cached; + + const stateStore = createAtprotoStateStore(db); + const sessionStore = createAtprotoSessionStore(db); + + let client: NodeOAuthClient; + + if (isLoopback(publicUrl)) { + // Localhost development: use ATProto loopback client ID format. + // Redirect URI must use 127.0.0.1, not localhost (per ATProto spec). + const { port } = new URL(publicUrl); + const redirectUri = `http://127.0.0.1:${port}/_emdash/api/auth/atproto/callback`; + // Scope is "atproto" (the default), so only redirect_uri needs to be in the query + const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}`; + + client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: "EmDash CMS", + client_uri: publicUrl, + redirect_uris: [redirectUri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "atproto", + dpop_bound_access_tokens: true, + application_type: "web", + }, + stateStore, + sessionStore, + allowHttp: true, + }); + } else { + // Production: client ID is the client-metadata.json URL + const clientId = `${publicUrl}/_emdash/api/auth/atproto/client-metadata.json`; + const redirectUri = `${publicUrl}/_emdash/api/auth/atproto/callback`; + + client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: "EmDash CMS", + client_uri: publicUrl, + redirect_uris: [redirectUri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "atproto", + dpop_bound_access_tokens: true, + application_type: "web", + }, + stateStore, + sessionStore, + allowHttp, + }); + } + + clientCache.set(publicUrl, client); + return client; +} + +/** + * Clean up expired ATProto state and session entries from auth_challenges. + * Call periodically or after successful login. + */ +export async function cleanupAtprotoEntries(db: Kysely): Promise { + const now = new Date().toISOString(); + await db + .deleteFrom("auth_challenges") + .where("type", "in", ["atproto", "atproto_session", "atproto_pending"]) + .where("expires_at", "<", now) + .execute(); +} diff --git a/packages/core/src/auth/atproto/stores.ts b/packages/core/src/auth/atproto/stores.ts new file mode 100644 index 000000000..df070fabd --- /dev/null +++ b/packages/core/src/auth/atproto/stores.ts @@ -0,0 +1,139 @@ +/** + * ATProto OAuth store adapters + * + * Implements the NodeSavedStateStore and NodeSavedSessionStore interfaces + * required by @atproto/oauth-client-node, backed by the existing + * auth_challenges table. + * + * State entries (type="atproto") are short-lived PKCE state during the + * OAuth flow (~10 min TTL). Session entries (type="atproto_session") hold + * DPoP keys and tokens; they are deleted after login completes. + */ + +import type { NodeSavedSession, NodeSavedState } from "@atproto/oauth-client-node"; +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; + +type SimpleStore = { + get(key: K): Promise; + set(key: K, value: V): Promise; + del(key: K): Promise; +}; + +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (safety net for abandoned flows) + +export function createAtprotoStateStore(db: Kysely): SimpleStore { + return { + async get(key: string): Promise { + const row = await db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", key) + .where("type", "=", "atproto") + .executeTakeFirst(); + + if (!row?.data) return undefined; + + if (new Date(row.expires_at).getTime() < Date.now()) { + await this.del(key); + return undefined; + } + + try { + return JSON.parse(row.data) as NodeSavedState; + } catch { + return undefined; + } + }, + + async set(key: string, value: NodeSavedState): Promise { + const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString(); + + await db + .insertInto("auth_challenges") + .values({ + challenge: key, + type: "atproto", + user_id: null, + data: JSON.stringify(value), + expires_at: expiresAt, + }) + .onConflict((oc) => + oc.column("challenge").doUpdateSet({ + type: "atproto", + data: JSON.stringify(value), + expires_at: expiresAt, + }), + ) + .execute(); + }, + + async del(key: string): Promise { + await db + .deleteFrom("auth_challenges") + .where("challenge", "=", key) + .where("type", "=", "atproto") + .execute(); + }, + }; +} + +export function createAtprotoSessionStore( + db: Kysely, +): SimpleStore { + return { + async get(key: string): Promise { + const row = await db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", key) + .where("type", "=", "atproto_session") + .executeTakeFirst(); + + if (!row?.data) return undefined; + + if (new Date(row.expires_at).getTime() < Date.now()) { + await this.del(key); + return undefined; + } + + try { + return JSON.parse(row.data) as NodeSavedSession; + } catch { + return undefined; + } + }, + + async set(key: string, value: NodeSavedSession): Promise { + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + + await db + .insertInto("auth_challenges") + .values({ + challenge: key, + type: "atproto_session", + user_id: null, + data: JSON.stringify(value), + expires_at: expiresAt, + }) + .onConflict((oc) => + oc.column("challenge").doUpdateSet({ + type: "atproto_session", + data: JSON.stringify(value), + expires_at: expiresAt, + }), + ) + .execute(); + }, + + async del(key: string): Promise { + await db + .deleteFrom("auth_challenges") + .where("challenge", "=", key) + .where("type", "=", "atproto_session") + .execute(); + }, + }; +} diff --git a/packages/core/src/database/migrations/034_atproto_auth.ts b/packages/core/src/database/migrations/034_atproto_auth.ts new file mode 100644 index 000000000..3b0ad9720 --- /dev/null +++ b/packages/core/src/database/migrations/034_atproto_auth.ts @@ -0,0 +1,28 @@ +import { sql, type Kysely } from "kysely"; + +/** + * Migration: ATProto OAuth login support. + * + * Adds atproto_did column to users table so ATProto identities (DIDs) + * can be associated with user accounts. This enables login via Bluesky + * handle or any AT Protocol PDS. + * + * ATProto OAuth state and session data reuse the existing auth_challenges + * table with type="atproto" and type="atproto_session". + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("users").addColumn("atproto_did", "text").execute(); + + // Unique index — each DID maps to exactly one user + await db.schema + .createIndex("idx_users_atproto_did") + .on("users") + .column("atproto_did") + .unique() + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("idx_users_atproto_did").execute(); + await db.schema.alterTable("users").dropColumn("atproto_did").execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 48205dfb7..13d1c7995 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -34,6 +34,7 @@ import * as m030 from "./030_widen_scheduled_index.js"; import * as m031 from "./031_bylines.js"; import * as m032 from "./032_rate_limits.js"; import * as m033 from "./033_optimize_content_indexes.js"; +import * as m034 from "./034_atproto_auth.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -67,6 +68,8 @@ const MIGRATIONS: Readonly> = Object.freeze({ "030_widen_scheduled_index": m030, "031_bylines": m031, "032_rate_limits": m032, + "033_optimize_content_indexes": m033, + "034_atproto_auth": m034, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/user.ts b/packages/core/src/database/repositories/user.ts index bf67b05e5..b0530a6d2 100644 --- a/packages/core/src/database/repositories/user.ts +++ b/packages/core/src/database/repositories/user.ts @@ -61,6 +61,7 @@ export class UserRepository { avatar_url: input.avatarUrl ?? null, email_verified: 0, data: input.data ? JSON.stringify(input.data) : null, + atproto_did: null, }; await this.db.insertInto("users").values(row).execute(); diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index d13f9bd05..3b1e06179 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -65,6 +65,7 @@ export interface UserTable { email_verified: number; // 0 or 1 data: string | null; // JSON disabled: Generated; // 0 or 1 + atproto_did: string | null; // AT Protocol DID (did:plc:* or did:web:*) created_at: Generated; updated_at: Generated; } diff --git a/packages/core/tests/unit/auth/atproto-stores.test.ts b/packages/core/tests/unit/auth/atproto-stores.test.ts new file mode 100644 index 000000000..1b8376fd3 --- /dev/null +++ b/packages/core/tests/unit/auth/atproto-stores.test.ts @@ -0,0 +1,190 @@ +import type { Kysely } from "kysely"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { + createAtprotoStateStore, + createAtprotoSessionStore, +} from "../../../src/auth/atproto/stores.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase } from "../../utils/test-db.js"; + +// Minimal mock data matching the SDK's NodeSavedState shape +const mockState = { + iss: "https://bsky.social", + dpopJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, + authMethod: "none" as const, + verifier: "test-pkce-verifier", + appState: "test-app-state", +}; + +// Minimal mock data matching the SDK's NodeSavedSession shape +const mockSession = { + dpopJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, + authMethod: "none" as const, + tokenSet: { + iss: "https://bsky.social", + sub: "did:plc:test123" as `did:${string}`, + aud: "https://bsky.social", + scope: "atproto", + access_token: "test-access-token", + token_type: "DPoP" as const, + }, +}; + +describe("ATProto State Store", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it("stores and retrieves state", async () => { + const store = createAtprotoStateStore(db); + + await store.set("state-key-1", mockState); + const result = await store.get("state-key-1"); + + expect(result).toEqual(mockState); + }); + + it("returns undefined for non-existent key", async () => { + const store = createAtprotoStateStore(db); + + const result = await store.get("does-not-exist"); + expect(result).toBeUndefined(); + }); + + it("deletes state", async () => { + const store = createAtprotoStateStore(db); + + await store.set("to-delete", mockState); + await store.del("to-delete"); + + const result = await store.get("to-delete"); + expect(result).toBeUndefined(); + }); + + it("overwrites existing state on conflict", async () => { + const store = createAtprotoStateStore(db); + + await store.set("overwrite-key", mockState); + + const updated = { ...mockState, verifier: "updated-verifier" }; + await store.set("overwrite-key", updated); + + const result = await store.get("overwrite-key"); + expect(result?.verifier).toBe("updated-verifier"); + }); + + it("returns undefined for expired state", async () => { + vi.useFakeTimers(); + const store = createAtprotoStateStore(db); + + await store.set("expiring-key", mockState); + + // Advance past the 10-minute TTL + vi.advanceTimersByTime(11 * 60 * 1000); + + const result = await store.get("expiring-key"); + expect(result).toBeUndefined(); + + vi.useRealTimers(); + }); + + it("does not return session store entries", async () => { + const stateStore = createAtprotoStateStore(db); + const sessionStore = createAtprotoSessionStore(db); + + await sessionStore.set("shared-key", mockSession); + + const result = await stateStore.get("shared-key"); + expect(result).toBeUndefined(); + }); + + it("does not throw when deleting non-existent key", async () => { + const store = createAtprotoStateStore(db); + await expect(store.del("non-existent")).resolves.not.toThrow(); + }); +}); + +describe("ATProto Session Store", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it("stores and retrieves session", async () => { + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:test123", mockSession); + const result = await store.get("did:plc:test123"); + + expect(result).toEqual(mockSession); + }); + + it("returns undefined for non-existent key", async () => { + const store = createAtprotoSessionStore(db); + + const result = await store.get("did:plc:nonexistent"); + expect(result).toBeUndefined(); + }); + + it("deletes session", async () => { + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:to-delete", mockSession); + await store.del("did:plc:to-delete"); + + const result = await store.get("did:plc:to-delete"); + expect(result).toBeUndefined(); + }); + + it("overwrites existing session on conflict", async () => { + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:overwrite", mockSession); + + const updated = { + ...mockSession, + tokenSet: { ...mockSession.tokenSet, access_token: "new-token" }, + }; + await store.set("did:plc:overwrite", updated); + + const result = await store.get("did:plc:overwrite"); + expect(result?.tokenSet.access_token).toBe("new-token"); + }); + + it("returns undefined for expired session", async () => { + vi.useFakeTimers(); + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:expiring", mockSession); + + // Advance past the 1-hour TTL + vi.advanceTimersByTime(61 * 60 * 1000); + + const result = await store.get("did:plc:expiring"); + expect(result).toBeUndefined(); + + vi.useRealTimers(); + }); + + it("does not return state store entries", async () => { + const stateStore = createAtprotoStateStore(db); + const sessionStore = createAtprotoSessionStore(db); + + await stateStore.set("shared-key", mockState); + + const result = await sessionStore.get("shared-key"); + expect(result).toBeUndefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65158c06c..08ff70a1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,6 +808,9 @@ importers: '@astrojs/react': specifier: '>=5.0.0-beta.0' version: 5.0.0-beta.4(@types/node@24.10.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) + '@atproto/oauth-client-node': + specifier: ^0.3.17 + version: 0.3.17 '@emdash-cms/admin': specifier: workspace:* version: link:../admin @@ -873,10 +876,10 @@ importers: version: 3.7.0 astro: specifier: '>=6.0.0-beta.0' - version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro-portabletext: specifier: ^0.11.0 - version: 0.11.4(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 0.11.4(astro@6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) better-sqlite3: specifier: 'catalog:' version: 11.10.0 @@ -1718,30 +1721,99 @@ packages: '@astrojs/yaml2ts@0.2.2': resolution: {integrity: sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==} + '@atproto-labs/did-resolver@0.2.6': + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} + + '@atproto-labs/fetch-node@0.2.0': + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/fetch@0.2.3': + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + + '@atproto-labs/handle-resolver-node@0.1.25': + resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/handle-resolver@0.3.6': + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} + + '@atproto-labs/identity-resolver@0.3.6': + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} + + '@atproto-labs/pipe@0.1.1': + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} + + '@atproto-labs/simple-store-memory@0.1.4': + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} + + '@atproto-labs/simple-store@0.3.0': + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + '@atproto/api@0.13.35': resolution: {integrity: sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==} '@atproto/common-web@0.4.12': resolution: {integrity: sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==} + '@atproto/common-web@0.4.19': + resolution: {integrity: sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw==} + + '@atproto/did@0.3.0': + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} + + '@atproto/jwk-jose@0.1.11': + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} + + '@atproto/jwk-webcrypto@0.2.0': + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} + + '@atproto/jwk@0.6.0': + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + + '@atproto/lex-data@0.0.14': + resolution: {integrity: sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ==} + '@atproto/lex-data@0.0.8': resolution: {integrity: sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==} + '@atproto/lex-json@0.0.14': + resolution: {integrity: sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww==} + '@atproto/lex-json@0.0.8': resolution: {integrity: sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==} '@atproto/lexicon@0.4.14': resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==} + '@atproto/lexicon@0.6.2': + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} + + '@atproto/oauth-client-node@0.3.17': + resolution: {integrity: sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==} + engines: {node: '>=18.7.0'} + + '@atproto/oauth-client@0.6.0': + resolution: {integrity: sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==} + + '@atproto/oauth-types@0.6.3': + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} + '@atproto/syntax@0.3.4': resolution: {integrity: sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==} '@atproto/syntax@0.4.2': resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} + '@atproto/syntax@0.5.3': + resolution: {integrity: sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ==} + '@atproto/xrpc@0.6.12': resolution: {integrity: sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==} + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@axe-core/playwright@4.11.1': resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==} peerDependencies: @@ -5252,6 +5324,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -5950,6 +6025,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -6039,6 +6118,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -7792,6 +7874,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + undici@7.18.2: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} @@ -8547,7 +8633,7 @@ snapshots: dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0) + '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1)) astro: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -8573,7 +8659,7 @@ snapshots: dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0) + '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1)) astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -8873,6 +8959,53 @@ snapshots: dependencies: yaml: 2.8.2 + '@atproto-labs/did-resolver@0.2.6': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/fetch-node@0.2.0': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + ipaddr.js: 2.3.0 + undici: 6.24.1 + + '@atproto-labs/fetch@0.2.3': + dependencies: + '@atproto-labs/pipe': 0.1.1 + + '@atproto-labs/handle-resolver-node@0.1.25': + dependencies: + '@atproto-labs/fetch-node': 0.2.0 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto/did': 0.3.0 + + '@atproto-labs/handle-resolver@0.3.6': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/identity-resolver@0.3.6': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver': 0.3.6 + + '@atproto-labs/pipe@0.1.1': {} + + '@atproto-labs/simple-store-memory@0.1.4': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + lru-cache: 10.4.3 + + '@atproto-labs/simple-store@0.3.0': {} + '@atproto/api@0.13.35': dependencies: '@atproto/common-web': 0.4.12 @@ -8890,6 +9023,40 @@ snapshots: '@atproto/lex-json': 0.0.8 zod: 3.25.76 + '@atproto/common-web@0.4.19': + dependencies: + '@atproto/lex-data': 0.0.14 + '@atproto/lex-json': 0.0.14 + '@atproto/syntax': 0.5.3 + zod: 3.25.76 + + '@atproto/did@0.3.0': + dependencies: + zod: 3.25.76 + + '@atproto/jwk-jose@0.1.11': + dependencies: + '@atproto/jwk': 0.6.0 + jose: 5.10.0 + + '@atproto/jwk-webcrypto@0.2.0': + dependencies: + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + zod: 3.25.76 + + '@atproto/jwk@0.6.0': + dependencies: + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/lex-data@0.0.14': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + '@atproto/lex-data@0.0.8': dependencies: '@atproto/syntax': 0.4.2 @@ -8898,6 +9065,11 @@ snapshots: uint8arrays: 3.0.0 unicode-segmenter: 0.14.5 + '@atproto/lex-json@0.0.14': + dependencies: + '@atproto/lex-data': 0.0.14 + tslib: 2.8.1 + '@atproto/lex-json@0.0.8': dependencies: '@atproto/lex-data': 0.0.8 @@ -8911,15 +9083,66 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/lexicon@0.6.2': + dependencies: + '@atproto/common-web': 0.4.19 + '@atproto/syntax': 0.5.3 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-client-node@0.3.17': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver-node': 0.1.25 + '@atproto-labs/simple-store': 0.3.0 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + '@atproto/jwk-webcrypto': 0.2.0 + '@atproto/oauth-client': 0.6.0 + '@atproto/oauth-types': 0.6.3 + + '@atproto/oauth-client@0.6.0': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto-labs/identity-resolver': 0.3.6 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/oauth-types': 0.6.3 + '@atproto/xrpc': 0.7.7 + core-js: 3.49.0 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-types@0.6.3': + dependencies: + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + zod: 3.25.76 + '@atproto/syntax@0.3.4': {} '@atproto/syntax@0.4.2': {} + '@atproto/syntax@0.5.3': + dependencies: + tslib: 2.8.1 + '@atproto/xrpc@0.6.12': dependencies: '@atproto/lexicon': 0.4.14 zod: 3.25.76 + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.2 + zod: 3.25.76 + '@axe-core/playwright@4.11.1(playwright-core@1.58.2)': dependencies: axe-core: 4.11.1 @@ -9328,7 +9551,7 @@ snapshots: optionalDependencies: workerd: 1.20260401.1 - '@cloudflare/vite-plugin@1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0)': + '@cloudflare/vite-plugin@1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1))': dependencies: '@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260401.1) miniflare: 4.20260301.1 @@ -12089,11 +12312,11 @@ snapshots: astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) rehype-expressive-code: 0.41.6 - astro-portabletext@0.11.4(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): + astro-portabletext@0.11.4(astro@6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): dependencies: '@portabletext/toolkit': 3.0.3 '@portabletext/types': 2.0.15 - astro: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro@6.0.0-beta.20(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: @@ -12684,6 +12907,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.49.0: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -13546,6 +13771,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} + iron-webcrypto@1.2.1: {} is-alphabetical@2.0.1: {} @@ -13607,6 +13834,8 @@ snapshots: jiti@2.6.1: {} + jose@5.10.0: {} + jose@6.1.3: {} jpeg-js@0.4.4: {} @@ -15852,6 +16081,8 @@ snapshots: undici-types@7.24.6: optional: true + undici@6.24.1: {} + undici@7.18.2: {} undici@7.24.4: {}