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 c77b75ef..a23ccfd4 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -225,12 +225,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([]); + }); + }); +});