···11+---
22+"@atmo-dev/contrail-appview": minor
33+---
44+55+feat(getRecord): resolve a handle in the URI authority
66+77+`<ns>.<collection>.getRecord` now accepts an AT-URI whose authority is a handle
88+(e.g. `at://alice.bsky.social/<coll>/<rkey>`), not just a DID. The authority is
99+resolved through the same `resolveActor` the actor-param endpoints
1010+(`listRecords`/`getProfile`/`getFeed`) already use — local-first via the indexed
1111+`identities` table, network only on a miss — so a handle-routed consumer can
1212+hand the URI straight to `getRecord` instead of resolving handle→DID itself.
1313+1414+Fully backward compatible: a DID authority resolves to itself unchanged, so
1515+existing DID-URI callers are unaffected. Applies to both the public and
1616+per-space (`?spaceUri=`) paths. `getRecord` stays a fast read — no blocking
1717+backfill is added. Unresolvable authority → 400 (matching `listRecords`),
1818+missing record → 404. Parsing now uses atcute's `parseResourceUri`, which
1919+validates the actor / NSID / record-key shapes, so a syntactically invalid
2020+`uri` returns 400 instead of silently 404ing.
2121+2222+Internal: the hand-rolled `parseAtUri` (notify) is reimplemented over atcute's
2323+`parseCanonicalResourceUri`; its signature is unchanged.
···44import { applyEvents, lookupExistingRecords } from "../db/records";
55import { getPDS } from "../client";
66import type { Did } from "@atcute/lexicons";
77+import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax";
7888-/** Parse an AT URI into its components. */
99+/** Parse a canonical (DID-authority) record AT-URI into its components, or null
1010+ * if it isn't a valid full record URI. Backed by atcute's validator, which
1111+ * also enforces the DID / NSID / record-key character classes. */
912export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null {
1010- const match = uri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/]+)$/);
1111- if (!match) return null;
1212- return { did: match[1], collection: match[2], rkey: match[3] };
1313+ const parsed = parseCanonicalResourceUri(uri);
1414+ if (!parsed.ok) return null;
1515+ const { repo, collection, rkey } = parsed.value;
1616+ return { did: repo, collection, rkey };
1317}
14181519/**
+125
packages/contrail/tests/get-record-handle.test.ts
···11+/** getRecord resolves a handle in the URI authority (like the actor-param
22+ * endpoints) via resolveActor — local-first, network only on a miss. DID URIs
33+ * are unaffected (resolveActor returns a DID unchanged). */
44+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
55+import { Contrail } from "../src/contrail";
66+import { createSqliteDatabase } from "../src/adapters/sqlite";
77+import { applyEvents } from "../src/core/db/records";
88+import { __resetPdsCachesForTests } from "../src/core/client";
99+import type { Database, IngestEvent } from "../src/core/types";
1010+1111+const COLL = "com.example.event";
1212+const AUTHOR = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa";
1313+const HANDLE = "alice.example.com";
1414+const RKEY = "rec1";
1515+const URI = `at://${AUTHOR}/${COLL}/${RKEY}`;
1616+1717+function ev(): IngestEvent {
1818+ const now = Date.now() * 1000;
1919+ return {
2020+ uri: URI,
2121+ did: AUTHOR,
2222+ collection: COLL,
2323+ rkey: RKEY,
2424+ operation: "create",
2525+ cid: "bafy-1",
2626+ record: JSON.stringify({ name: "Test Event" }),
2727+ time_us: now,
2828+ indexed_at: now,
2929+ };
3030+}
3131+3232+async function setup() {
3333+ const db = createSqliteDatabase(":memory:");
3434+ const contrail = new Contrail({
3535+ namespace: "ex",
3636+ collections: { event: { collection: COLL } },
3737+ db,
3838+ });
3939+ await contrail.init();
4040+ await applyEvents(db, [ev()], contrail.config);
4141+ return { db, contrail };
4242+}
4343+4444+async function seedIdentity(db: Database, did: string, handle: string) {
4545+ await db
4646+ .prepare(
4747+ "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)"
4848+ )
4949+ .bind(did, handle, null, Date.now())
5050+ .run();
5151+}
5252+5353+function getRecord(contrail: Contrail, uri: string) {
5454+ return contrail
5555+ .app()
5656+ .fetch(
5757+ new Request(
5858+ `http://localhost/xrpc/ex.event.getRecord?uri=${encodeURIComponent(uri)}`
5959+ )
6060+ );
6161+}
6262+6363+let fetchSpy: ReturnType<typeof vi.spyOn>;
6464+6565+beforeEach(() => {
6666+ __resetPdsCachesForTests();
6767+ fetchSpy = vi.spyOn(global, "fetch");
6868+});
6969+afterEach(() => {
7070+ fetchSpy.mockRestore();
7171+ __resetPdsCachesForTests();
7272+});
7373+7474+describe("getRecord authority resolution", () => {
7575+ it("returns the record for a DID URI (unchanged behavior, no network)", async () => {
7676+ const { contrail } = await setup();
7777+ const res = await getRecord(contrail, URI);
7878+ expect(res.status).toBe(200);
7979+ const body = (await res.json()) as { uri: string };
8080+ expect(body.uri).toBe(URI);
8181+ expect(fetchSpy).not.toHaveBeenCalled();
8282+ });
8383+8484+ it("resolves a handle URI from identities (no network) and returns the canonical record", async () => {
8585+ const { db, contrail } = await setup();
8686+ await seedIdentity(db, AUTHOR, HANDLE);
8787+8888+ const res = await getRecord(contrail, `at://${HANDLE}/${COLL}/${RKEY}`);
8989+ expect(res.status).toBe(200);
9090+ const body = (await res.json()) as { uri: string; did: string };
9191+ expect(body.uri).toBe(URI); // canonicalized to the DID URI
9292+ expect(body.did).toBe(AUTHOR);
9393+ expect(fetchSpy).not.toHaveBeenCalled(); // served from identities
9494+ });
9595+9696+ it("400s on a malformed URI", async () => {
9797+ const { contrail } = await setup();
9898+ const res = await getRecord(contrail, "not-an-at-uri");
9999+ expect(res.status).toBe(400);
100100+ expect(fetchSpy).not.toHaveBeenCalled();
101101+ });
102102+103103+ it("400s on a non-record URI (repo only, no collection/rkey)", async () => {
104104+ const { contrail } = await setup();
105105+ const res = await getRecord(contrail, `at://${AUTHOR}`);
106106+ expect(res.status).toBe(400);
107107+ });
108108+109109+ it("404s when the actor resolves but the record doesn't exist", async () => {
110110+ const { contrail } = await setup();
111111+ const res = await getRecord(contrail, `at://${AUTHOR}/${COLL}/missing`);
112112+ expect(res.status).toBe(404);
113113+ });
114114+115115+ it("400s when a handle can't be resolved (local miss + network failure)", async () => {
116116+ const { contrail } = await setup();
117117+ // No identities row for this handle; slingshot returns non-ok.
118118+ fetchSpy.mockResolvedValue(new Response("", { status: 404 }));
119119+120120+ const res = await getRecord(contrail, `at://bob.example.com/${COLL}/${RKEY}`);
121121+ expect(res.status).toBe(400);
122122+ const body = (await res.json()) as { error: string };
123123+ expect(body.error).toBe("Could not resolve actor");
124124+ });
125125+});