Skip to content

Commit 75c4152

Browse files
simnautTest User
authored andcommitted
fix: address PR review — split auth-atproto package, add provider storage, update terminology
Addresses all 10 review comments on PR #398: - Split auth provider into @emdash-cms/auth-atproto (npm-installable), keep syndication plugin in @emdash-cms/plugin-atproto (marketplace) - Add `storage` field to AuthProviderDescriptor, reuse plugin storage infrastructure instead of manual SQL table creation - Rename "AT Protocol" → "Atmosphere", remove "PDS" from user-facing strings - Forbid self-signup when no allowlists configured (except first admin) - Fix core callback.ts import to use #db alias - Fix env.d.ts to reference emdash/locals package
1 parent 530108b commit 75c4152

29 files changed

Lines changed: 356 additions & 197 deletions

.changeset/stale-knives-fix.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
"emdash": minor
33
"@emdash-cms/admin": minor
4-
"@emdash-cms/plugin-atproto": minor
4+
"@emdash-cms/auth-atproto": minor
55
"@emdash-cms/auth": patch
66
---
77

demos/simple/astro.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import node from "@astrojs/node";
22
import react from "@astrojs/react";
3-
import { atproto } from "@emdash-cms/plugin-atproto/auth";
3+
import { atproto } from "@emdash-cms/auth-atproto";
44
import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
55
import { defineConfig } from "astro/config";
66
import emdash, { local } from "emdash/astro";

demos/simple/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@astrojs/node": "catalog:",
2020
"@astrojs/react": "catalog:",
21+
"@emdash-cms/auth-atproto": "workspace:*",
2122
"@emdash-cms/plugin-atproto": "workspace:*",
2223
"@emdash-cms/plugin-audit-log": "workspace:*",
2324
"@emdash-cms/plugin-color": "workspace:*",

