From bb7a760c920e214bd7e653507b79096ef92a5b20 Mon Sep 17 00:00:00 2001 From: simnaut <110426028+simnaut@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:33:57 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20pluggable=20auth=20providers=20?= =?UTF-8?q?=E2=80=94=20add=20AT=20Protocol,=20refactor=20GitHub/Google?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a pluggable auth provider system and uses it to add AT Protocol authentication as the first plugin-based provider. GitHub and Google OAuth are refactored from hardcoded buttons into the same provider interface. - AuthProviderDescriptor interface with admin UI, routes, and public routes - virtual:emdash/auth-providers Vite module for distributing provider components - Shared findOrCreateOAuthUser() in @emdash-cms/auth for consistent signup gating - AT Protocol auth via @atcute/oauth-node-client with PKCE (public client) - allowedDIDs and allowedHandles config with independent handle verification via DNS-over-HTTPS + HTTP well-known (never trusts PDS handle claims) - Default role for new signups changed to Subscriber - First user becomes Admin during setup regardless of provider --- .changeset/stale-knives-fix.md | 8 + demos/simple/astro.config.mjs | 4 + demos/simple/package.json | 1 + packages/admin/src/App.tsx | 17 +- packages/admin/src/components/LoginPage.tsx | 168 ++++----- packages/admin/src/components/SetupWizard.tsx | 133 ++++++-- packages/admin/src/index.ts | 9 + packages/admin/src/lib/api/client.ts | 17 + packages/admin/src/lib/api/index.ts | 1 + .../admin/src/lib/auth-provider-context.tsx | 62 ++++ packages/auth/src/index.ts | 2 + packages/auth/src/oauth/consumer.ts | 24 +- packages/core/package.json | 12 + packages/core/src/api/route-utils.ts | 12 + packages/core/src/api/schemas/setup.ts | 8 + packages/core/src/api/setup-complete.ts | 40 +++ packages/core/src/astro/integration/index.ts | 15 +- packages/core/src/astro/integration/routes.ts | 28 ++ .../core/src/astro/integration/runtime.ts | 20 +- .../src/astro/integration/virtual-modules.ts | 41 +++ .../core/src/astro/integration/vite-config.ts | 10 + packages/core/src/astro/middleware.ts | 16 + packages/core/src/astro/middleware/auth.ts | 21 ++ .../core/src/astro/routes/PluginRegistry.tsx | 11 +- .../core/src/astro/routes/api/auth/mode.ts | 57 ++++ .../astro/routes/api/auth/oauth/[provider].ts | 14 +- .../api/auth/oauth/[provider]/callback.ts | 21 +- .../core/src/astro/routes/api/setup/admin.ts | 7 +- .../core/src/astro/routes/api/setup/index.ts | 4 +- .../core/src/astro/routes/api/setup/status.ts | 2 +- packages/core/src/auth/mode.ts | 18 +- .../core/src/auth/providers/github-admin.tsx | 32 ++ packages/core/src/auth/providers/github.ts | 31 ++ .../core/src/auth/providers/google-admin.tsx | 47 +++ packages/core/src/auth/providers/google.ts | 31 ++ packages/core/src/auth/types.ts | 106 +++++- packages/core/src/index.ts | 5 +- packages/core/src/virtual-modules.d.ts | 22 +- packages/core/tsconfig.json | 3 +- packages/plugins/atproto/package.json | 34 +- packages/plugins/atproto/src/admin.tsx | 186 ++++++++++ packages/plugins/atproto/src/auth.ts | 100 ++++++ packages/plugins/atproto/src/db-store.ts | 95 ++++++ packages/plugins/atproto/src/env.d.ts | 1 + packages/plugins/atproto/src/oauth-client.ts | 215 ++++++++++++ .../plugins/atproto/src/resolve-handle.ts | 52 +++ .../plugins/atproto/src/routes/callback.ts | 203 +++++++++++ .../atproto/src/routes/client-metadata.ts | 37 ++ packages/plugins/atproto/src/routes/login.ts | 41 +++ .../plugins/atproto/src/routes/setup-admin.ts | 70 ++++ packages/plugins/atproto/tests/auth.test.ts | 122 +++++++ .../atproto/tests/oauth-client.test.ts | 98 ++++++ packages/plugins/atproto/tests/plugin.test.ts | 81 ++--- .../atproto/tests/resolve-handle.test.ts | 27 ++ pnpm-lock.yaml | 321 ++++++++++++++---- 55 files changed, 2455 insertions(+), 308 deletions(-) create mode 100644 .changeset/stale-knives-fix.md create mode 100644 packages/admin/src/lib/auth-provider-context.tsx create mode 100644 packages/core/src/api/route-utils.ts create mode 100644 packages/core/src/api/setup-complete.ts create mode 100644 packages/core/src/astro/routes/api/auth/mode.ts create mode 100644 packages/core/src/auth/providers/github-admin.tsx create mode 100644 packages/core/src/auth/providers/github.ts create mode 100644 packages/core/src/auth/providers/google-admin.tsx create mode 100644 packages/core/src/auth/providers/google.ts create mode 100644 packages/plugins/atproto/src/admin.tsx create mode 100644 packages/plugins/atproto/src/auth.ts create mode 100644 packages/plugins/atproto/src/db-store.ts create mode 100644 packages/plugins/atproto/src/env.d.ts create mode 100644 packages/plugins/atproto/src/oauth-client.ts create mode 100644 packages/plugins/atproto/src/resolve-handle.ts create mode 100644 packages/plugins/atproto/src/routes/callback.ts create mode 100644 packages/plugins/atproto/src/routes/client-metadata.ts create mode 100644 packages/plugins/atproto/src/routes/login.ts create mode 100644 packages/plugins/atproto/src/routes/setup-admin.ts create mode 100644 packages/plugins/atproto/tests/auth.test.ts create mode 100644 packages/plugins/atproto/tests/oauth-client.test.ts create mode 100644 packages/plugins/atproto/tests/resolve-handle.test.ts diff --git a/.changeset/stale-knives-fix.md b/.changeset/stale-knives-fix.md new file mode 100644 index 000000000..ee387f3a3 --- /dev/null +++ b/.changeset/stale-knives-fix.md @@ -0,0 +1,8 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +"@emdash-cms/plugin-atproto": minor +"@emdash-cms/auth": patch +--- + +Adds pluggable auth provider system with AT Protocol as the first plugin-based provider. Refactors GitHub and Google OAuth from hardcoded buttons into the same `AuthProviderDescriptor` interface. All auth methods (passkey, AT Protocol, GitHub, Google) are equal options on the login page and setup wizard. diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index d77553265..94a4eb524 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -1,8 +1,11 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; +import { atproto } from "@emdash-cms/plugin-atproto/auth"; import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; import { defineConfig } from "astro/config"; import emdash, { local } from "emdash/astro"; +import { github } from "emdash/auth/providers/github"; +import { google } from "emdash/auth/providers/google"; import { sqlite } from "emdash/db"; export default defineConfig({ @@ -29,6 +32,7 @@ export default defineConfig({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), + authProviders: [github(), google(), atproto()], plugins: [auditLogPlugin()], // HTTPS reverse proxy: uncomment so passkey verify matches browser origin // passkeyPublicOrigin: "https://emdash.local:8443", diff --git a/demos/simple/package.json b/demos/simple/package.json index 3b103993c..b2a287c96 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -18,6 +18,7 @@ "dependencies": { "@astrojs/node": "catalog:", "@astrojs/react": "catalog:", + "@emdash-cms/plugin-atproto": "workspace:*", "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-color": "workspace:*", "astro": "catalog:", diff --git a/packages/admin/src/App.tsx b/packages/admin/src/App.tsx index 053769e34..758407b16 100644 --- a/packages/admin/src/App.tsx +++ b/packages/admin/src/App.tsx @@ -17,6 +17,7 @@ import { RouterProvider } from "@tanstack/react-router"; import * as React from "react"; import { ThemeProvider } from "./components/ThemeProvider"; +import { AuthProviderProvider, type AuthProviders } from "./lib/auth-provider-context"; import { PluginAdminProvider, type PluginAdmins } from "./lib/plugin-context"; import { createAdminRouter } from "./router"; @@ -36,6 +37,8 @@ const router = createAdminRouter(queryClient); export interface AdminAppProps { /** Plugin admin modules keyed by plugin ID */ pluginAdmins?: PluginAdmins; + /** Auth provider UI modules keyed by provider ID */ + authProviders?: AuthProviders; /** Active locale code */ locale?: string; /** Compiled Lingui messages for the active locale */ @@ -46,9 +49,11 @@ export interface AdminAppProps { * Main Admin Application */ const EMPTY_PLUGINS: PluginAdmins = {}; +const EMPTY_AUTH_PROVIDERS: AuthProviders = {}; export function AdminApp({ pluginAdmins = EMPTY_PLUGINS, + authProviders = EMPTY_AUTH_PROVIDERS, locale = "en", messages = {}, }: AdminAppProps) { @@ -66,11 +71,13 @@ export function AdminApp({ - - - - - + + + + + + + diff --git a/packages/admin/src/components/LoginPage.tsx b/packages/admin/src/components/LoginPage.tsx index c74563268..0295188f8 100644 --- a/packages/admin/src/components/LoginPage.tsx +++ b/packages/admin/src/components/LoginPage.tsx @@ -5,8 +5,8 @@ * It's a standalone page for authentication. * * Supports: - * - Passkey authentication (primary) - * - OAuth (GitHub, Google) when configured + * - Passkey authentication (always available) + * - Pluggable auth providers (AT Protocol, GitHub, Google, etc.) when configured * - Magic link (email) when configured * * When external auth (e.g., Cloudflare Access) is configured, this page @@ -19,7 +19,8 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; -import { apiFetch, fetchManifest } from "../lib/api"; +import { apiFetch, fetchAuthMode } from "../lib/api"; +import { useAuthProviderList } from "../lib/auth-provider-context"; import { sanitizeRedirectUrl } from "../lib/url"; import { SUPPORTED_LOCALES } from "../locales/index.js"; import { useLocale } from "../locales/useLocale.js"; @@ -37,64 +38,6 @@ interface LoginPageProps { type LoginMethod = "passkey" | "magic-link"; -interface OAuthProvider { - id: string; - name: string; - icon: React.ReactNode; -} - -// ============================================================================ -// OAuth Icons -// ============================================================================ - -function GitHubIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function GoogleIcon({ className }: { className?: string }) { - return ( - - - - - - - ); -} - -// ============================================================================ -// OAuth Providers -// ============================================================================ - -const OAUTH_PROVIDERS: OAuthProvider[] = [ - { - id: "github", - name: "GitHub", - icon: , - }, - { - id: "google", - name: "Google", - icon: , - }, -]; - // ============================================================================ // Components // ============================================================================ @@ -217,11 +160,6 @@ function MagicLinkForm({ onBack }: MagicLinkFormProps) { // Main Component // ============================================================================ -function handleOAuthClick(providerId: string) { - // Redirect to OAuth endpoint - window.location.href = `/_emdash/api/auth/oauth/${providerId}`; -} - export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { // Defense-in-depth: sanitize even if the caller already validated const safeRedirectUrl = sanitizeRedirectUrl(redirectUrl); @@ -229,21 +167,25 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { const { locale, setLocale } = useLocale(); const [method, setMethod] = React.useState("passkey"); const [urlError, setUrlError] = React.useState(null); + const [activeProvider, setActiveProvider] = React.useState(null); - // Fetch manifest to check auth mode - const { data: manifest, isLoading: manifestLoading } = useQuery({ - queryKey: ["manifest"], - queryFn: fetchManifest, + // Auth provider components from virtual module (via context) + const authProviderList = useAuthProviderList(); + + // Fetch auth mode from public endpoint (works without authentication) + const { data: authInfo, isLoading: authModeLoading } = useQuery({ + queryKey: ["authMode"], + queryFn: fetchAuthMode, }); // Redirect to admin when using external auth (authentication is handled externally) React.useEffect(() => { - if (manifest?.authMode && manifest.authMode !== "passkey") { + if (authInfo?.authMode && authInfo.authMode !== "passkey") { window.location.href = safeRedirectUrl; } - }, [manifest, safeRedirectUrl]); + }, [authInfo, safeRedirectUrl]); - // Check for error in URL (from OAuth redirect) + // Check for error in URL (from OAuth/provider redirect) React.useEffect(() => { const params = new URLSearchParams(window.location.search); const error = params.get("error"); @@ -261,8 +203,11 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { window.location.href = safeRedirectUrl; }; + // All providers with a LoginButton show in the button grid + const buttonProviders = authProviderList.filter((p) => p.LoginButton); + // Show loading state while checking auth mode - if (manifestLoading || (manifest?.authMode && manifest.authMode !== "passkey")) { + if (authModeLoading || (authInfo?.authMode && authInfo.authMode !== "passkey")) { return (
@@ -280,12 +225,15 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {

- {method === "passkey" && t`Sign in to your site`} - {method === "magic-link" && t`Sign in with email`} + {method === "magic-link" + ? t`Sign in with email` + : activeProvider + ? t`Sign in with ${authProviderList.find((p) => p.id === activeProvider)?.label ?? activeProvider}` + : t`Sign in to your site`}

- {/* Error from URL (OAuth failure) */} + {/* Error from URL (provider failure) */} {urlError && (
{urlError} @@ -294,7 +242,7 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { {/* Login Card */}
- {method === "passkey" && ( + {method === "passkey" && !activeProvider && (
{/* Passkey Login */}
- {/* OAuth Providers */} -
- {OAUTH_PROVIDERS.map((provider) => ( - - ))} -
+ {/* Auth provider buttons */} + {buttonProviders.length > 0 && ( +
+ {buttonProviders.map((provider) => { + const Btn = provider.LoginButton!; + const hasForm = !!provider.LoginForm; + const selectProvider = () => setActiveProvider(provider.id); + return ( +
+ +
+ ); + })} +
+ )} {/* Magic Link Option */} +
+ ); + })()} + {method === "magic-link" && setMethod("passkey")} />}
{/* Help text */}

- {method === "passkey" - ? t`Use your registered passkey to sign in securely.` - : t`We'll send you a link to sign in without a password.`} + {method === "magic-link" + ? t`We'll send you a link to sign in without a password.` + : activeProvider + ? t`Enter your handle to sign in.` + : t`Use your registered passkey to sign in securely.`}

{/* Signup link — only shown when self-signup is enabled */} - {manifest?.signupEnabled && ( + {authInfo?.signupEnabled && (

Don't have an account?{" "} diff --git a/packages/admin/src/components/SetupWizard.tsx b/packages/admin/src/components/SetupWizard.tsx index ed5a0f3d6..96e93cf15 100644 --- a/packages/admin/src/components/SetupWizard.tsx +++ b/packages/admin/src/components/SetupWizard.tsx @@ -6,8 +6,9 @@ * * Steps: * 1. Site Configuration (title, tagline, sample content) - * 2. Admin Account (email, name) - * 3. Passkey Registration + * 2. Create admin account — user picks any available auth method: + * - Passkey (always available) + * - Any configured auth provider (AT Protocol, GitHub, Google, etc.) */ import { Button, Checkbox, Input, Loader } from "@cloudflare/kumo"; @@ -15,6 +16,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { apiFetch, parseApiResponse } from "../lib/api/client"; +import { useAuthProviderList, type AuthProviderModule } from "../lib/auth-provider-context"; import { PasskeyRegistration } from "./auth/PasskeyRegistration"; import { LogoLockup } from "./Logo.js"; @@ -258,50 +260,96 @@ function AdminStep({ onNext, onBack, isLoading, error }: AdminStepProps) { ); } -interface PasskeyStepProps { +function handleSetupSuccess() { + window.location.href = "/_emdash/admin"; +} + +interface AuthMethodStepProps { adminData: SetupAdminRequest; + providers: AuthProviderModule[]; onBack: () => void; } -function handlePasskeySuccess() { - // Redirect to admin dashboard after successful registration - window.location.href = "/_emdash/admin"; -} +function AuthMethodStep({ adminData, providers, onBack }: AuthMethodStepProps) { + const [activeProvider, setActiveProvider] = React.useState(null); + + const buttonProviders = providers.filter((p) => p.LoginButton); + const hasProviders = buttonProviders.length > 0; + + // Show provider form (full card replacement) + if (activeProvider) { + const provider = providers.find((p) => p.id === activeProvider); + if (provider && (provider.SetupStep || provider.LoginForm)) { + return ( +

+
+

Sign in with {provider.label}

+
+ {provider.SetupStep ? ( + + ) : provider.LoginForm ? ( + + ) : null} + +
+ ); + } + } -function PasskeyStep({ adminData, onBack }: PasskeyStepProps) { return (
+ {/* Passkey option */}
-
- - - -
-

Set up your passkey

+

Choose how to sign in

- Passkeys are more secure than passwords. You'll use your device's biometrics, PIN, or - security key to sign in. + Pick any method to create your admin account.

+ {/* Auth provider options */} + {hasProviders && ( + <> +
+
+ +
+
+ Or continue with +
+
+ +
+ {buttonProviders.map((provider) => { + const Btn = provider.LoginButton!; + const hasForm = !!provider.LoginForm || !!provider.SetupStep; + const selectProvider = () => setActiveProvider(provider.id); + return ( +
+ +
+ ); + })} +
+ + )} + @@ -325,7 +373,7 @@ function StepIndicator({ currentStep, useAccessAuth }: StepIndicatorProps) { : ([ { key: "site", label: "Site" }, { key: "admin", label: "Account" }, - { key: "passkey", label: "Passkey" }, + { key: "passkey", label: "Sign In" }, ] as const); const currentIndex = steps.findIndex((s) => s.key === currentStep); @@ -388,6 +436,23 @@ export function SetupWizard() { const [_siteData, setSiteData] = React.useState(null); const [adminData, setAdminData] = React.useState(null); const [error, setError] = React.useState(); + const [urlError, setUrlError] = React.useState(null); + + // Auth provider components from virtual module (via context) + const authProviderList = useAuthProviderList(); + + // Check for error in URL (from OAuth/provider redirect) + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const errorParam = params.get("error"); + const message = params.get("message"); + + if (errorParam) { + setUrlError(message || `Authentication error: ${errorParam}`); + // Clean up URL + window.history.replaceState({}, "", window.location.pathname); + } + }, []); // Check setup status const { @@ -413,7 +478,7 @@ export function SetupWizard() { window.location.href = "/_emdash/admin"; return; } - // Otherwise continue to admin account creation + // Continue to admin account creation setCurrentStep("admin"); }, onError: (err: Error) => { @@ -493,6 +558,13 @@ export function SetupWizard() { )}
+ {/* Error from URL (provider failure) */} + {urlError && ( +
+ {urlError} +
+ )} + {/* Progress */} @@ -520,8 +592,9 @@ export function SetupWizard() { )} {currentStep === "passkey" && adminData && ( - { setError(undefined); setCurrentStep("admin"); diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts index aadc83fde..75e0bba03 100644 --- a/packages/admin/src/index.ts +++ b/packages/admin/src/index.ts @@ -26,6 +26,15 @@ export { type PluginAdmins, } from "./lib/plugin-context"; +// Auth provider context (for accessing pluggable auth provider components) +export { + AuthProviderProvider, + useAuthProviders, + useAuthProviderList, + type AuthProviderModule, + type AuthProviders, +} from "./lib/auth-provider-context"; + // Locales export { useLocale, diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 0c5e65b65..748dd62b1 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -160,3 +160,20 @@ export async function fetchManifest(): Promise { const response = await apiFetch(`${API_BASE}/manifest`); return parseApiResponse(response, "Failed to fetch manifest"); } + +/** + * Fetch auth mode (public endpoint — works without authentication). + * Used by the login page to determine which login UI to render. + */ +export async function fetchAuthMode(): Promise<{ + authMode: string; + signupEnabled?: boolean; + providers?: Array<{ id: string; label: string }>; +}> { + const response = await apiFetch(`${API_BASE}/auth/mode`); + return parseApiResponse<{ + authMode: string; + signupEnabled?: boolean; + providers?: Array<{ id: string; label: string }>; + }>(response, "Failed to fetch auth mode"); +} diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index 5511bc552..ebe05df2a 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -13,6 +13,7 @@ export { type FindManyResult, type AdminManifest, fetchManifest, + fetchAuthMode, } from "./client.js"; // Content CRUD and revisions diff --git a/packages/admin/src/lib/auth-provider-context.tsx b/packages/admin/src/lib/auth-provider-context.tsx new file mode 100644 index 000000000..539be0231 --- /dev/null +++ b/packages/admin/src/lib/auth-provider-context.tsx @@ -0,0 +1,62 @@ +/** + * Auth Provider Context + * + * Provides pluggable auth provider UI components (LoginButton, LoginForm, SetupStep) + * to the admin UI via React context. Auth providers are registered in astro.config.ts + * and their admin components are bundled via the virtual:emdash/auth-providers module. + */ + +import * as React from "react"; +import { createContext, useContext } from "react"; + +/** Shape of a single auth provider's admin exports */ +export interface AuthProviderModule { + id: string; + label: string; + /** Compact button for the login page (icon + label) */ + LoginButton?: React.ComponentType; + /** Full form if the provider needs custom input (e.g., handle field) */ + LoginForm?: React.ComponentType; + /** Component for the setup wizard admin creation step */ + SetupStep?: React.ComponentType<{ onComplete: () => void }>; +} + +/** All auth provider modules keyed by provider ID */ +export type AuthProviders = Record; + +const AuthProviderContext = createContext({}); + +export interface AuthProviderContextProps { + children: React.ReactNode; + authProviders: AuthProviders; +} + +/** + * Provider that makes auth provider components available to all descendants + */ +export function AuthProviderProvider({ children, authProviders }: AuthProviderContextProps) { + return ( + {children} + ); +} + +/** + * Get all auth provider modules + */ +export function useAuthProviders(): AuthProviders { + return useContext(AuthProviderContext); +} + +/** + * Get auth providers as an ordered array (buttons first, then forms) + */ +export function useAuthProviderList(): AuthProviderModule[] { + const providers = useContext(AuthProviderContext); + const list = Object.values(providers); + // Sort: providers with only LoginButton first (compact), then those with LoginForm + return list.toSorted((a, b) => { + const aHasForm = a.LoginForm ? 1 : 0; + const bHasForm = b.LoginForm ? 1 : 0; + return aHasForm - bHasForm; + }); +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 9c3981482..32be9e695 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -109,9 +109,11 @@ export { export { createAuthorizationUrl, handleOAuthCallback, + findOrCreateOAuthUser, OAuthError, github, google, + type CanSelfSignup, type StateStore, type OAuthConsumerConfig, } from "./oauth/consumer.js"; diff --git a/packages/auth/src/oauth/consumer.ts b/packages/auth/src/oauth/consumer.ts index 910e3a3aa..c849999e8 100644 --- a/packages/auth/src/oauth/consumer.ts +++ b/packages/auth/src/oauth/consumer.ts @@ -111,7 +111,7 @@ export async function handleOAuthCallback( const profile = await fetchProfile(provider, tokens.accessToken, providerName); // Find or create user - return findOrCreateUser(config, adapter, providerName, profile); + return findOrCreateOAuthUser(adapter, providerName, profile, config.canSelfSignup); } /** @@ -199,13 +199,25 @@ async function fetchProfile( } /** - * Find existing user or create new one (with auto-linking) + * Signup policy callback. + * Return `{ allowed: true, role }` to permit signup, or `null` to deny. */ -async function findOrCreateUser( - config: OAuthConsumerConfig, +export type CanSelfSignup = ( + email: string, +) => Promise<{ allowed: boolean; role: RoleLevel } | null>; + +/** + * Find existing user or create new one (with auto-linking). + * + * Shared across all OAuth providers (GitHub, Google, AT Protocol, etc.). + * The provider-specific token exchange happens before this function is called; + * this function only deals with the EmDash user record. + */ +export async function findOrCreateOAuthUser( adapter: AuthAdapter, providerName: string, profile: OAuthProfile, + canSelfSignup?: CanSelfSignup, ): Promise { // Check if OAuth account already linked const existingAccount = await adapter.getOAuthAccount(providerName, profile.id); @@ -237,8 +249,8 @@ async function findOrCreateUser( } // Check if self-signup is allowed - if (config.canSelfSignup) { - const signup = await config.canSelfSignup(profile.email); + if (canSelfSignup) { + const signup = await canSelfSignup(profile.email); if (signup?.allowed) { // Create new user const user = await adapter.createUser({ diff --git a/packages/core/package.json b/packages/core/package.json index f319b6fce..036eaf130 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,12 @@ "default": "./dist/cli/index.mjs" }, "./routes/*": "./src/astro/routes/*", + "./api/route-utils": "./src/api/route-utils.ts", + "./api/schemas": "./src/api/schemas/index.ts", + "./auth/providers/github": "./src/auth/providers/github.ts", + "./auth/providers/github-admin": "./src/auth/providers/github-admin.tsx", + "./auth/providers/google": "./src/auth/providers/google.ts", + "./auth/providers/google-admin": "./src/auth/providers/google-admin.tsx", "./db": { "types": "./dist/db/index.d.mts", "default": "./dist/db/index.mjs" @@ -198,12 +204,18 @@ }, "peerDependencies": { "@astrojs/react": ">=5.0.0-beta.0", + "@emdash-cms/plugin-atproto": "workspace:*", "@tanstack/react-query": ">=5.0.0", "@tanstack/react-router": ">=1.100.0", "astro": ">=6.0.0-beta.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" }, + "peerDependenciesMeta": { + "@emdash-cms/plugin-atproto": { + "optional": true + } + }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@arethetypeswrong/cli": "catalog:", diff --git a/packages/core/src/api/route-utils.ts b/packages/core/src/api/route-utils.ts new file mode 100644 index 000000000..711c02e4f --- /dev/null +++ b/packages/core/src/api/route-utils.ts @@ -0,0 +1,12 @@ +/** + * Public API route utilities for auth provider routes. + * + * This module re-exports the utilities that auth provider route handlers + * need from core. Auth providers (plugins) import these via `emdash/api/route-utils`. + */ + +export { apiError, apiSuccess, handleError } from "./error.js"; +export { parseBody, parseQuery, isParseError } from "./parse.js"; +export type { ParseResult } from "./parse.js"; +export { finalizeSetup } from "./setup-complete.js"; +export { OptionsRepository } from "../database/repositories/options.js"; diff --git a/packages/core/src/api/schemas/setup.ts b/packages/core/src/api/schemas/setup.ts index 6b7581e14..70d16c82e 100644 --- a/packages/core/src/api/schemas/setup.ts +++ b/packages/core/src/api/schemas/setup.ts @@ -35,3 +35,11 @@ export const setupAdminBody = z.object({ export const setupAdminVerifyBody = z.object({ credential: registrationCredential, }); + +export const atprotoLoginBody = z.object({ + handle: z.string().trim().min(1), +}); + +export const setupAtprotoAdminBody = z.object({ + handle: z.string().trim().min(1), +}); diff --git a/packages/core/src/api/setup-complete.ts b/packages/core/src/api/setup-complete.ts new file mode 100644 index 000000000..2e13ca8e4 --- /dev/null +++ b/packages/core/src/api/setup-complete.ts @@ -0,0 +1,40 @@ +/** + * Shared setup completion logic. + * + * Called by OAuth callbacks and the passkey verify step when the first user + * is created during setup. Persists site title/tagline from setup state + * and marks setup as complete. + */ + +import type { Kysely } from "kysely"; + +import { OptionsRepository } from "../database/repositories/options.js"; +import type { Database } from "../database/types.js"; + +/** + * Finalize setup after the first admin user is created. + * + * Reads the setup_state option (written by the setup wizard's step 1), + * persists site_title and site_tagline, then marks setup complete. + * + * Safe to call multiple times — checks setup_complete first and no-ops + * if already done. + */ +export async function finalizeSetup(db: Kysely): Promise { + const options = new OptionsRepository(db); + + const setupComplete = await options.get("emdash:setup_complete"); + if (setupComplete === true || setupComplete === "true") return; + + // Persist site title/tagline from setup state (stored in step 1) + const setupState = await options.get>("emdash:setup_state"); + if (setupState?.title && typeof setupState.title === "string") { + await options.set("emdash:site_title", setupState.title); + } + if (setupState?.tagline && typeof setupState.tagline === "string") { + await options.set("emdash:site_tagline", setupState.tagline); + } + + await options.set("emdash:setup_complete", true); + await options.delete("emdash:setup_state"); +} diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 3ed69a2d2..236ab2576 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -14,7 +14,12 @@ import type { AstroIntegration, AstroIntegrationLogger } from "astro"; import type { ResolvedPlugin } from "../../plugins/types.js"; import { local } from "../storage/adapters.js"; -import { injectCoreRoutes, injectBuiltinAuthRoutes, injectMcpRoute } from "./routes.js"; +import { + injectCoreRoutes, + injectBuiltinAuthRoutes, + injectAuthProviderRoutes, + injectMcpRoute, +} from "./routes.js"; import type { EmDashConfig, PluginDescriptor } from "./runtime.js"; import { createViteConfig } from "./vite-config.js"; @@ -151,6 +156,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { database: resolvedConfig.database, storage: resolvedConfig.storage, auth: resolvedConfig.auth, + authProviders: resolvedConfig.authProviders, marketplace: resolvedConfig.marketplace, passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin, }; @@ -200,7 +206,12 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { // Inject all core routes injectCoreRoutes(injectRoute); - // Only inject passkey/oauth/magic-link routes when NOT using external auth + // Inject routes from pluggable auth providers (authProviders config) + if (resolvedConfig.authProviders?.length) { + injectAuthProviderRoutes(injectRoute, resolvedConfig.authProviders); + } + + // Inject passkey/oauth/magic-link routes unless transparent external auth is active if (!useExternalAuth) { injectBuiltinAuthRoutes(injectRoute); } diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index a3eb49d53..9fd6b5c8b 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -46,6 +46,12 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/manifest.ts"), }); + // Auth mode endpoint (public — used by the login page to pick the right UI) + injectRoute({ + pattern: "/_emdash/api/auth/mode", + entrypoint: resolveRoute("api/auth/mode.ts"), + }); + injectRoute({ pattern: "/_emdash/api/dashboard", entrypoint: resolveRoute("api/dashboard.ts"), @@ -725,6 +731,28 @@ export function injectMcpRoute(injectRoute: InjectRoute): void { }); } +/** + * Injects routes from pluggable auth providers. + * + * Each provider declares the routes it needs in its `AuthProviderDescriptor.routes` array. + * Routes are injected at build time so Vite can bundle them. + */ +export function injectAuthProviderRoutes( + injectRoute: InjectRoute, + providers: Array<{ routes?: Array<{ pattern: string; entrypoint: string }> }>, +): void { + for (const provider of providers) { + if (provider.routes) { + for (const route of provider.routes) { + injectRoute({ + pattern: route.pattern, + entrypoint: route.entrypoint, + }); + } + } + } +} + /** * Injects passkey/oauth/magic-link auth routes. * Only used when NOT using external auth. diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 7b000d738..cfa46ebb3 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -7,7 +7,7 @@ * DO NOT import Node.js-only modules here (fs, path, module, etc.) */ -import type { AuthDescriptor } from "../../auth/types.js"; +import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js"; import type { DatabaseDescriptor } from "../../db/adapters.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; @@ -222,6 +222,24 @@ export interface EmDashConfig { */ auth?: AuthDescriptor; + /** + * Pluggable auth providers (login methods on the login page). + * + * Auth providers appear as options alongside passkey on the login page + * and setup wizard. Any provider can be used to create the initial + * admin account. Passkey is built-in; providers listed here are additive. + * + * @example + * ```ts + * import { atproto } from "@emdash-cms/plugin-atproto/auth"; + * + * emdash({ + * authProviders: [atproto()], + * }) + * ``` + */ + authProviders?: AuthProviderDescriptor[]; + /** * Enable the MCP (Model Context Protocol) server endpoint. * diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 5a99937b2..93779765c 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -10,6 +10,7 @@ import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { resolve } from "node:path"; +import type { AuthProviderDescriptor } from "../../auth/types.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; import { defaultSeed } from "../../seed/default.js"; import type { PluginDescriptor } from "./runtime.js"; @@ -47,6 +48,9 @@ export const RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID = "\0" + VIRTUAL_SANDBOXED_PL export const VIRTUAL_AUTH_ID = "virtual:emdash/auth"; export const RESOLVED_VIRTUAL_AUTH_ID = "\0" + VIRTUAL_AUTH_ID; +export const VIRTUAL_AUTH_PROVIDERS_ID = "virtual:emdash/auth-providers"; +export const RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID = "\0" + VIRTUAL_AUTH_PROVIDERS_ID; + export const VIRTUAL_MEDIA_PROVIDERS_ID = "virtual:emdash/media-providers"; export const RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID = "\0" + VIRTUAL_MEDIA_PROVIDERS_ID; @@ -152,6 +156,43 @@ export const authenticate = _authenticate; `; } +/** + * Generates the auth providers module. + * + * Statically imports each auth provider's `adminEntry` module and exports + * a registry keyed by provider ID. The admin UI uses this to render + * provider-specific login buttons/forms and setup steps. + * + * Follows the same pattern as `generateAdminRegistryModule()` for plugins. + */ +export function generateAuthProvidersModule(descriptors: AuthProviderDescriptor[]): string { + const withAdmin = descriptors.filter((d) => d.adminEntry); + + if (withAdmin.length === 0) { + return `export const authProviders = {};`; + } + + const imports: string[] = []; + const entries: string[] = []; + + withAdmin.forEach((descriptor, index) => { + const varName = `authProvider${index}`; + imports.push(`import * as ${varName} from ${JSON.stringify(descriptor.adminEntry)};`); + entries.push( + ` ${JSON.stringify(descriptor.id)}: { ...${varName}, id: ${JSON.stringify(descriptor.id)}, label: ${JSON.stringify(descriptor.label)} },`, + ); + }); + + return ` +// Auto-generated auth provider registry +${imports.join("\n")} + +export const authProviders = { +${entries.join("\n")} +}; +`; +} + /** * Generates the plugins module. * Imports and instantiates all plugins at runtime. diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 41d45ee5d..3427ffacb 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -31,6 +31,8 @@ import { RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID, VIRTUAL_AUTH_ID, RESOLVED_VIRTUAL_AUTH_ID, + VIRTUAL_AUTH_PROVIDERS_ID, + RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID, VIRTUAL_MEDIA_PROVIDERS_ID, RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID, VIRTUAL_BLOCK_COMPONENTS_ID, @@ -42,6 +44,7 @@ import { generateDialectModule, generateStorageModule, generateAuthModule, + generateAuthProvidersModule, generatePluginsModule, generateAdminRegistryModule, generateSandboxRunnerModule, @@ -155,6 +158,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === VIRTUAL_AUTH_ID) { return RESOLVED_VIRTUAL_AUTH_ID; } + if (id === VIRTUAL_AUTH_PROVIDERS_ID) { + return RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID; + } if (id === VIRTUAL_MEDIA_PROVIDERS_ID) { return RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID; } @@ -210,6 +216,10 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { } return generateAuthModule(authDescriptor.entrypoint); } + // Generate auth providers module (pluggable login methods) + if (id === RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID) { + return generateAuthProvidersModule(resolvedConfig.authProviders ?? []); + } // Generate media providers module if (id === RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID) { return generateMediaProvidersModule(resolvedConfig.mediaProviders ?? []); diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 67475220d..7d80d0a69 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -182,6 +182,22 @@ export const onRequest = defineMiddleware(async (context, next) => { const { request, locals, cookies } = context; const url = context.url; + // Fast path: routes outside /_emdash/ that plugins inject (e.g., + // /.well-known/atproto-client-metadata.json) skip the entire runtime + // init + middleware chain. External servers fetch these with tight + // timeouts (~1-2s) so they must respond quickly even on cold starts. + if (!url.pathname.startsWith("/_emdash") && virtualConfig?.authProviders) { + const isPluginFastRoute = virtualConfig.authProviders.some( + (p: { routes?: { pattern?: string }[] }) => + p.routes?.some((r: { pattern?: string }) => r.pattern && url.pathname === r.pattern), + ); + if (isPluginFastRoute) { + const response = await next(); + setBaselineSecurityHeaders(response); + return response; + } + } + // Process /_emdash routes and public routes with an active session // (logged-in editors need the runtime for toolbar/visual editing on public pages) const isEmDashRoute = url.pathname.startsWith("/_emdash"); diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 76228d1c6..265229e1f 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -17,6 +17,8 @@ import { ulid } from "ulidx"; // Import auth provider via virtual module (statically bundled) // This avoids dynamic import issues in Cloudflare Workers import { authenticate as virtualAuthenticate } from "virtual:emdash/auth"; +// @ts-ignore - virtual module +import virtualConfig from "virtual:emdash/config"; import { checkPublicCsrf } from "../../api/csrf.js"; import { apiError } from "../../api/error.js"; @@ -106,13 +108,32 @@ const PUBLIC_API_PREFIXES = [ const PUBLIC_API_EXACT = new Set([ "/_emdash/api/auth/passkey/options", "/_emdash/api/auth/passkey/verify", + "/_emdash/api/auth/mode", "/_emdash/api/oauth/token", "/_emdash/api/snapshot", ]); +// Build merged public routes at module load from auth provider descriptors. +// Routes ending with "/" are treated as prefixes; all others are exact matches. +const { exact: _providerExactRoutes, prefixes: _providerPrefixRoutes } = (() => { + const exact = new Set(); + const prefixes: string[] = []; + if (!virtualConfig?.authProviders) return { exact, prefixes }; + for (const route of virtualConfig.authProviders.flatMap((p) => p.publicRoutes ?? [])) { + if (route.endsWith("/")) { + prefixes.push(route); + } else { + exact.add(route); + } + } + return { exact, prefixes }; +})(); + function isPublicEmDashRoute(pathname: string): boolean { if (PUBLIC_API_EXACT.has(pathname)) return true; if (PUBLIC_API_PREFIXES.some((p) => pathname.startsWith(p))) return true; + if (_providerExactRoutes.has(pathname)) return true; + if (_providerPrefixRoutes.some((p) => pathname.startsWith(p))) return true; if (import.meta.env.DEV && pathname === "/_emdash/api/typegen") return true; return false; } diff --git a/packages/core/src/astro/routes/PluginRegistry.tsx b/packages/core/src/astro/routes/PluginRegistry.tsx index a131ec1f9..50d526e41 100644 --- a/packages/core/src/astro/routes/PluginRegistry.tsx +++ b/packages/core/src/astro/routes/PluginRegistry.tsx @@ -10,6 +10,8 @@ import { AdminApp } from "@emdash-cms/admin"; import type { Messages } from "@lingui/core"; // @ts-ignore - virtual module generated by integration import { pluginAdmins } from "virtual:emdash/admin-registry"; +// @ts-ignore - virtual module generated by integration +import { authProviders } from "virtual:emdash/auth-providers"; interface AdminWrapperProps { locale: string; @@ -17,5 +19,12 @@ interface AdminWrapperProps { } export default function AdminWrapper({ locale, messages }: AdminWrapperProps) { - return ; + return ( + + ); } diff --git a/packages/core/src/astro/routes/api/auth/mode.ts b/packages/core/src/astro/routes/api/auth/mode.ts new file mode 100644 index 000000000..97fb735ac --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/mode.ts @@ -0,0 +1,57 @@ +/** + * GET /_emdash/api/auth/mode + * + * Public endpoint that returns the active authentication mode. + * Used by the login page to determine which login UI to render. + * + * Unlike the full manifest endpoint, this is intentionally public + * and returns only the auth mode — no collection schemas, plugin + * info, or other internal details. + */ + +import type { APIRoute } from "astro"; + +import { getAuthMode } from "#auth/mode.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ locals }) => { + const { emdash } = locals; + + const authMode = getAuthMode(emdash?.config); + + // Only check signup for passkey auth (external providers handle their own) + let signupEnabled = false; + if (emdash?.db && authMode.type === "passkey") { + try { + const { sql } = await import("kysely"); + const result = await sql<{ cnt: unknown }>` + SELECT COUNT(*) as cnt FROM allowed_domains WHERE enabled = 1 + `.execute(emdash.db); + signupEnabled = Number(result.rows[0]?.cnt ?? 0) > 0; + } catch { + // Table may not exist yet + } + } + + // Collect pluggable auth providers (from authProviders config) + const providers = (emdash?.config?.authProviders ?? []).map((p) => ({ + id: p.id, + label: p.label, + })); + + return Response.json( + { + data: { + authMode: authMode.type === "external" ? authMode.providerType : "passkey", + signupEnabled, + providers, + }, + }, + { + headers: { + "Cache-Control": "private, no-store", + }, + }, + ); +}; diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts index d8150d5c6..840e3e171 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -70,16 +70,22 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { const { emdash } = locals; const provider = params.provider; + // Determine where to redirect errors (setup wizard or login page) + const referer = request.headers.get("referer") ?? ""; + const errorRedirectBase = referer.includes("/setup") + ? "/_emdash/admin/setup" + : "/_emdash/admin/login"; + // Validate provider if (!provider || !isValidProvider(provider)) { return redirect( - `/_emdash/admin/login?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`, + `${errorRedirectBase}?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`, ); } if (!emdash?.db) { return redirect( - `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, + `${errorRedirectBase}?error=server_error&message=${encodeURIComponent("Database not configured")}`, ); } @@ -96,7 +102,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { if (!providers[provider]) { return redirect( - `/_emdash/admin/login?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured`)}`, + `${errorRedirectBase}?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured. Set either EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_ID and EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_SECRET, or ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET.`)}`, ); } @@ -113,7 +119,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { } catch (error) { console.error("OAuth initiation error:", error); return redirect( - `/_emdash/admin/login?error=oauth_error&message=${encodeURIComponent("Failed to start OAuth flow. Please try again.")}`, + `${errorRedirectBase}?error=oauth_error&message=${encodeURIComponent("Failed to start OAuth flow. Please try again.")}`, ); } }; diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index 7c69cd613..42310af5b 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -17,6 +17,7 @@ import { } from "@emdash-cms/auth"; import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import { finalizeSetup } from "#api/setup-complete.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; type ProviderName = "github" | "google"; @@ -125,10 +126,19 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect ); } + const adapter = createKyselyAdapter(emdash.db); + const stateStore = createOAuthStateStore(emdash.db); + const config: OAuthConsumerConfig = { baseUrl: `${url.origin}/_emdash`, providers, canSelfSignup: async (email: string) => { + // During setup: first user becomes admin + const userCount = await adapter.countUsers(); + if (userCount === 0) { + return { allowed: true, role: Role.ADMIN }; + } + // Extract domain from email const domain = email.split("@")[1]?.toLowerCase(); if (!domain) { @@ -167,10 +177,15 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect }, }; - const adapter = createKyselyAdapter(emdash.db); - const stateStore = createOAuthStateStore(emdash.db); - + const userCountBefore = await adapter.countUsers(); const user = await handleOAuthCallback(config, adapter, provider, code, state, stateStore); + const isFirstUser = userCountBefore === 0; + + // Finalize setup outside the transaction (idempotent, safe if two callbacks race). + if (isFirstUser) { + await finalizeSetup(emdash.db); + console.log(`[oauth] Setup complete: created admin user via ${provider} (${user.email})`); + } // Create session if (session) { diff --git a/packages/core/src/astro/routes/api/setup/admin.ts b/packages/core/src/astro/routes/api/setup/admin.ts index fe091cba1..729a8a19f 100644 --- a/packages/core/src/astro/routes/api/setup/admin.ts +++ b/packages/core/src/astro/routes/api/setup/admin.ts @@ -47,8 +47,10 @@ export const POST: APIRoute = async ({ request, locals }) => { const body = await parseBody(request, setupAdminBody); if (isParseError(body)) return body; - // Store admin info in setup state for later + // Merge admin info into existing setup state (preserves title/tagline from step 1) + const existingState = await options.get>("emdash:setup_state"); await options.set("emdash:setup_state", { + ...existingState, step: "admin", email: body.email.toLowerCase(), name: body.name || null, @@ -77,8 +79,9 @@ export const POST: APIRoute = async ({ request, locals }) => { challengeStore, ); - // Store the temp user ID with the setup state + // Merge temp user ID into setup state (preserves title/tagline from step 1) await options.set("emdash:setup_state", { + ...existingState, step: "admin", email: body.email.toLowerCase(), name: body.name || null, diff --git a/packages/core/src/astro/routes/api/setup/index.ts b/packages/core/src/astro/routes/api/setup/index.ts index a76ddde07..2d98ad15b 100644 --- a/packages/core/src/astro/routes/api/setup/index.ts +++ b/packages/core/src/astro/routes/api/setup/index.ts @@ -80,7 +80,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // 5. Store setup state // In external auth mode, mark setup complete immediately (first user to login becomes admin) - // In passkey mode, setup_complete is set after admin user is created + // Otherwise, setup_complete is set after admin user is created (passkey or auth provider) const authMode = getAuthMode(emdash.config); const useExternalAuth = authMode.type === "external"; @@ -101,7 +101,7 @@ export const POST: APIRoute = async ({ request, locals }) => { await options.set("emdash:site_tagline", body.tagline); } } else { - // Passkey mode: store state for next step (admin creation) + // Passkey/provider mode: store state for next step (admin creation) await options.set("emdash:setup_state", { step: "site_complete", title: body.title, diff --git a/packages/core/src/astro/routes/api/setup/status.ts b/packages/core/src/astro/routes/api/setup/status.ts index 4f9c068b8..99c71a741 100644 --- a/packages/core/src/astro/routes/api/setup/status.ts +++ b/packages/core/src/astro/routes/api/setup/status.ts @@ -91,7 +91,7 @@ export const GET: APIRoute = async ({ locals }) => { const authMode = getAuthMode(emdash.config); const useExternalAuth = authMode.type === "external"; - // In external auth mode, setup is complete if flag is set (no users required initially) + // In external auth mode (not atproto), setup is complete if flag is set (no users required initially) if (useExternalAuth && isComplete) { return apiSuccess({ needsSetup: false, diff --git a/packages/core/src/auth/mode.ts b/packages/core/src/auth/mode.ts index e59998ae8..45a46dbb2 100644 --- a/packages/core/src/auth/mode.ts +++ b/packages/core/src/auth/mode.ts @@ -6,9 +6,21 @@ */ import type { EmDashConfig } from "../astro/integration/runtime.js"; -import type { AuthDescriptor, AuthResult, ExternalAuthConfig } from "./types.js"; +import type { + AuthDescriptor, + AuthProviderDescriptor, + AuthRouteDescriptor, + AuthResult, + ExternalAuthConfig, +} from "./types.js"; -export type { AuthDescriptor, AuthResult, ExternalAuthConfig }; +export type { + AuthDescriptor, + AuthProviderDescriptor, + AuthRouteDescriptor, + AuthResult, + ExternalAuthConfig, +}; /** * Passkey auth mode (default) @@ -59,7 +71,7 @@ export function getAuthMode( ): AuthMode { const auth = config?.auth; - // Check for AuthDescriptor (new style) + // Check for AuthDescriptor (transparent external auth like Cloudflare Access) if (auth && "entrypoint" in auth && auth.entrypoint) { return { type: "external", diff --git a/packages/core/src/auth/providers/github-admin.tsx b/packages/core/src/auth/providers/github-admin.tsx new file mode 100644 index 000000000..facea18fb --- /dev/null +++ b/packages/core/src/auth/providers/github-admin.tsx @@ -0,0 +1,32 @@ +/** + * GitHub OAuth Admin Components + * + * LoginButton for the login page, rendered via the auth provider virtual module. + */ + +import * as React from "react"; + +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function handleClick() { + window.location.href = "/_emdash/api/auth/oauth/github"; +} + +export function LoginButton() { + return ( + + ); +} diff --git a/packages/core/src/auth/providers/github.ts b/packages/core/src/auth/providers/github.ts new file mode 100644 index 000000000..199d56d51 --- /dev/null +++ b/packages/core/src/auth/providers/github.ts @@ -0,0 +1,31 @@ +/** + * GitHub OAuth Auth Provider + * + * Returns an AuthProviderDescriptor for GitHub OAuth login. + * Credentials are read from environment variables at runtime. + * + * @example + * ```ts + * import { github } from "emdash/auth/providers/github"; + * + * emdash({ + * authProviders: [github()], + * }) + * ``` + */ + +import type { AuthProviderDescriptor } from "../types.js"; + +/** + * Configure GitHub OAuth as an auth provider. + * + * Requires `EMDASH_OAUTH_GITHUB_CLIENT_ID` and `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` + * (or `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`) environment variables. + */ +export function github(): AuthProviderDescriptor { + return { + id: "github", + label: "GitHub", + adminEntry: "emdash/auth/providers/github-admin", + }; +} diff --git a/packages/core/src/auth/providers/google-admin.tsx b/packages/core/src/auth/providers/google-admin.tsx new file mode 100644 index 000000000..8c874cc15 --- /dev/null +++ b/packages/core/src/auth/providers/google-admin.tsx @@ -0,0 +1,47 @@ +/** + * Google OAuth Admin Components + * + * LoginButton for the login page, rendered via the auth provider virtual module. + */ + +import * as React from "react"; + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function handleClick() { + window.location.href = "/_emdash/api/auth/oauth/google"; +} + +export function LoginButton() { + return ( + + ); +} diff --git a/packages/core/src/auth/providers/google.ts b/packages/core/src/auth/providers/google.ts new file mode 100644 index 000000000..924ab9ca5 --- /dev/null +++ b/packages/core/src/auth/providers/google.ts @@ -0,0 +1,31 @@ +/** + * Google OAuth Auth Provider + * + * Returns an AuthProviderDescriptor for Google OAuth login. + * Credentials are read from environment variables at runtime. + * + * @example + * ```ts + * import { google } from "emdash/auth/providers/google"; + * + * emdash({ + * authProviders: [google()], + * }) + * ``` + */ + +import type { AuthProviderDescriptor } from "../types.js"; + +/** + * Configure Google OAuth as an auth provider. + * + * Requires `EMDASH_OAUTH_GOOGLE_CLIENT_ID` and `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET` + * (or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`) environment variables. + */ +export function google(): AuthProviderDescriptor { + return { + id: "google", + label: "Google", + adminEntry: "emdash/auth/providers/google-admin", + }; +} diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index 23fc2af14..4be08cd96 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -2,7 +2,13 @@ * Auth Provider Types * * Defines the interfaces for pluggable authentication providers. - * Providers like Cloudflare Access implement these interfaces. + * + * Two systems coexist: + * - `AuthDescriptor` — transparent auth (Cloudflare Access) that authenticates + * every request via headers/cookies. No login UI needed. + * - `AuthProviderDescriptor` — pluggable login methods (GitHub, Google, + * AT Protocol, etc.) that appear as options on the login page and setup + * wizard. Passkey is built-in; providers are additive. */ /** @@ -22,10 +28,10 @@ export interface AuthResult { } /** - * Auth descriptor - returned by auth adapter functions (e.g., access()) + * Auth descriptor — transparent auth providers (e.g., Cloudflare Access). * - * Similar to DatabaseDescriptor and StorageDescriptor, this allows - * auth providers to be configured at build time and loaded at runtime. + * These authenticate every request via headers/cookies. No login UI needed. + * The module's `authenticate()` function is called by middleware on each request. */ export interface AuthDescriptor { /** @@ -64,6 +70,98 @@ export interface AuthProviderModule { authenticate(request: Request, config: unknown): Promise; } +// --------------------------------------------------------------------------- +// Pluggable Auth Providers (additive login methods) +// --------------------------------------------------------------------------- + +/** + * Descriptor for a pluggable auth provider. + * + * Auth providers appear as login options on the login page and setup wizard. + * They coexist with passkey (which is built-in) and with each other. + * Any provider can be used to create the initial admin account. + * + * @example + * ```ts + * // astro.config.ts + * import { atproto } from "@emdash-cms/plugin-atproto/auth"; + * + * emdash({ + * authProviders: [atproto(), github(), google()], + * }) + * ``` + */ +export interface AuthProviderDescriptor { + /** Unique provider ID (e.g., "github", "atproto") */ + id: string; + + /** Human-readable label for UI (e.g., "GitHub", "AT Protocol") */ + label: string; + + /** Provider-specific config (JSON-serializable) */ + config?: unknown; + + /** + * Module exporting React components for the admin UI. + * Statically imported at build time via virtual module. + * + * The module should export components matching `AuthProviderAdminExports`. + */ + adminEntry?: string; + + /** + * Astro route handlers this provider needs injected at build time. + * Used for login initiation, OAuth callbacks, well-known endpoints, etc. + */ + routes?: AuthRouteDescriptor[]; + + /** + * URL prefixes/paths that should bypass auth middleware. + * Added to the public routes set so login/callback endpoints work + * for unauthenticated users. + */ + publicRoutes?: string[]; +} + +/** + * A route that an auth provider needs injected into the Astro app. + */ +export interface AuthRouteDescriptor { + /** URL pattern (e.g., "/_emdash/api/auth/atproto/login") */ + pattern: string; + /** Module specifier for the Astro route handler */ + entrypoint: string; +} + +/** + * Expected exports from an auth provider's `adminEntry` module. + * + * All exports are optional. Providers export whichever components + * make sense for their auth flow. + */ +export interface AuthProviderAdminExports { + /** + * Compact button for the login page (icon + label). + * Used for providers with a simple redirect flow (GitHub, Google). + * Rendered in the "Or continue with" section. + */ + LoginButton?: import("react").ComponentType; + + /** + * Full login form for providers that need custom input. + * Used for providers like AT Protocol that need a handle field. + * Rendered as an expandable section on the login page. + */ + LoginForm?: import("react").ComponentType; + + /** + * Setup wizard step for creating the admin account via this provider. + * When present, this provider appears as an option in the setup wizard's + * "Create admin account" step. + */ + SetupStep?: import("react").ComponentType<{ onComplete: () => void }>; +} + /** * Configuration options common to external auth providers */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f343cade..f072f6855 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -463,11 +463,14 @@ export type { SearchStats, } from "./search/index.js"; -// Auth types (for platform-specific auth providers) +// Auth types (for platform-specific auth providers and pluggable login methods) export type { AuthDescriptor, + AuthProviderDescriptor, + AuthProviderAdminExports, AuthProviderModule, AuthResult, + AuthRouteDescriptor, ExternalAuthConfig, } from "./auth/types.js"; diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 3752a727e..cd1fe4ca9 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -7,12 +7,18 @@ declare module "virtual:emdash/config" { import type { I18nConfig } from "./i18n/config.js"; - import type { DatabaseDescriptor, StorageDescriptor, AuthDescriptor } from "./index.js"; + import type { + AuthDescriptor, + AuthProviderDescriptor, + DatabaseDescriptor, + StorageDescriptor, + } from "./index.js"; interface VirtualConfig { database?: DatabaseDescriptor; storage?: StorageDescriptor; auth?: AuthDescriptor; + authProviders?: AuthProviderDescriptor[]; i18n?: I18nConfig | null; } @@ -91,6 +97,20 @@ declare module "virtual:emdash/block-components" { export const pluginBlockComponents: Record; } +declare module "virtual:emdash/auth-providers" { + import type { ComponentType } from "react"; + + interface AuthProviderEntry { + id: string; + label: string; + LoginButton?: ComponentType; + LoginForm?: ComponentType; + SetupStep?: ComponentType<{ onComplete: () => void }>; + } + + export const authProviders: Record; +} + declare module "virtual:emdash/admin-registry" { /** * Plugin admin module registry. diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 1899f205c..2f23235f1 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -16,6 +16,7 @@ "src/astro/**", "src/components/**", "src/preview/**", - "src/ui.ts" + "src/ui.ts", + "src/auth/providers/*-admin.tsx" ] } diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index 89d7911c0..b18417cef 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -3,16 +3,18 @@ "version": "0.1.1", "description": "AT Protocol / standard.site syndication plugin for EmDash CMS", "type": "module", - "main": "dist/index.mjs", + "main": "src/index.ts", "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./sandbox": "./dist/sandbox-entry.mjs" + ".": "./src/index.ts", + "./sandbox": "./src/sandbox-entry.ts", + "./auth": "./src/auth.ts", + "./admin": "./src/admin.tsx", + "./oauth-client": "./src/oauth-client.ts", + "./resolve-handle": "./src/resolve-handle.ts", + "./routes/*": "./src/routes/*" }, "files": [ - "dist" + "src" ], "keywords": [ "emdash", @@ -26,17 +28,17 @@ ], "author": "Matt Kane", "license": "MIT", - "dependencies": { - "emdash": "workspace:*" + "peerDependencies": { + "astro": ">=5", + "emdash": "workspace:*", + "react": ">=18" }, "devDependencies": { - "tsdown": "catalog:", - "typescript": "catalog:", + "@atcute/lexicons": "^1.2.10", + "@types/react": "^19.0.0", "vitest": "catalog:" }, "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", "test": "vitest run", "typecheck": "tsgo --noEmit" }, @@ -44,5 +46,11 @@ "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/plugins/atproto" + }, + "dependencies": { + "@atcute/identity-resolver": "^1.2.2", + "@atcute/oauth-node-client": "^1.1.0", + "@emdash-cms/auth": "workspace:*", + "kysely": "^0.27.6" } } diff --git a/packages/plugins/atproto/src/admin.tsx b/packages/plugins/atproto/src/admin.tsx new file mode 100644 index 000000000..aefc949f3 --- /dev/null +++ b/packages/plugins/atproto/src/admin.tsx @@ -0,0 +1,186 @@ +/** + * AT Protocol Auth Provider Admin Components + * + * Provides LoginForm and SetupStep components for the pluggable auth system. + * These are imported at build time via the virtual:emdash/auth-providers module. + */ + +import * as React from "react"; + +// ============================================================================ +// Shared icon +// ============================================================================ + +function AtprotoIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// ============================================================================ +// LoginButton — compact button shown in the provider grid +// ============================================================================ + +export function LoginButton() { + return ( + + ); +} + +// ============================================================================ +// LoginForm — expanded form shown when LoginButton is clicked +// ============================================================================ + +export function LoginForm() { + 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(); + if (!handle.trim()) return; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/_emdash/api/auth/atproto/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }, + 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 AT Protocol login"); + } + + const result: { data: { url: string } } = await response.json(); + window.location.href = result.data.url; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start AT Protocol login"); + setIsLoading(false); + } + }; + + return ( + +
+ + setHandle(e.target.value)} + placeholder="you.bsky.social" + disabled={isLoading} + className="w-full rounded-md border border-kumo-tint bg-kumo-base px-3 py-2 text-sm text-kumo-default placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-brand" + /> +
+ + {error && ( +
{error}
+ )} + + + + ); +} + +// ============================================================================ +// SetupStep — shown in the setup wizard +// ============================================================================ + +export function SetupStep({ onComplete }: { onComplete: () => void }) { + const [handle, setHandle] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + // Suppress unused variable warning — onComplete is called after redirect + void onComplete; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!handle.trim()) return; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/_emdash/api/setup/atproto-admin", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }, + 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 AT Protocol login"); + } + + const result: { data: { url: string } } = await response.json(); + // Redirect to PDS authorization page — onComplete will be called after redirect back + window.location.href = result.data.url; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start AT Protocol login"); + setIsLoading(false); + } + }; + + return ( +
+
+

AT Protocol

+

Sign in with your PDS handle

+
+ +
+ setHandle(e.target.value)} + placeholder="you.bsky.social" + disabled={isLoading} + className="w-full rounded-md border border-kumo-tint bg-kumo-base px-3 py-2 text-sm text-kumo-default placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-brand" + /> +
+ + {error && ( +
{error}
+ )} + + +
+ ); +} diff --git a/packages/plugins/atproto/src/auth.ts b/packages/plugins/atproto/src/auth.ts new file mode 100644 index 000000000..395a7c0aa --- /dev/null +++ b/packages/plugins/atproto/src/auth.ts @@ -0,0 +1,100 @@ +/** + * AT Protocol PDS Authentication Provider + * + * Config-time function that returns an AuthProviderDescriptor for use in astro.config.ts. + * When configured, EmDash adds AT Protocol as a login option alongside passkey and + * any other configured auth providers. + * + * @example + * ```ts + * import { atproto } from "@emdash-cms/plugin-atproto/auth"; + * + * export default defineConfig({ + * integrations: [ + * emdash({ + * authProviders: [ + * atproto({ allowedDIDs: ["did:plc:abc123"] }), + * ], + * }), + * ], + * }); + * ``` + */ + +import type { AuthProviderDescriptor } from "emdash"; + +/** + * Configuration for AT Protocol PDS authentication + */ +export interface AtprotoAuthConfig { + /** + * Restrict login to specific DIDs (optional allowlist). + * DIDs are permanent cryptographic identifiers that can't be spoofed. + * + * @example ["did:plc:abc123", "did:web:example.com"] + */ + allowedDIDs?: string[]; + + /** + * Restrict login to handles matching these patterns (optional allowlist). + * Supports exact matches and wildcard domains (e.g., `"*.example.com"`). + * + * Handle ownership is independently verified via DNS TXT / HTTP resolution + * (not trusting the PDS's claim), so this is safe for org-level gating + * where the org controls the domain. + * + * If both `allowedDIDs` and `allowedHandles` are set, a user matching + * either list is allowed. + * + * @example ["*.mycompany.com", "alice.bsky.social"] + */ + allowedHandles?: string[]; + + /** + * Default role level for users who are not the first user. + * First user always gets Admin (50). + * Valid values: 10 (Subscriber), 20 (Contributor), 30 (Author), 40 (Editor), 50 (Admin). + * @default 10 (Subscriber) + */ + defaultRole?: number; +} + +/** + * Configure AT Protocol PDS authentication as a pluggable auth provider. + * + * Users authenticate by signing in through their PDS's authorization page. + * No passkeys or app passwords required — the user authenticates however + * their PDS supports (password, passkey, etc.). + * + * @param config Optional configuration + * @returns AuthProviderDescriptor for use in `emdash({ authProviders: [...] })` + */ +export function atproto(config?: AtprotoAuthConfig): AuthProviderDescriptor { + return { + id: "atproto", + label: "AT Protocol", + config: config ?? {}, + adminEntry: "@emdash-cms/plugin-atproto/admin", + routes: [ + { + pattern: "/_emdash/api/auth/atproto/login", + entrypoint: "@emdash-cms/plugin-atproto/routes/login.ts", + }, + { + pattern: "/_emdash/api/auth/atproto/callback", + entrypoint: "@emdash-cms/plugin-atproto/routes/callback.ts", + }, + { + pattern: "/_emdash/api/setup/atproto-admin", + entrypoint: "@emdash-cms/plugin-atproto/routes/setup-admin.ts", + }, + { + // Served at root /.well-known/ (not /_emdash/) so PDS authorization + // servers can fetch them quickly without hitting the EmDash middleware chain. + pattern: "/.well-known/atproto-client-metadata.json", + entrypoint: "@emdash-cms/plugin-atproto/routes/client-metadata.ts", + }, + ], + publicRoutes: ["/_emdash/api/auth/atproto/"], + }; +} diff --git a/packages/plugins/atproto/src/db-store.ts b/packages/plugins/atproto/src/db-store.ts new file mode 100644 index 000000000..23a014d4e --- /dev/null +++ b/packages/plugins/atproto/src/db-store.ts @@ -0,0 +1,95 @@ +/** + * Database-backed store for AT Protocol OAuth state and sessions. + * + * Replaces MemoryStore to support multi-instance deployments (e.g., Cloudflare + * Workers) where in-memory state is lost between requests. + * + * Uses a single `_emdash_atproto_store` table with (namespace, key) as the + * composite primary key. The table is auto-created on first access. + */ + +import type { Store } from "@atcute/oauth-node-client"; +import { sql, type Kysely } from "kysely"; + +interface AtprotoStoreTable { + namespace: string; + key: string; + value: string; + expires_at: number | null; +} + +interface AtprotoStoreDb { + _emdash_atproto_store: AtprotoStoreTable; +} + +async function ensureTable(db: Kysely): Promise { + await sql`CREATE TABLE IF NOT EXISTS _emdash_atproto_store ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER, + PRIMARY KEY (namespace, key) + )`.execute(db); +} + +/** + * Create a database-backed Store for the atcute OAuth client. + * + * @param getDb - Function that returns the current Kysely instance. + * Using a getter instead of a direct reference because on + * Cloudflare Workers the db binding changes per request. + * @param namespace - Store namespace (e.g., "states" or "sessions") + */ +export function createDbStore( + getDb: () => Kysely, + namespace: string, +): Store { + return { + async get(key: K): Promise { + const db = getDb(); + await ensureTable(db); + const result = await sql<{ value: string; expires_at: number | null }>` + SELECT value, expires_at FROM _emdash_atproto_store + WHERE namespace = ${namespace} AND key = ${key} + `.execute(db); + const row = (result as { rows: { value: string; expires_at: number | null }[] }).rows[0]; + if (!row) return undefined; + // Check expiry + if (row.expires_at && Date.now() > row.expires_at * 1000) { + await sql`DELETE FROM _emdash_atproto_store + WHERE namespace = ${namespace} AND key = ${key}`.execute(db); + return undefined; + } + return JSON.parse(row.value) as V; + }, + + async set(key: K, value: V): Promise { + const db = getDb(); + await ensureTable(db); + const json = JSON.stringify(value); + // Extract expiresAt from StoredState if present + const expiresAt = (value as { expiresAt?: number }).expiresAt ?? null; + await (db as unknown as Kysely) + .insertInto("_emdash_atproto_store") + .values({ namespace, key, value: json, expires_at: expiresAt }) + .onConflict((oc) => + oc.columns(["namespace", "key"]).doUpdateSet({ value: json, expires_at: expiresAt }), + ) + .execute(); + }, + + async delete(key: K): Promise { + const db = getDb(); + await ensureTable(db); + await sql`DELETE FROM _emdash_atproto_store + WHERE namespace = ${namespace} AND key = ${key}`.execute(db); + }, + + async clear(): Promise { + const db = getDb(); + await ensureTable(db); + await sql`DELETE FROM _emdash_atproto_store + WHERE namespace = ${namespace}`.execute(db); + }, + }; +} diff --git a/packages/plugins/atproto/src/env.d.ts b/packages/plugins/atproto/src/env.d.ts new file mode 100644 index 000000000..ae1bf769b --- /dev/null +++ b/packages/plugins/atproto/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/plugins/atproto/src/oauth-client.ts b/packages/plugins/atproto/src/oauth-client.ts new file mode 100644 index 000000000..d0e4e47b5 --- /dev/null +++ b/packages/plugins/atproto/src/oauth-client.ts @@ -0,0 +1,215 @@ +/** + * AT Protocol OAuth Client + * + * Creates and manages the @atcute/oauth-node-client OAuthClient instance + * for AT Protocol PDS authentication. + * + * The OAuthClient handles all atproto-specific OAuth complexity: + * - DPoP (proof-of-possession tokens) + * - PAR (Pushed Authorization Requests) + * - PKCE (Proof Key for Code Exchange) + * - Session management with automatic token refresh + * - Actor resolution (handle → DID → PDS) + * + * Uses a public client with PKCE in all environments. Per the AT Protocol + * OAuth spec, public clients have a 2-week session lifetime cap (vs unlimited + * for confidential clients), which is acceptable for a CMS admin panel. + * This avoids the complexity of key management, JWKS endpoints, and + * client assertion signing that confidential clients require. + * + * In dev (http://localhost), uses a loopback client per RFC 8252 — no client + * metadata endpoint needed. In production (HTTPS), the PDS fetches the + * client metadata document to verify the client. + */ + +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, +} from "@atcute/identity-resolver"; +import { + MemoryStore, + OAuthClient, + type OAuthSession, + type StoredSession, + type StoredState, +} from "@atcute/oauth-node-client"; +import type { Kysely } from "kysely"; + +import { createDbStore } from "./db-store.js"; + +type Did = `did:${string}:${string}`; + +// Singleton OAuthClient instance (lazily created). +// On Workers, the db binding changes per request, so we store a mutable +// reference that DB-backed stores read via a getter. +let _client: OAuthClient | null = null; +let _clientBaseUrl: string | null = null; +let _currentDb: Kysely | null = null; +let _clientHasDb = false; + +function isLoopback(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + } catch { + return false; + } +} + +/** + * Get or create the AT Protocol OAuth client. + * + * The client is lazily initialized on first use and cached as a singleton. + * The baseUrl must be the public-facing URL of the EmDash site + * (used for client_id and redirect_uri). + * + * Uses a public client with PKCE in all environments: + * - Loopback (localhost/127.0.0.1): No client metadata needed — PDS derives + * metadata from client_id URL parameters per RFC 8252. + * - Production (HTTPS): PDS fetches the client metadata document to verify + * the client. No JWKS or key management needed. + * + * @param db - Database instance for persistent OAuth state/session storage. + * Required for multi-instance deployments (e.g., Workers). + * Pass `null` to use in-memory storage (dev only). + */ +export async function getAtprotoOAuthClient( + baseUrl: string, + db?: Kysely | null, +): Promise { + // Normalize localhost ↔ 127.0.0.1 so the singleton survives the OAuth + // round-trip (authorize uses localhost, callback arrives on 127.0.0.1). + if (isLoopback(baseUrl)) { + baseUrl = baseUrl.replace("://localhost", "://127.0.0.1"); + } + + // Update the mutable db reference so cached DB-backed stores use + // the current request's binding (critical on Workers). + if (db) _currentDb = db; + + // Return cached client if baseUrl matches and store backend hasn't upgraded. + // If the cached client uses MemoryStore but a db is now available, recreate + // with DB-backed stores so state survives across Workers requests. + if (_client && _clientBaseUrl === baseUrl && (!db || _clientHasDb)) { + return _client; + } + + const actorResolver = new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" }), + http: new WellKnownHandleResolver(), + }, + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }), + }); + + // Use database-backed stores when a db is provided (required for + // multi-instance deployments like Cloudflare Workers where in-memory + // state doesn't survive across requests). Fall back to MemoryStore + // for local dev where the singleton process persists. + const getDb = () => _currentDb!; + const stores = db + ? { + sessions: createDbStore(getDb, "sessions"), + states: createDbStore(getDb, "states"), + } + : { + sessions: new MemoryStore(), + states: new MemoryStore(), + }; + + let client: OAuthClient; + + if (isLoopback(baseUrl)) { + // Loopback public client for local development. + // AT Protocol spec allows loopback IPs with public clients. + // No client metadata endpoints needed — the PDS derives + // metadata from the client_id URL parameters. + // baseUrl is already normalized to 127.0.0.1 above (RFC 8252). + client = new OAuthClient({ + metadata: { + redirect_uris: [`${baseUrl}/_emdash/api/auth/atproto/callback`], + scope: "atproto transition:generic", + }, + stores, + actorResolver, + }); + } else { + // Public client for production (HTTPS). + // Uses PKCE for security — no client secret or key management needed. + // The PDS fetches the client metadata document to verify redirect_uris. + client = new OAuthClient({ + metadata: { + client_id: `${baseUrl}/.well-known/atproto-client-metadata.json`, + redirect_uris: [`${baseUrl}/_emdash/api/auth/atproto/callback`], + scope: "atproto transition:generic", + }, + stores, + actorResolver, + }); + } + + _client = client; + _clientBaseUrl = baseUrl; + _clientHasDb = !!db; + + return client; +} + +/** + * Resolve an AT Protocol user's display name and handle from their PDS. + * + * Uses the authenticated session to call com.atproto.repo.getRecord + * for the app.bsky.actor.profile record. Returns displayName and handle + * (falls back to DID if resolution fails). + */ +export async function resolveAtprotoProfile( + atprotoSession: OAuthSession, +): Promise<{ displayName: string | null; handle: string }> { + const did = atprotoSession.did; + + // Resolve handle and displayName as independent best-effort steps. + // Handle comes from getSession (authoritative PDS record). + // DisplayName comes from the profile record (optional, cosmetic). + let handle: string = did; + let displayName: string | null = null; + + // 1. Handle via getSession (needed for allowlist checks — fetch independently) + try { + const sessionRes = await atprotoSession.handle("/xrpc/com.atproto.server.getSession"); + if (sessionRes.ok) { + const sessionData = (await sessionRes.json()) as { handle?: string }; + if (sessionData.handle) handle = sessionData.handle; + } + } catch (error) { + console.warn("[atproto-auth] Failed to resolve handle via getSession:", error); + } + + // 2. DisplayName via profile record (cosmetic — failure is fine) + try { + const res = await atprotoSession.handle( + `/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, + ); + if (res.ok) { + const data = (await res.json()) as { + value?: { displayName?: string }; + }; + displayName = data.value?.displayName || null; + } + } catch (error) { + console.warn("[atproto-auth] Failed to resolve profile record:", error); + } + + return { displayName, handle }; +} diff --git a/packages/plugins/atproto/src/resolve-handle.ts b/packages/plugins/atproto/src/resolve-handle.ts new file mode 100644 index 000000000..632c98b71 --- /dev/null +++ b/packages/plugins/atproto/src/resolve-handle.ts @@ -0,0 +1,52 @@ +/** + * Independent AT Protocol handle resolution. + * + * Verifies the handle→DID binding directly against the handle's domain, + * without trusting any PDS or relay. This is critical for security when + * using handle-based allowlists — a malicious PDS could claim any handle + * for its DIDs, so we must verify independently. + * + * Uses @atcute/identity-resolver which supports: + * - DNS over HTTPS (works on Cloudflare Workers, no node:dns needed) + * - HTTP well-known (`https://{handle}/.well-known/atproto-did`) + * - Composite strategies (race both methods for speed) + */ + +import { + CompositeHandleResolver, + DohJsonHandleResolver, + WellKnownHandleResolver, +} from "@atcute/identity-resolver"; + +let resolver: CompositeHandleResolver | undefined; + +function getResolver(): CompositeHandleResolver { + if (!resolver) { + resolver = new CompositeHandleResolver({ + strategy: "race", + methods: { + dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" }), + http: new WellKnownHandleResolver(), + }, + }); + } + return resolver; +} + +/** + * Resolve an AT Protocol handle to a DID by verifying the binding + * directly against the handle's domain (DNS-over-HTTPS + HTTP, raced). + * + * Returns the verified DID, or null if resolution fails. + */ +export async function verifyHandleDID(handle: string): Promise { + // Basic validation — must be at least `x.y` (atcute expects `${string}.${string}`) + if (!handle.includes(".")) return null; + + try { + const did = await getResolver().resolve(handle as `${string}.${string}`); + return did; + } catch { + return null; + } +} diff --git a/packages/plugins/atproto/src/routes/callback.ts b/packages/plugins/atproto/src/routes/callback.ts new file mode 100644 index 000000000..4c909b40c --- /dev/null +++ b/packages/plugins/atproto/src/routes/callback.ts @@ -0,0 +1,203 @@ +/** + * GET /_emdash/api/auth/atproto/callback + * + * Handles the OAuth callback from the user's PDS after authentication. + * Exchanges the authorization code for tokens, resolves the user's identity, + * finds or creates an EmDash user, and establishes a session. + * + * User lookup uses oauth_accounts (provider="atproto", provider_account_id=DID) + * rather than email, since AT Protocol doesn't guarantee email access. + * + * For the first user (setup flow), the real email from the setup wizard is used. + * For subsequent users, a synthetic email is generated from the DID. + */ + +import type { APIRoute } from "astro"; + +export const prerender = false; + +import { + Role, + toRoleLevel, + findOrCreateOAuthUser, + OAuthError, + type RoleLevel, + type OAuthProfile, +} from "@emdash-cms/auth"; +import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import type { AuthProviderDescriptor } from "emdash"; +import { finalizeSetup, OptionsRepository } from "emdash/api/route-utils"; + +export const GET: APIRoute = async ({ request, locals, session, redirect }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return redirect( + `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, + ); + } + + try { + const url = new URL(request.url); + const baseUrl = url.origin; + + // Handle OAuth errors from PDS + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + if (error) { + const message = errorDescription || error; + return redirect( + `/_emdash/admin/login?error=atproto_denied&message=${encodeURIComponent(message)}`, + ); + } + + // Exchange code for session via atcute + const { getAtprotoOAuthClient, resolveAtprotoProfile } = + await import("@emdash-cms/plugin-atproto/oauth-client"); + const client = await getAtprotoOAuthClient(baseUrl, emdash.db); + const { session: atprotoSession } = await client.callback(url.searchParams); + + const did = atprotoSession.did; + + // Resolve profile for display name and handle + const { displayName, handle } = await resolveAtprotoProfile(atprotoSession); + + // Get auth config from authProviders + const providers = ( + emdash.config as { authProviders?: AuthProviderDescriptor[] } | null | undefined + )?.authProviders; + const atprotoProvider = providers?.find((p) => p.id === "atproto"); + const config = (atprotoProvider?.config ?? {}) as { + allowedDIDs?: string[]; + allowedHandles?: string[]; + defaultRole?: number; + }; + + // Check allowlists if configured (DID or handle match = allowed) + const hasAllowedDIDs = config.allowedDIDs && config.allowedDIDs.length > 0; + const hasAllowedHandles = config.allowedHandles && config.allowedHandles.length > 0; + + if (hasAllowedDIDs || hasAllowedHandles) { + const didAllowed = hasAllowedDIDs && config.allowedDIDs!.includes(did); + + let handleAllowed = false; + if (!didAllowed && hasAllowedHandles) { + // Independently verify the handle→DID binding before trusting it. + // A malicious PDS could claim any handle — we verify via DNS/HTTP. + const { verifyHandleDID } = await import("@emdash-cms/plugin-atproto/resolve-handle"); + const verifiedDid = await verifyHandleDID(handle); + + if (verifiedDid === did) { + const normalizedHandle = handle.toLowerCase(); + handleAllowed = config.allowedHandles!.some((pattern) => { + const p = pattern.toLowerCase(); + return ( + normalizedHandle === p || + (p.startsWith("*.") && normalizedHandle.endsWith(p.slice(1))) + ); + }); + } else { + console.warn( + `[atproto-auth] Handle verification failed for ${handle}: expected DID ${did}, got ${verifiedDid}`, + ); + } + } + + if (!didAllowed && !handleAllowed) { + return redirect( + `/_emdash/admin/login?error=not_allowed&message=${encodeURIComponent("Your account is not in the allowlist")}`, + ); + } + } + + // Resolve default role from config + let defaultRole: RoleLevel = Role.SUBSCRIBER; + try { + if (config.defaultRole != null) defaultRole = toRoleLevel(config.defaultRole); + } catch { + console.warn( + `[atproto-auth] Invalid defaultRole ${config.defaultRole}, using SUBSCRIBER (${Role.SUBSCRIBER})`, + ); + } + + // Build synthetic email — AT Protocol doesn't guarantee email access. + // For the first user, read the real email from the setup wizard state. + const adapter = createKyselyAdapter(emdash.db); + const userCount = await adapter.countUsers(); + const isFirstUser = userCount === 0; + + let email: string; + if (isFirstUser) { + const options = new OptionsRepository(emdash.db); + const setupState = await options.get>("emdash:setup_state"); + email = (setupState?.email as string) || `${did.replaceAll(":", "-")}@atproto.invalid`; + } else { + email = `${did.replaceAll(":", "-")}@atproto.invalid`; + } + + const profile: OAuthProfile = { + id: did, + email, + name: displayName || handle, + avatarUrl: null, + emailVerified: isFirstUser, + }; + + // Use shared find-or-create with canSelfSignup policy + const user = await findOrCreateOAuthUser(adapter, "atproto", profile, async () => { + if (isFirstUser) { + return { allowed: true, role: Role.ADMIN }; + } + return { allowed: true, role: defaultRole }; + }); + + if (isFirstUser) { + await finalizeSetup(emdash.db); + console.log(`[atproto-auth] Setup complete: created admin user via atproto (${did})`); + } + + // Update display name on each login in case it changed + const newName = displayName || handle; + if (user.name !== newName) { + await adapter.updateUser(user.id, { name: newName }); + } + + // Check if user is disabled + if (user.disabled) { + return redirect( + `/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Account disabled")}`, + ); + } + + // Create Astro session + if (session) { + session.set("user", { id: user.id }); + } + + // Redirect to admin dashboard + return redirect("/_emdash/admin"); + } catch (callbackError) { + console.error("[atproto-auth] Callback error:", callbackError); + + let message = "AT Protocol authentication failed. Please try again."; + let errorCode = "atproto_error"; + + if (callbackError instanceof OAuthError) { + errorCode = callbackError.code; + switch (callbackError.code) { + case "signup_not_allowed": + message = "Self-signup is not allowed. Please contact an administrator."; + break; + case "user_not_found": + message = "Your account was not found. It may have been deleted."; + break; + default: + break; + } + } + + return redirect( + `/_emdash/admin/login?error=${errorCode}&message=${encodeURIComponent(message)}`, + ); + } +}; diff --git a/packages/plugins/atproto/src/routes/client-metadata.ts b/packages/plugins/atproto/src/routes/client-metadata.ts new file mode 100644 index 000000000..602bd0c20 --- /dev/null +++ b/packages/plugins/atproto/src/routes/client-metadata.ts @@ -0,0 +1,37 @@ +/** + * GET /.well-known/atproto-client-metadata.json + * + * Serves the OAuth client metadata document required by the AT Protocol OAuth spec. + * The user's PDS fetches this URL during authorization to verify the client. + */ + +import type { APIRoute } from "astro"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const baseUrl = new URL(request.url).origin; + + // Build metadata statically — no keyset or OAuthClient needed. + // This must be fast because PDS authorization servers fetch it + // during PAR with short timeouts (~1-2s). + const metadata = { + client_id: `${baseUrl}/.well-known/atproto-client-metadata.json`, + redirect_uris: [`${baseUrl}/_emdash/api/auth/atproto/callback`], + scope: "atproto transition:generic", + application_type: "web", + subject_type: "public", + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + token_endpoint_auth_method: "none", + dpop_bound_access_tokens: true, + }; + + return new Response(JSON.stringify(metadata), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*", + }, + }); +}; diff --git a/packages/plugins/atproto/src/routes/login.ts b/packages/plugins/atproto/src/routes/login.ts new file mode 100644 index 000000000..e702f628e --- /dev/null +++ b/packages/plugins/atproto/src/routes/login.ts @@ -0,0 +1,41 @@ +/** + * POST /_emdash/api/auth/atproto/login + * + * Initiates the AT Protocol OAuth flow by generating an authorization URL. + * The client should redirect the browser to the returned URL. + */ + +import type { APIRoute } from "astro"; + +export const prerender = false; + +import type { ActorIdentifier } from "@atcute/lexicons"; +import { apiError, apiSuccess, handleError, isParseError, parseBody } from "emdash/api/route-utils"; +import { atprotoLoginBody } from "emdash/api/schemas"; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + try { + const body = await parseBody(request, atprotoLoginBody); + if (isParseError(body)) return body; + + const url = new URL(request.url); + const baseUrl = url.origin; + + const { getAtprotoOAuthClient } = await import("@emdash-cms/plugin-atproto/oauth-client"); + const client = await getAtprotoOAuthClient(baseUrl, emdash.db); + + const { url: authUrl } = await client.authorize({ + target: { type: "account", identifier: body.handle as ActorIdentifier }, + }); + + return apiSuccess({ url: authUrl.toString() }); + } catch (error) { + return handleError(error, "Failed to start AT Protocol login", "ATPROTO_LOGIN_ERROR"); + } +}; diff --git a/packages/plugins/atproto/src/routes/setup-admin.ts b/packages/plugins/atproto/src/routes/setup-admin.ts new file mode 100644 index 000000000..190f823fb --- /dev/null +++ b/packages/plugins/atproto/src/routes/setup-admin.ts @@ -0,0 +1,70 @@ +/** + * POST /_emdash/api/setup/atproto-admin + * + * Step 2 of setup for atproto auth: initiate OAuth flow with user's PDS. + * Returns the authorization URL for the client to redirect to. + * + * The actual admin creation happens in the OAuth callback + * (routes/callback.ts) when the PDS redirects back. + */ + +import type { APIRoute } from "astro"; + +export const prerender = false; + +import type { ActorIdentifier } from "@atcute/lexicons"; +import { + apiError, + apiSuccess, + handleError, + isParseError, + OptionsRepository, + parseBody, +} from "emdash/api/route-utils"; +import { setupAtprotoAdminBody } from "emdash/api/schemas"; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + try { + // Check if setup is already complete + const options = new OptionsRepository(emdash.db); + const setupComplete = await options.get("emdash:setup_complete"); + + if (setupComplete === true || setupComplete === "true") { + return apiError("SETUP_COMPLETE", "Setup already complete", 400); + } + + // Parse request body + const body = await parseBody(request, setupAtprotoAdminBody); + if (isParseError(body)) return body; + + // Merge into existing setup state (preserves title/tagline from step 1) + const existing = (await options.get>("emdash:setup_state")) ?? {}; + await options.set("emdash:setup_state", { + ...existing, + step: "atproto_admin", + handle: body.handle, + }); + + // Get OAuth client and generate authorization URL + const url = new URL(request.url); + const baseUrl = url.origin; + const { getAtprotoOAuthClient } = await import("@emdash-cms/plugin-atproto/oauth-client"); + const client = await getAtprotoOAuthClient(baseUrl, emdash.db); + + const { url: authUrl } = await client.authorize({ + target: { type: "account", identifier: body.handle as ActorIdentifier }, + }); + + return apiSuccess({ + url: authUrl.toString(), + }); + } catch (error) { + return handleError(error, "Failed to start AT Protocol setup", "SETUP_ATPROTO_ERROR"); + } +}; diff --git a/packages/plugins/atproto/tests/auth.test.ts b/packages/plugins/atproto/tests/auth.test.ts new file mode 100644 index 000000000..b8986035a --- /dev/null +++ b/packages/plugins/atproto/tests/auth.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; + +import { atproto, type AtprotoAuthConfig } from "../src/auth.js"; + +const PLUGIN_ROUTES_RE = /^@emdash-cms\/plugin-atproto\/routes\//; + +describe("atproto auth config", () => { + describe("AuthProviderDescriptor contract", () => { + it("returns id 'atproto'", () => { + const descriptor = atproto(); + expect(descriptor.id).toBe("atproto"); + }); + + it("has label 'AT Protocol'", () => { + const descriptor = atproto(); + expect(descriptor.label).toBe("AT Protocol"); + }); + + it("points adminEntry to the admin module", () => { + const descriptor = atproto(); + expect(descriptor.adminEntry).toBe("@emdash-cms/plugin-atproto/admin"); + }); + + it("defaults config to empty object when no options provided", () => { + const descriptor = atproto(); + expect(descriptor.config).toEqual({}); + }); + + it("defaults config to empty object when undefined is passed", () => { + const descriptor = atproto(undefined); + expect(descriptor.config).toEqual({}); + }); + + it("declares routes pointing to plugin package", () => { + const descriptor = atproto(); + expect(descriptor.routes).toBeDefined(); + expect(descriptor.routes!.length).toBe(4); + for (const route of descriptor.routes!) { + expect(route.entrypoint).toMatch(PLUGIN_ROUTES_RE); + } + }); + + it("declares publicRoutes with specific paths", () => { + const descriptor = atproto(); + expect(descriptor.publicRoutes).toBeDefined(); + expect(descriptor.publicRoutes).toContain("/_emdash/api/auth/atproto/"); + // Should not have overly broad prefixes + expect(descriptor.publicRoutes).not.toContain("/_emdash/.well-known/"); + }); + }); + + describe("config passthrough", () => { + it("passes allowedDIDs through", () => { + const config: AtprotoAuthConfig = { + allowedDIDs: ["did:plc:abc123", "did:web:example.com"], + }; + const descriptor = atproto(config); + const result = descriptor.config as AtprotoAuthConfig; + expect(result.allowedDIDs).toEqual(["did:plc:abc123", "did:web:example.com"]); + }); + + it("passes defaultRole through", () => { + const descriptor = atproto({ defaultRole: 20 }); + const result = descriptor.config as AtprotoAuthConfig; + expect(result.defaultRole).toBe(20); + }); + + it("passes allowedHandles through", () => { + const config: AtprotoAuthConfig = { + allowedHandles: ["*.example.com", "alice.bsky.social"], + }; + const descriptor = atproto(config); + const result = descriptor.config as AtprotoAuthConfig; + expect(result.allowedHandles).toEqual(["*.example.com", "alice.bsky.social"]); + }); + + it("passes full config through unchanged", () => { + const config: AtprotoAuthConfig = { + allowedDIDs: ["did:plc:me123"], + allowedHandles: ["*.example.com"], + defaultRole: 40, + }; + const descriptor = atproto(config); + expect(descriptor.config).toEqual(config); + }); + + it("does not mutate the input config", () => { + const config: AtprotoAuthConfig = { + allowedDIDs: ["did:plc:alice123"], + allowedHandles: ["*.example.com"], + defaultRole: 30, + }; + const original = { + ...config, + allowedDIDs: [...config.allowedDIDs!], + allowedHandles: [...config.allowedHandles!], + }; + atproto(config); + expect(config).toEqual(original); + }); + }); + + describe("descriptor shape invariants", () => { + it("id is always a non-empty string", () => { + const descriptor = atproto(); + expect(typeof descriptor.id).toBe("string"); + expect(descriptor.id.length).toBeGreaterThan(0); + }); + + it("label is always a non-empty string", () => { + const descriptor = atproto(); + expect(typeof descriptor.label).toBe("string"); + expect(descriptor.label.length).toBeGreaterThan(0); + }); + + it("adminEntry is always a non-empty string", () => { + const descriptor = atproto(); + expect(typeof descriptor.adminEntry).toBe("string"); + expect(descriptor.adminEntry!.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/plugins/atproto/tests/oauth-client.test.ts b/packages/plugins/atproto/tests/oauth-client.test.ts new file mode 100644 index 000000000..5695068df --- /dev/null +++ b/packages/plugins/atproto/tests/oauth-client.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const LOCALHOST_RE = /^http:\/\/localhost/; + +// Reset the module singleton between tests by re-importing fresh copies +async function freshImport() { + // Clear the module cache so the singleton resets + vi.resetModules(); + return import("../src/oauth-client.js"); +} + +describe("getAtprotoOAuthClient (HTTPS - public client)", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns an OAuthClient instance", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("https://example.com"); + expect(client).toBeDefined(); + expect(client.metadata).toBeDefined(); + }); + + it("sets client_id to the well-known metadata URL", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("https://example.com"); + expect(client.metadata.client_id).toBe( + "https://example.com/.well-known/atproto-client-metadata.json", + ); + }); + + it("sets redirect_uri to the callback endpoint", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("https://example.com"); + expect(client.metadata.redirect_uris).toEqual([ + "https://example.com/_emdash/api/auth/atproto/callback", + ]); + }); + + it("does not set jwks_uri (public client)", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("https://example.com"); + expect(client.metadata.jwks_uri).toBeUndefined(); + }); + + it("requests atproto scope", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("https://example.com"); + expect(client.metadata.scope).toBe("atproto transition:generic"); + }); + + it("returns the same instance for the same baseUrl (singleton)", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client1 = await getAtprotoOAuthClient("https://example.com"); + const client2 = await getAtprotoOAuthClient("https://example.com"); + expect(client1).toBe(client2); + }); + + it("creates a new instance when baseUrl changes", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client1 = await getAtprotoOAuthClient("https://example.com"); + const client2 = await getAtprotoOAuthClient("https://other.com"); + expect(client1).not.toBe(client2); + expect(client2.metadata.client_id).toContain("other.com"); + }); +}); + +describe("getAtprotoOAuthClient (localhost - loopback public client)", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("creates a loopback client for http://localhost", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("http://localhost:4321"); + expect(client).toBeDefined(); + expect(client.metadata).toBeDefined(); + }); + + it("uses http://localhost client_id (loopback format)", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("http://localhost:4321"); + // Loopback clients have client_id starting with http://localhost + expect(client.metadata.client_id).toMatch(LOCALHOST_RE); + }); + + it("does not set jwks_uri for loopback clients", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("http://localhost:4321"); + expect(client.metadata.jwks_uri).toBeUndefined(); + }); + + it("also treats 127.0.0.1 as loopback", async () => { + const { getAtprotoOAuthClient } = await freshImport(); + const client = await getAtprotoOAuthClient("http://127.0.0.1:4321"); + expect(client.metadata.client_id).toMatch(LOCALHOST_RE); + }); +}); diff --git a/packages/plugins/atproto/tests/plugin.test.ts b/packages/plugins/atproto/tests/plugin.test.ts index ab2f93815..31057352a 100644 --- a/packages/plugins/atproto/tests/plugin.test.ts +++ b/packages/plugins/atproto/tests/plugin.test.ts @@ -1,82 +1,39 @@ import { describe, it, expect } from "vitest"; -import { atprotoPlugin, createPlugin } from "../src/index.js"; +import { atprotoPlugin } from "../src/index.js"; describe("atprotoPlugin descriptor", () => { it("returns a valid PluginDescriptor", () => { const descriptor = atprotoPlugin(); expect(descriptor.id).toBe("atproto"); expect(descriptor.version).toBe("0.1.0"); - expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-atproto"); + expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-atproto/sandbox"); expect(descriptor.adminPages).toHaveLength(1); expect(descriptor.adminWidgets).toHaveLength(1); }); - it("passes options through", () => { - const descriptor = atprotoPlugin({}); - expect(descriptor.options).toEqual({}); - }); -}); - -describe("createPlugin", () => { - it("returns a valid ResolvedPlugin", () => { - const plugin = createPlugin(); - expect(plugin.id).toBe("atproto"); - expect(plugin.version).toBe("0.1.0"); - expect(plugin.capabilities).toContain("read:content"); - expect(plugin.capabilities).toContain("network:fetch:any"); - }); - - it("uses unrestricted network access (implies network:fetch)", () => { - const plugin = createPlugin(); - expect(plugin.capabilities).toContain("network:fetch:any"); - // network:fetch:any implies network:fetch via definePlugin normalization - expect(plugin.capabilities).toContain("network:fetch"); - }); - - it("declares storage with records collection", () => { - const plugin = createPlugin(); - expect(plugin.storage).toHaveProperty("records"); - expect(plugin.storage!.records!.indexes).toContain("contentId"); - expect(plugin.storage!.records!.indexes).toContain("status"); - }); - - it("has content:afterSave hook with errorPolicy continue", () => { - const plugin = createPlugin(); - const hook = plugin.hooks!["content:afterSave"]; - expect(hook).toBeDefined(); - // Hook is configured with full config object - expect((hook as { errorPolicy: string }).errorPolicy).toBe("continue"); - }); - - it("has content:afterDelete hook", () => { - const plugin = createPlugin(); - expect(plugin.hooks!["content:afterDelete"]).toBeDefined(); + it("uses standard format", () => { + const descriptor = atprotoPlugin(); + expect(descriptor.format).toBe("standard"); }); - it("has page:metadata hook", () => { - const plugin = createPlugin(); - expect(plugin.hooks!["page:metadata"]).toBeDefined(); + it("declares required capabilities", () => { + const descriptor = atprotoPlugin(); + expect(descriptor.capabilities).toContain("read:content"); + expect(descriptor.capabilities).toContain("network:fetch:any"); }); - it("has settings schema with required fields", () => { - const plugin = createPlugin(); - const schema = plugin.admin!.settingsSchema!; - expect(schema).toHaveProperty("handle"); - expect(schema).toHaveProperty("appPassword"); - expect(schema).toHaveProperty("siteUrl"); - expect(schema).toHaveProperty("enableBskyCrosspost"); - expect(schema).toHaveProperty("crosspostTemplate"); - expect(schema).toHaveProperty("langs"); - expect(schema.appPassword!.type).toBe("secret"); + it("declares storage with publications collection", () => { + const descriptor = atprotoPlugin(); + expect(descriptor.storage).toHaveProperty("publications"); + expect(descriptor.storage!.publications!.indexes).toContain("contentId"); + expect(descriptor.storage!.publications!.indexes).toContain("platform"); + expect(descriptor.storage!.publications!.indexes).toContain("publishedAt"); }); - it("has routes for status, test-connection, sync-publication", () => { - const plugin = createPlugin(); - expect(plugin.routes).toHaveProperty("status"); - expect(plugin.routes).toHaveProperty("test-connection"); - expect(plugin.routes).toHaveProperty("sync-publication"); - expect(plugin.routes).toHaveProperty("recent-syncs"); - expect(plugin.routes).toHaveProperty("verification"); + it("has admin pages and widgets", () => { + const descriptor = atprotoPlugin(); + expect(descriptor.adminPages![0]!.label).toBe("AT Protocol"); + expect(descriptor.adminWidgets![0]!.title).toBe("AT Protocol"); }); }); diff --git a/packages/plugins/atproto/tests/resolve-handle.test.ts b/packages/plugins/atproto/tests/resolve-handle.test.ts new file mode 100644 index 000000000..41196a955 --- /dev/null +++ b/packages/plugins/atproto/tests/resolve-handle.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { verifyHandleDID } from "../src/resolve-handle.js"; + +describe("verifyHandleDID", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns null for handles without a dot", async () => { + expect(await verifyHandleDID("localhost")).toBeNull(); + expect(await verifyHandleDID("")).toBeNull(); + }); + + it("returns null when resolution fails", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("network error")); + expect(await verifyHandleDID("nobody.example.com")).toBeNull(); + }); + + it("returns null when HTTP returns non-ok", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("", { status: 404 })); + expect(await verifyHandleDID("nobody.example.com")).toBeNull(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f182f3bd0..8e10041d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,19 +26,19 @@ catalogs: version: 4.20260305.1 '@lingui/babel-plugin-lingui-macro': specifier: ^5.9.4 - version: 5.9.4 + version: 5.9.5 '@lingui/cli': specifier: ^5.9.4 - version: 5.9.4 + version: 5.9.5 '@lingui/core': specifier: ^5.9.4 - version: 5.9.4 + version: 5.9.5 '@lingui/macro': specifier: ^5.9.4 - version: 5.9.4 + version: 5.9.5 '@lingui/react': specifier: ^5.9.4 - version: 5.9.4 + version: 5.9.5 '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10 @@ -235,6 +235,40 @@ importers: specifier: 'catalog:' version: 4.80.0(@cloudflare/workers-types@4.20260305.1) + demos/my-site: + dependencies: + '@astrojs/cloudflare': + specifier: 'catalog:' + version: 13.1.7(@types/node@24.10.13)(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))(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1))(yaml@2.8.2) + '@astrojs/react': + specifier: 'catalog:' + version: 5.0.0(@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) + '@emdash-cms/cloudflare': + specifier: workspace:* + version: link:../../packages/cloudflare + astro: + specifier: 'catalog:' + 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@6.0.0-beta)(yaml@2.8.2) + emdash: + specifier: workspace:* + version: link:../../packages/core + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + devDependencies: + '@astrojs/check': + specifier: 'catalog:' + version: 0.9.7(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@6.0.0-beta) + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260305.1 + wrangler: + specifier: 'catalog:' + version: 4.80.0(@cloudflare/workers-types@4.20260305.1) + demos/playground: dependencies: '@astrojs/cloudflare': @@ -400,6 +434,9 @@ importers: '@astrojs/react': specifier: 'catalog:' version: 5.0.0(@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) + '@emdash-cms/plugin-atproto': + specifier: workspace:* + version: link:../../packages/plugins/atproto '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log @@ -505,10 +542,10 @@ importers: version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@lingui/core': specifier: 'catalog:' - version: 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3)) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3)) '@lingui/react': specifier: 'catalog:' - version: 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3))(react@19.2.4) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3))(react@19.2.4) '@phosphor-icons/react': specifier: 'catalog:' version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -596,13 +633,13 @@ importers: version: 7.29.0 '@lingui/babel-plugin-lingui-macro': specifier: 'catalog:' - version: 5.9.4(typescript@5.9.3) + version: 5.9.5(typescript@5.9.3) '@lingui/cli': specifier: 'catalog:' - version: 5.9.4(typescript@5.9.3) + version: 5.9.5(typescript@5.9.3) '@lingui/macro': specifier: 'catalog:' - version: 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3))(react@19.2.4) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3))(react@19.2.4) '@tailwindcss/cli': specifier: ^4.1.10 version: 4.1.18 @@ -850,6 +887,9 @@ importers: '@emdash-cms/gutenberg-to-portable-text': specifier: workspace:* version: link:../gutenberg-to-portable-text + '@emdash-cms/plugin-atproto': + specifier: workspace:* + version: link:../plugins/atproto '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -906,10 +946,10 @@ importers: version: 3.7.0 astro: specifier: '>=6.0.0-beta.0' - 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) + 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) astro-portabletext: specifier: ^0.11.0 - 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)) + 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)) better-sqlite3: specifier: 'catalog:' version: 11.10.0 @@ -1138,16 +1178,34 @@ importers: packages/plugins/atproto: dependencies: + '@atcute/identity-resolver': + specifier: ^1.2.2 + version: 1.2.2(@atcute/identity@1.1.4) + '@atcute/oauth-node-client': + specifier: ^1.1.0 + version: 1.1.0 + '@emdash-cms/auth': + specifier: workspace:* + version: link:../../auth + astro: + specifier: '>=5' + 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) emdash: specifier: workspace:* version: link:../../core + kysely: + specifier: ^0.27.6 + version: 0.27.6 + react: + specifier: '>=18' + version: 19.2.4 devDependencies: - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 + '@atcute/lexicons': + specifier: ^1.2.10 + version: 1.2.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 vitest: specifier: 'catalog:' version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1751,6 +1809,44 @@ packages: '@astrojs/yaml2ts@0.2.2': resolution: {integrity: sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==} + '@atcute/client@4.2.1': + resolution: {integrity: sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==} + + '@atcute/identity-resolver@1.2.2': + resolution: {integrity: sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw==} + peerDependencies: + '@atcute/identity': ^1.0.0 + + '@atcute/identity@1.1.4': + resolution: {integrity: sha512-RCw1IqflfuSYCxK5m0lZCm0UnvIzcUnuhngiBhJEJb9a9Mc2SEf1xP3H8N5r8pvEH1LoAYd6/zrvCNU+uy9esw==} + + '@atcute/lexicons@1.2.10': + resolution: {integrity: sha512-0EfRDQQjOgb06VSFOUWXLnqKY11ljWB2bXS3cJVPYJp0jTWudgRp6OTW4vReNAeVZaY4kVr2ud/I/Zn9mjix3g==} + + '@atcute/multibase@1.2.0': + resolution: {integrity: sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ==} + + '@atcute/oauth-crypto@0.1.0': + resolution: {integrity: sha512-qZYDCNLF/4B6AndYT1rsQelN8621AC5u/sL5PHvlr/qqAbmmUwCBGjEgRSyZtHE1AqD60VNiSMlOgAuEQTSl3w==} + + '@atcute/oauth-keyset@0.1.0': + resolution: {integrity: sha512-+wqT/+I5Lg9VzKnKY3g88+N45xbq+wsdT6bHDGqCVa2u57gRvolFF4dY+weMfc/OX641BIZO6/o+zFtKBsMQnQ==} + + '@atcute/oauth-node-client@1.1.0': + resolution: {integrity: sha512-xCp/VfjtvTeKscKR/oI2hdMTp1/DaF/7ll8b6yZOCgbKlVDDfhCn5mmKNVARGTNaoywxrXG3XffbWCIx3/E87w==} + + '@atcute/oauth-types@0.1.1': + resolution: {integrity: sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg==} + + '@atcute/uint8array@1.1.1': + resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} + + '@atcute/util-fetch@1.0.5': + resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==} + + '@atcute/util-text@1.2.0': + resolution: {integrity: sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ==} + '@atproto/api@0.13.35': resolution: {integrity: sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==} @@ -1888,6 +1984,10 @@ packages: resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==} engines: {node: ^20.19.0 || >=22.12.0} + '@badrap/valita@0.4.6': + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} + engines: {node: '>= 18'} + '@base-ui/react@1.2.0': resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==} engines: {node: '>=14.0.0'} @@ -2796,12 +2896,12 @@ packages: cpu: [x64] os: [win32] - '@lingui/babel-plugin-extract-messages@5.9.4': - resolution: {integrity: sha512-sFH5lufIBCOLwjM2hyByMIi7gaGjAPhU7md8XMQYgcEjUVtzjBQvZ9APGDdDQ5BB8xRDyqF2kvaJpJvWZu19zA==} + '@lingui/babel-plugin-extract-messages@5.9.5': + resolution: {integrity: sha512-XOAXMPOkpy45784q5bCNN5PizoAecxkBm8kv8CEusI/f9kR3vMCcpH4kvSchU05JkKAVE8eIsdxb2zM6eDJTeA==} engines: {node: '>=20.0.0'} - '@lingui/babel-plugin-lingui-macro@5.9.4': - resolution: {integrity: sha512-Gj+H48MQWY6rV40TBVG7U91/KETznbXOJpJsf8U4merBRPZgOMCy6VuWZGy1i+YJZJF/LiberlsCCEiiPbBRqg==} + '@lingui/babel-plugin-lingui-macro@5.9.5': + resolution: {integrity: sha512-TDIrOa2hAz8kXrZ0JfMGaIiFIE4TEdqI2he4OpkTSCfBh3ec/gSCn1kNW5HdviO7x46Gvy567YOgHNOI9/e4Fg==} engines: {node: '>=20.0.0'} peerDependencies: babel-plugin-macros: 2 || 3 @@ -2809,20 +2909,20 @@ packages: babel-plugin-macros: optional: true - '@lingui/cli@5.9.4': - resolution: {integrity: sha512-0QAsZCWu6PZhxYmeQfoa6cJbNRRsTkeNQ1jTow/GzBYpFlO9iXw8dCG5cBh5pHHjzjoX3koxiKyUTFyLBmKNiQ==} + '@lingui/cli@5.9.5': + resolution: {integrity: sha512-gonY7U75nzKic8GvEciy1/otQv1WpfwGW5wGMjmBXUMaMnIsycm/wo3t0+2hzqFp+RNfEKZcScoM7aViK3XuLQ==} engines: {node: '>=20.0.0'} hasBin: true - '@lingui/conf@5.9.4': - resolution: {integrity: sha512-crF3AQgYXg52Caz4ffJKSTXWUU/4iOGOBRnSeOkw8lsOtOYlPTaWxeSGyDTEwaGCFl6P/1aew+pvHOSCxOAyrg==} + '@lingui/conf@5.9.5': + resolution: {integrity: sha512-k5r9ssOZirhS5BlqdsK5L0rzlqnHeryoJHAQIpUpeh8g5ymgpbUN7L4+4C4hAX/tddAFiCFN8boHTiu6Wbt83Q==} engines: {node: '>=20.0.0'} - '@lingui/core@5.9.4': - resolution: {integrity: sha512-MsYYc8ue/w1C8bgAbC3h4cNik64bqZ6xGxMjsVdoGQBUe+b/ij+rOEiuJXbwvlo4GXBsvsan7EzeH7sx11IsYQ==} + '@lingui/core@5.9.5': + resolution: {integrity: sha512-Y+iZq9NqnqZOqHNgPomUFP21KH/zs4oTTizWoz0AKAkBbq9T9yb1DSz/ugtBRjF1YLtKMF9tq28v3thMHANSiQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.4 + '@lingui/babel-plugin-lingui-macro': 5.9.5 babel-plugin-macros: 2 || 3 peerDependenciesMeta: '@lingui/babel-plugin-lingui-macro': @@ -2830,15 +2930,15 @@ packages: babel-plugin-macros: optional: true - '@lingui/format-po@5.9.4': - resolution: {integrity: sha512-B+e8YF6S5EOUPF6i3gaSX69pPs/QkP6MIE97vYA48W9Lty7KFOHuYBk/YzCY9CSQaF7gW3GAI5ZsXX2+ZLVyZw==} + '@lingui/format-po@5.9.5': + resolution: {integrity: sha512-abawxkaEMhAUCqxrnim2NTTeu2gd55X9tkFN8jfRM0B1LE2KjZLWCA8gSD90J/DblDwej8jK8A2BynXlcQdluQ==} engines: {node: '>=20.0.0'} - '@lingui/macro@5.9.4': - resolution: {integrity: sha512-p1/uPc8sQTMLdv0EJqjaFUvuFKBiwNVThdJp80GX7FayPzF570EO6wsS8U81g1p8NoCS/UY6cglK9YJeNVwKLw==} + '@lingui/macro@5.9.5': + resolution: {integrity: sha512-WZsF93jKwk0IW4xmvAGd+6S3dT+9ZzhXAasKIiJL9qB4viO9oj+oecGhXuPYTYV74mcoL7L1494hca0CsB/BLQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.4 + '@lingui/babel-plugin-lingui-macro': 5.9.5 babel-plugin-macros: 2 || 3 peerDependenciesMeta: '@lingui/babel-plugin-lingui-macro': @@ -2846,15 +2946,15 @@ packages: babel-plugin-macros: optional: true - '@lingui/message-utils@5.9.4': - resolution: {integrity: sha512-YzAVzILdsqdUqwHmryl7rfwZXRHYs6QY2wPLH5gxrV7wlieiCaskaKPeSk2SuN/gmC8im1GDrQHcwgKapFU3Sg==} + '@lingui/message-utils@5.9.5': + resolution: {integrity: sha512-t3dNbjb1dWkvcpXGMXIEyBDO3l4B8J2ColZXi0NTG1ioAj+sDfFxFB8fepVgd3JAk+AwARlOLvF14oS0mAdgpw==} engines: {node: '>=20.0.0'} - '@lingui/react@5.9.4': - resolution: {integrity: sha512-ev/PvJd0WNy6OqeyghQV1QCGAFYku5xHyaattN2kg0wy6RPVfGsCaM8treRUK9TLiETra79GLVY8sTjfeH/M5Q==} + '@lingui/react@5.9.5': + resolution: {integrity: sha512-jzYoA/f4jrTfpOB+jrMhlC835UwqSXJdepr7cfWsmg+Rpp3HBSREtfrogaz1LqLI/AVnkmfp10Mo6VOp/8qeOQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.4 + '@lingui/babel-plugin-lingui-macro': 5.9.5 babel-plugin-macros: 2 || 3 react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: @@ -5735,6 +5835,9 @@ packages: eslint-plugin-depend@1.4.0: resolution: {integrity: sha512-MQs+m4nHSfgAO9bJDsBzqw0ofK/AOA0vfeY/6ahofqcUMLeM6/D1sTYs21fOhc17kNU/gn58YCtj20XaAssh2A==} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esm@3.2.25: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} @@ -6912,6 +7015,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -9214,6 +9322,75 @@ snapshots: dependencies: yaml: 2.8.2 + '@atcute/client@4.2.1': + dependencies: + '@atcute/identity': 1.1.4 + '@atcute/lexicons': 1.2.10 + + '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4)': + dependencies: + '@atcute/identity': 1.1.4 + '@atcute/lexicons': 1.2.10 + '@atcute/util-fetch': 1.0.5 + '@badrap/valita': 0.4.6 + + '@atcute/identity@1.1.4': + dependencies: + '@atcute/lexicons': 1.2.10 + '@badrap/valita': 0.4.6 + + '@atcute/lexicons@1.2.10': + dependencies: + '@atcute/uint8array': 1.1.1 + '@atcute/util-text': 1.2.0 + '@standard-schema/spec': 1.1.0 + esm-env: 1.2.2 + + '@atcute/multibase@1.2.0': + dependencies: + '@atcute/uint8array': 1.1.1 + + '@atcute/oauth-crypto@0.1.0': + dependencies: + '@atcute/multibase': 1.2.0 + '@atcute/uint8array': 1.1.1 + '@badrap/valita': 0.4.6 + nanoid: 5.1.7 + + '@atcute/oauth-keyset@0.1.0': + dependencies: + '@atcute/oauth-crypto': 0.1.0 + + '@atcute/oauth-node-client@1.1.0': + dependencies: + '@atcute/client': 4.2.1 + '@atcute/identity': 1.1.4 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) + '@atcute/lexicons': 1.2.10 + '@atcute/oauth-crypto': 0.1.0 + '@atcute/oauth-keyset': 0.1.0 + '@atcute/oauth-types': 0.1.1 + '@atcute/util-fetch': 1.0.5 + '@badrap/valita': 0.4.6 + nanoid: 5.1.7 + + '@atcute/oauth-types@0.1.1': + dependencies: + '@atcute/identity': 1.1.4 + '@atcute/lexicons': 1.2.10 + '@atcute/oauth-keyset': 0.1.0 + '@badrap/valita': 0.4.6 + + '@atcute/uint8array@1.1.1': {} + + '@atcute/util-fetch@1.0.5': + dependencies: + '@badrap/valita': 0.4.6 + + '@atcute/util-text@1.2.0': + dependencies: + unicode-segmenter: 0.14.5 + '@atproto/api@0.13.35': dependencies: '@atproto/common-web': 0.4.12 @@ -9402,6 +9579,8 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.1 + '@badrap/valita@0.4.6': {} + '@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@babel/runtime': 7.28.6 @@ -10237,33 +10416,33 @@ snapshots: '@libsql/win32-x64-msvc@0.3.19': optional: true - '@lingui/babel-plugin-extract-messages@5.9.4': {} + '@lingui/babel-plugin-extract-messages@5.9.5': {} - '@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3)': + '@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3)': dependencies: '@babel/core': 7.29.0 '@babel/runtime': 7.28.6 '@babel/types': 7.29.0 - '@lingui/conf': 5.9.4(typescript@5.9.3) - '@lingui/core': 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3)) - '@lingui/message-utils': 5.9.4 + '@lingui/conf': 5.9.5(typescript@5.9.3) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3)) + '@lingui/message-utils': 5.9.5 transitivePeerDependencies: - supports-color - typescript - '@lingui/cli@5.9.4(typescript@5.9.3)': + '@lingui/cli@5.9.5(typescript@5.9.3)': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 '@babel/runtime': 7.28.6 '@babel/types': 7.29.0 - '@lingui/babel-plugin-extract-messages': 5.9.4 - '@lingui/babel-plugin-lingui-macro': 5.9.4(typescript@5.9.3) - '@lingui/conf': 5.9.4(typescript@5.9.3) - '@lingui/core': 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3)) - '@lingui/format-po': 5.9.4(typescript@5.9.3) - '@lingui/message-utils': 5.9.4 + '@lingui/babel-plugin-extract-messages': 5.9.5 + '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@5.9.3) + '@lingui/conf': 5.9.5(typescript@5.9.3) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3)) + '@lingui/format-po': 5.9.5(typescript@5.9.3) + '@lingui/message-utils': 5.9.5 chokidar: 3.5.1 cli-table: 0.3.11 commander: 10.0.1 @@ -10285,7 +10464,7 @@ snapshots: - supports-color - typescript - '@lingui/conf@5.9.4(typescript@5.9.3)': + '@lingui/conf@5.9.5(typescript@5.9.3)': dependencies: '@babel/runtime': 7.28.6 cosmiconfig: 8.3.6(typescript@5.9.3) @@ -10295,43 +10474,43 @@ snapshots: transitivePeerDependencies: - typescript - '@lingui/core@5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3))': + '@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3))': dependencies: '@babel/runtime': 7.28.6 - '@lingui/message-utils': 5.9.4 + '@lingui/message-utils': 5.9.5 optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.4(typescript@5.9.3) + '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@5.9.3) - '@lingui/format-po@5.9.4(typescript@5.9.3)': + '@lingui/format-po@5.9.5(typescript@5.9.3)': dependencies: - '@lingui/conf': 5.9.4(typescript@5.9.3) - '@lingui/message-utils': 5.9.4 + '@lingui/conf': 5.9.5(typescript@5.9.3) + '@lingui/message-utils': 5.9.5 date-fns: 3.6.0 pofile: 1.1.4 transitivePeerDependencies: - typescript - '@lingui/macro@5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3))(react@19.2.4)': + '@lingui/macro@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3))(react@19.2.4)': dependencies: - '@lingui/core': 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3)) - '@lingui/react': 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3))(react@19.2.4) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3)) + '@lingui/react': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3))(react@19.2.4) optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.4(typescript@5.9.3) + '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@5.9.3) transitivePeerDependencies: - react - '@lingui/message-utils@5.9.4': + '@lingui/message-utils@5.9.5': dependencies: '@messageformat/parser': 5.1.1 js-sha256: 0.10.1 - '@lingui/react@5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3))(react@19.2.4)': + '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3))(react@19.2.4)': dependencies: '@babel/runtime': 7.28.6 - '@lingui/core': 5.9.4(@lingui/babel-plugin-lingui-macro@5.9.4(typescript@5.9.3)) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@5.9.3)) react: 19.2.4 optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.4(typescript@5.9.3) + '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@5.9.3) '@loaderkit/resolve@1.0.2': dependencies: @@ -12563,11 +12742,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.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@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)): dependencies: '@portabletext/toolkit': 3.0.3 '@portabletext/types': 2.0.15 - 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.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.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: @@ -13512,6 +13691,8 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.4 + esm-env@1.2.2: {} + esm@3.2.25: optional: true @@ -15051,6 +15232,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.7: {} + napi-build-utils@2.0.0: {} negotiator@1.0.0: {} @@ -16458,7 +16641,7 @@ snapshots: rolldown: 1.0.0-rc.3 rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 - tinyexec: 1.0.4 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2 From 18f2f208b3545379dfdbed3205b0a877a0487c3d Mon Sep 17 00:00:00 2001 From: simnaut <110426028+simnaut@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:08:39 -0700 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?TOCTOU=20race,=20ensureTable=20memoization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace countUsers() with setup_complete option flag check in both GitHub/Google and ATProto OAuth callbacks to prevent concurrent callbacks from both claiming first-user admin role - Memoize ensureTable() in db-store.ts with a module-level boolean so CREATE TABLE IF NOT EXISTS only runs once per process --- .../routes/api/auth/oauth/[provider]/callback.ts | 15 ++++++++++----- packages/plugins/atproto/src/db-store.ts | 4 ++++ packages/plugins/atproto/src/routes/callback.ts | 14 +++++++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index 42310af5b..31eb87495 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -19,6 +19,7 @@ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import { finalizeSetup } from "#api/setup-complete.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; +import { OptionsRepository } from "../../../../../../database/repositories/options.js"; type ProviderName = "github" | "google"; @@ -133,9 +134,12 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect baseUrl: `${url.origin}/_emdash`, providers, canSelfSignup: async (email: string) => { - // During setup: first user becomes admin - const userCount = await adapter.countUsers(); - if (userCount === 0) { + // During setup: first user becomes admin. + // Check setup_complete flag instead of countUsers() to avoid + // a TOCTOU race where concurrent callbacks both see 0 users. + const options = new OptionsRepository(emdash.db); + const setupComplete = await options.get("emdash:setup_complete"); + if (setupComplete !== true && setupComplete !== "true") { return { allowed: true, role: Role.ADMIN }; } @@ -177,9 +181,10 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect }, }; - const userCountBefore = await adapter.countUsers(); + const options = new OptionsRepository(emdash.db); + const setupCompleteBefore = await options.get("emdash:setup_complete"); const user = await handleOAuthCallback(config, adapter, provider, code, state, stateStore); - const isFirstUser = userCountBefore === 0; + const isFirstUser = setupCompleteBefore !== true && setupCompleteBefore !== "true"; // Finalize setup outside the transaction (idempotent, safe if two callbacks race). if (isFirstUser) { diff --git a/packages/plugins/atproto/src/db-store.ts b/packages/plugins/atproto/src/db-store.ts index 23a014d4e..f581f26f8 100644 --- a/packages/plugins/atproto/src/db-store.ts +++ b/packages/plugins/atproto/src/db-store.ts @@ -22,7 +22,10 @@ interface AtprotoStoreDb { _emdash_atproto_store: AtprotoStoreTable; } +let _tableCreated = false; + async function ensureTable(db: Kysely): Promise { + if (_tableCreated) return; await sql`CREATE TABLE IF NOT EXISTS _emdash_atproto_store ( namespace TEXT NOT NULL, key TEXT NOT NULL, @@ -30,6 +33,7 @@ async function ensureTable(db: Kysely): Promise { expires_at INTEGER, PRIMARY KEY (namespace, key) )`.execute(db); + _tableCreated = true; } /** diff --git a/packages/plugins/atproto/src/routes/callback.ts b/packages/plugins/atproto/src/routes/callback.ts index 4c909b40c..29eb6ad85 100644 --- a/packages/plugins/atproto/src/routes/callback.ts +++ b/packages/plugins/atproto/src/routes/callback.ts @@ -120,15 +120,18 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => { ); } - // Build synthetic email — AT Protocol doesn't guarantee email access. - // For the first user, read the real email from the setup wizard state. + // Check setup_complete as the authoritative first-user gate. + // Using an option flag instead of countUsers() avoids a TOCTOU race + // where two concurrent callbacks both see 0 users and both create admins. const adapter = createKyselyAdapter(emdash.db); - const userCount = await adapter.countUsers(); - const isFirstUser = userCount === 0; + const options = new OptionsRepository(emdash.db); + const setupComplete = await options.get("emdash:setup_complete"); + const isFirstUser = setupComplete !== true && setupComplete !== "true"; + // Build synthetic email — AT Protocol doesn't guarantee email access. + // For the first user, read the real email from the setup wizard state. let email: string; if (isFirstUser) { - const options = new OptionsRepository(emdash.db); const setupState = await options.get>("emdash:setup_state"); email = (setupState?.email as string) || `${did.replaceAll(":", "-")}@atproto.invalid`; } else { @@ -152,6 +155,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => { }); if (isFirstUser) { + // finalizeSetup is idempotent — safe if two callbacks race past the check await finalizeSetup(emdash.db); console.log(`[atproto-auth] Setup complete: created admin user via atproto (${did})`); } From 6664e0e187c6f791df9db5c53a757de0787f108e Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Thu, 9 Apr 2026 03:08:58 +0000 Subject: [PATCH 3/7] style: format --- .../core/src/astro/routes/api/auth/oauth/[provider]/callback.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index 31eb87495..9bbfba9de 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -19,6 +19,7 @@ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import { finalizeSetup } from "#api/setup-complete.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; + import { OptionsRepository } from "../../../../../../database/repositories/options.js"; type ProviderName = "github" | "google"; From 5f8b24e7d02ecfd956a96f270d5162e62aaf0520 Mon Sep 17 00:00:00 2001 From: simnaut <110426028+simnaut@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:28:35 -0700 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?split=20auth-atproto=20package,=20add=20provider=20storage,=20u?= =?UTF-8?q?pdate=20terminology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all 10 review comments on PR #398: - Split auth provider into @emdash-cms/auth-atproto (npm-installable), keep syndication plugin in @emdash-cms/plugin-atproto (marketplace) - Add `storage` field to AuthProviderDescriptor, reuse plugin storage infrastructure instead of manual SQL table creation - Rename "AT Protocol" → "Atmosphere", remove "PDS" from user-facing strings - Forbid self-signup when no allowlists configured (except first admin) - Fix core callback.ts import to use #db alias - Fix env.d.ts to reference emdash/locals package --- .changeset/stale-knives-fix.md | 2 +- demos/simple/astro.config.mjs | 2 +- demos/simple/package.json | 1 + packages/auth-atproto/package.json | 52 ++++++++++ .../atproto => auth-atproto}/src/admin.tsx | 10 +- .../atproto => auth-atproto}/src/auth.ts | 16 +-- packages/auth-atproto/src/db-store.ts | 67 +++++++++++++ packages/auth-atproto/src/env.d.ts | 1 + .../src/oauth-client.ts | 64 +++++++----- .../src/resolve-handle.ts | 0 .../src/routes/callback.ts | 15 ++- .../src/routes/client-metadata.ts | 0 .../src/routes/login.ts | 6 +- .../src/routes/setup-admin.ts | 6 +- packages/auth-atproto/src/storage.ts | 35 +++++++ .../tests/auth.test.ts | 15 ++- .../tests/oauth-client.test.ts | 0 .../tests/resolve-handle.test.ts | 0 packages/auth-atproto/tsconfig.json | 9 ++ packages/core/package.json | 4 +- packages/core/src/api/auth-storage.ts | 37 +++++++ packages/core/src/api/route-utils.ts | 1 + .../core/src/astro/integration/runtime.ts | 2 +- .../api/auth/oauth/[provider]/callback.ts | 3 +- packages/core/src/auth/types.ts | 14 ++- packages/plugins/atproto/package.json | 21 +--- packages/plugins/atproto/src/db-store.ts | 99 ------------------- packages/plugins/atproto/src/env.d.ts | 1 - pnpm-lock.yaml | 70 ++++++++----- 29 files changed, 356 insertions(+), 197 deletions(-) create mode 100644 packages/auth-atproto/package.json rename packages/{plugins/atproto => auth-atproto}/src/admin.tsx (96%) rename packages/{plugins/atproto => auth-atproto}/src/auth.ts (86%) create mode 100644 packages/auth-atproto/src/db-store.ts create mode 100644 packages/auth-atproto/src/env.d.ts rename packages/{plugins/atproto => auth-atproto}/src/oauth-client.ts (77%) rename packages/{plugins/atproto => auth-atproto}/src/resolve-handle.ts (100%) rename packages/{plugins/atproto => auth-atproto}/src/routes/callback.ts (91%) rename packages/{plugins/atproto => auth-atproto}/src/routes/client-metadata.ts (100%) rename packages/{plugins/atproto => auth-atproto}/src/routes/login.ts (78%) rename packages/{plugins/atproto => auth-atproto}/src/routes/setup-admin.ts (86%) create mode 100644 packages/auth-atproto/src/storage.ts rename packages/{plugins/atproto => auth-atproto}/tests/auth.test.ts (87%) rename packages/{plugins/atproto => auth-atproto}/tests/oauth-client.test.ts (100%) rename packages/{plugins/atproto => auth-atproto}/tests/resolve-handle.test.ts (100%) create mode 100644 packages/auth-atproto/tsconfig.json create mode 100644 packages/core/src/api/auth-storage.ts delete mode 100644 packages/plugins/atproto/src/db-store.ts delete mode 100644 packages/plugins/atproto/src/env.d.ts diff --git a/.changeset/stale-knives-fix.md b/.changeset/stale-knives-fix.md index ee387f3a3..e31500182 100644 --- a/.changeset/stale-knives-fix.md +++ b/.changeset/stale-knives-fix.md @@ -1,7 +1,7 @@ --- "emdash": minor "@emdash-cms/admin": minor -"@emdash-cms/plugin-atproto": minor +"@emdash-cms/auth-atproto": minor "@emdash-cms/auth": patch --- diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index 94a4eb524..fdae13eb7 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -1,6 +1,6 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; -import { atproto } from "@emdash-cms/plugin-atproto/auth"; +import { atproto } from "@emdash-cms/auth-atproto"; import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; import { defineConfig } from "astro/config"; import emdash, { local } from "emdash/astro"; diff --git a/demos/simple/package.json b/demos/simple/package.json index b2a287c96..45561e8b9 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -18,6 +18,7 @@ "dependencies": { "@astrojs/node": "catalog:", "@astrojs/react": "catalog:", + "@emdash-cms/auth-atproto": "workspace:*", "@emdash-cms/plugin-atproto": "workspace:*", "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-color": "workspace:*", diff --git a/packages/auth-atproto/package.json b/packages/auth-atproto/package.json new file mode 100644 index 000000000..9f8207214 --- /dev/null +++ b/packages/auth-atproto/package.json @@ -0,0 +1,52 @@ +{ + "name": "@emdash-cms/auth-atproto", + "version": "0.1.0", + "description": "AT Protocol / Atmosphere authentication provider for EmDash CMS", + "type": "module", + "main": "src/auth.ts", + "exports": { + ".": "./src/auth.ts", + "./admin": "./src/admin.tsx", + "./oauth-client": "./src/oauth-client.ts", + "./resolve-handle": "./src/resolve-handle.ts", + "./routes/*": "./src/routes/*" + }, + "files": [ + "src" + ], + "keywords": [ + "emdash", + "cms", + "auth", + "atproto", + "bluesky", + "atmosphere" + ], + "author": "Matt Kane", + "license": "MIT", + "peerDependencies": { + "astro": ">=5", + "emdash": "workspace:*", + "react": ">=18" + }, + "devDependencies": { + "@atcute/lexicons": "^1.2.10", + "@types/react": "^19.0.0", + "vitest": "catalog:" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsgo --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/auth-atproto" + }, + "dependencies": { + "@atcute/identity-resolver": "^1.2.2", + "@atcute/oauth-node-client": "^1.1.0", + "@emdash-cms/auth": "workspace:*", + "kysely": "^0.27.6" + } +} diff --git a/packages/plugins/atproto/src/admin.tsx b/packages/auth-atproto/src/admin.tsx similarity index 96% rename from packages/plugins/atproto/src/admin.tsx rename to packages/auth-atproto/src/admin.tsx index aefc949f3..5a39ec855 100644 --- a/packages/plugins/atproto/src/admin.tsx +++ b/packages/auth-atproto/src/admin.tsx @@ -30,7 +30,7 @@ export function LoginButton() { className="w-full inline-flex items-center justify-center gap-2 rounded-md border border-kumo-tint bg-kumo-base px-4 py-2 text-sm font-medium text-kumo-default hover:bg-kumo-tint" > - AT Protocol + Atmosphere ); } @@ -81,7 +81,7 @@ export function LoginForm() { htmlFor="atproto-handle" className="block text-sm font-medium text-kumo-default mb-1" > - AT Protocol Handle + Atmosphere Handle - {isLoading ? "Connecting..." : "Sign in with PDS"} + {isLoading ? "Connecting..." : "Sign in"} ); @@ -156,7 +156,7 @@ export function SetupStep({ onComplete }: { onComplete: () => void }) {

AT Protocol

-

Sign in with your PDS handle

+

Sign in with your Bluesky/Atmosphere handle

@@ -179,7 +179,7 @@ export function SetupStep({ onComplete }: { onComplete: () => void }) { disabled={isLoading || !handle.trim()} className="w-full justify-center rounded-md border border-kumo-tint bg-kumo-base px-4 py-2 text-sm font-medium text-kumo-default hover:bg-kumo-tint disabled:opacity-50 disabled:cursor-not-allowed" > - {isLoading ? "Connecting..." : "Sign in with PDS"} + {isLoading ? "Connecting..." : "Sign in"} ); diff --git a/packages/plugins/atproto/src/auth.ts b/packages/auth-atproto/src/auth.ts similarity index 86% rename from packages/plugins/atproto/src/auth.ts rename to packages/auth-atproto/src/auth.ts index 395a7c0aa..639d1728a 100644 --- a/packages/plugins/atproto/src/auth.ts +++ b/packages/auth-atproto/src/auth.ts @@ -7,7 +7,7 @@ * * @example * ```ts - * import { atproto } from "@emdash-cms/plugin-atproto/auth"; + * import { atproto } from "@emdash-cms/auth-atproto"; * * export default defineConfig({ * integrations: [ @@ -74,27 +74,31 @@ export function atproto(config?: AtprotoAuthConfig): AuthProviderDescriptor { id: "atproto", label: "AT Protocol", config: config ?? {}, - adminEntry: "@emdash-cms/plugin-atproto/admin", + adminEntry: "@emdash-cms/auth-atproto/admin", routes: [ { pattern: "/_emdash/api/auth/atproto/login", - entrypoint: "@emdash-cms/plugin-atproto/routes/login.ts", + entrypoint: "@emdash-cms/auth-atproto/routes/login.ts", }, { pattern: "/_emdash/api/auth/atproto/callback", - entrypoint: "@emdash-cms/plugin-atproto/routes/callback.ts", + entrypoint: "@emdash-cms/auth-atproto/routes/callback.ts", }, { pattern: "/_emdash/api/setup/atproto-admin", - entrypoint: "@emdash-cms/plugin-atproto/routes/setup-admin.ts", + entrypoint: "@emdash-cms/auth-atproto/routes/setup-admin.ts", }, { // Served at root /.well-known/ (not /_emdash/) so PDS authorization // servers can fetch them quickly without hitting the EmDash middleware chain. pattern: "/.well-known/atproto-client-metadata.json", - entrypoint: "@emdash-cms/plugin-atproto/routes/client-metadata.ts", + entrypoint: "@emdash-cms/auth-atproto/routes/client-metadata.ts", }, ], publicRoutes: ["/_emdash/api/auth/atproto/"], + storage: { + states: { indexes: [] }, + sessions: { indexes: [] }, + }, }; } diff --git a/packages/auth-atproto/src/db-store.ts b/packages/auth-atproto/src/db-store.ts new file mode 100644 index 000000000..706abbf77 --- /dev/null +++ b/packages/auth-atproto/src/db-store.ts @@ -0,0 +1,67 @@ +/** + * Database-backed store for AT Protocol OAuth state and sessions. + * + * Wraps EmDash's plugin storage infrastructure to implement the `Store` + * interface required by @atcute/oauth-node-client. Data is stored in the + * shared `_plugin_storage` table under the `auth:atproto` namespace. + * + * Each store instance maps to a storage collection (e.g., "states" or + * "sessions") and handles JSON serialization and TTL expiry checks. + */ + +import type { Store } from "@atcute/oauth-node-client"; + +interface StorageCollection { + get(id: string): Promise; + put(id: string, data: T): Promise; + delete(id: string): Promise; + deleteMany(ids: string[]): Promise; + query(options?: { limit?: number }): Promise<{ items: Array<{ id: string; data: T }> }>; +} + +interface StoredEntry { + value: V; + expiresAt: number | null; +} + +/** + * Create a Store backed by a StorageCollection. + * + * @param getCollection - Function returning the StorageCollection instance. + * Using a getter because on Cloudflare Workers the db + * binding (and thus the collection) changes per request. + */ +export function createDbStore( + getCollection: () => StorageCollection>, +): Store { + return { + async get(key: K): Promise { + const entry = await getCollection().get(key); + if (!entry) return undefined; + + // Check TTL + if (entry.expiresAt && Date.now() > entry.expiresAt * 1000) { + await getCollection().delete(key); + return undefined; + } + return entry.value; + }, + + async set(key: K, value: V): Promise { + const expiresAt = (value as { expiresAt?: number }).expiresAt ?? null; + await getCollection().put(key, { value, expiresAt }); + }, + + async delete(key: K): Promise { + await getCollection().delete(key); + }, + + async clear(): Promise { + // Query all items and delete them in batch + const result = await getCollection().query({ limit: 10000 }); + if (result.items.length > 0) { + await getCollection().deleteMany(result.items.map((i) => i.id)); + } + }, + }; +} diff --git a/packages/auth-atproto/src/env.d.ts b/packages/auth-atproto/src/env.d.ts new file mode 100644 index 000000000..87c21e991 --- /dev/null +++ b/packages/auth-atproto/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/plugins/atproto/src/oauth-client.ts b/packages/auth-atproto/src/oauth-client.ts similarity index 77% rename from packages/plugins/atproto/src/oauth-client.ts rename to packages/auth-atproto/src/oauth-client.ts index d0e4e47b5..8e89a519d 100644 --- a/packages/plugins/atproto/src/oauth-client.ts +++ b/packages/auth-atproto/src/oauth-client.ts @@ -38,19 +38,28 @@ import { type StoredSession, type StoredState, } from "@atcute/oauth-node-client"; -import type { Kysely } from "kysely"; import { createDbStore } from "./db-store.js"; type Did = `did:${string}:${string}`; +interface StorageCollectionLike { + get(id: string): Promise; + put(id: string, data: T): Promise; + delete(id: string): Promise; + deleteMany(ids: string[]): Promise; + query(options?: { limit?: number }): Promise<{ items: Array<{ id: string; data: T }> }>; +} + +type AuthProviderStorageMap = Record; + // Singleton OAuthClient instance (lazily created). -// On Workers, the db binding changes per request, so we store a mutable +// On Workers, the storage binding changes per request, so we store a mutable // reference that DB-backed stores read via a getter. let _client: OAuthClient | null = null; let _clientBaseUrl: string | null = null; -let _currentDb: Kysely | null = null; -let _clientHasDb = false; +let _currentStorage: AuthProviderStorageMap | null = null; +let _clientHasStorage = false; function isLoopback(url: string): boolean { try { @@ -74,13 +83,13 @@ function isLoopback(url: string): boolean { * - Production (HTTPS): PDS fetches the client metadata document to verify * the client. No JWKS or key management needed. * - * @param db - Database instance for persistent OAuth state/session storage. - * Required for multi-instance deployments (e.g., Workers). - * Pass `null` to use in-memory storage (dev only). + * @param baseUrl - The site's public URL. + * @param storage - Auth provider storage collections from `getAuthProviderStorage()`. + * Pass `null` to use in-memory storage (dev only). */ export async function getAtprotoOAuthClient( baseUrl: string, - db?: Kysely | null, + storage?: AuthProviderStorageMap | null, ): Promise { // Normalize localhost ↔ 127.0.0.1 so the singleton survives the OAuth // round-trip (authorize uses localhost, callback arrives on 127.0.0.1). @@ -88,14 +97,14 @@ export async function getAtprotoOAuthClient( baseUrl = baseUrl.replace("://localhost", "://127.0.0.1"); } - // Update the mutable db reference so cached DB-backed stores use + // Update the mutable storage reference so cached DB-backed stores use // the current request's binding (critical on Workers). - if (db) _currentDb = db; + if (storage) _currentStorage = storage; // Return cached client if baseUrl matches and store backend hasn't upgraded. - // If the cached client uses MemoryStore but a db is now available, recreate + // If the cached client uses MemoryStore but storage is now available, recreate // with DB-backed stores so state survives across Workers requests. - if (_client && _clientBaseUrl === baseUrl && (!db || _clientHasDb)) { + if (_client && _clientBaseUrl === baseUrl && (!storage || _clientHasStorage)) { return _client; } @@ -114,15 +123,26 @@ export async function getAtprotoOAuthClient( }), }); - // Use database-backed stores when a db is provided (required for - // multi-instance deployments like Cloudflare Workers where in-memory - // state doesn't survive across requests). Fall back to MemoryStore - // for local dev where the singleton process persists. - const getDb = () => _currentDb!; - const stores = db + // Use plugin storage when available (required for multi-instance deployments + // like Cloudflare Workers where in-memory state doesn't survive across + // requests). Fall back to MemoryStore for local dev where the singleton + // process persists. + const stores = storage ? { - sessions: createDbStore(getDb, "sessions"), - states: createDbStore(getDb, "states"), + sessions: createDbStore( + () => + _currentStorage!.sessions as StorageCollectionLike<{ + value: StoredSession; + expiresAt: number | null; + }>, + ), + states: createDbStore( + () => + _currentStorage!.states as StorageCollectionLike<{ + value: StoredState; + expiresAt: number | null; + }>, + ), } : { sessions: new MemoryStore(), @@ -135,7 +155,7 @@ export async function getAtprotoOAuthClient( // Loopback public client for local development. // AT Protocol spec allows loopback IPs with public clients. // No client metadata endpoints needed — the PDS derives - // metadata from the client_id URL parameters. + // metadata from the client_id URL parameters per RFC 8252. // baseUrl is already normalized to 127.0.0.1 above (RFC 8252). client = new OAuthClient({ metadata: { @@ -162,7 +182,7 @@ export async function getAtprotoOAuthClient( _client = client; _clientBaseUrl = baseUrl; - _clientHasDb = !!db; + _clientHasStorage = !!storage; return client; } diff --git a/packages/plugins/atproto/src/resolve-handle.ts b/packages/auth-atproto/src/resolve-handle.ts similarity index 100% rename from packages/plugins/atproto/src/resolve-handle.ts rename to packages/auth-atproto/src/resolve-handle.ts diff --git a/packages/plugins/atproto/src/routes/callback.ts b/packages/auth-atproto/src/routes/callback.ts similarity index 91% rename from packages/plugins/atproto/src/routes/callback.ts rename to packages/auth-atproto/src/routes/callback.ts index 29eb6ad85..a247ba631 100644 --- a/packages/plugins/atproto/src/routes/callback.ts +++ b/packages/auth-atproto/src/routes/callback.ts @@ -53,8 +53,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => { // Exchange code for session via atcute const { getAtprotoOAuthClient, resolveAtprotoProfile } = - await import("@emdash-cms/plugin-atproto/oauth-client"); - const client = await getAtprotoOAuthClient(baseUrl, emdash.db); + await import("@emdash-cms/auth-atproto/oauth-client"); + const { getAtprotoStorage } = await import("../storage.js"); + const storage = await getAtprotoStorage(emdash as Parameters[0]); + const client = await getAtprotoOAuthClient(baseUrl, storage); const { session: atprotoSession } = await client.callback(url.searchParams); const did = atprotoSession.did; @@ -84,7 +86,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => { if (!didAllowed && hasAllowedHandles) { // Independently verify the handle→DID binding before trusting it. // A malicious PDS could claim any handle — we verify via DNS/HTTP. - const { verifyHandleDID } = await import("@emdash-cms/plugin-atproto/resolve-handle"); + const { verifyHandleDID } = await import("@emdash-cms/auth-atproto/resolve-handle"); const verifiedDid = await verifyHandleDID(handle); if (verifiedDid === did) { @@ -146,11 +148,16 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => { emailVerified: isFirstUser, }; - // Use shared find-or-create with canSelfSignup policy + // Use shared find-or-create with canSelfSignup policy. + // When no allowlists are configured, forbid self-signup — only the + // initial admin (first user during setup) is allowed through. const user = await findOrCreateOAuthUser(adapter, "atproto", profile, async () => { if (isFirstUser) { return { allowed: true, role: Role.ADMIN }; } + if (!hasAllowedDIDs && !hasAllowedHandles) { + return null; + } return { allowed: true, role: defaultRole }; }); diff --git a/packages/plugins/atproto/src/routes/client-metadata.ts b/packages/auth-atproto/src/routes/client-metadata.ts similarity index 100% rename from packages/plugins/atproto/src/routes/client-metadata.ts rename to packages/auth-atproto/src/routes/client-metadata.ts diff --git a/packages/plugins/atproto/src/routes/login.ts b/packages/auth-atproto/src/routes/login.ts similarity index 78% rename from packages/plugins/atproto/src/routes/login.ts rename to packages/auth-atproto/src/routes/login.ts index e702f628e..32354d8a1 100644 --- a/packages/plugins/atproto/src/routes/login.ts +++ b/packages/auth-atproto/src/routes/login.ts @@ -27,8 +27,10 @@ export const POST: APIRoute = async ({ request, locals }) => { const url = new URL(request.url); const baseUrl = url.origin; - const { getAtprotoOAuthClient } = await import("@emdash-cms/plugin-atproto/oauth-client"); - const client = await getAtprotoOAuthClient(baseUrl, emdash.db); + const { getAtprotoOAuthClient } = await import("@emdash-cms/auth-atproto/oauth-client"); + const { getAtprotoStorage } = await import("../storage.js"); + const storage = await getAtprotoStorage(emdash as Parameters[0]); + const client = await getAtprotoOAuthClient(baseUrl, storage); const { url: authUrl } = await client.authorize({ target: { type: "account", identifier: body.handle as ActorIdentifier }, diff --git a/packages/plugins/atproto/src/routes/setup-admin.ts b/packages/auth-atproto/src/routes/setup-admin.ts similarity index 86% rename from packages/plugins/atproto/src/routes/setup-admin.ts rename to packages/auth-atproto/src/routes/setup-admin.ts index 190f823fb..15c30e699 100644 --- a/packages/plugins/atproto/src/routes/setup-admin.ts +++ b/packages/auth-atproto/src/routes/setup-admin.ts @@ -54,8 +54,10 @@ export const POST: APIRoute = async ({ request, locals }) => { // Get OAuth client and generate authorization URL const url = new URL(request.url); const baseUrl = url.origin; - const { getAtprotoOAuthClient } = await import("@emdash-cms/plugin-atproto/oauth-client"); - const client = await getAtprotoOAuthClient(baseUrl, emdash.db); + const { getAtprotoOAuthClient } = await import("@emdash-cms/auth-atproto/oauth-client"); + const { getAtprotoStorage } = await import("../storage.js"); + const storage = await getAtprotoStorage(emdash as Parameters[0]); + const client = await getAtprotoOAuthClient(baseUrl, storage); const { url: authUrl } = await client.authorize({ target: { type: "account", identifier: body.handle as ActorIdentifier }, diff --git a/packages/auth-atproto/src/storage.ts b/packages/auth-atproto/src/storage.ts new file mode 100644 index 000000000..dd8e98fb1 --- /dev/null +++ b/packages/auth-atproto/src/storage.ts @@ -0,0 +1,35 @@ +/** + * Auth provider storage accessor. + * + * Resolves the atproto auth provider's storage collections from the + * EmDash runtime config. Used by route handlers to get storage for + * the OAuth client. + */ + +import type { Kysely } from "kysely"; + +interface AuthProviderDescriptorLike { + id: string; + storage?: Record }>; +} + +interface EmdashLocals { + db: Kysely; + config: { authProviders?: AuthProviderDescriptorLike[] }; +} + +/** + * Get the auth provider storage collections for the atproto provider. + * Returns null if the provider has no storage declared or is not found. + */ +export async function getAtprotoStorage(emdash: EmdashLocals) { + const { getAuthProviderStorage } = await import("emdash/api/route-utils"); + const provider = emdash.config.authProviders?.find((p) => p.id === "atproto"); + if (!provider?.storage) return null; + + return getAuthProviderStorage( + emdash.db as Parameters[0], + "atproto", + provider.storage as Parameters[2], + ); +} diff --git a/packages/plugins/atproto/tests/auth.test.ts b/packages/auth-atproto/tests/auth.test.ts similarity index 87% rename from packages/plugins/atproto/tests/auth.test.ts rename to packages/auth-atproto/tests/auth.test.ts index b8986035a..4eafa7605 100644 --- a/packages/plugins/atproto/tests/auth.test.ts +++ b/packages/auth-atproto/tests/auth.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { atproto, type AtprotoAuthConfig } from "../src/auth.js"; -const PLUGIN_ROUTES_RE = /^@emdash-cms\/plugin-atproto\/routes\//; +const AUTH_ROUTES_RE = /^@emdash-cms\/auth-atproto\/routes\//; describe("atproto auth config", () => { describe("AuthProviderDescriptor contract", () => { @@ -18,7 +18,7 @@ describe("atproto auth config", () => { it("points adminEntry to the admin module", () => { const descriptor = atproto(); - expect(descriptor.adminEntry).toBe("@emdash-cms/plugin-atproto/admin"); + expect(descriptor.adminEntry).toBe("@emdash-cms/auth-atproto/admin"); }); it("defaults config to empty object when no options provided", () => { @@ -31,15 +31,22 @@ describe("atproto auth config", () => { expect(descriptor.config).toEqual({}); }); - it("declares routes pointing to plugin package", () => { + it("declares routes pointing to auth package", () => { const descriptor = atproto(); expect(descriptor.routes).toBeDefined(); expect(descriptor.routes!.length).toBe(4); for (const route of descriptor.routes!) { - expect(route.entrypoint).toMatch(PLUGIN_ROUTES_RE); + expect(route.entrypoint).toMatch(AUTH_ROUTES_RE); } }); + it("declares storage collections for OAuth state", () => { + const descriptor = atproto(); + expect(descriptor.storage).toBeDefined(); + expect(descriptor.storage).toHaveProperty("states"); + expect(descriptor.storage).toHaveProperty("sessions"); + }); + it("declares publicRoutes with specific paths", () => { const descriptor = atproto(); expect(descriptor.publicRoutes).toBeDefined(); diff --git a/packages/plugins/atproto/tests/oauth-client.test.ts b/packages/auth-atproto/tests/oauth-client.test.ts similarity index 100% rename from packages/plugins/atproto/tests/oauth-client.test.ts rename to packages/auth-atproto/tests/oauth-client.test.ts diff --git a/packages/plugins/atproto/tests/resolve-handle.test.ts b/packages/auth-atproto/tests/resolve-handle.test.ts similarity index 100% rename from packages/plugins/atproto/tests/resolve-handle.test.ts rename to packages/auth-atproto/tests/resolve-handle.test.ts diff --git a/packages/auth-atproto/tsconfig.json b/packages/auth-atproto/tsconfig.json new file mode 100644 index 000000000..7af7ebfcc --- /dev/null +++ b/packages/auth-atproto/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../plugins/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 036eaf130..dc6906fc6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -204,7 +204,7 @@ }, "peerDependencies": { "@astrojs/react": ">=5.0.0-beta.0", - "@emdash-cms/plugin-atproto": "workspace:*", + "@emdash-cms/auth-atproto": "workspace:*", "@tanstack/react-query": ">=5.0.0", "@tanstack/react-router": ">=1.100.0", "astro": ">=6.0.0-beta.0", @@ -212,7 +212,7 @@ "react-dom": ">=18.0.0" }, "peerDependenciesMeta": { - "@emdash-cms/plugin-atproto": { + "@emdash-cms/auth-atproto": { "optional": true } }, diff --git a/packages/core/src/api/auth-storage.ts b/packages/core/src/api/auth-storage.ts new file mode 100644 index 000000000..c145a8556 --- /dev/null +++ b/packages/core/src/api/auth-storage.ts @@ -0,0 +1,37 @@ +/** + * Auth provider storage helper. + * + * Gives auth provider routes access to plugin-style storage collections + * namespaced under `auth:`. Reuses the existing `_plugin_storage` + * table and `PluginStorageRepository` infrastructure. + */ + +import type { Kysely } from "kysely"; + +import type { Database } from "../database/types.js"; +import { createStorageAccess } from "../plugins/context.js"; +import type { StorageCollection, StorageCollectionConfig } from "../plugins/types.js"; + +/** + * Get storage collections for an auth provider. + * + * Returns a record of `StorageCollection` instances, one per declared + * collection in the provider's `storage` config. Data is stored in the + * shared `_plugin_storage` table under the namespace `auth:`. + * + * @example + * ```ts + * const storage = getAuthProviderStorage(emdash.db, "atproto", { + * states: { indexes: [] }, + * sessions: { indexes: [] }, + * }); + * const session = await storage.sessions.get(sessionId); + * ``` + */ +export function getAuthProviderStorage( + db: Kysely, + providerId: string, + storageConfig: Record, +): Record { + return createStorageAccess(db, `auth:${providerId}`, storageConfig); +} diff --git a/packages/core/src/api/route-utils.ts b/packages/core/src/api/route-utils.ts index 711c02e4f..89038a398 100644 --- a/packages/core/src/api/route-utils.ts +++ b/packages/core/src/api/route-utils.ts @@ -10,3 +10,4 @@ export { parseBody, parseQuery, isParseError } from "./parse.js"; export type { ParseResult } from "./parse.js"; export { finalizeSetup } from "./setup-complete.js"; export { OptionsRepository } from "../database/repositories/options.js"; +export { getAuthProviderStorage } from "./auth-storage.js"; diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index cfa46ebb3..3cb36cff7 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -231,7 +231,7 @@ export interface EmDashConfig { * * @example * ```ts - * import { atproto } from "@emdash-cms/plugin-atproto/auth"; + * import { atproto } from "@emdash-cms/auth-atproto"; * * emdash({ * authProviders: [atproto()], diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index 9bbfba9de..2f9914f6e 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -19,8 +19,7 @@ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import { finalizeSetup } from "#api/setup-complete.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; - -import { OptionsRepository } from "../../../../../../database/repositories/options.js"; +import { OptionsRepository } from "#db/repositories/options.js"; type ProviderName = "github" | "google"; diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index 4be08cd96..521850144 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -84,7 +84,7 @@ export interface AuthProviderModule { * @example * ```ts * // astro.config.ts - * import { atproto } from "@emdash-cms/plugin-atproto/auth"; + * import { atproto } from "@emdash-cms/auth-atproto"; * * emdash({ * authProviders: [atproto(), github(), google()], @@ -121,6 +121,18 @@ export interface AuthProviderDescriptor { * for unauthenticated users. */ publicRoutes?: string[]; + + /** + * Storage collections for persistent auth state (e.g., OAuth sessions). + * Same format as plugin storage — collections are stored in the shared + * `_plugin_storage` table namespaced under `auth:`. + * + * Access via `getAuthProviderStorage()` from `emdash/api/route-utils`. + */ + storage?: Record< + string, + { indexes?: Array; uniqueIndexes?: Array } + >; } /** diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index b18417cef..885198a46 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -6,12 +6,7 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./sandbox": "./src/sandbox-entry.ts", - "./auth": "./src/auth.ts", - "./admin": "./src/admin.tsx", - "./oauth-client": "./src/oauth-client.ts", - "./resolve-handle": "./src/resolve-handle.ts", - "./routes/*": "./src/routes/*" + "./sandbox": "./src/sandbox-entry.ts" }, "files": [ "src" @@ -29,13 +24,9 @@ "author": "Matt Kane", "license": "MIT", "peerDependencies": { - "astro": ">=5", - "emdash": "workspace:*", - "react": ">=18" + "emdash": "workspace:*" }, "devDependencies": { - "@atcute/lexicons": "^1.2.10", - "@types/react": "^19.0.0", "vitest": "catalog:" }, "scripts": { @@ -47,10 +38,6 @@ "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/plugins/atproto" }, - "dependencies": { - "@atcute/identity-resolver": "^1.2.2", - "@atcute/oauth-node-client": "^1.1.0", - "@emdash-cms/auth": "workspace:*", - "kysely": "^0.27.6" - } + "dependencies": {} + } diff --git a/packages/plugins/atproto/src/db-store.ts b/packages/plugins/atproto/src/db-store.ts deleted file mode 100644 index f581f26f8..000000000 --- a/packages/plugins/atproto/src/db-store.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Database-backed store for AT Protocol OAuth state and sessions. - * - * Replaces MemoryStore to support multi-instance deployments (e.g., Cloudflare - * Workers) where in-memory state is lost between requests. - * - * Uses a single `_emdash_atproto_store` table with (namespace, key) as the - * composite primary key. The table is auto-created on first access. - */ - -import type { Store } from "@atcute/oauth-node-client"; -import { sql, type Kysely } from "kysely"; - -interface AtprotoStoreTable { - namespace: string; - key: string; - value: string; - expires_at: number | null; -} - -interface AtprotoStoreDb { - _emdash_atproto_store: AtprotoStoreTable; -} - -let _tableCreated = false; - -async function ensureTable(db: Kysely): Promise { - if (_tableCreated) return; - await sql`CREATE TABLE IF NOT EXISTS _emdash_atproto_store ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - expires_at INTEGER, - PRIMARY KEY (namespace, key) - )`.execute(db); - _tableCreated = true; -} - -/** - * Create a database-backed Store for the atcute OAuth client. - * - * @param getDb - Function that returns the current Kysely instance. - * Using a getter instead of a direct reference because on - * Cloudflare Workers the db binding changes per request. - * @param namespace - Store namespace (e.g., "states" or "sessions") - */ -export function createDbStore( - getDb: () => Kysely, - namespace: string, -): Store { - return { - async get(key: K): Promise { - const db = getDb(); - await ensureTable(db); - const result = await sql<{ value: string; expires_at: number | null }>` - SELECT value, expires_at FROM _emdash_atproto_store - WHERE namespace = ${namespace} AND key = ${key} - `.execute(db); - const row = (result as { rows: { value: string; expires_at: number | null }[] }).rows[0]; - if (!row) return undefined; - // Check expiry - if (row.expires_at && Date.now() > row.expires_at * 1000) { - await sql`DELETE FROM _emdash_atproto_store - WHERE namespace = ${namespace} AND key = ${key}`.execute(db); - return undefined; - } - return JSON.parse(row.value) as V; - }, - - async set(key: K, value: V): Promise { - const db = getDb(); - await ensureTable(db); - const json = JSON.stringify(value); - // Extract expiresAt from StoredState if present - const expiresAt = (value as { expiresAt?: number }).expiresAt ?? null; - await (db as unknown as Kysely) - .insertInto("_emdash_atproto_store") - .values({ namespace, key, value: json, expires_at: expiresAt }) - .onConflict((oc) => - oc.columns(["namespace", "key"]).doUpdateSet({ value: json, expires_at: expiresAt }), - ) - .execute(); - }, - - async delete(key: K): Promise { - const db = getDb(); - await ensureTable(db); - await sql`DELETE FROM _emdash_atproto_store - WHERE namespace = ${namespace} AND key = ${key}`.execute(db); - }, - - async clear(): Promise { - const db = getDb(); - await ensureTable(db); - await sql`DELETE FROM _emdash_atproto_store - WHERE namespace = ${namespace}`.execute(db); - }, - }; -} diff --git a/packages/plugins/atproto/src/env.d.ts b/packages/plugins/atproto/src/env.d.ts deleted file mode 100644 index ae1bf769b..000000000 --- a/packages/plugins/atproto/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e10041d3..7d0f44cf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: '@astrojs/react': specifier: 'catalog:' version: 5.0.0(@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) + '@emdash-cms/auth-atproto': + specifier: workspace:* + version: link:../../packages/auth-atproto '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare @@ -434,6 +437,9 @@ importers: '@astrojs/react': specifier: 'catalog:' version: 5.0.0(@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) + '@emdash-cms/auth-atproto': + specifier: workspace:* + version: link:../../packages/auth-atproto '@emdash-cms/plugin-atproto': specifier: workspace:* version: link:../../packages/plugins/atproto @@ -735,6 +741,40 @@ importers: specifier: 'catalog:' version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + packages/auth-atproto: + dependencies: + '@atcute/identity-resolver': + specifier: ^1.2.2 + version: 1.2.2(@atcute/identity@1.1.4) + '@atcute/oauth-node-client': + specifier: ^1.1.0 + version: 1.1.0 + '@emdash-cms/auth': + specifier: workspace:* + version: link:../auth + astro: + specifier: '>=5' + 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) + emdash: + specifier: workspace:* + version: link:../core + kysely: + specifier: ^0.27.6 + version: 0.27.6 + react: + specifier: '>=18' + version: 19.2.4 + devDependencies: + '@atcute/lexicons': + specifier: ^1.2.10 + version: 1.2.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + packages/blocks: dependencies: '@cloudflare/kumo': @@ -884,12 +924,12 @@ importers: '@emdash-cms/auth': specifier: workspace:* version: link:../auth + '@emdash-cms/auth-atproto': + specifier: workspace:* + version: link:../auth-atproto '@emdash-cms/gutenberg-to-portable-text': specifier: workspace:* version: link:../gutenberg-to-portable-text - '@emdash-cms/plugin-atproto': - specifier: workspace:* - version: link:../plugins/atproto '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1178,34 +1218,10 @@ importers: packages/plugins/atproto: dependencies: - '@atcute/identity-resolver': - specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.4) - '@atcute/oauth-node-client': - specifier: ^1.1.0 - version: 1.1.0 - '@emdash-cms/auth': - specifier: workspace:* - version: link:../../auth - astro: - specifier: '>=5' - 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) emdash: specifier: workspace:* version: link:../../core - kysely: - specifier: ^0.27.6 - version: 0.27.6 - react: - specifier: '>=18' - version: 19.2.4 devDependencies: - '@atcute/lexicons': - specifier: ^1.2.10 - version: 1.2.10 - '@types/react': - specifier: ^19.0.0 - version: 19.2.14 vitest: specifier: 'catalog:' version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) From 73daa248edeead9d27eb5d2b2e5d1124af56c873 Mon Sep 17 00:00:00 2001 From: simnaut <110426028+simnaut@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:26:01 -0700 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20CI=20failures=20=E2=80=94=20typechec?= =?UTF-8?q?k,=20lint,=20bundle,=20and=20i18n=20catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix auth-atproto typecheck by aligning tsconfig with core's settings (noUncheckedIndexedAccess, lib targets) and adding explicit return type to storage.ts to avoid cross-package type portability issue - Add @types/react to core devDependencies for auth/types.ts React refs - Fix plugin bundle CLI: clean stale temp dirs before bundling, and expand TS_EXT_RE to strip .mjs/.js/.cjs extensions from dist entries - Fix floating promise lint error in InlinePortableTextEditor - Regenerate Lingui catalog to include interpolated "Sign in with {0}" --- packages/admin/src/locales/en/messages.po | 49 +++++++++++-------- packages/auth-atproto/src/storage.ts | 5 +- packages/auth-atproto/tsconfig.json | 4 +- packages/core/package.json | 1 + packages/core/src/cli/commands/bundle.ts | 4 +- .../components/InlinePortableTextEditor.tsx | 2 +- pnpm-lock.yaml | 3 ++ 7 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/admin/src/locales/en/messages.po b/packages/admin/src/locales/en/messages.po index fd4296104..2fae96f64 100644 --- a/packages/admin/src/locales/en/messages.po +++ b/packages/admin/src/locales/en/messages.po @@ -37,16 +37,16 @@ msgstr "Allow users from specific domains to sign up" msgid "API Tokens" msgstr "API Tokens" -#: packages/admin/src/components/LoginPage.tsx:253 +#: packages/admin/src/components/LoginPage.tsx:195 msgid "Authentication error: {error}" msgstr "Authentication error: {error}" -#: packages/admin/src/components/LoginPage.tsx:174 -#: packages/admin/src/components/LoginPage.tsx:210 +#: packages/admin/src/components/LoginPage.tsx:117 +#: packages/admin/src/components/LoginPage.tsx:153 msgid "Back to login" msgstr "Back to login" -#: packages/admin/src/components/LoginPage.tsx:158 +#: packages/admin/src/components/LoginPage.tsx:101 msgid "Check your email" msgstr "Check your email" @@ -54,7 +54,7 @@ msgstr "Check your email" msgid "Choose your preferred admin language" msgstr "Choose your preferred admin language" -#: packages/admin/src/components/LoginPage.tsx:169 +#: packages/admin/src/components/LoginPage.tsx:112 msgid "Click the link in the email to sign in." msgstr "Click the link in the email to sign in." @@ -62,7 +62,7 @@ msgstr "Click the link in the email to sign in." msgid "Create personal access tokens for programmatic API access" msgstr "Create personal access tokens for programmatic API access" -#: packages/admin/src/components/LoginPage.tsx:358 +#: packages/admin/src/components/LoginPage.tsx:332 msgid "Don't have an account? <0>Sign up" msgstr "Don't have an account? <0>Sign up" @@ -70,12 +70,16 @@ msgstr "Don't have an account? <0>Sign up" msgid "Email" msgstr "Email" -#: packages/admin/src/components/LoginPage.tsx:183 +#: packages/admin/src/components/LoginPage.tsx:126 msgid "Email address" msgstr "Email address" -#: packages/admin/src/components/LoginPage.tsx:127 -#: packages/admin/src/components/LoginPage.tsx:132 +#: packages/admin/src/components/LoginPage.tsx:325 +msgid "Enter your handle to sign in." +msgstr "Enter your handle to sign in." + +#: packages/admin/src/components/LoginPage.tsx:70 +#: packages/admin/src/components/LoginPage.tsx:75 msgid "Failed to send magic link" msgstr "Failed to send magic link" @@ -83,7 +87,7 @@ msgstr "Failed to send magic link" msgid "General" msgstr "General" -#: packages/admin/src/components/LoginPage.tsx:160 +#: packages/admin/src/components/LoginPage.tsx:103 msgid "If an account exists for <0>{email}, we've sent a sign-in link." msgstr "If an account exists for <0>{email}, we've sent a sign-in link." @@ -99,7 +103,7 @@ msgstr "Locale" msgid "Manage your passkeys and authentication" msgstr "Manage your passkeys and authentication" -#: packages/admin/src/components/LoginPage.tsx:313 +#: packages/admin/src/components/LoginPage.tsx:261 msgid "Or continue with" msgstr "Or continue with" @@ -115,11 +119,11 @@ msgstr "Security" msgid "Self-Signup Domains" msgstr "Self-Signup Domains" -#: packages/admin/src/components/LoginPage.tsx:206 +#: packages/admin/src/components/LoginPage.tsx:149 msgid "Send magic link" msgstr "Send magic link" -#: packages/admin/src/components/LoginPage.tsx:206 +#: packages/admin/src/components/LoginPage.tsx:149 msgid "Sending..." msgstr "Sending..." @@ -131,19 +135,24 @@ msgstr "SEO" msgid "Settings" msgstr "Settings" -#: packages/admin/src/components/LoginPage.tsx:283 +#: packages/admin/src/components/LoginPage.tsx:232 msgid "Sign in to your site" msgstr "Sign in to your site" -#: packages/admin/src/components/LoginPage.tsx:284 +#. placeholder {0}: authProviderList.find((p) => p.id === activeProvider)?.label ?? activeProvider +#: packages/admin/src/components/LoginPage.tsx:231 +msgid "Sign in with {0}" +msgstr "Sign in with {0}" + +#: packages/admin/src/components/LoginPage.tsx:229 msgid "Sign in with email" msgstr "Sign in with email" -#: packages/admin/src/components/LoginPage.tsx:340 +#: packages/admin/src/components/LoginPage.tsx:290 msgid "Sign in with email link" msgstr "Sign in with email link" -#: packages/admin/src/components/LoginPage.tsx:304 +#: packages/admin/src/components/LoginPage.tsx:252 msgid "Sign in with Passkey" msgstr "Sign in with Passkey" @@ -159,11 +168,11 @@ msgstr "Social Links" msgid "Social media profile links" msgstr "Social media profile links" -#: packages/admin/src/components/LoginPage.tsx:170 +#: packages/admin/src/components/LoginPage.tsx:113 msgid "The link will expire in 15 minutes." msgstr "The link will expire in 15 minutes." -#: packages/admin/src/components/LoginPage.tsx:351 +#: packages/admin/src/components/LoginPage.tsx:326 msgid "Use your registered passkey to sign in securely." msgstr "Use your registered passkey to sign in securely." @@ -171,6 +180,6 @@ msgstr "Use your registered passkey to sign in securely." msgid "View email provider status and send test emails" msgstr "View email provider status and send test emails" -#: packages/admin/src/components/LoginPage.tsx:352 +#: packages/admin/src/components/LoginPage.tsx:323 msgid "We'll send you a link to sign in without a password." msgstr "We'll send you a link to sign in without a password." diff --git a/packages/auth-atproto/src/storage.ts b/packages/auth-atproto/src/storage.ts index dd8e98fb1..aed8a01be 100644 --- a/packages/auth-atproto/src/storage.ts +++ b/packages/auth-atproto/src/storage.ts @@ -22,7 +22,10 @@ interface EmdashLocals { * Get the auth provider storage collections for the atproto provider. * Returns null if the provider has no storage declared or is not found. */ -export async function getAtprotoStorage(emdash: EmdashLocals) { +export async function getAtprotoStorage( + emdash: EmdashLocals, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { const { getAuthProviderStorage } = await import("emdash/api/route-utils"); const provider = emdash.config.authProviders?.find((p) => p.id === "atproto"); if (!provider?.storage) return null; diff --git a/packages/auth-atproto/tsconfig.json b/packages/auth-atproto/tsconfig.json index 7af7ebfcc..8e16995f0 100644 --- a/packages/auth-atproto/tsconfig.json +++ b/packages/auth-atproto/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../plugins/tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "noUncheckedIndexedAccess": false, + "lib": ["es2023", "DOM", "DOM.Iterable", "esnext.typedarrays"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/packages/core/package.json b/packages/core/package.json index dc6906fc6..4e1c06a02 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -221,6 +221,7 @@ "@arethetypeswrong/cli": "catalog:", "@emdash-cms/blocks": "workspace:*", "@types/better-sqlite3": "^7.6.12", + "@types/react": "catalog:", "@types/pg": "^8.16.0", "@types/sanitize-html": "^2.16.0", "@types/sax": "^1.2.7", diff --git a/packages/core/src/cli/commands/bundle.ts b/packages/core/src/cli/commands/bundle.ts index a250e5653..b1d6f88b9 100644 --- a/packages/core/src/cli/commands/bundle.ts +++ b/packages/core/src/cli/commands/bundle.ts @@ -38,7 +38,7 @@ import { ICON_SIZE, } from "./bundle-utils.js"; -const TS_EXT_RE = /\.tsx?$/; +const TS_EXT_RE = /\.(tsx?|[mc]?js)$/; const SLASH_RE = /\//g; const LEADING_AT_RE = /^@/; const emdash_SCOPE_RE = /^@emdash-cms\//; @@ -163,6 +163,8 @@ export const bundleCommand = defineCommand({ const tmpDir = join(pluginDir, ".emdash-bundle-tmp"); try { + // Clean up any stale temp directory from a previous failed run + await rm(tmpDir, { recursive: true, force: true }); await mkdir(tmpDir, { recursive: true }); // Build main entry to extract manifest. diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index 3db577b27..9056c42c2 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -1793,7 +1793,7 @@ export function InlinePortableTextEditor({ // Don't save if focus moved to the slash menu (portalled to body) if (related?.closest(".emdash-slash-menu")) return; if (related?.closest(".emdash-media-picker")) return; - save(); + void save(); }, [save, mediaPickerOpen], ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d0f44cf5..51325e8da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1063,6 +1063,9 @@ importers: '@types/pg': specifier: ^8.16.0 version: 8.16.0 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 '@types/sanitize-html': specifier: ^2.16.0 version: 2.16.0 From 7a663140866f4270c33c813c35adb26e3a0d21ee Mon Sep 17 00:00:00 2001 From: simnaut <110426028+simnaut@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:04:34 -0700 Subject: [PATCH 6/7] fix: update tests and plugin config for pluggable auth changes - Fix atproto plugin: add build step, tsdown dep, and point exports to dist files so plugin validation passes - Update LoginPage browser test to mock fetchAuthMode instead of the removed fetchManifest - Update SetupWizard browser test: step label changed from "Passkey" to "Sign In" - Update E2E tests: passkey step text changed from "Set up your passkey" to "Choose how to sign in" --- .../passkey-full-setup-virtual-auth.spec.ts | 4 ++-- e2e/tests/setup-wizard.spec.ts | 2 +- .../admin/tests/components/LoginPage.test.tsx | 16 ++++------------ .../admin/tests/components/SetupWizard.test.tsx | 2 +- packages/plugins/atproto/package.json | 12 +++++++++--- pnpm-lock.yaml | 7 +++++-- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/e2e/tests/passkey-full-setup-virtual-auth.spec.ts b/e2e/tests/passkey-full-setup-virtual-auth.spec.ts index 4a3e5534c..5423b6a2c 100644 --- a/e2e/tests/passkey-full-setup-virtual-auth.spec.ts +++ b/e2e/tests/passkey-full-setup-virtual-auth.spec.ts @@ -60,12 +60,12 @@ test.describe("Setup wizard passkey with virtual authenticator (localhost)", () await page.getByLabel("Your Name").fill("Virtual Auth User"); await page.getByRole("button", { name: "Continue" }).click(); - await expect(page.locator("text=Set up your passkey")).toBeVisible(); + await expect(page.locator("text=Choose how to sign in")).toBeVisible(); await page.getByRole("button", { name: "Create Passkey" }).click(); // admin-verify creates the user but does not set a session; wizard sends user to /_emdash/admin and auth redirects to login. await expect(page).toHaveURL(ADMIN_AFTER_SETUP_URL, { timeout: 60_000 }); - await expect(page.locator("text=Set up your passkey")).toHaveCount(0); + await expect(page.locator("text=Choose how to sign in")).toHaveCount(0); await expect(page.locator("text=Registration was cancelled or timed out")).toHaveCount(0); await expect(page.locator("text=Invalid origin")).toHaveCount(0); } finally { diff --git a/e2e/tests/setup-wizard.spec.ts b/e2e/tests/setup-wizard.spec.ts index e007a8bb6..733cb0c77 100644 --- a/e2e/tests/setup-wizard.spec.ts +++ b/e2e/tests/setup-wizard.spec.ts @@ -89,7 +89,7 @@ test.describe("Setup Wizard", () => { await admin.page.getByRole("button", { name: "Continue" }).click(); await expect(admin.page.locator("text=Secure your account")).toBeVisible(); - await expect(admin.page.locator("text=Set up your passkey")).toBeVisible(); + await expect(admin.page.locator("text=Choose how to sign in")).toBeVisible(); }); test("setup wizard not accessible after setup complete", async ({ admin }) => { diff --git a/packages/admin/tests/components/LoginPage.test.tsx b/packages/admin/tests/components/LoginPage.test.tsx index ee329c375..70f615c86 100644 --- a/packages/admin/tests/components/LoginPage.test.tsx +++ b/packages/admin/tests/components/LoginPage.test.tsx @@ -18,20 +18,16 @@ vi.mock("@tanstack/react-router", async () => { }; }); -// Mock API — keep a reference so tests can override fetchManifest -const mockFetchManifest = vi.fn().mockResolvedValue({ +// Mock API — keep a reference so tests can override fetchAuthMode +const mockFetchAuthMode = vi.fn().mockResolvedValue({ authMode: "passkey", - collections: {}, - plugins: {}, - version: "1", - hash: "", }); vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); return { ...actual, - fetchManifest: (...args: unknown[]) => mockFetchManifest(...args), + fetchAuthMode: (...args: unknown[]) => mockFetchAuthMode(...args), apiFetch: vi .fn() .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })), @@ -132,12 +128,8 @@ describe("LoginPage", () => { }); it("shows sign up link when signup is enabled", async () => { - mockFetchManifest.mockResolvedValueOnce({ + mockFetchAuthMode.mockResolvedValueOnce({ authMode: "passkey", - collections: {}, - plugins: {}, - version: "1", - hash: "", signupEnabled: true, }); diff --git a/packages/admin/tests/components/SetupWizard.test.tsx b/packages/admin/tests/components/SetupWizard.test.tsx index e4012d806..a7609fab2 100644 --- a/packages/admin/tests/components/SetupWizard.test.tsx +++ b/packages/admin/tests/components/SetupWizard.test.tsx @@ -133,6 +133,6 @@ describe("SetupWizard", () => { await expect.element(screen.getByText("Set up your site")).toBeInTheDocument(); // Step indicator labels - use exact matching via role await expect.element(screen.getByText("Account")).toBeInTheDocument(); - await expect.element(screen.getByText("Passkey")).toBeInTheDocument(); + await expect.element(screen.getByText("Sign In")).toBeInTheDocument(); }); }); diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index 885198a46..ea30b58e4 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -3,12 +3,16 @@ "version": "0.1.1", "description": "AT Protocol / standard.site syndication plugin for EmDash CMS", "type": "module", - "main": "src/index.ts", + "main": "dist/index.mjs", "exports": { - ".": "./src/index.ts", - "./sandbox": "./src/sandbox-entry.ts" + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/sandbox-entry.mjs" }, "files": [ + "dist", "src" ], "keywords": [ @@ -27,9 +31,11 @@ "emdash": "workspace:*" }, "devDependencies": { + "tsdown": "catalog:", "vitest": "catalog:" }, "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", "test": "vitest run", "typecheck": "tsgo --noEmit" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3cae7156..48e5b4868 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1237,6 +1237,9 @@ importers: specifier: workspace:* version: link:../../core devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) vitest: specifier: 'catalog:' version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) @@ -3004,7 +3007,7 @@ packages: resolution: {integrity: sha512-yTCCjuQapvRz6S30B8DyqHu1WYsbYRCww6uNsmbQU4GQVf5gJzJSB60qUHj+qBSxReLtRL/mhmhYhrIc9jVFTw==} '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc': - resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} + resolution: {integrity: sha512-k8sHBM7S10HBa39fxsJcOGYMGrbru5UZ9vMS4kmCa9o6dJTUP6rt3zKVEs7uEsHAYasoXyiC6wre2Jiqs3X+zQ==, tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} version: 0.1.1 engines: {node: '>=18.17.0'} @@ -16775,7 +16778,7 @@ snapshots: rolldown: 1.0.0-rc.3 rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2 From 6306fe6236485b7fd56d37ac613f8ecb2aef45b7 Mon Sep 17 00:00:00 2001 From: simnaut <110426028+simnaut@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:39:52 -0700 Subject: [PATCH 7/7] fix(i18n): wrap missing "Back to login" string with Lingui t tag --- packages/admin/src/components/LoginPage.tsx | 2 +- packages/admin/src/locales/de/messages.po | 50 ++++++++++++--------- packages/admin/src/locales/en/messages.po | 1 + 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/admin/src/components/LoginPage.tsx b/packages/admin/src/components/LoginPage.tsx index 0295188f8..ef482fbd7 100644 --- a/packages/admin/src/components/LoginPage.tsx +++ b/packages/admin/src/components/LoginPage.tsx @@ -308,7 +308,7 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { className="w-full justify-center" onClick={() => setActiveProvider(null)} > - Back to login + {t`Back to login`}
); diff --git a/packages/admin/src/locales/de/messages.po b/packages/admin/src/locales/de/messages.po index f94b1bc24..7e795f7e2 100644 --- a/packages/admin/src/locales/de/messages.po +++ b/packages/admin/src/locales/de/messages.po @@ -37,16 +37,17 @@ msgstr "Benutzern von bestimmten Domains die Registrierung erlauben" msgid "API Tokens" msgstr "API-Tokens" -#: packages/admin/src/components/LoginPage.tsx:253 +#: packages/admin/src/components/LoginPage.tsx:195 msgid "Authentication error: {error}" msgstr "Authentifizierungsfehler: {error}" -#: packages/admin/src/components/LoginPage.tsx:174 -#: packages/admin/src/components/LoginPage.tsx:210 +#: packages/admin/src/components/LoginPage.tsx:117 +#: packages/admin/src/components/LoginPage.tsx:153 +#: packages/admin/src/components/LoginPage.tsx:311 msgid "Back to login" msgstr "Zurück zur Anmeldung" -#: packages/admin/src/components/LoginPage.tsx:158 +#: packages/admin/src/components/LoginPage.tsx:101 msgid "Check your email" msgstr "Überprüfen Sie Ihre E-Mail" @@ -54,7 +55,7 @@ msgstr "Überprüfen Sie Ihre E-Mail" msgid "Choose your preferred admin language" msgstr "Wählen Sie Ihre bevorzugte Admin-Sprache" -#: packages/admin/src/components/LoginPage.tsx:169 +#: packages/admin/src/components/LoginPage.tsx:112 msgid "Click the link in the email to sign in." msgstr "Klicken Sie auf den Link in der E-Mail, um sich anzumelden." @@ -62,7 +63,7 @@ msgstr "Klicken Sie auf den Link in der E-Mail, um sich anzumelden." msgid "Create personal access tokens for programmatic API access" msgstr "Persönliche Zugangstokens für programmatischen API-Zugriff erstellen" -#: packages/admin/src/components/LoginPage.tsx:358 +#: packages/admin/src/components/LoginPage.tsx:332 msgid "Don't have an account? <0>Sign up" msgstr "Noch kein Konto? <0>Registrieren" @@ -70,12 +71,16 @@ msgstr "Noch kein Konto? <0>Registrieren" msgid "Email" msgstr "E-Mail" -#: packages/admin/src/components/LoginPage.tsx:183 +#: packages/admin/src/components/LoginPage.tsx:126 msgid "Email address" msgstr "E-Mail-Adresse" -#: packages/admin/src/components/LoginPage.tsx:127 -#: packages/admin/src/components/LoginPage.tsx:132 +#: packages/admin/src/components/LoginPage.tsx:325 +msgid "Enter your handle to sign in." +msgstr "" + +#: packages/admin/src/components/LoginPage.tsx:70 +#: packages/admin/src/components/LoginPage.tsx:75 msgid "Failed to send magic link" msgstr "Magic Link konnte nicht gesendet werden" @@ -83,7 +88,7 @@ msgstr "Magic Link konnte nicht gesendet werden" msgid "General" msgstr "Allgemein" -#: packages/admin/src/components/LoginPage.tsx:160 +#: packages/admin/src/components/LoginPage.tsx:103 msgid "If an account exists for <0>{email}, we've sent a sign-in link." msgstr "Falls ein Konto für <0>{email} existiert, haben wir einen Anmeldelink gesendet." @@ -99,7 +104,7 @@ msgstr "Sprache" msgid "Manage your passkeys and authentication" msgstr "Passkeys und Authentifizierung verwalten" -#: packages/admin/src/components/LoginPage.tsx:313 +#: packages/admin/src/components/LoginPage.tsx:261 msgid "Or continue with" msgstr "Oder fortfahren mit" @@ -115,11 +120,11 @@ msgstr "Sicherheit" msgid "Self-Signup Domains" msgstr "Selbstregistrierungs-Domains" -#: packages/admin/src/components/LoginPage.tsx:206 +#: packages/admin/src/components/LoginPage.tsx:149 msgid "Send magic link" msgstr "Magic Link senden" -#: packages/admin/src/components/LoginPage.tsx:206 +#: packages/admin/src/components/LoginPage.tsx:149 msgid "Sending..." msgstr "Wird gesendet..." @@ -131,19 +136,24 @@ msgstr "SEO" msgid "Settings" msgstr "Einstellungen" -#: packages/admin/src/components/LoginPage.tsx:283 +#: packages/admin/src/components/LoginPage.tsx:232 msgid "Sign in to your site" msgstr "Bei Ihrer Website anmelden" -#: packages/admin/src/components/LoginPage.tsx:284 +#. placeholder {0}: authProviderList.find((p) => p.id === activeProvider)?.label ?? activeProvider +#: packages/admin/src/components/LoginPage.tsx:231 +msgid "Sign in with {0}" +msgstr "" + +#: packages/admin/src/components/LoginPage.tsx:229 msgid "Sign in with email" msgstr "Mit E-Mail anmelden" -#: packages/admin/src/components/LoginPage.tsx:340 +#: packages/admin/src/components/LoginPage.tsx:290 msgid "Sign in with email link" msgstr "Mit E-Mail-Link anmelden" -#: packages/admin/src/components/LoginPage.tsx:304 +#: packages/admin/src/components/LoginPage.tsx:252 msgid "Sign in with Passkey" msgstr "Mit Passkey anmelden" @@ -159,11 +169,11 @@ msgstr "Soziale Netzwerke" msgid "Social media profile links" msgstr "Links zu Social-Media-Profilen" -#: packages/admin/src/components/LoginPage.tsx:170 +#: packages/admin/src/components/LoginPage.tsx:113 msgid "The link will expire in 15 minutes." msgstr "Der Link ist 15 Minuten gültig." -#: packages/admin/src/components/LoginPage.tsx:351 +#: packages/admin/src/components/LoginPage.tsx:326 msgid "Use your registered passkey to sign in securely." msgstr "Verwenden Sie Ihren registrierten Passkey, um sich sicher anzumelden." @@ -171,6 +181,6 @@ msgstr "Verwenden Sie Ihren registrierten Passkey, um sich sicher anzumelden." msgid "View email provider status and send test emails" msgstr "E-Mail-Anbieter-Status anzeigen und Test-E-Mails senden" -#: packages/admin/src/components/LoginPage.tsx:352 +#: packages/admin/src/components/LoginPage.tsx:323 msgid "We'll send you a link to sign in without a password." msgstr "Wir senden Ihnen einen Link, um sich ohne Passwort anzumelden." diff --git a/packages/admin/src/locales/en/messages.po b/packages/admin/src/locales/en/messages.po index 2fae96f64..7509d1944 100644 --- a/packages/admin/src/locales/en/messages.po +++ b/packages/admin/src/locales/en/messages.po @@ -43,6 +43,7 @@ msgstr "Authentication error: {error}" #: packages/admin/src/components/LoginPage.tsx:117 #: packages/admin/src/components/LoginPage.tsx:153 +#: packages/admin/src/components/LoginPage.tsx:311 msgid "Back to login" msgstr "Back to login"