[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

getRecord: allow passing in handle in at uri

Florian (Jun 7, 2026, 9:56 PM +0200) 1aeee9a3 a000ff92

+175 -13
+23
.changeset/getrecord-handle-authority.md
··· 1 + --- 2 + "@atmo-dev/contrail-appview": minor 3 + --- 4 + 5 + feat(getRecord): resolve a handle in the URI authority 6 + 7 + `<ns>.<collection>.getRecord` now accepts an AT-URI whose authority is a handle 8 + (e.g. `at://alice.bsky.social/<coll>/<rkey>`), not just a DID. The authority is 9 + resolved through the same `resolveActor` the actor-param endpoints 10 + (`listRecords`/`getProfile`/`getFeed`) already use — local-first via the indexed 11 + `identities` table, network only on a miss — so a handle-routed consumer can 12 + hand the URI straight to `getRecord` instead of resolving handle→DID itself. 13 + 14 + Fully backward compatible: a DID authority resolves to itself unchanged, so 15 + existing DID-URI callers are unaffected. Applies to both the public and 16 + per-space (`?spaceUri=`) paths. `getRecord` stays a fast read — no blocking 17 + backfill is added. Unresolvable authority → 400 (matching `listRecords`), 18 + missing record → 404. Parsing now uses atcute's `parseResourceUri`, which 19 + validates the actor / NSID / record-key shapes, so a syntactically invalid 20 + `uri` returns 400 instead of silently 404ing. 21 + 22 + Internal: the hand-rolled `parseAtUri` (notify) is reimplemented over atcute's 23 + `parseCanonicalResourceUri`; its signature is unchanged.
+19 -9
packages/contrail-appview/src/core/router/collection.ts
··· 23 23 import { hashInviteToken } from "../invite/token"; 24 24 import type { SpacesContext } from "."; 25 25 import type { Nsid } from "@atcute/lexicons"; 26 + import { parseResourceUri } from "@atcute/lexicons/syntax"; 26 27 import type { RealtimeEvent } from "../realtime/types"; 27 28 import { sseResponse } from "../realtime/sse"; 28 29 import { spaceTopic, communityTopic, parseSpaceTopic } from "../realtime/types"; ··· 1043 1044 } 1044 1045 1045 1046 app.get(`/xrpc/${ns}.${collection}.getRecord`, async (c) => { 1046 - const uri = c.req.query("uri"); 1047 - if (!uri) return c.json({ error: "uri parameter required" }, 400); 1047 + const rawUri = c.req.query("uri"); 1048 + if (!rawUri) return c.json({ error: "uri parameter required" }, 400); 1049 + 1050 + // Parse the AT-URI (atcute validates the actor / NSID / rkey shapes) and 1051 + // resolve the authority to a DID up front — like the actor-param 1052 + // endpoints, the authority may be a handle. resolveActor is local-first 1053 + // (identities table, indexed) and returns a DID unchanged, so DID-URI 1054 + // callers are unaffected. The `rkey` guard rejects repo-only / 1055 + // collection-only URIs, narrowing to a full record URI. 1056 + const parsed = parseResourceUri(rawUri); 1057 + if (!parsed.ok || parsed.value.rkey === undefined) { 1058 + return c.json({ error: "InvalidRequest", message: "uri must be at://<actor>/<collection>/<rkey>" }, 400); 1059 + } 1060 + const did = await resolveActor(db, parsed.value.repo, config); 1061 + if (!did) return c.json({ error: "Could not resolve actor" }, 400); 1062 + const rkey = parsed.value.rkey; 1063 + const uri = `at://${did}/${parsed.value.collection}/${rkey}`; 1048 1064 1049 1065 // Spaces path — `?spaceUri=` routes to the per-space store + ACL gate. 1050 1066 const spaceUri = c.req.query("spaceUri") || undefined; ··· 1052 1068 const gated = await gateSpaceAccess(c, spaceUri, "read"); 1053 1069 if (gated instanceof Response) return gated; 1054 1070 1055 - // Parse author + rkey from the record uri `at://<did>/<collection>/<rkey>` 1056 - const m = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/); 1057 - if (!m) return c.json({ error: "InvalidRequest", message: "uri must be at://<did>/<collection>/<rkey>" }, 400); 1058 - const authorDid = m[1]; 1059 - const rkey = m[2]; 1060 - 1061 1071 const nsid = colConfig.collection; 1062 - const record = await spacesCtx!.adapter.getRecord(spaceUri, nsid, authorDid, rkey); 1072 + const record = await spacesCtx!.adapter.getRecord(spaceUri, nsid, did, rkey); 1063 1073 if (!record) return c.json({ error: "NotFound" }, 404); 1064 1074 return c.json({ record }); 1065 1075 }
+8 -4
packages/contrail-appview/src/core/router/notify.ts
··· 4 4 import { applyEvents, lookupExistingRecords } from "../db/records"; 5 5 import { getPDS } from "../client"; 6 6 import type { Did } from "@atcute/lexicons"; 7 + import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; 7 8 8 - /** Parse an AT URI into its components. */ 9 + /** Parse a canonical (DID-authority) record AT-URI into its components, or null 10 + * if it isn't a valid full record URI. Backed by atcute's validator, which 11 + * also enforces the DID / NSID / record-key character classes. */ 9 12 export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null { 10 - const match = uri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/]+)$/); 11 - if (!match) return null; 12 - return { did: match[1], collection: match[2], rkey: match[3] }; 13 + const parsed = parseCanonicalResourceUri(uri); 14 + if (!parsed.ok) return null; 15 + const { repo, collection, rkey } = parsed.value; 16 + return { did: repo, collection, rkey }; 13 17 } 14 18 15 19 /**
+125
packages/contrail/tests/get-record-handle.test.ts
··· 1 + /** getRecord resolves a handle in the URI authority (like the actor-param 2 + * endpoints) via resolveActor — local-first, network only on a miss. DID URIs 3 + * are unaffected (resolveActor returns a DID unchanged). */ 4 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 5 + import { Contrail } from "../src/contrail"; 6 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 7 + import { applyEvents } from "../src/core/db/records"; 8 + import { __resetPdsCachesForTests } from "../src/core/client"; 9 + import type { Database, IngestEvent } from "../src/core/types"; 10 + 11 + const COLL = "com.example.event"; 12 + const AUTHOR = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; 13 + const HANDLE = "alice.example.com"; 14 + const RKEY = "rec1"; 15 + const URI = `at://${AUTHOR}/${COLL}/${RKEY}`; 16 + 17 + function ev(): IngestEvent { 18 + const now = Date.now() * 1000; 19 + return { 20 + uri: URI, 21 + did: AUTHOR, 22 + collection: COLL, 23 + rkey: RKEY, 24 + operation: "create", 25 + cid: "bafy-1", 26 + record: JSON.stringify({ name: "Test Event" }), 27 + time_us: now, 28 + indexed_at: now, 29 + }; 30 + } 31 + 32 + async function setup() { 33 + const db = createSqliteDatabase(":memory:"); 34 + const contrail = new Contrail({ 35 + namespace: "ex", 36 + collections: { event: { collection: COLL } }, 37 + db, 38 + }); 39 + await contrail.init(); 40 + await applyEvents(db, [ev()], contrail.config); 41 + return { db, contrail }; 42 + } 43 + 44 + async function seedIdentity(db: Database, did: string, handle: string) { 45 + await db 46 + .prepare( 47 + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)" 48 + ) 49 + .bind(did, handle, null, Date.now()) 50 + .run(); 51 + } 52 + 53 + function getRecord(contrail: Contrail, uri: string) { 54 + return contrail 55 + .app() 56 + .fetch( 57 + new Request( 58 + `http://localhost/xrpc/ex.event.getRecord?uri=${encodeURIComponent(uri)}` 59 + ) 60 + ); 61 + } 62 + 63 + let fetchSpy: ReturnType<typeof vi.spyOn>; 64 + 65 + beforeEach(() => { 66 + __resetPdsCachesForTests(); 67 + fetchSpy = vi.spyOn(global, "fetch"); 68 + }); 69 + afterEach(() => { 70 + fetchSpy.mockRestore(); 71 + __resetPdsCachesForTests(); 72 + }); 73 + 74 + describe("getRecord authority resolution", () => { 75 + it("returns the record for a DID URI (unchanged behavior, no network)", async () => { 76 + const { contrail } = await setup(); 77 + const res = await getRecord(contrail, URI); 78 + expect(res.status).toBe(200); 79 + const body = (await res.json()) as { uri: string }; 80 + expect(body.uri).toBe(URI); 81 + expect(fetchSpy).not.toHaveBeenCalled(); 82 + }); 83 + 84 + it("resolves a handle URI from identities (no network) and returns the canonical record", async () => { 85 + const { db, contrail } = await setup(); 86 + await seedIdentity(db, AUTHOR, HANDLE); 87 + 88 + const res = await getRecord(contrail, `at://${HANDLE}/${COLL}/${RKEY}`); 89 + expect(res.status).toBe(200); 90 + const body = (await res.json()) as { uri: string; did: string }; 91 + expect(body.uri).toBe(URI); // canonicalized to the DID URI 92 + expect(body.did).toBe(AUTHOR); 93 + expect(fetchSpy).not.toHaveBeenCalled(); // served from identities 94 + }); 95 + 96 + it("400s on a malformed URI", async () => { 97 + const { contrail } = await setup(); 98 + const res = await getRecord(contrail, "not-an-at-uri"); 99 + expect(res.status).toBe(400); 100 + expect(fetchSpy).not.toHaveBeenCalled(); 101 + }); 102 + 103 + it("400s on a non-record URI (repo only, no collection/rkey)", async () => { 104 + const { contrail } = await setup(); 105 + const res = await getRecord(contrail, `at://${AUTHOR}`); 106 + expect(res.status).toBe(400); 107 + }); 108 + 109 + it("404s when the actor resolves but the record doesn't exist", async () => { 110 + const { contrail } = await setup(); 111 + const res = await getRecord(contrail, `at://${AUTHOR}/${COLL}/missing`); 112 + expect(res.status).toBe(404); 113 + }); 114 + 115 + it("400s when a handle can't be resolved (local miss + network failure)", async () => { 116 + const { contrail } = await setup(); 117 + // No identities row for this handle; slingshot returns non-ok. 118 + fetchSpy.mockResolvedValue(new Response("", { status: 404 })); 119 + 120 + const res = await getRecord(contrail, `at://bob.example.com/${COLL}/${RKEY}`); 121 + expect(res.status).toBe(400); 122 + const body = (await res.json()) as { error: string }; 123 + expect(body.error).toBe("Could not resolve actor"); 124 + }); 125 + });