From 284ca2ec42a4557ecbf3902bf108c051fd1be804 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 06:14:40 +0000 Subject: [PATCH] feat: implement app.bsky.actor preferences methods Implements persistent storage and retrieval of user preferences for the AT Protocol PDS. This allows Bluesky apps and other clients to store user settings such as content filters, saved feeds, muted words, etc. Changes: - Add preferences table to SQLite schema in storage.ts - Implement getPreferences() and putPreferences() methods in SqliteRepoStorage - Add rpcGetPreferences() and rpcPutPreferences() RPC methods to AccountDurableObject - Replace stub implementations in index.ts with full functionality - Add comprehensive test suite covering all preference operations All tests pass (126 total, including 7 new preferences tests). --- packages/pds/src/account-do.ts | 17 ++ packages/pds/src/index.ts | 12 +- packages/pds/src/storage.ts | 35 ++++ packages/pds/test/preferences.test.ts | 263 ++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 packages/pds/test/preferences.test.ts diff --git a/packages/pds/src/account-do.ts b/packages/pds/src/account-do.ts index 991084f8..5e9621b3 100644 --- a/packages/pds/src/account-do.ts +++ b/packages/pds/src/account-do.ts @@ -914,6 +914,23 @@ export class AccountDurableObject extends DurableObject { console.error("WebSocket error:", error); } + /** + * RPC method: Get user preferences + */ + async rpcGetPreferences(): Promise<{ preferences: unknown[] }> { + const storage = await this.getStorage(); + const preferences = await storage.getPreferences(); + return { preferences }; + } + + /** + * RPC method: Put user preferences + */ + async rpcPutPreferences(preferences: unknown[]): Promise { + const storage = await this.getStorage(); + await storage.putPreferences(preferences); + } + /** * Emit an identity event to notify downstream services to refresh identity cache. */ diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 627eeef6..050144cd 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -222,12 +222,16 @@ app.get( server.getServiceAuth, ); -// Actor preferences (stub - returns empty preferences) -app.get("/xrpc/app.bsky.actor.getPreferences", requireAuth, (c) => { - return c.json({ preferences: [] }); +// Actor preferences +app.get("/xrpc/app.bsky.actor.getPreferences", requireAuth, async (c) => { + const accountDO = getAccountDO(c.env); + const result = await accountDO.rpcGetPreferences(); + return c.json(result); }); app.post("/xrpc/app.bsky.actor.putPreferences", requireAuth, async (c) => { - // TODO: persist preferences in DO + const body = await c.req.json<{ preferences: unknown[] }>(); + const accountDO = getAccountDO(c.env); + await accountDO.rpcPutPreferences(body.preferences); return c.json({}); }); diff --git a/packages/pds/src/storage.ts b/packages/pds/src/storage.ts index 8a8e7047..e17f558b 100644 --- a/packages/pds/src/storage.ts +++ b/packages/pds/src/storage.ts @@ -51,6 +51,15 @@ export class SqliteRepoStorage ); CREATE INDEX IF NOT EXISTS idx_firehose_created_at ON firehose_events(created_at); + + -- User preferences (single row, stores JSON array) + CREATE TABLE IF NOT EXISTS preferences ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL DEFAULT '[]' + ); + + -- Initialize with empty preferences array if not exists + INSERT OR IGNORE INTO preferences (id, data) VALUES (1, '[]'); `); } @@ -245,4 +254,30 @@ export class SqliteRepoStorage .toArray(); return rows.length > 0 ? ((rows[0]!.count as number) ?? 0) : 0; } + + /** + * Get user preferences. + */ + async getPreferences(): Promise { + const rows = this.sql + .exec("SELECT data FROM preferences WHERE id = 1") + .toArray(); + if (rows.length === 0 || !rows[0]?.data) { + return []; + } + const data = rows[0]!.data as string; + try { + return JSON.parse(data); + } catch { + return []; + } + } + + /** + * Update user preferences. + */ + async putPreferences(preferences: unknown[]): Promise { + const data = JSON.stringify(preferences); + this.sql.exec("UPDATE preferences SET data = ? WHERE id = 1", data); + } } diff --git a/packages/pds/test/preferences.test.ts b/packages/pds/test/preferences.test.ts new file mode 100644 index 00000000..c492bc40 --- /dev/null +++ b/packages/pds/test/preferences.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect } from "vitest"; +import { env, worker } from "./helpers"; + +describe("Preferences", () => { + describe("getPreferences", () => { + it("returns empty preferences by default", async () => { + const response = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.getPreferences", { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }), + env, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { preferences: unknown[] }; + expect(body.preferences).toEqual([]); + }); + + it("requires authentication", async () => { + const response = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.getPreferences"), + env, + ); + + expect(response.status).toBe(401); + }); + }); + + describe("putPreferences", () => { + it("persists preferences", async () => { + const preferences = [ + { + $type: "app.bsky.actor.defs#adultContentPref", + enabled: true, + }, + { + $type: "app.bsky.actor.defs#contentLabelPref", + label: "nsfw", + visibility: "warn", + }, + ]; + + // Put preferences + const putResponse = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ preferences }), + }), + env, + ); + + expect(putResponse.status).toBe(200); + + // Get preferences to verify persistence + const getResponse = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.getPreferences", { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }), + env, + ); + + expect(getResponse.status).toBe(200); + const body = (await getResponse.json()) as { preferences: unknown[] }; + expect(body.preferences).toEqual(preferences); + }); + + it("updates existing preferences", async () => { + const initialPreferences = [ + { + $type: "app.bsky.actor.defs#adultContentPref", + enabled: false, + }, + ]; + + const updatedPreferences = [ + { + $type: "app.bsky.actor.defs#adultContentPref", + enabled: true, + }, + { + $type: "app.bsky.actor.defs#threadViewPref", + sort: "oldest", + }, + ]; + + // Set initial preferences + await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ preferences: initialPreferences }), + }), + env, + ); + + // Update preferences + await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ preferences: updatedPreferences }), + }), + env, + ); + + // Verify updated preferences + const getResponse = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.getPreferences", { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }), + env, + ); + + const body = (await getResponse.json()) as { preferences: unknown[] }; + expect(body.preferences).toEqual(updatedPreferences); + }); + + it("handles complex preference types", async () => { + const preferences = [ + { + $type: "app.bsky.actor.defs#savedFeedsPrefV2", + items: [ + { + id: "feed1", + type: "feed", + value: "at://did:web:example.com/app.bsky.feed.generator/feed1", + pinned: true, + }, + { + id: "feed2", + type: "timeline", + value: "timeline", + pinned: false, + }, + ], + }, + { + $type: "app.bsky.actor.defs#mutedWordsPref", + items: [ + { + value: "spam", + targets: ["content", "tag"], + actorTarget: "all", + }, + ], + }, + { + $type: "app.bsky.actor.defs#labelersPref", + labelers: [ + { + did: "did:web:labeler.example.com", + }, + ], + }, + ]; + + // Put complex preferences + await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ preferences }), + }), + env, + ); + + // Verify persistence + const getResponse = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.getPreferences", { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }), + env, + ); + + const body = (await getResponse.json()) as { preferences: unknown[] }; + expect(body.preferences).toEqual(preferences); + }); + + it("requires authentication", async () => { + const response = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ preferences: [] }), + }), + env, + ); + + expect(response.status).toBe(401); + }); + + it("handles empty preferences array", async () => { + // Set some preferences first + await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ + preferences: [ + { + $type: "app.bsky.actor.defs#adultContentPref", + enabled: true, + }, + ], + }), + }), + env, + ); + + // Clear preferences with empty array + await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.putPreferences", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ preferences: [] }), + }), + env, + ); + + // Verify preferences are cleared + const getResponse = await worker.fetch( + new Request("http://pds.test/xrpc/app.bsky.actor.getPreferences", { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + }), + env, + ); + + const body = (await getResponse.json()) as { preferences: unknown[] }; + expect(body.preferences).toEqual([]); + }); + }); +});