Skip to content

Commit 92a2b39

Browse files
ascorbicclaude
andauthored
fix(pds): normalize JSON blob refs for correct dag-cbor encoding (#119)
* fix(pds): normalize JSON blob refs for correct dag-cbor encoding Incoming API records contain blob references with nested $link objects (e.g. { "$type": "blob", "ref": { "$link": "bafk..." } }). Without normalization, @atproto/repo's lexToIpld walks these as plain objects, encoding the ref as a CBOR map instead of a CID tag. This produces incorrect block hashes that cause blob resolution failures downstream. Add normalizeRecordLinks() to convert $link → CID and $bytes → Uint8Array before records hit repo.applyWrites(). Applied on all write paths: createRecord, putRecord, applyWrites. Also handle Uint8Array → $bytes serialization on the read path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: use @atproto/lexicon jsonToLex instead of hand-rolled normalization Replace the custom normalizeRecordLinks implementation with a re-export of jsonToLex from @atproto/lexicon. This is better because: - Battle-tested against edge cases the custom impl may miss - Also converts blob refs to proper BlobRef instances (not just CIDs) - One line instead of 75 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: use jsonToLex directly instead of format.ts alias Import jsonToLex from @atproto/lexicon directly in account-do.ts instead of re-exporting it as normalizeRecordLinks from format.ts. Removes the unnecessary alias and the test file that was just testing library behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: use @atproto/lex-json directly, remove @atproto/lexicon dep * fix: regenerate lockfile without spurious dep resolution changes The previous lockfile bumped @atproto/common-web from 0.4.7 to 0.4.8 inside @atproto/lexicon, which likely caused CI test failures in vitest-pool-workers. Reset from main and cleanly added only @atproto/lex-json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test(pds): add blob ref normalization integration tests Verifies that JSON-serialized blob refs ({"$link": "..."}) are properly normalized to CID objects when stored in the repo via createRecord, putRecord, and applyWrites. Uses raw repo access to confirm internal representation matches expectations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Lock freshen * Use vitest beta --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82301c5 commit 92a2b39

File tree

7 files changed

+1588
-1380
lines changed

7 files changed

+1588
-1380
lines changed

.changeset/normalize-blob-refs.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@getcirrus/pds": patch
3+
---
4+
5+
Normalize JSON blob references for correct dag-cbor encoding
6+
7+
Incoming API records contain blob references with nested `$link` objects
8+
(for example, `{ "$type": "blob", "ref": { "$link": "bafk..." } }`). These
9+
must be converted to actual CID instances before CBOR encoding, otherwise
10+
the blob ref's `ref` field gets encoded as a map instead of a proper CID tag.
11+
This causes incorrect block hashes, which can lead to blob resolution failures
12+
on the Bluesky network.
13+
14+
Uses `jsonToLex` from `@atproto/lex-json` to convert `$link` → CID and
15+
`$bytes` → Uint8Array on all record write paths (createRecord, putRecord,
16+
applyWrites).

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@
2020
"knip": "^5.78.0",
2121
"prettier": "^3.7.4",
2222
"typescript": "^5.9.3",
23-
"vitest": "^3.2.4"
23+
"vitest": "4.1.0-beta.1"
2424
},
25-
"packageManager": "pnpm@10.26.2"
25+
"packageManager": "pnpm@10.26.2",
26+
"pnpm": {
27+
"overrides": {
28+
"wrangler": "^4.63.0",
29+
"miniflare": "^4.20260205.0"
30+
}
31+
}
2632
}

packages/oauth-provider/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"publint": "^0.3.16",
3131
"tsdown": "^0.18.3",
3232
"typescript": "^5.9.3",
33-
"vitest": "^4.0.0"
33+
"vitest": "4.1.0-beta.1"
3434
},
3535
"repository": {
3636
"type": "git",

packages/pds/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@atproto/crypto": "^0.4.5",
3535
"@atproto/lex-cbor": "^0.0.3",
3636
"@atproto/lex-data": "^0.0.3",
37+
"@atproto/lex-json": "^0.0.11",
3738
"@atproto/repo": "^0.8.12",
3839
"@clack/prompts": "^0.11.0",
3940
"@getcirrus/oauth-provider": "workspace:*",
@@ -59,7 +60,7 @@
5960
"tsx": "^4.21.0",
6061
"typescript": "^5.9.3",
6162
"vite": "^6.4.1",
62-
"vitest": "^4.0.0",
63+
"vitest": "4.1.0-beta.1",
6364
"wrangler": "^4.54.0",
6465
"ws": "^8.18.3"
6566
},

packages/pds/src/account-do.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type CommitData,
2828
} from "./sequencer";
2929
import { BlobStore, type BlobRef } from "./blobs";
30+
import { jsonToLex } from "@atproto/lex-json";
3031
import type { PDSEnv } from "./types";
3132

3233
/**
@@ -330,7 +331,7 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
330331
action: WriteOpAction.Create,
331332
collection,
332333
rkey: actualRkey,
333-
record: record as RepoRecord,
334+
record: jsonToLex(record) as RepoRecord,
334335
};
335336

336337
const prevRev = repo.commit.rev;
@@ -471,18 +472,19 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
471472
const existing = await repo.getRecord(collection, rkey);
472473
const isUpdate = existing !== null;
473474

475+
const normalizedRecord = jsonToLex(record) as RepoRecord;
474476
const op: RecordWriteOp = isUpdate
475477
? ({
476478
action: WriteOpAction.Update,
477479
collection,
478480
rkey,
479-
record: record as RepoRecord,
481+
record: normalizedRecord,
480482
} as RecordUpdateOp)
481483
: ({
482484
action: WriteOpAction.Create,
483485
collection,
484486
rkey,
485-
record: record as RepoRecord,
487+
record: normalizedRecord,
486488
} as RecordCreateOp);
487489

488490
const prevRev = repo.commit.rev;
@@ -581,7 +583,7 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
581583
action: WriteOpAction.Create,
582584
collection: write.collection,
583585
rkey,
584-
record: write.value as RepoRecord,
586+
record: jsonToLex(write.value) as RepoRecord,
585587
};
586588
ops.push(op);
587589
results.push({
@@ -598,7 +600,7 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
598600
action: WriteOpAction.Update,
599601
collection: write.collection,
600602
rkey: write.rkey,
601-
record: write.value as RepoRecord,
603+
record: jsonToLex(write.value) as RepoRecord,
602604
};
603605
ops.push(op);
604606
results.push({
@@ -1528,6 +1530,15 @@ function serializeRecord(obj: unknown): unknown {
15281530
return { $link: cid.toString() };
15291531
}
15301532

1533+
// Convert Uint8Array to { $bytes: "<base64>" }
1534+
if (obj instanceof Uint8Array) {
1535+
let binary = "";
1536+
for (let i = 0; i < obj.length; i++) {
1537+
binary += String.fromCharCode(obj[i]!);
1538+
}
1539+
return { $bytes: btoa(binary) };
1540+
}
1541+
15311542
if (Array.isArray(obj)) {
15321543
return obj.map(serializeRecord);
15331544
}

0 commit comments

Comments
 (0)