@emdash-cms/plugin-atproto 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@emdash-cms/plugin-atproto",
3
+ "version": "0.0.1",
4
+ "description": "AT Protocol / standard.site syndication plugin for EmDash CMS",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./sandbox": "./src/sandbox-entry.ts"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "keywords": [
15
+ "emdash",
16
+ "cms",
17
+ "plugin",
18
+ "atproto",
19
+ "bluesky",
20
+ "standard-site",
21
+ "syndication",
22
+ "fediverse"
23
+ ],
24
+ "author": "Matt Kane",
25
+ "license": "MIT",
26
+ "peerDependencies": {
27
+ "emdash": "0.0.1"
28
+ },
29
+ "devDependencies": {
30
+ "vitest": "^4.0.18"
31
+ },
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "typecheck": "tsgo --noEmit"
35
+ }
36
+ }
package/src/atproto.ts ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * AT Protocol client helpers
3
+ *
4
+ * Handles session management, record CRUD, and handle resolution.
5
+ * All HTTP goes through ctx.http.fetch() for sandbox compatibility.
6
+ */
7
+
8
+ import type { PluginContext } from "emdash";
9
+
10
+ // ── Types ───────────────────────────────────────────────────────
11
+
12
+ export interface AtSession {
13
+ accessJwt: string;
14
+ refreshJwt: string;
15
+ did: string;
16
+ handle: string;
17
+ }
18
+
19
+ export interface AtRecord {
20
+ uri: string;
21
+ cid: string;
22
+ }
23
+
24
+ export interface BlobRef {
25
+ $type: "blob";
26
+ ref: { $link: string };
27
+ mimeType: string;
28
+ size: number;
29
+ }
30
+
31
+ // ── Helpers ─────────────────────────────────────────────────────
32
+
33
+ /** Get the HTTP client from plugin context, or throw a helpful error. */
34
+ export function requireHttp(ctx: PluginContext) {
35
+ if (!ctx.http) {
36
+ throw new Error("AT Protocol plugin requires the network:fetch capability");
37
+ }
38
+ return ctx.http;
39
+ }
40
+
41
+ /** Validate that a PDS response contains expected string fields. */
42
+ function requireString(data: Record<string, unknown>, field: string, context: string): string {
43
+ const value = data[field];
44
+ if (typeof value !== "string") {
45
+ throw new Error(`${context}: missing or invalid '${field}' in response`);
46
+ }
47
+ return value;
48
+ }
49
+
50
+ // ── Session management ──────────────────────────────────────────
51
+
52
+ /**
53
+ * Create a new session with the PDS using an app password.
54
+ */
55
+ export async function createSession(
56
+ ctx: PluginContext,
57
+ pdsHost: string,
58
+ identifier: string,
59
+ password: string,
60
+ ): Promise<AtSession> {
61
+ const http = requireHttp(ctx);
62
+ const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ identifier, password }),
66
+ });
67
+
68
+ if (!res.ok) {
69
+ const body = await res.text().catch(() => "");
70
+ throw new Error(`createSession failed (${res.status}): ${body}`);
71
+ }
72
+
73
+ const data = (await res.json()) as Record<string, unknown>;
74
+ return {
75
+ accessJwt: requireString(data, "accessJwt", "createSession"),
76
+ refreshJwt: requireString(data, "refreshJwt", "createSession"),
77
+ did: requireString(data, "did", "createSession"),
78
+ handle: requireString(data, "handle", "createSession"),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Refresh an existing session using the refresh token.
84
+ */
85
+ export async function refreshSession(
86
+ ctx: PluginContext,
87
+ pdsHost: string,
88
+ refreshJwt: string,
89
+ ): Promise<AtSession> {
90
+ const http = requireHttp(ctx);
91
+ const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.refreshSession`, {
92
+ method: "POST",
93
+ headers: { Authorization: `Bearer ${refreshJwt}` },
94
+ });
95
+
96
+ if (!res.ok) {
97
+ const body = await res.text().catch(() => "");
98
+ throw new Error(`refreshSession failed (${res.status}): ${body}`);
99
+ }
100
+
101
+ const data = (await res.json()) as Record<string, unknown>;
102
+ return {
103
+ accessJwt: requireString(data, "accessJwt", "refreshSession"),
104
+ refreshJwt: requireString(data, "refreshJwt", "refreshSession"),
105
+ did: requireString(data, "did", "refreshSession"),
106
+ handle: requireString(data, "handle", "refreshSession"),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * In-flight refresh promise for deduplication.
112
+ * Prevents concurrent publishes from racing on token refresh,
113
+ * which would corrupt tokens since PDS invalidates refresh tokens after use.
114
+ */
115
+ let refreshInFlight: Promise<AtSession> | null = null;
116
+
117
+ /**
118
+ * Get a valid access token, refreshing if needed.
119
+ * Uses promise deduplication to prevent concurrent refresh races.
120
+ */
121
+ export async function ensureSession(ctx: PluginContext): Promise<{
122
+ accessJwt: string;
123
+ did: string;
124
+ pdsHost: string;
125
+ }> {
126
+ const pdsHost = (await ctx.kv.get<string>("settings:pdsHost")) || "bsky.social";
127
+ const handle = await ctx.kv.get<string>("settings:handle");
128
+ const appPassword = await ctx.kv.get<string>("settings:appPassword");
129
+
130
+ if (!handle || !appPassword) {
131
+ throw new Error("AT Protocol credentials not configured");
132
+ }
133
+
134
+ // Try existing tokens first
135
+ const existingAccess = await ctx.kv.get<string>("state:accessJwt");
136
+ const existingRefresh = await ctx.kv.get<string>("state:refreshJwt");
137
+ const existingDid = await ctx.kv.get<string>("state:did");
138
+
139
+ if (existingAccess && existingDid) {
140
+ return { accessJwt: existingAccess, did: existingDid, pdsHost };
141
+ }
142
+
143
+ // Try refresh if we have a refresh token (deduplicated)
144
+ if (existingRefresh) {
145
+ if (!refreshInFlight) {
146
+ refreshInFlight = refreshSession(ctx, pdsHost, existingRefresh)
147
+ .then(async (session) => {
148
+ await persistSession(ctx, session);
149
+ return session;
150
+ })
151
+ .finally(() => {
152
+ refreshInFlight = null;
153
+ });
154
+ }
155
+ try {
156
+ const session = await refreshInFlight;
157
+ return { accessJwt: session.accessJwt, did: session.did, pdsHost };
158
+ } catch {
159
+ // Refresh failed, fall through to full login
160
+ }
161
+ }
162
+
163
+ // Full login
164
+ const session = await createSession(ctx, pdsHost, handle, appPassword);
165
+ await persistSession(ctx, session);
166
+ return { accessJwt: session.accessJwt, did: session.did, pdsHost };
167
+ }
168
+
169
+ async function persistSession(ctx: PluginContext, session: AtSession): Promise<void> {
170
+ await ctx.kv.set("state:accessJwt", session.accessJwt);
171
+ await ctx.kv.set("state:refreshJwt", session.refreshJwt);
172
+ await ctx.kv.set("state:did", session.did);
173
+ }
174
+
175
+ // ── Record CRUD ─────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Create a record on the PDS. Returns the AT-URI and CID.
179
+ * Retries once on 401 (expired token) by refreshing the session.
180
+ */
181
+ export async function createRecord(
182
+ ctx: PluginContext,
183
+ pdsHost: string,
184
+ accessJwt: string,
185
+ did: string,
186
+ collection: string,
187
+ record: unknown,
188
+ ): Promise<AtRecord> {
189
+ const http = requireHttp(ctx);
190
+ let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
191
+ method: "POST",
192
+ headers: {
193
+ Authorization: `Bearer ${accessJwt}`,
194
+ "Content-Type": "application/json",
195
+ },
196
+ body: JSON.stringify({ repo: did, collection, record }),
197
+ });
198
+
199
+ // Retry once on 401 with refreshed token
200
+ if (res.status === 401) {
201
+ const refreshed = await ensureSessionFresh(ctx, pdsHost);
202
+ if (refreshed) {
203
+ res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
204
+ method: "POST",
205
+ headers: {
206
+ Authorization: `Bearer ${refreshed.accessJwt}`,
207
+ "Content-Type": "application/json",
208
+ },
209
+ body: JSON.stringify({ repo: refreshed.did, collection, record }),
210
+ });
211
+ }
212
+ }
213
+
214
+ if (!res.ok) {
215
+ const body = await res.text().catch(() => "");
216
+ throw new Error(`createRecord failed (${res.status}): ${body}`);
217
+ }
218
+
219
+ const data = (await res.json()) as Record<string, unknown>;
220
+ return {
221
+ uri: requireString(data, "uri", "createRecord"),
222
+ cid: requireString(data, "cid", "createRecord"),
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Update (upsert) a record on the PDS.
228
+ * Retries once on 401 (expired token).
229
+ */
230
+ export async function putRecord(
231
+ ctx: PluginContext,
232
+ pdsHost: string,
233
+ accessJwt: string,
234
+ did: string,
235
+ collection: string,
236
+ rkey: string,
237
+ record: unknown,
238
+ ): Promise<AtRecord> {
239
+ const http = requireHttp(ctx);
240
+ let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
241
+ method: "POST",
242
+ headers: {
243
+ Authorization: `Bearer ${accessJwt}`,
244
+ "Content-Type": "application/json",
245
+ },
246
+ body: JSON.stringify({ repo: did, collection, rkey, record }),
247
+ });
248
+
249
+ if (res.status === 401) {
250
+ const refreshed = await ensureSessionFresh(ctx, pdsHost);
251
+ if (refreshed) {
252
+ res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
253
+ method: "POST",
254
+ headers: {
255
+ Authorization: `Bearer ${refreshed.accessJwt}`,
256
+ "Content-Type": "application/json",
257
+ },
258
+ body: JSON.stringify({ repo: refreshed.did, collection, rkey, record }),
259
+ });
260
+ }
261
+ }
262
+
263
+ if (!res.ok) {
264
+ const body = await res.text().catch(() => "");
265
+ throw new Error(`putRecord failed (${res.status}): ${body}`);
266
+ }
267
+
268
+ const data = (await res.json()) as Record<string, unknown>;
269
+ return {
270
+ uri: requireString(data, "uri", "putRecord"),
271
+ cid: requireString(data, "cid", "putRecord"),
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Delete a record from the PDS.
277
+ * Retries once on 401 (expired token).
278
+ */
279
+ export async function deleteRecord(
280
+ ctx: PluginContext,
281
+ pdsHost: string,
282
+ accessJwt: string,
283
+ did: string,
284
+ collection: string,
285
+ rkey: string,
286
+ ): Promise<void> {
287
+ const http = requireHttp(ctx);
288
+ let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
289
+ method: "POST",
290
+ headers: {
291
+ Authorization: `Bearer ${accessJwt}`,
292
+ "Content-Type": "application/json",
293
+ },
294
+ body: JSON.stringify({ repo: did, collection, rkey }),
295
+ });
296
+
297
+ if (res.status === 401) {
298
+ const refreshed = await ensureSessionFresh(ctx, pdsHost);
299
+ if (refreshed) {
300
+ res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
301
+ method: "POST",
302
+ headers: {
303
+ Authorization: `Bearer ${refreshed.accessJwt}`,
304
+ "Content-Type": "application/json",
305
+ },
306
+ body: JSON.stringify({ repo: refreshed.did, collection, rkey }),
307
+ });
308
+ }
309
+ }
310
+
311
+ if (!res.ok) {
312
+ const body = await res.text().catch(() => "");
313
+ throw new Error(`deleteRecord failed (${res.status}): ${body}`);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Force a session refresh (for 401 retry). Clears the stale access token
319
+ * and delegates to ensureSession, which handles refresh deduplication.
320
+ * Returns null if refresh fails.
321
+ */
322
+ async function ensureSessionFresh(
323
+ ctx: PluginContext,
324
+ _pdsHost: string,
325
+ ): Promise<{ accessJwt: string; did: string } | null> {
326
+ // Clear stale access token so ensureSession will attempt a refresh
327
+ await ctx.kv.set("state:accessJwt", "");
328
+
329
+ try {
330
+ const result = await ensureSession(ctx);
331
+ return { accessJwt: result.accessJwt, did: result.did };
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ // ── Handle resolution ───────────────────────────────────────────
338
+
339
+ /**
340
+ * Resolve an AT Protocol handle to a DID.
341
+ * Uses the public API -- no auth required.
342
+ */
343
+ export async function resolveHandle(ctx: PluginContext, handle: string): Promise<string> {
344
+ const http = requireHttp(ctx);
345
+ const res = await http.fetch(
346
+ `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
347
+ );
348
+
349
+ if (!res.ok) {
350
+ throw new Error(`resolveHandle failed for ${handle} (${res.status})`);
351
+ }
352
+
353
+ const data = (await res.json()) as Record<string, unknown>;
354
+ return requireString(data, "did", "resolveHandle");
355
+ }
356
+
357
+ // ── Blob upload ─────────────────────────────────────────────────
358
+
359
+ /**
360
+ * Upload a blob (image) to the PDS. Returns a blob reference for embedding.
361
+ */
362
+ export async function uploadBlob(
363
+ ctx: PluginContext,
364
+ pdsHost: string,
365
+ accessJwt: string,
366
+ imageBytes: ArrayBuffer,
367
+ mimeType: string,
368
+ ): Promise<BlobRef> {
369
+ const http = requireHttp(ctx);
370
+ const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.uploadBlob`, {
371
+ method: "POST",
372
+ headers: {
373
+ Authorization: `Bearer ${accessJwt}`,
374
+ "Content-Type": mimeType,
375
+ },
376
+ body: imageBytes,
377
+ });
378
+
379
+ if (!res.ok) {
380
+ const body = await res.text().catch(() => "");
381
+ throw new Error(`uploadBlob failed (${res.status}): ${body}`);
382
+ }
383
+
384
+ const data = (await res.json()) as Record<string, unknown>;
385
+ if (!data.blob || typeof data.blob !== "object") {
386
+ throw new Error("uploadBlob: missing 'blob' in response");
387
+ }
388
+ const blob = data.blob as Record<string, unknown>;
389
+ if (!blob.ref || typeof blob.ref !== "object") {
390
+ throw new Error("uploadBlob: malformed blob reference in response");
391
+ }
392
+ return data.blob as BlobRef;
393
+ }
394
+
395
+ // ── Utilities ───────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Extract the rkey from an AT-URI.
399
+ * at://did:plc:xxx/collection/rkey -> rkey
400
+ */
401
+ export function rkeyFromUri(uri: string): string {
402
+ const parts = uri.split("/");
403
+ const rkey = parts.at(-1);
404
+ if (!rkey) {
405
+ throw new Error(`Invalid AT-URI: ${uri}`);
406
+ }
407
+ return rkey;
408
+ }
package/src/bluesky.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Bluesky cross-posting helpers
3
+ *
4
+ * Builds app.bsky.feed.post records with link cards and rich text facets.
5
+ */
6
+
7
+ import type { BlobRef } from "./atproto.js";
8
+
9
+ // ── Pre-compiled regexes ────────────────────────────────────────
10
+
11
+ const TEMPLATE_TITLE_RE = /\{title\}/g;
12
+ const TEMPLATE_URL_RE = /\{url\}/g;
13
+ const TEMPLATE_EXCERPT_RE = /\{excerpt\}/g;
14
+ const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/;
15
+ // Global regexes for facet detection -- reset lastIndex before each use
16
+ const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
17
+ const HASHTAG_REGEX = /(?<=\s|^)#([a-zA-Z0-9_]+)/g;
18
+
19
+ // ── Types ───────────────────────────────────────────────────────
20
+
21
+ export interface BskyPost {
22
+ $type: "app.bsky.feed.post";
23
+ text: string;
24
+ createdAt: string;
25
+ langs?: string[];
26
+ facets?: BskyFacet[];
27
+ embed?: BskyEmbed;
28
+ }
29
+
30
+ export interface BskyFacet {
31
+ index: { byteStart: number; byteEnd: number };
32
+ features: Array<
33
+ | { $type: "app.bsky.richtext.facet#link"; uri: string }
34
+ | { $type: "app.bsky.richtext.facet#tag"; tag: string }
35
+ >;
36
+ }
37
+
38
+ export type BskyEmbed = {
39
+ $type: "app.bsky.embed.external";
40
+ external: {
41
+ uri: string;
42
+ title: string;
43
+ description: string;
44
+ thumb?: BlobRef;
45
+ };
46
+ };
47
+
48
+ // ── Post builder ────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Build a Bluesky post record for cross-posting published content.
52
+ */
53
+ export function buildBskyPost(opts: {
54
+ template: string;
55
+ content: Record<string, unknown>;
56
+ siteUrl: string;
57
+ thumbBlob?: BlobRef;
58
+ langs?: string[];
59
+ }): BskyPost {
60
+ const { template, content, siteUrl, thumbBlob, langs } = opts;
61
+
62
+ const title = (content.title as string) || "Untitled";
63
+ const slug = content.slug as string;
64
+ const excerpt = (content.excerpt || content.description || "") as string;
65
+ const url = slug ? `${stripTrailingSlash(siteUrl)}/${slug}` : siteUrl;
66
+
67
+ // Apply template -- substitute before truncation so we can detect
68
+ // if the URL survives intact after truncation
69
+ const fullText = template
70
+ .replace(TEMPLATE_TITLE_RE, title)
71
+ .replace(TEMPLATE_URL_RE, url)
72
+ .replace(TEMPLATE_EXCERPT_RE, excerpt);
73
+
74
+ // Truncate to 300 graphemes (Bluesky limit)
75
+ const text = truncateGraphemes(fullText, 300);
76
+ const wasTruncated = text !== fullText;
77
+
78
+ const post: BskyPost = {
79
+ $type: "app.bsky.feed.post",
80
+ text,
81
+ createdAt: new Date().toISOString(),
82
+ };
83
+
84
+ if (langs && langs.length > 0) {
85
+ post.langs = langs.slice(0, 3); // Max 3 per spec
86
+ }
87
+
88
+ // Auto-detect URLs in text and build facets.
89
+ // If text was truncated, skip facets -- truncation may have cut
90
+ // a URL mid-string, producing a broken link facet.
91
+ if (!wasTruncated) {
92
+ const facets = buildFacets(text);
93
+ if (facets.length > 0) {
94
+ post.facets = facets;
95
+ }
96
+ }
97
+
98
+ // Link card embed
99
+ post.embed = {
100
+ $type: "app.bsky.embed.external",
101
+ external: {
102
+ uri: url,
103
+ title,
104
+ description: truncateGraphemes(excerpt, 300),
105
+ ...(thumbBlob ? { thumb: thumbBlob } : {}),
106
+ },
107
+ };
108
+
109
+ return post;
110
+ }
111
+
112
+ // ── Rich text facets ────────────────────────────────────────────
113
+
114
+ /**
115
+ * Build rich text facets for URLs and hashtags in text.
116
+ *
117
+ * CRITICAL: Facet byte offsets use UTF-8 bytes, not JavaScript string indices.
118
+ */
119
+ export function buildFacets(text: string): BskyFacet[] {
120
+ const encoder = new TextEncoder();
121
+ const facets: BskyFacet[] = [];
122
+
123
+ // Detect URLs
124
+ let match: RegExpExecArray | null;
125
+ URL_REGEX.lastIndex = 0;
126
+ while ((match = URL_REGEX.exec(text)) !== null) {
127
+ // Strip trailing punctuation that was captured by the greedy regex
128
+ const cleanUrl = match[0].replace(TRAILING_PUNCTUATION_RE, "");
129
+ const beforeBytes = encoder.encode(text.slice(0, match.index));
130
+ const matchBytes = encoder.encode(cleanUrl);
131
+ facets.push({
132
+ index: {
133
+ byteStart: beforeBytes.length,
134
+ byteEnd: beforeBytes.length + matchBytes.length,
135
+ },
136
+ features: [{ $type: "app.bsky.richtext.facet#link", uri: cleanUrl }],
137
+ });
138
+ }
139
+
140
+ // Detect hashtags
141
+ HASHTAG_REGEX.lastIndex = 0;
142
+ while ((match = HASHTAG_REGEX.exec(text)) !== null) {
143
+ const tag = match[1];
144
+ if (!tag) continue;
145
+
146
+ // Include the # in the byte range
147
+ const beforeBytes = encoder.encode(text.slice(0, match.index));
148
+ const matchBytes = encoder.encode(match[0]);
149
+ facets.push({
150
+ index: {
151
+ byteStart: beforeBytes.length,
152
+ byteEnd: beforeBytes.length + matchBytes.length,
153
+ },
154
+ features: [{ $type: "app.bsky.richtext.facet#tag", tag }],
155
+ });
156
+ }
157
+
158
+ return facets;
159
+ }
160
+
161
+ // ── Utilities ───────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Truncate a string to a maximum number of graphemes.
165
+ * Uses Intl.Segmenter for correct Unicode handling.
166
+ */
167
+ function truncateGraphemes(text: string, maxGraphemes: number): string {
168
+ // Intl.Segmenter handles multi-codepoint graphemes (emoji, combining chars)
169
+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
170
+ const segments = [...segmenter.segment(text)];
171
+
172
+ if (segments.length <= maxGraphemes) return text;
173
+
174
+ // Truncate and add ellipsis
175
+ return (
176
+ segments
177
+ .slice(0, maxGraphemes - 1)
178
+ .map((s) => s.segment)
179
+ .join("") + "\u2026"
180
+ );
181
+ }
182
+
183
+ function stripTrailingSlash(url: string): string {
184
+ return url.endsWith("/") ? url.slice(0, -1) : url;
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * AT Protocol / standard.site Plugin for EmDash CMS
3
+ *
4
+ * Syndicates published content to the AT Protocol network using the
5
+ * standard.site lexicons, with optional cross-posting to Bluesky.
6
+ *
7
+ * Features:
8
+ * - Creates site.standard.publication record (one per site)
9
+ * - Creates site.standard.document records on publish
10
+ * - Optional Bluesky cross-post with link card
11
+ * - Automatic <link rel="site.standard.document"> injection via page:metadata
12
+ * - Sync status tracking in plugin storage
13
+ *
14
+ * Designed for sandboxed execution:
15
+ * - All HTTP via ctx.http.fetch()
16
+ * - Block Kit admin UI (no React components)
17
+ * - Capabilities: read:content, network:fetch:any
18
+ */
19
+
20
+ import type { PluginDescriptor } from "emdash";
21
+
22
+ // ── Descriptor ──────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Create the AT Protocol plugin descriptor.
26
+ * Import this in your astro.config.mjs / live.config.ts.
27
+ */
28
+ export function atprotoPlugin(): PluginDescriptor {
29
+ return {
30
+ id: "atproto",
31
+ version: "0.1.0",
32
+ format: "standard",
33
+ entrypoint: "@emdash-cms/plugin-atproto/sandbox",
34
+ capabilities: ["read:content", "network:fetch:any"],
35
+ storage: {
36
+ publications: { indexes: ["contentId", "platform", "publishedAt"] },
37
+ },
38
+ // Block Kit admin pages (no adminEntry needed -- sandboxed)
39
+ adminPages: [{ path: "/status", label: "AT Protocol", icon: "globe" }],
40
+ adminWidgets: [{ id: "sync-status", title: "AT Protocol", size: "third" }],
41
+ };
42
+ }