Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# @emdash-cms/auth-atproto
## 0.2.1
### Patch Changes
- Updated dependencies [[`e2b3c6c`](https://github.com/emdash-cms/emdash/commit/e2b3c6cd930d5fa6fc607a0b26fd796f5b0f98b2), [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea), [`e0dc6fb`](https://github.com/emdash-cms/emdash/commit/e0dc6fb8adadc0e048f3f314d62bfa98d9bb48d4), [`c22fb3a`](https://github.com/emdash-cms/emdash/commit/c22fb3a10d445f12cca91620c9258d50695afa44), [`6a4e9b8`](https://github.com/emdash-cms/emdash/commit/6a4e9b8b0fa6064989224a42b14de435f487a76f), [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf), [`22a16ee`](https://github.com/emdash-cms/emdash/commit/22a16eed607a4e81391ecb6c45fe2e59aaca92fe), [`1e2b024`](https://github.com/emdash-cms/emdash/commit/1e2b02486ee0407e4f50b8342ba1a9e7d060e405), [`81662e9`](https://github.com/emdash-cms/emdash/commit/81662e98fcf1ad0ee880d4f1af96271c527d7423), [`2f22f57`](https://github.com/emdash-cms/emdash/commit/2f22f57abadf305cf6d3ce07ee78290178e032d1), [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86), [`a9c29ea`](https://github.com/emdash-cms/emdash/commit/a9c29ea584300f6cf67206bedcb1d39f05ea1c26), [`e7df21f`](https://github.com/emdash-cms/emdash/commit/e7df21f0adca795cdb233d6e64cd543ead7e2347), [`d5f7c48`](https://github.com/emdash-cms/emdash/commit/d5f7c481a507868f470361cfd715a5828640d45a), [`8ae227c`](https://github.com/emdash-cms/emdash/commit/8ae227cceade5c9852897c7b56f89e7422ee82a1), [`e2d5d16`](https://github.com/emdash-cms/emdash/commit/e2d5d160acea4444945b1ea79c80ca9ce138965b), [`0d98c62`](https://github.com/emdash-cms/emdash/commit/0d98c620a5f407648f3b7f3cbd30b642c74be607), [`64bf5b9`](https://github.com/emdash-cms/emdash/commit/64bf5b98125ca18ec26f7e0e65a71fcbe71fd44f), [`e81aa0f`](https://github.com/emdash-cms/emdash/commit/e81aa0f717be11bacdff30ed9bbc454824268555), [`0041d76`](https://github.com/emdash-cms/emdash/commit/0041d7699b32b77b4cd2ecd77b97340f0dd3abce), [`cee403d`](https://github.com/emdash-cms/emdash/commit/cee403d5c008feb9ca60bb7201e151b828737743), [`a8bac5d`](https://github.com/emdash-cms/emdash/commit/a8bac5d7216e185b1bd9a2aaaeaa9a0306ab066e), [`5b6f059`](https://github.com/emdash-cms/emdash/commit/5b6f059d06175ae0cb740d1ba32867d1ec6b2249), [`a86ff80`](https://github.com/emdash-cms/emdash/commit/a86ff80836fed175508ff06f744c7ad6b805627c), [`d4be24f`](https://github.com/emdash-cms/emdash/commit/d4be24f478a0c8d0a7bba3c299e11105bba3ed94), [`eb6dbd0`](https://github.com/emdash-cms/emdash/commit/eb6dbd056717fd076a8b5fa807d91516a00f5f2f)]:
- emdash@0.9.0
- @emdash-cms/auth@0.9.0
## 0.2.0
### Minor Changes
- [#398](https://github.com/emdash-cms/emdash/pull/398) [`31333dc`](https://github.com/emdash-cms/emdash/commit/31333dc593e2b9128113e4e923455209f11853fd) Thanks [@simnaut](https://github.com/simnaut)! - Adds pluggable auth provider system with AT Protocol as the first plugin-based provider. Refactors GitHub and Google OAuth from hardcoded buttons into the same `AuthProviderDescriptor` interface. All auth methods (passkey, AT Protocol, GitHub, Google) are equal options on the login page and setup wizard.
### Patch Changes
- Updated dependencies [[`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`37ada52`](https://github.com/emdash-cms/emdash/commit/37ada52a62e94f4f0581f4356ba55dc978863f49), [`0557b62`](https://github.com/emdash-cms/emdash/commit/0557b62ec646e49eeb5e28686d50b4e8746338be), [`5a581d9`](https://github.com/emdash-cms/emdash/commit/5a581d966cc1da72637a76ad42a7ac3b81ec59c3), [`0ecd3b4`](https://github.com/emdash-cms/emdash/commit/0ecd3b4901eb721825b36eb4812506032e43da14), [`3138432`](https://github.com/emdash-cms/emdash/commit/31384322537070db8c35e4f93f4ffe8225d784d6), [`70924cd`](https://github.com/emdash-cms/emdash/commit/70924cd19b4227b3a1ecfad6618f1a80530a378b), [`1f0f6f2`](https://github.com/emdash-cms/emdash/commit/1f0f6f2507d026f2b5c60c254432bfc327b3474f), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`e402890`](https://github.com/emdash-cms/emdash/commit/e402890fcd8647fdfe847bb34aa9f9e7094473dd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`f5658f0`](https://github.com/emdash-cms/emdash/commit/f5658f052f7294039f7ea8c5eb8b49af263beb0d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`b6cb2e6`](https://github.com/emdash-cms/emdash/commit/b6cb2e6c7001d37a0558e22953eba41013457528), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`cf1edae`](https://github.com/emdash-cms/emdash/commit/cf1edae6ac3e5cd8c72fd43a09bb80bae5cc8031), [`b352e88`](https://github.com/emdash-cms/emdash/commit/b352e881fedb7f6fdc35f9d75402f67caba7f154), [`31333dc`](https://github.com/emdash-cms/emdash/commit/31333dc593e2b9128113e4e923455209f11853fd), [`da3d065`](https://github.com/emdash-cms/emdash/commit/da3d0656a4431365176cca65dc2bedf5eca19ce3), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`47978b5`](https://github.com/emdash-cms/emdash/commit/47978b5e1b69b671d2ea5c08ee0bbf4c72d1594d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd)]:
- emdash@1.0.0
- @emdash-cms/auth@1.0.0

View File

@@ -0,0 +1,63 @@
# @emdash-cms/auth-atproto
Atmosphere/AT Protocol login provider for [EmDash](https://emdashcms.com). Lets users sign in to your EmDash admin with their [Atmosphere account](https://atmosphereaccount.com) — the same identity behind [Bluesky](https://bsky.app) and the wider AT Protocol network.
No client secrets, no OAuth-app registration. Users authenticate at their own provider; EmDash never sees a password.
## Installation
```shell
pnpm add @emdash-cms/auth-atproto
```
## Quick Start
```js
// astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { atproto } from "@emdash-cms/auth-atproto";
export default defineConfig({
server: {
host: "127.0.0.1", // required for local dev — see below
},
integrations: [
emdash({
authProviders: [atproto()],
}),
],
});
```
This adds **Sign in with Atmosphere** to the login page and the setup wizard. With no allowlist, the first user becomes Admin and self-signup is closed for everyone after that.
## Configuration
```js
atproto({
allowedDIDs: ["did:plc:abc123..."],
allowedHandles: ["*.example.com", "alice.bsky.social"],
defaultRole: 30, // Author
});
```
| Option | Type | Default | Description |
| ---------------- | ---------- | ----------------- | --------------------------------------------------------------------------- |
| `allowedDIDs` | `string[]` | — | DID allowlist. DIDs are permanent and can't be spoofed. |
| `allowedHandles` | `string[]` | — | Handle allowlist. Supports leading-wildcard patterns (`*.example.com`). |
| `defaultRole` | `number` | `10` (Subscriber) | Role assigned to allowed users after the first. First user is always Admin. |
If both lists are set, a user matching either is admitted. Handle matches are independently verified against the handle's DNS/HTTP record before being trusted.
## Local development
The AT Protocol OAuth profile requires loopback redirect URIs to use the IP literal `127.0.0.1` rather than `localhost`. Vite (the dev server Astro uses) binds to `localhost` by default, so set `server.host` to `127.0.0.1` and visit `http://127.0.0.1:4321/_emdash/admin` for the whole flow. Otherwise the cookie set on `localhost` won't be visible after the redirect lands you on `127.0.0.1`.
## Production
The provider serves its own OAuth client metadata at `/.well-known/atproto-client-metadata.json`. Authorization servers fetch this URL during login, so your deployment needs to be reachable on the public internet over HTTPS. Set [`siteUrl`](https://docs.emdashcms.com/reference/configuration#siteurl) if you're behind a TLS-terminating reverse proxy.
## Documentation
See the [Atmosphere login guide](https://docs.emdashcms.com/guides/atmosphere-auth/) for the full reference, including allowlist semantics, role assignment, and troubleshooting.

View File

@@ -0,0 +1,53 @@
{
"name": "@emdash-cms/auth-atproto",
"version": "0.2.1",
"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:>=0.9.0",
"react": ">=18"
},
"devDependencies": {
"@atcute/lexicons": "^1.2.10",
"@cloudflare/kumo": "^1.16.0",
"@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"
}
}

View File

@@ -0,0 +1,172 @@
/**
* 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 { Button, Input } from "@cloudflare/kumo";
import * as React from "react";
// ============================================================================
// Shared icon
// ============================================================================
function AtprotoIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 600 527" fill="currentColor">
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" />
</svg>
);
}
// ============================================================================
// LoginButton — compact button shown in the provider grid
// ============================================================================
export function LoginButton() {
return (
<Button type="button" variant="outline" className="w-full justify-center">
<AtprotoIcon className="h-5 w-5" />
<span>Atmosphere</span>
</Button>
);
}
// ============================================================================
// 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<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-3">
<Input
label="Atmosphere Handle"
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="you.bsky.social"
disabled={isLoading}
/>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !handle.trim()}>
{isLoading ? "Connecting..." : "Sign in"}
</Button>
</form>
);
}
// ============================================================================
// 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<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="text-center mb-2">
<p className="text-sm font-medium text-kumo-default">Atmosphere</p>
<p className="text-xs text-kumo-subtle">Sign in with your Bluesky/Atmosphere handle</p>
</div>
<Input
label="Atmosphere Handle"
name="handle"
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="you.bsky.social"
disabled={isLoading}
className="w-full"
/>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">{error}</div>
)}
<Button
type="submit"
variant="outline"
className="w-full"
disabled={isLoading || !handle.trim()}
>
{isLoading ? "Connecting..." : "Sign in"}
</Button>
</form>
);
}

View File

@@ -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: "Atmosphere",
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: [] },
},
};
}

View File

@@ -0,0 +1,68 @@
/**
* 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<T = unknown> {
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
deleteMany(ids: string[]): Promise<number>;
query(options?: { limit?: number }): Promise<{ items: Array<{ id: string; data: T }> }>;
}
interface StoredEntry<V> {
value: V;
expiresAt: number | null;
}
/**
* Create a Store<K, V> 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<K extends string, V>(
getCollection: () => StorageCollection<StoredEntry<V>>,
): Store<K, V> {
return {
async get(key: K): Promise<V | undefined> {
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<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowing to check optional expiresAt on opaque Store value type
const expiresAt = (value as { expiresAt?: number }).expiresAt ?? null;
await getCollection().put(key, { value, expiresAt });
},
async delete(key: K): Promise<void> {
await getCollection().delete(key);
},
async clear(): Promise<void> {
// 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));
}
},
};
}

1
packages/auth-atproto/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="emdash/locals" />

View File

@@ -0,0 +1,213 @@
/**
* 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<T = unknown> {
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
deleteMany(ids: string[]): Promise<number>;
query(options?: { limit?: number }): Promise<{ items: Array<{ id: string; data: T }> }>;
}
type AuthProviderStorageMap = Record<string, StorageCollectionLike>;
function isLoopback(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
} catch {
return false;
}
}
/**
* Create an AT Protocol OAuth client for a single request.
*
* Constructed per-request to avoid leaking state between requests on Workers
* (where module-scope vars persist across isolate reuses) and between
* concurrent requests on Node.
*
* 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<OAuthClient> {
// RFC 8252 §8.3: loopback redirect URIs MUST use an IP literal (127.0.0.1),
// not "localhost". The atcute library enforces this — see loopbackRedirectUriSchema.
// The admin UI normalizes the browser to 127.0.0.1 before initiating the flow
// (ensureLoopbackIP in admin.tsx) so cookies stay on one origin.
if (isLoopback(baseUrl)) {
baseUrl = baseUrl.replace("://localhost", "://127.0.0.1");
}
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.
const stores = storage
? {
sessions: createDbStore<Did, StoredSession>(
() =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- plugin storage collections match StorageCollectionLike shape
storage.sessions as StorageCollectionLike<{
value: StoredSession;
expiresAt: number | null;
}>,
),
states: createDbStore<string, StoredState>(
() =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- plugin storage collections match StorageCollectionLike shape
storage.states as StorageCollectionLike<{
value: StoredState;
expiresAt: number | null;
}>,
),
}
: {
sessions: new MemoryStore<Did, StoredSession>(),
states: new MemoryStore<string, StoredState>(),
};
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).
return new OAuthClient({
metadata: {
redirect_uris: [`${baseUrl}/_emdash/api/auth/atproto/callback`],
scope: "atproto transition:generic",
},
stores,
actorResolver,
});
}
// 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.
return 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,
});
}
/**
* 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) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- atproto XRPC getSession returns { handle?: string }
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) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- atproto XRPC getRecord returns { value?: { displayName?: string } }
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 };
}

View File

@@ -0,0 +1,53 @@
/**
* 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<string | null> {
// Basic validation — must be at least `x.y` (atcute expects `${string}.${string}`)
if (!handle.includes(".")) return null;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated above with includes("."), satisfies atcute's template literal type
const did = await getResolver().resolve(handle as `${string}.${string}`);
return did;
} catch {
return null;
}
}

View File

@@ -0,0 +1,218 @@
/**
* 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, getPublicOrigin, 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 = getPublicOrigin(url, emdash?.config);
// 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");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- emdash locals satisfy EmdashLocals shape required by getAtprotoStorage
const storage = await getAtprotoStorage(emdash as Parameters<typeof getAtprotoStorage>[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 =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- emdash.config has authProviders but Astro locals type is opaque
(emdash.config as { authProviders?: AuthProviderDescriptor[] } | null | undefined)
?.authProviders;
const atprotoProvider = providers?.find((p) => p.id === "atproto");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- provider config is an opaque Record, narrowing to known atproto config shape
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<Record<string, unknown>>("emdash:setup_state");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- setup_state is a Record<string, unknown> with optional email string
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)}`,
);
}
};

View File

@@ -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": "*",
},
});
};

View File

@@ -0,0 +1,52 @@
/**
* 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,
getPublicOrigin,
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 = getPublicOrigin(url, emdash?.config);
const { getAtprotoOAuthClient } = await import("@emdash-cms/auth-atproto/oauth-client");
const { getAtprotoStorage } = await import("../storage.js");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- emdash locals satisfy EmdashLocals shape required by getAtprotoStorage
const storage = await getAtprotoStorage(emdash as Parameters<typeof getAtprotoStorage>[0]);
const client = await getAtprotoOAuthClient(baseUrl, storage);
const { url: authUrl } = await client.authorize({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- body.handle is a validated string, ActorIdentifier is atcute's branded type
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");
}
};

View File

@@ -0,0 +1,75 @@
/**
* 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,
getPublicOrigin,
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<Record<string, unknown>>("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 = getPublicOrigin(url, emdash?.config);
const { getAtprotoOAuthClient } = await import("@emdash-cms/auth-atproto/oauth-client");
const { getAtprotoStorage } = await import("../storage.js");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- emdash locals satisfy EmdashLocals shape required by getAtprotoStorage
const storage = await getAtprotoStorage(emdash as Parameters<typeof getAtprotoStorage>[0]);
const client = await getAtprotoOAuthClient(baseUrl, storage);
const { url: authUrl } = await client.authorize({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- body.handle is a validated string, ActorIdentifier is atcute's branded type
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");
}
};

View File

@@ -0,0 +1,40 @@
/**
* 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<string, { indexes?: Array<string | string[]> }>;
}
interface EmdashLocals {
db: Kysely<unknown>;
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<any> {
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(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Kysely<unknown> satisfies getAuthProviderStorage's Database parameter
emdash.db as Parameters<typeof getAuthProviderStorage>[0],
"atproto",
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- provider.storage shape matches getAuthProviderStorage's expected Record type
provider.storage as Parameters<typeof getAuthProviderStorage>[2],
);
}

View File

@@ -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 'Atmosphere'", () => {
const descriptor = atproto();
expect(descriptor.label).toBe("Atmosphere");
});
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);
});
});
});

View File

@@ -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("creates a fresh instance per call (no shared state between requests)", async () => {
const { getAtprotoOAuthClient } = await freshImport();
const client1 = await getAtprotoOAuthClient("https://example.com");
const client2 = await getAtprotoOAuthClient("https://example.com");
expect(client1).not.toBe(client2);
});
it("creates distinct instances for different baseUrls", 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);
});
});

View File

@@ -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();
});
});

View File

@@ -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"]
}