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:
21
packages/auth-atproto/CHANGELOG.md
Normal file
21
packages/auth-atproto/CHANGELOG.md
Normal 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
|
||||
63
packages/auth-atproto/README.md
Normal file
63
packages/auth-atproto/README.md
Normal 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.
|
||||
53
packages/auth-atproto/package.json
Normal file
53
packages/auth-atproto/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
172
packages/auth-atproto/src/admin.tsx
Normal file
172
packages/auth-atproto/src/admin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
packages/auth-atproto/src/auth.ts
Normal file
104
packages/auth-atproto/src/auth.ts
Normal 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: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
68
packages/auth-atproto/src/db-store.ts
Normal file
68
packages/auth-atproto/src/db-store.ts
Normal 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
1
packages/auth-atproto/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="emdash/locals" />
|
||||
213
packages/auth-atproto/src/oauth-client.ts
Normal file
213
packages/auth-atproto/src/oauth-client.ts
Normal 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 };
|
||||
}
|
||||
53
packages/auth-atproto/src/resolve-handle.ts
Normal file
53
packages/auth-atproto/src/resolve-handle.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
218
packages/auth-atproto/src/routes/callback.ts
Normal file
218
packages/auth-atproto/src/routes/callback.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
37
packages/auth-atproto/src/routes/client-metadata.ts
Normal file
37
packages/auth-atproto/src/routes/client-metadata.ts
Normal 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": "*",
|
||||
},
|
||||
});
|
||||
};
|
||||
52
packages/auth-atproto/src/routes/login.ts
Normal file
52
packages/auth-atproto/src/routes/login.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
75
packages/auth-atproto/src/routes/setup-admin.ts
Normal file
75
packages/auth-atproto/src/routes/setup-admin.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
40
packages/auth-atproto/src/storage.ts
Normal file
40
packages/auth-atproto/src/storage.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
129
packages/auth-atproto/tests/auth.test.ts
Normal file
129
packages/auth-atproto/tests/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
packages/auth-atproto/tests/oauth-client.test.ts
Normal file
98
packages/auth-atproto/tests/oauth-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
packages/auth-atproto/tests/resolve-handle.test.ts
Normal file
27
packages/auth-atproto/tests/resolve-handle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
11
packages/auth-atproto/tsconfig.json
Normal file
11
packages/auth-atproto/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user