Something went wrong. Try again.
Automations and webhooks for the AT Protocol airglow.run
automation webhook atproto atprotocol
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/db/index.js", async () => { const { createTestDb } = await import("../test/db.js"); return { db: await createTestDb() };});
vi.mock("@/automations/pds.js", () => ({ patchArbitraryRecord: vi.fn(),}));
vi.mock("@/pds/resolver.js", async (importOriginal) => { const original = await importOriginal<typeof import("@/pds/resolver.js")>(); return { ...original, fetchRecord: vi.fn(), };});
import { executePatchRecord } from "./patch-record.js";import { patchArbitraryRecord } from "../automations/pds.js";import { fetchRecord } from "../pds/resolver.js";import { db } from "../db/index.js";import { automations, deliveryLogs } from "../db/schema.js";import { makeMatch, makeAutomation, makePatchRecordAction } from "../test/fixtures.js";import { isRetryable } from "./delivery.js";
const mockPatchRecord = vi.mocked(patchArbitraryRecord);const mockFetchRecord = vi.mocked(fetchRecord);
const ALICE_DID = "did:plc:testuser123";const ALICE_DOC_URI = `at://${ALICE_DID}/site.standard.document/abc`;
function makeFetchResult(record: Record<string, unknown>, cid = "cid-base") { return { found: true as const, uri: ALICE_DOC_URI, cid, did: ALICE_DID, collection: "site.standard.document", rkey: "abc", record: { $type: "site.standard.document", ...record }, };}
function makeMatchFor(action: ReturnType<typeof makePatchRecordAction>) { return makeMatch({ automation: { actions: [action] }, event: { did: ALICE_DID, time_us: 1700000000000000, kind: "commit", commit: { rev: "r", operation: "create", collection: "site.standard.document", rkey: "abc", record: { text: "trigger" }, }, }, });}
describe("executePatchRecord — collection mismatch guard", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); mockPatchRecord.mockReset(); mockFetchRecord.mockReset();
await db.delete(deliveryLogs); await db.delete(automations); await db.insert(automations).values(makeAutomation()); });
afterEach(() => { vi.useRealTimers(); });
it("rejects with 400 when the resolved URI's collection differs from targetCollection", async () => { // The action targets `run.airglow.automation` (its default in the fixture // is `site.standard.document`, but we override here). The resolved URI // points at `app.bsky.feed.post`, simulating an attacker-injected item // whose uri redirects the patch to a different collection. const action = makePatchRecordAction({ targetCollection: "run.airglow.automation", baseRecordUri: "at://{{event.did}}/app.bsky.feed.post/abc123", }); const match = makeMatch({ automation: { actions: [action] } }); const result = await executePatchRecord(match, 0);
expect(result.statusCode).toBe(400); expect(result.error).toMatch(/does not match targetCollection/); // PDS write was never attempted — the attack is fully blocked. expect(mockPatchRecord).not.toHaveBeenCalled(); });
it("proceeds normally when the URI's collection matches", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
// Default fixture: targetCollection is "site.standard.document" and // baseRecordUri uses `{{event.commit.collection}}`. We construct a match // whose event.commit.collection is also "site.standard.document" so the // resolved URI's collection matches. const action = makePatchRecordAction({ recordTemplate: '{"title":"new"}', });
const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); expect(mockPatchRecord).toHaveBeenCalledOnce(); });});
describe("executePatchRecord: swapRecord (CID precondition)", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); mockPatchRecord.mockReset(); mockFetchRecord.mockReset(); await db.delete(deliveryLogs); await db.delete(automations); await db.insert(automations).values(makeAutomation()); }); afterEach(() => { vi.useRealTimers(); });
it("passes the fetched CID as swapRecord on the shallow path", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" }, "cid-at-fetch")); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ recordTemplate: '{"title":"new"}' }); await executePatchRecord(makeMatchFor(action), 0);
expect(mockPatchRecord).toHaveBeenCalledWith( ALICE_DID, "site.standard.document", "abc", expect.any(Object), "cid-at-fetch", ); });
it("passes the fetched CID as swapRecord on the deep path", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" }, "cid-deep-fetch")); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/title","value":"new"}]', }); await executePatchRecord(makeMatchFor(action), 0);
expect(mockPatchRecord).toHaveBeenCalledWith( ALICE_DID, "site.standard.document", "abc", expect.any(Object), "cid-deep-fetch", ); });
it("surfaces a 409 from a stale CID as a non-retryable failure", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" }, "cid-stale")); mockPatchRecord.mockRejectedValueOnce( new Error("InvalidSwap: Record was at another value (409)"), );
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/title","value":"new"}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(409); expect(result.error).toMatch(/InvalidSwap/); expect(isRetryable(result.statusCode)).toBe(false); });});
describe("executePatchRecord: deep mode (JSON Patch)", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); mockPatchRecord.mockReset(); mockFetchRecord.mockReset(); await db.delete(deliveryLogs); await db.delete(automations); await db.insert(automations).values(makeAutomation()); }); afterEach(() => { vi.useRealTimers(); });
it("updates a nested object field without disturbing siblings", async () => { mockFetchRecord.mockResolvedValueOnce( makeFetchResult({ profile: { displayName: "old", bio: "preserved" } }), ); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/profile/displayName","value":"new"}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(200); const written = mockPatchRecord.mock.calls[0]![3]; expect(written).toEqual({ profile: { displayName: "new", bio: "preserved" }, }); });
it("toggles a single field on a specific array index (the driving use case)", async () => { const layers = Array.from({ length: 9 }, () => ({ equipped: false })); mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ layers })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/layers/8/equipped","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(200); const written = mockPatchRecord.mock.calls[0]![3] as { layers: { equipped: boolean }[] }; expect(written.layers).toHaveLength(9); expect(written.layers[8]).toEqual({ equipped: true }); for (let i = 0; i < 8; i++) { expect(written.layers[i]).toEqual({ equipped: false }); } });
it("applies a multi-op patch touching both nested and top-level fields", async () => { mockFetchRecord.mockResolvedValueOnce( makeFetchResult({ title: "t", profile: { displayName: "old" } }), ); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: JSON.stringify([ { op: "replace", path: "/title", value: "t2" }, { op: "replace", path: "/profile/displayName", value: "new" }, ]), }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ title: "t2", profile: { displayName: "new" }, }); });
it("returns a deterministic failure when an op targets an out-of-range index", async () => { const layers = Array.from({ length: 9 }, () => ({ equipped: false })); mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ layers }));
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/layers/99/equipped","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(0); expect(result.error).toMatch(/op 0/); expect(result.error).toMatch(/out of range/); expect(mockPatchRecord).not.toHaveBeenCalled(); });
it("respects the test op as a precondition (passes)", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 1 })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: JSON.stringify([ { op: "test", path: "/v", value: 1 }, { op: "replace", path: "/v", value: 2 }, ]), }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ v: 2 }); });
it("respects the test op as a precondition (fails before write)", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 5 }));
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: JSON.stringify([ { op: "test", path: "/v", value: 1 }, { op: "replace", path: "/v", value: 2 }, ]), }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(0); expect(result.error).toMatch(/test failed/); expect(mockPatchRecord).not.toHaveBeenCalled(); });
it("rejects a template that doesn't render to an op array", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 1 }));
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '{"v":2}', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(0); expect(result.error).toMatch(/Template error/); expect(mockPatchRecord).not.toHaveBeenCalled(); });
it("resolves {{placeholders}} inside op values", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ lastFiredBy: "old" })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/lastFiredBy","value":"{{event.did}}"}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ lastFiredBy: ALICE_DID }); });
it("rejects an op array that would replace the root with a non-object", async () => { // A `replace` with path "" swaps the whole document. If the value is a // scalar (or array), the result can't be written as a PDS record. The // execute layer guards against this independently of applyJsonPatch. mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 1 }));
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"","value":42}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(0); expect(result.error).toMatch(/non-object result/); expect(mockPatchRecord).not.toHaveBeenCalled(); });
it("resolves {{placeholders}} embedded inside an op path", async () => { // Driving variant of the layers case where the array index is itself // event-derived. Confirms the save-time validator doesn't choke on the // placeholder substitution sentinel and that render-time resolution // produces a well-formed JSON Pointer. const layers = Array.from({ length: 4 }, () => ({ equipped: false })); mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ layers })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/layers/{{event.commit.record.text}}/equipped","value":true}]', }); const match = makeMatch({ automation: { actions: [action] }, event: { did: ALICE_DID, time_us: 1700000000000000, kind: "commit", commit: { rev: "r", operation: "create", collection: "site.standard.document", rkey: "abc", record: { text: "2" }, }, }, }); const result = await executePatchRecord(match, 0);
expect(result.statusCode).toBe(200); const written = mockPatchRecord.mock.calls[0]![3] as { layers: { equipped: boolean }[] }; expect(written.layers[2]).toEqual({ equipped: true }); expect(written.layers[1]).toEqual({ equipped: false }); });
it("applies a JSONPath filter to flip the matched object's field", async () => { mockFetchRecord.mockResolvedValueOnce( makeFetchResult({ users: [ { id: "a", active: false }, { id: "b", active: false }, ], }), ); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" });
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"$.users[?@.id==\'b\'].active","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ users: [ { id: "a", active: false }, { id: "b", active: true }, ], }); });
it("returns an informative failure when a JSONPath matches nothing", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ users: [{ id: "a", active: false }] }));
const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"$.users[?@.id==\'b\'].active","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0);
expect(result.statusCode).toBe(0); expect(result.error).toMatch(/JSON Patch op 0: JSONPath .* matched nothing/); expect(mockPatchRecord).not.toHaveBeenCalled(); });});