@@ -280,12 +225,15 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
- {/* 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 && (
- {/* OAuth Providers */}
-
- {OAUTH_PROVIDERS.map((provider) => (
- handleOAuthClick(provider.id)}
- className="w-full justify-center"
- >
- {provider.icon}
- {provider.name}
-
- ))}
-
+ {/* 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 */}
)}
+ {/* Provider form (full card replacement, like magic link) */}
+ {method === "passkey" &&
+ activeProvider &&
+ (() => {
+ const provider = authProviderList.find((p) => p.id === activeProvider);
+ if (!provider?.LoginForm) return null;
+ const Form = provider.LoginForm;
+ return (
+
+
+ setActiveProvider(null)}
+ >
+ {t`Back to login`}
+
+
+ );
+ })()}
+
{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}
+
setActiveProvider(null)}
+ >
+ ← Back
+
+
+ );
+ }
+ }
-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 (
+
+
+
+ );
+ })}
+
+ >
+ )}
+
← Back
@@ -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 up0>"
msgstr "Noch kein Konto? <0>Registrieren0>"
@@ -70,12 +71,16 @@ msgstr "Noch kein Konto? <0>Registrieren0>"
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}0>, we've sent a sign-in link."
msgstr "Falls ein Konto für <0>{email}0> 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 up0>"
msgstr "Don't have an account? <0>Sign up0>"
@@ -70,12 +71,16 @@ msgstr "Don't have an account? <0>Sign up0>"
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}0>, we've sent a sign-in link."
msgstr "If an account exists for <0>{email}0>, 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 (
+
+
+ Atmosphere
+
+ );
+}
+
+// ============================================================================
+// 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 (
+
+ );
+}
+
+// ============================================================================
+// 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 (
+
+ );
+}
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 (
+
+
+ GitHub
+
+ );
+}
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 (
+
+
+ Google
+
+ );
+}
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: {}