packages/auth-atproto/package.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "@emdash-cms/auth-atproto",
3+
"version": "0.1.0",
4+
"description": "AT Protocol / Atmosphere authentication provider for EmDash CMS",
5+
"type": "module",
6+
"main": "src/auth.ts",
7+
"exports": {
8+
".": "./src/auth.ts",
9+
"./admin": "./src/admin.tsx",
10+
"./oauth-client": "./src/oauth-client.ts",
11+
"./resolve-handle": "./src/resolve-handle.ts",
12+
"./routes/*": "./src/routes/*"
13+
},
14+
"files": [
15+
"src"
16+
],
17+
"keywords": [
18+
"emdash",
19+
"cms",
20+
"auth",
21+
"atproto",
22+
"bluesky",
23+
"atmosphere"
24+
],
25+
"author": "Matt Kane",
26+
"license": "MIT",
27+
"peerDependencies": {
28+
"astro": ">=5",
29+
"emdash": "workspace:*",
30+
"react": ">=18"
31+
},
32+
"devDependencies": {
33+
"@atcute/lexicons": "^1.2.10",
34+
"@types/react": "^19.0.0",
35+
"vitest": "catalog:"
36+
},
37+
"scripts": {
38+
"test": "vitest run",
39+
"typecheck": "tsgo --noEmit"
40+
},
41+
"repository": {
42+
"type": "git",
43+
"url": "git+https://github.com/emdash-cms/emdash.git",
44+
"directory": "packages/auth-atproto"
45+
},
46+
"dependencies": {
47+
"@atcute/identity-resolver": "^1.2.2",
48+
"@atcute/oauth-node-client": "^1.1.0",
49+
"@emdash-cms/auth": "workspace:*",
50+
"kysely": "^0.27.6"
51+
}
52+
}
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function LoginButton() {
3030
className="w-full inline-flex items-center justify-center gap-2 rounded-md border border-kumo-tint bg-kumo-base px-4 py-2 text-sm font-medium text-kumo-default hover:bg-kumo-tint"
3131
>
3232
<AtprotoIcon className="h-5 w-5" />
33-
<span>AT Protocol</span>
33+
<span>Atmosphere</span>
3434
</button>
3535
);
3636
}
@@ -81,7 +81,7 @@ export function LoginForm() {
8181
htmlFor="atproto-handle"
8282
className="block text-sm font-medium text-kumo-default mb-1"
8383
>
84-
AT Protocol Handle
84+
Atmosphere Handle
8585
</label>
8686
<input
8787
id="atproto-handle"
@@ -103,7 +103,7 @@ export function LoginForm() {
103103
disabled={isLoading || !handle.trim()}
104104
className="w-full justify-center rounded-md bg-kumo-brand px-4 py-2 text-sm font-medium text-white hover:bg-kumo-brand/90 disabled:opacity-50 disabled:cursor-not-allowed"
105105
>
106-
{isLoading ? "Connecting..." : "Sign in with PDS"}
106+
{isLoading ? "Connecting..." : "Sign in"}
107107
</button>
108108
</form>
109109
);
@@ -156,7 +156,7 @@ export function SetupStep({ onComplete }: { onComplete: () => void }) {
156156
<form onSubmit={handleSubmit} className="space-y-3">
157157
<div className="text-center mb-2">
158158
<p className="text-sm font-medium text-kumo-default">AT Protocol</p>
159-
<p className="text-xs text-kumo-subtle">Sign in with your PDS handle</p>
159+
<p className="text-xs text-kumo-subtle">Sign in with your Bluesky/Atmosphere handle</p>
160160
</div>
161161

162162
<div>
@@ -179,7 +179,7 @@ export function SetupStep({ onComplete }: { onComplete: () => void }) {
179179
disabled={isLoading || !handle.trim()}
180180
className="w-full justify-center rounded-md border border-kumo-tint bg-kumo-base px-4 py-2 text-sm font-medium text-kumo-default hover:bg-kumo-tint disabled:opacity-50 disabled:cursor-not-allowed"
181181
>
182-
{isLoading ? "Connecting..." : "Sign in with PDS"}
182+
{isLoading ? "Connecting..." : "Sign in"}
183183
</button>
184184
</form>
185185
);
Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*
88
* @example
99
* ```ts
10-
* import { atproto } from "@emdash-cms/plugin-atproto/auth";
10+
* import { atproto } from "@emdash-cms/auth-atproto";
1111
*
1212
* export default defineConfig({
1313
* integrations: [
@@ -74,27 +74,31 @@ export function atproto(config?: AtprotoAuthConfig): AuthProviderDescriptor {
7474
id: "atproto",
7575
label: "AT Protocol",
7676
config: config ?? {},
77-
adminEntry: "@emdash-cms/plugin-atproto/admin",
77+
adminEntry: "@emdash-cms/auth-atproto/admin",
7878
routes: [
7979
{
8080
pattern: "/_emdash/api/auth/atproto/login",
81-
entrypoint: "@emdash-cms/plugin-atproto/routes/login.ts",
81+
entrypoint: "@emdash-cms/auth-atproto/routes/login.ts",
8282
},
8383
{
8484
pattern: "/_emdash/api/auth/atproto/callback",
85-
entrypoint: "@emdash-cms/plugin-atproto/routes/callback.ts",
85+
entrypoint: "@emdash-cms/auth-atproto/routes/callback.ts",
8686
},
8787
{
8888
pattern: "/_emdash/api/setup/atproto-admin",
89-
entrypoint: "@emdash-cms/plugin-atproto/routes/setup-admin.ts",
89+
entrypoint: "@emdash-cms/auth-atproto/routes/setup-admin.ts",
9090
},
9191
{
9292
// Served at root /.well-known/ (not /_emdash/) so PDS authorization
9393
// servers can fetch them quickly without hitting the EmDash middleware chain.
9494
pattern: "/.well-known/atproto-client-metadata.json",
95-
entrypoint: "@emdash-cms/plugin-atproto/routes/client-metadata.ts",
95+
entrypoint: "@emdash-cms/auth-atproto/routes/client-metadata.ts",
9696
},
9797
],
9898
publicRoutes: ["/_emdash/api/auth/atproto/"],
99+
storage: {
100+
states: { indexes: [] },
101+
sessions: { indexes: [] },
102+
},
99103
};
100104
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Database-backed store for AT Protocol OAuth state and sessions.
3+
*
4+
* Wraps EmDash's plugin storage infrastructure to implement the `Store`
5+
* interface required by @atcute/oauth-node-client. Data is stored in the
6+
* shared `_plugin_storage` table under the `auth:atproto` namespace.
7+
*
8+
* Each store instance maps to a storage collection (e.g., "states" or
9+
* "sessions") and handles JSON serialization and TTL expiry checks.
10+
*/
11+
12+
import type { Store } from "@atcute/oauth-node-client";
13+
14+
interface StorageCollection<T = unknown> {
15+
get(id: string): Promise<T | null>;
16+
put(id: string, data: T): Promise<void>;
17+
delete(id: string): Promise<boolean>;
18+
deleteMany(ids: string[]): Promise<number>;
19+
query(options?: { limit?: number }): Promise<{ items: Array<{ id: string; data: T }> }>;
20+
}
21+
22+
interface StoredEntry<V> {
23+
value: V;
24+
expiresAt: number | null;
25+
}
26+
27+
/**
28+
* Create a Store<K, V> backed by a StorageCollection.
29+
*
30+
* @param getCollection - Function returning the StorageCollection instance.
31+
* Using a getter because on Cloudflare Workers the db
32+
* binding (and thus the collection) changes per request.
33+
*/
34+
export function createDbStore<K extends string, V>(
35+
getCollection: () => StorageCollection<StoredEntry<V>>,
36+
): Store<K, V> {
37+
return {
38+
async get(key: K): Promise<V | undefined> {
39+
const entry = await getCollection().get(key);
40+
if (!entry) return undefined;
41+
42+
// Check TTL
43+
if (entry.expiresAt && Date.now() > entry.expiresAt * 1000) {
44+
await getCollection().delete(key);
45+
return undefined;
46+
}
47+
return entry.value;
48+
},
49+
50+
async set(key: K, value: V): Promise<void> {
51+
const expiresAt = (value as { expiresAt?: number }).expiresAt ?? null;
52+
await getCollection().put(key, { value, expiresAt });
53+
},
54+
55+
async delete(key: K): Promise<void> {
56+
await getCollection().delete(key);
57+
},
58+
59+
async clear(): Promise<void> {
60+
// Query all items and delete them in batch
61+
const result = await getCollection().query({ limit: 10000 });
62+
if (result.items.length > 0) {
63+
await getCollection().deleteMany(result.items.map((i) => i.id));
64+
}
65+
},
66+
};
67+
}

packages/auth-atproto/src/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="emdash/locals" />

packages/plugins/atproto/src/oauth-client.ts renamed to packages/auth-atproto/src/oauth-client.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,28 @@ import {
3838
type StoredSession,
3939
type StoredState,
4040
} from "@atcute/oauth-node-client";
41-
import type { Kysely } from "kysely";
4241

4342
import { createDbStore } from "./db-store.js";
4443

4544
type Did = `did:${string}:${string}`;
4645

46+
interface StorageCollectionLike<T = unknown> {
47+
get(id: string): Promise<T | null>;
48+
put(id: string, data: T): Promise<void>;
49+
delete(id: string): Promise<boolean>;
50+
deleteMany(ids: string[]): Promise<number>;
51+
query(options?: { limit?: number }): Promise<{ items: Array<{ id: string; data: T }> }>;
52+
}
53+
54+
type AuthProviderStorageMap = Record<string, StorageCollectionLike>;
55+
4756
// Singleton OAuthClient instance (lazily created).
48-
// On Workers, the db binding changes per request, so we store a mutable
57+
// On Workers, the storage binding changes per request, so we store a mutable
4958
// reference that DB-backed stores read via a getter.
5059
let _client: OAuthClient | null = null;
5160
let _clientBaseUrl: string | null = null;
52-
let _currentDb: Kysely<unknown> | null = null;
53-
let _clientHasDb = false;
61+
let _currentStorage: AuthProviderStorageMap | null = null;
62+
let _clientHasStorage = false;
5463

5564
function isLoopback(url: string): boolean {
5665
try {
@@ -74,28 +83,28 @@ function isLoopback(url: string): boolean {
7483
* - Production (HTTPS): PDS fetches the client metadata document to verify
7584
* the client. No JWKS or key management needed.
7685
*
77-
* @param db - Database instance for persistent OAuth state/session storage.
78-
* Required for multi-instance deployments (e.g., Workers).
79-
* Pass `null` to use in-memory storage (dev only).
86+
* @param baseUrl - The site's public URL.
87+
* @param storage - Auth provider storage collections from `getAuthProviderStorage()`.
88+
* Pass `null` to use in-memory storage (dev only).
8089
*/
8190
export async function getAtprotoOAuthClient(
8291
baseUrl: string,
83-
db?: Kysely<unknown> | null,
92+
storage?: AuthProviderStorageMap | null,
8493
): Promise<OAuthClient> {
8594
// Normalize localhost ↔ 127.0.0.1 so the singleton survives the OAuth
8695
// round-trip (authorize uses localhost, callback arrives on 127.0.0.1).
8796
if (isLoopback(baseUrl)) {
8897
baseUrl = baseUrl.replace("://localhost", "://127.0.0.1");
8998
}
9099

91-
// Update the mutable db reference so cached DB-backed stores use
100+
// Update the mutable storage reference so cached DB-backed stores use
92101
// the current request's binding (critical on Workers).
93-
if (db) _currentDb = db;
102+
if (storage) _currentStorage = storage;
94103

95104
// Return cached client if baseUrl matches and store backend hasn't upgraded.
96-
// If the cached client uses MemoryStore but a db is now available, recreate
105+
// If the cached client uses MemoryStore but storage is now available, recreate
97106
// with DB-backed stores so state survives across Workers requests.
98-
if (_client && _clientBaseUrl === baseUrl && (!db || _clientHasDb)) {
107+
if (_client && _clientBaseUrl === baseUrl && (!storage || _clientHasStorage)) {
99108
return _client;
100109
}
101110

@@ -114,15 +123,26 @@ export async function getAtprotoOAuthClient(
114123
}),
115124
});
116125

117-
// Use database-backed stores when a db is provided (required for
118-
// multi-instance deployments like Cloudflare Workers where in-memory
119-
// state doesn't survive across requests). Fall back to MemoryStore
120-
// for local dev where the singleton process persists.
121-
const getDb = () => _currentDb!;
122-
const stores = db
126+
// Use plugin storage when available (required for multi-instance deployments
127+
// like Cloudflare Workers where in-memory state doesn't survive across
128+
// requests). Fall back to MemoryStore for local dev where the singleton
129+
// process persists.
130+
const stores = storage
123131
? {
124-
sessions: createDbStore<Did, StoredSession>(getDb, "sessions"),
125-
states: createDbStore<string, StoredState>(getDb, "states"),
132+
sessions: createDbStore<Did, StoredSession>(
133+
() =>
134+
_currentStorage!.sessions as StorageCollectionLike<{
135+
value: StoredSession;
136+
expiresAt: number | null;
137+
}>,
138+
),
139+
states: createDbStore<string, StoredState>(
140+
() =>
141+
_currentStorage!.states as StorageCollectionLike<{
142+
value: StoredState;
143+
expiresAt: number | null;
144+
}>,
145+
),
126146
}
127147
: {
128148
sessions: new MemoryStore<Did, StoredSession>(),
@@ -135,7 +155,7 @@ export async function getAtprotoOAuthClient(
135155
// Loopback public client for local development.
136156
// AT Protocol spec allows loopback IPs with public clients.
137157
// No client metadata endpoints needed — the PDS derives
138-
// metadata from the client_id URL parameters.
158+
// metadata from the client_id URL parameters per RFC 8252.
139159
// baseUrl is already normalized to 127.0.0.1 above (RFC 8252).
140160
client = new OAuthClient({
141161
metadata: {
@@ -162,7 +182,7 @@ export async function getAtprotoOAuthClient(
162182

163183
_client = client;
164184
_clientBaseUrl = baseUrl;
165-
_clientHasDb = !!db;
185+
_clientHasStorage = !!storage;
166186

167187
return client;
168188
}
File renamed without changes.

0 commit comments

Comments
 (0)