diff --git a/.changeset/stale-knives-fix.md b/.changeset/stale-knives-fix.md new file mode 100644 index 000000000..e31500182 --- /dev/null +++ b/.changeset/stale-knives-fix.md @@ -0,0 +1,8 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +"@emdash-cms/auth-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 fe222d948..6426f11ff 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/auth-atproto"; 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 all origin-dependent features match browser // siteUrl: "https://emdash.local:8443", diff --git a/demos/simple/package.json b/demos/simple/package.json index 3b103993c..45561e8b9 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -18,6 +18,8 @@ "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:*", "astro": "catalog:", 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/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..ef482fbd7 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 9afc5084a..030f4d2dc 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -170,3 +170,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/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 fd4296104..7509d1944 100644 --- a/packages/admin/src/locales/en/messages.po +++ b/packages/admin/src/locales/en/messages.po @@ -37,16 +37,17 @@ 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 +#: packages/admin/src/components/LoginPage.tsx:311 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 +55,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 +63,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 +71,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 +88,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 +104,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 +120,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 +136,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 +169,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 +181,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/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/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/auth-atproto/src/admin.tsx b/packages/auth-atproto/src/admin.tsx new file mode 100644 index 000000000..5a39ec855 --- /dev/null +++ b/packages/auth-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 Bluesky/Atmosphere 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/auth-atproto/src/auth.ts b/packages/auth-atproto/src/auth.ts new file mode 100644 index 000000000..639d1728a --- /dev/null +++ b/packages/auth-atproto/src/auth.ts @@ -0,0 +1,104 @@ +/** + * 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/auth-atproto"; + * + * 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/auth-atproto/admin", + routes: [ + { + pattern: "/_emdash/api/auth/atproto/login", + entrypoint: "@emdash-cms/auth-atproto/routes/login.ts", + }, + { + pattern: "/_emdash/api/auth/atproto/callback", + entrypoint: "@emdash-cms/auth-atproto/routes/callback.ts", + }, + { + pattern: "/_emdash/api/setup/atproto-admin", + 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/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/auth-atproto/src/oauth-client.ts b/packages/auth-atproto/src/oauth-client.ts new file mode 100644 index 000000000..8e89a519d --- /dev/null +++ b/packages/auth-atproto/src/oauth-client.ts @@ -0,0 +1,235 @@ +/** + * 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 { 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 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 _currentStorage: AuthProviderStorageMap | null = null; +let _clientHasStorage = 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 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, + 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). + if (isLoopback(baseUrl)) { + baseUrl = baseUrl.replace("://localhost", "://127.0.0.1"); + } + + // Update the mutable storage reference so cached DB-backed stores use + // the current request's binding (critical on Workers). + if (storage) _currentStorage = storage; + + // Return cached client if baseUrl matches and store backend hasn't upgraded. + // 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 && (!storage || _clientHasStorage)) { + 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 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( + () => + _currentStorage!.sessions as StorageCollectionLike<{ + value: StoredSession; + expiresAt: number | null; + }>, + ), + states: createDbStore( + () => + _currentStorage!.states as StorageCollectionLike<{ + value: StoredState; + expiresAt: number | null; + }>, + ), + } + : { + 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 per RFC 8252. + // 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; + _clientHasStorage = !!storage; + + 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/auth-atproto/src/resolve-handle.ts b/packages/auth-atproto/src/resolve-handle.ts new file mode 100644 index 000000000..632c98b71 --- /dev/null +++ b/packages/auth-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/auth-atproto/src/routes/callback.ts b/packages/auth-atproto/src/routes/callback.ts new file mode 100644 index 000000000..a247ba631 --- /dev/null +++ b/packages/auth-atproto/src/routes/callback.ts @@ -0,0 +1,214 @@ +/** + * 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/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; + + // 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/auth-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})`, + ); + } + + // 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 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 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. + // 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 }; + }); + + 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})`); + } + + // 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/auth-atproto/src/routes/client-metadata.ts b/packages/auth-atproto/src/routes/client-metadata.ts new file mode 100644 index 000000000..602bd0c20 --- /dev/null +++ b/packages/auth-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/auth-atproto/src/routes/login.ts b/packages/auth-atproto/src/routes/login.ts new file mode 100644 index 000000000..32354d8a1 --- /dev/null +++ b/packages/auth-atproto/src/routes/login.ts @@ -0,0 +1,43 @@ +/** + * 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/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 }, + }); + + return apiSuccess({ url: authUrl.toString() }); + } catch (error) { + return handleError(error, "Failed to start AT Protocol login", "ATPROTO_LOGIN_ERROR"); + } +}; diff --git a/packages/auth-atproto/src/routes/setup-admin.ts b/packages/auth-atproto/src/routes/setup-admin.ts new file mode 100644 index 000000000..15c30e699 --- /dev/null +++ b/packages/auth-atproto/src/routes/setup-admin.ts @@ -0,0 +1,72 @@ +/** + * 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/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 }, + }); + + return apiSuccess({ + url: authUrl.toString(), + }); + } catch (error) { + return handleError(error, "Failed to start AT Protocol setup", "SETUP_ATPROTO_ERROR"); + } +}; diff --git a/packages/auth-atproto/src/storage.ts b/packages/auth-atproto/src/storage.ts new file mode 100644 index 000000000..aed8a01be --- /dev/null +++ b/packages/auth-atproto/src/storage.ts @@ -0,0 +1,38 @@ +/** + * 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, + // 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; + + return getAuthProviderStorage( + emdash.db as Parameters[0], + "atproto", + provider.storage as Parameters[2], + ); +} diff --git a/packages/auth-atproto/tests/auth.test.ts b/packages/auth-atproto/tests/auth.test.ts new file mode 100644 index 000000000..4eafa7605 --- /dev/null +++ b/packages/auth-atproto/tests/auth.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; + +import { atproto, type AtprotoAuthConfig } from "../src/auth.js"; + +const AUTH_ROUTES_RE = /^@emdash-cms\/auth-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/auth-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 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(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(); + 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/auth-atproto/tests/oauth-client.test.ts b/packages/auth-atproto/tests/oauth-client.test.ts new file mode 100644 index 000000000..5695068df --- /dev/null +++ b/packages/auth-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/auth-atproto/tests/resolve-handle.test.ts b/packages/auth-atproto/tests/resolve-handle.test.ts new file mode 100644 index 000000000..41196a955 --- /dev/null +++ b/packages/auth-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/packages/auth-atproto/tsconfig.json b/packages/auth-atproto/tsconfig.json new file mode 100644 index 000000000..8e16995f0 --- /dev/null +++ b/packages/auth-atproto/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../plugins/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noUncheckedIndexedAccess": false, + "lib": ["es2023", "DOM", "DOM.Iterable", "esnext.typedarrays"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} 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 ac3f4a340..524f7c94f 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); } /** @@ -200,13 +200,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); @@ -238,8 +250,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..4e1c06a02 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,17 +204,24 @@ }, "peerDependencies": { "@astrojs/react": ">=5.0.0-beta.0", + "@emdash-cms/auth-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/auth-atproto": { + "optional": true + } + }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@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/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 new file mode 100644 index 000000000..89038a398 --- /dev/null +++ b/packages/core/src/api/route-utils.ts @@ -0,0 +1,13 @@ +/** + * 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"; +export { getAuthProviderStorage } from "./auth-storage.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 217686a4c..0e44af987 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"; @@ -156,6 +161,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { database: resolvedConfig.database, storage: resolvedConfig.storage, auth: resolvedConfig.auth, + authProviders: resolvedConfig.authProviders, marketplace: resolvedConfig.marketplace, siteUrl: resolvedConfig.siteUrl, }; @@ -223,7 +229,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 4bed26b5e..ace544d25 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"), @@ -736,6 +742,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 2b25d6867..abba2b018 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/auth-atproto"; + * + * 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 62b7766d2..a11205e89 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, @@ -166,6 +169,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; } @@ -221,6 +227,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 5c4bef975..c5ff870ae 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -184,6 +184,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 e9fe0303e..0224cb5c4 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"; @@ -79,6 +81,7 @@ 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", // Public site search — read-only. The query layer hardcodes status='published' @@ -87,9 +90,27 @@ const PUBLIC_API_EXACT = new Set([ "/_emdash/api/search", ]); +// 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 58e6ce174..0d7d8fa63 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -71,16 +71,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")}`, ); } @@ -97,7 +103,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.`)}`, ); } @@ -114,7 +120,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 c74994cd3..f7e7cc311 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 @@ -18,7 +18,9 @@ import { import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import { getPublicOrigin } from "#api/public-url.js"; +import { finalizeSetup } from "#api/setup-complete.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; +import { OptionsRepository } from "#db/repositories/options.js"; type ProviderName = "github" | "google"; @@ -126,10 +128,22 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect ); } + const adapter = createKyselyAdapter(emdash.db); + const stateStore = createOAuthStateStore(emdash.db); + const config: OAuthConsumerConfig = { baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`, providers, canSelfSignup: async (email: string) => { + // 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 }; + } + // Extract domain from email const domain = email.split("@")[1]?.toLowerCase(); if (!domain) { @@ -168,10 +182,16 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect }, }; - const adapter = createKyselyAdapter(emdash.db); - const stateStore = createOAuthStateStore(emdash.db); - + 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 = setupCompleteBefore !== true && setupCompleteBefore !== "true"; + + // 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 6827e728a..50f69bff0 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, @@ -78,8 +80,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 c4b246eba..3718f30a7 100644 --- a/packages/core/src/astro/routes/api/setup/index.ts +++ b/packages/core/src/astro/routes/api/setup/index.ts @@ -81,7 +81,7 @@ export const POST: APIRoute = async ({ request, url, 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"; @@ -102,7 +102,7 @@ export const POST: APIRoute = async ({ request, url, 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..521850144 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,110 @@ 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/auth-atproto"; + * + * 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[]; + + /** + * 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 } + >; +} + +/** + * 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/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 04721c4c9..661a985a6 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -1795,7 +1795,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/packages/core/src/index.ts b/packages/core/src/index.ts index a85d181b8..026a9b2df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -471,11 +471,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 a8c260dc7..ea30b58e4 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -12,7 +12,8 @@ "./sandbox": "./dist/sandbox-entry.mjs" }, "files": [ - "dist" + "dist", + "src" ], "keywords": [ "emdash", @@ -31,12 +32,10 @@ }, "devDependencies": { "tsdown": "catalog:", - "typescript": "catalog:", "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 +43,7 @@ "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/plugins/atproto" - } + }, + "dependencies": {} + } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 444785b5e..48e5b4868 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 @@ -238,6 +238,43 @@ 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/auth-atproto': + specifier: workspace:* + version: link:../../packages/auth-atproto + '@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': @@ -403,6 +440,12 @@ 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 '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log @@ -517,10 +560,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) @@ -608,13 +651,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 @@ -710,6 +753,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': @@ -859,6 +936,9 @@ 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 @@ -918,10 +998,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: 12.8.0 @@ -995,6 +1075,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 @@ -1157,9 +1240,6 @@ importers: 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 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) @@ -1763,6 +1843,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==} @@ -1900,6 +2018,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'} @@ -2814,12 +2936,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 @@ -2827,20 +2949,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': @@ -2848,15 +2970,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': @@ -2864,15 +2986,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: @@ -2885,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'} @@ -5768,6 +5890,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'} @@ -6953,6 +7078,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==} @@ -9269,6 +9399,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 @@ -9457,6 +9656,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 @@ -10300,33 +10501,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 @@ -10348,7 +10549,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) @@ -10358,43 +10559,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: @@ -12649,11 +12850,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: @@ -13602,6 +13803,8 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.4 + esm-env@1.2.2: {} + esm@3.2.25: optional: true @@ -15148,6 +15351,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.7: {} + napi-build-utils@2.0.0: {} negotiator@1.0.0: {}