Skip to content

Commit dcc2a0d

Browse files
ascorbicclaude
andauthored
feat(pds): add putRecord endpoint for profile editing (#14)
Add com.atproto.repo.putRecord endpoint that supports creating or updating records. This enables profile editing (displayName, description, avatar, etc.) via the standard AT Protocol API. - Add rpcPutRecord method to AccountDurableObject (create or update logic) - Add putRecord XRPC handler in repo.ts - Register PUT record route in index.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3f7afaa commit dcc2a0d

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

packages/pds/src/account-do.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,94 @@ export class AccountDurableObject extends DurableObject<Env> {
376376
};
377377
}
378378

379+
/**
380+
* RPC method: Put a record (create or update)
381+
*/
382+
async rpcPutRecord(
383+
collection: string,
384+
rkey: string,
385+
record: unknown,
386+
): Promise<{
387+
uri: string;
388+
cid: string;
389+
commit: { cid: string; rev: string };
390+
validationStatus: string;
391+
}> {
392+
const repo = await this.getRepo();
393+
const keypair = await this.getKeypair();
394+
395+
// Check if record exists to determine create vs update
396+
const existing = await repo.getRecord(collection, rkey);
397+
const isUpdate = existing !== null;
398+
399+
const op: RecordWriteOp = isUpdate
400+
? ({
401+
action: WriteOpAction.Update,
402+
collection,
403+
rkey,
404+
record: record as RepoRecord,
405+
} as RecordUpdateOp)
406+
: ({
407+
action: WriteOpAction.Create,
408+
collection,
409+
rkey,
410+
record: record as RepoRecord,
411+
} as RecordCreateOp);
412+
413+
const prevRev = repo.commit.rev;
414+
const updatedRepo = await repo.applyWrites([op], keypair);
415+
this.repo = updatedRepo;
416+
417+
// Get the CID for the record from the MST
418+
const dataKey = `${collection}/${rkey}`;
419+
const recordCid = await this.repo.data.get(dataKey);
420+
421+
if (!recordCid) {
422+
throw new Error(`Failed to put record: ${collection}/${rkey}`);
423+
}
424+
425+
// Sequence the commit for firehose
426+
if (this.sequencer) {
427+
const newBlocks = new BlockMap();
428+
const rows = this.ctx.storage.sql
429+
.exec(
430+
"SELECT cid, bytes FROM blocks WHERE rev = ?",
431+
this.repo.commit.rev,
432+
)
433+
.toArray();
434+
435+
for (const row of rows) {
436+
const cid = CID.parse(row.cid as string);
437+
const bytes = new Uint8Array(row.bytes as ArrayBuffer);
438+
newBlocks.set(cid, bytes);
439+
}
440+
441+
const opWithCid = { ...op, cid: recordCid };
442+
443+
const commitData: CommitData = {
444+
did: this.repo.did,
445+
commit: this.repo.cid,
446+
rev: this.repo.commit.rev,
447+
since: prevRev,
448+
newBlocks,
449+
ops: [opWithCid],
450+
};
451+
452+
const event = await this.sequencer.sequenceCommit(commitData);
453+
await this.broadcastCommit(event);
454+
}
455+
456+
return {
457+
uri: AtUri.make(this.repo.did, collection, rkey).toString(),
458+
cid: recordCid.toString(),
459+
commit: {
460+
cid: this.repo.cid.toString(),
461+
rev: this.repo.commit.rev,
462+
},
463+
validationStatus: "valid",
464+
};
465+
}
466+
379467
/**
380468
* RPC method: Apply multiple writes (batch create/update/delete)
381469
*/

packages/pds/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ app.post("/xrpc/com.atproto.repo.uploadBlob", requireAuth, (c) =>
178178
app.post("/xrpc/com.atproto.repo.applyWrites", requireAuth, (c) =>
179179
repo.applyWrites(c, getAccountDO(c.env)),
180180
);
181+
app.post("/xrpc/com.atproto.repo.putRecord", requireAuth, (c) =>
182+
repo.putRecord(c, getAccountDO(c.env)),
183+
);
181184

182185
// Server identity
183186
app.get("/xrpc/com.atproto.server.describeServer", server.describeServer);

packages/pds/src/xrpc/repo.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,47 @@ export async function deleteRecord(
253253
return c.json(result);
254254
}
255255

256+
export async function putRecord(
257+
c: Context<{ Bindings: Env }>,
258+
accountDO: DurableObjectStub<AccountDurableObject>,
259+
): Promise<Response> {
260+
const body = await c.req.json();
261+
const { repo, collection, rkey, record } = body;
262+
263+
if (!repo || !collection || !rkey || !record) {
264+
return c.json(
265+
{
266+
error: "InvalidRequest",
267+
message: "Missing required parameters: repo, collection, rkey, record",
268+
},
269+
400,
270+
);
271+
}
272+
273+
if (repo !== c.env.DID) {
274+
return c.json(
275+
{
276+
error: "InvalidRepo",
277+
message: `Invalid repository: ${repo}`,
278+
},
279+
400,
280+
);
281+
}
282+
283+
try {
284+
const result = await accountDO.rpcPutRecord(collection, rkey, record);
285+
return c.json(result);
286+
} catch (err) {
287+
return c.json(
288+
{
289+
error: "InvalidRequest",
290+
message: err instanceof Error ? err.message : String(err),
291+
},
292+
400,
293+
);
294+
}
295+
}
296+
256297
export async function applyWrites(
257298
c: Context<{ Bindings: Env }>,
258299
accountDO: DurableObjectStub<AccountDurableObject>,

0 commit comments

Comments
 (0)