Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/pds/src/account-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,23 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
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<void> {
const storage = await this.getStorage();
await storage.putPreferences(preferences);
}

/**
* Emit an identity event to notify downstream services to refresh identity cache.
*/
Expand Down
12 changes: 8 additions & 4 deletions packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});

Expand Down
35 changes: 35 additions & 0 deletions packages/pds/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '[]');
`);
}

Expand Down Expand Up @@ -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<unknown[]> {
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<void> {
const data = JSON.stringify(preferences);
this.sql.exec("UPDATE preferences SET data = ? WHERE id = 1", data);
}
}
263 changes: 263 additions & 0 deletions packages/pds/test/preferences.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});