Something went wrong. Try again.
Automations and webhooks for the AT Protocol airglow.run
automation webhook atproto atprotocol
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401/** * Native RFC 6902 (JSON Patch) implementation. Used by the patch-record * action's "deep" mode to apply surgical edits to a fetched PDS record before * writing it back. Op paths address arbitrary depth via either RFC 6901 JSON * Pointers (`/layers/8/equipped`) or RFC 9535 JSONPath expressions * (`$.layers[?@.id=='headphones'].equipped`, recognized by its leading `$`), * so a user can flip a single field in an item with unknown position without * having to re-supply the whole array. * * The RFC 6902 core is written by hand rather than pulled from * fast-json-patch: that library defaults to in-place mutation, which would * surprise callers here that pass the fetched base record straight through. * JSONPath resolution is delegated to the RFC 9535 compliant json-p3. */
import { compile, JSONPathError, type JSONPathQuery } from "json-p3";
import { isUnsafeKey } from "./placeholders.js";
export { JSONPathSyntaxError } from "json-p3";
export type JsonPatchOp = | { op: "add"; path: string; value: unknown } | { op: "remove"; path: string } | { op: "replace"; path: string; value: unknown } | { op: "move"; from: string; path: string } | { op: "copy"; from: string; path: string } | { op: "test"; path: string; value: unknown };
const KNOWN_OPS = new Set(["add", "remove", "replace", "move", "copy", "test"]);
export class JsonPatchError extends Error { readonly opIndex: number; constructor(opIndex: number, reason: string) { super(`op ${opIndex}: ${reason}`); this.opIndex = opIndex; this.name = "JsonPatchError"; }}
/** Parse a JSON Pointer (RFC 6901) into its segments. "" returns []. The leaf * segment may be "-" when the parent is an array (only meaningful for `add`). * Each segment has `~1` decoded to "/" and `~0` decoded to "~". Throws on a * non-empty string that doesn't start with "/". */function parsePointer(pointer: string): string[] { if (pointer === "") return []; if (!pointer.startsWith("/")) { throw new Error(`JSON Pointer must start with "/" or be empty (got "${pointer}")`); } return pointer .slice(1) .split("/") .map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"));}
/** Resolve an op's `path`/`from` to JSON Pointer segments. Accepts RFC 6901 * pointers as-is; for `$`-prefixed strings, compiles as RFC 9535 JSONPath, * queries the live `doc`, requires exactly one match, and returns that * node's location as pointer segments so the rest of the pipeline (unsafe-key * blocking, array bounds, parent walk) runs identically regardless of input * syntax. Throws plain `Error`; the `applyJsonPatch` loop wraps these with * the op index via `JsonPatchError`. */function resolveOpPath(rawPath: string, doc: unknown): string[] { if (!rawPath.startsWith("$")) return parsePointer(rawPath);
let query: JSONPathQuery; try { query = compile(rawPath); } catch (err) { if (err instanceof JSONPathError) { throw new Error(`invalid JSONPath expression "${rawPath}": ${err.message}`); } throw err; }
const nodes = query.find(doc as Parameters<typeof query.find>[0]); if (nodes.length === 0) { throw new Error(`JSONPath "${rawPath}" matched nothing`); } if (nodes.length > 1) { throw new Error(`JSONPath "${rawPath}" matched ${nodes.length} nodes (expected exactly 1)`); } return nodes[0]!.toPointer().tokens;}
/** Deep structured clone with no prototype-pollution risk. Only plain JSON * values flow through patch ops, so a JSON round-trip is sufficient and * matches the on-the-wire shape of PDS records. */function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T;}
function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a === null || b === null) return false; if (typeof a !== typeof b) return false; if (typeof a !== "object") return false; if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!deepEqual(a[i], b[i])) return false; } return true; } if (Array.isArray(b)) return false; const aKeys = Object.keys(a as object); const bKeys = Object.keys(b as object); if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false; if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) { return false; } } return true;}
type Container = Record<string, unknown> | unknown[];
function isContainer(value: unknown): value is Container { return value !== null && typeof value === "object";}
/** Walk the document to the parent of the leaf segment. Returns the parent * container and the leaf segment so callers can perform the final op * themselves (add/remove/replace each need slightly different parent * handling). Throws when an intermediate segment is missing or isn't a * container. */function resolveParent(doc: unknown, segments: string[]): { parent: Container; leaf: string } { if (segments.length === 0) { throw new Error("cannot operate on the document root via a parent walk"); } let cursor: unknown = doc; for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i]!; if (isUnsafeKey(seg)) { throw new Error(`unsafe path segment "${seg}"`); } if (!isContainer(cursor)) { throw new Error(`path resolves through a non-container at segment "${seg}"`); } if (Array.isArray(cursor)) { const idx = parseArrayIndex(seg, cursor.length); if (idx === null || idx >= cursor.length) { throw new Error(`array index "${seg}" out of range`); } cursor = cursor[idx]; } else { cursor = (cursor as Record<string, unknown>)[seg]; } } if (!isContainer(cursor)) { throw new Error("path's parent is not a container"); } const leaf = segments[segments.length - 1]!; if (isUnsafeKey(leaf)) { throw new Error(`unsafe path segment "${leaf}"`); } return { parent: cursor, leaf };}
/** Parse an array index segment. Returns null if not a valid non-negative * integer. `-` resolves to the array's current length so an `add` op can * append; callers using `replace`/`remove`/`test` reject this via the usual * `idx >= length` bound check. */function parseArrayIndex(segment: string, length: number): number | null { if (segment === "-") return length; if (!/^(0|[1-9]\d*)$/.test(segment)) return null; return Number(segment);}
function resolveValue(doc: unknown, segments: string[]): { found: boolean; value: unknown } { let cursor: unknown = doc; for (const seg of segments) { if (!isContainer(cursor)) return { found: false, value: undefined }; if (isUnsafeKey(seg)) return { found: false, value: undefined }; if (Array.isArray(cursor)) { const idx = parseArrayIndex(seg, cursor.length); if (idx === null || idx >= cursor.length) return { found: false, value: undefined }; cursor = cursor[idx]; } else { const obj = cursor as Record<string, unknown>; if (!Object.prototype.hasOwnProperty.call(obj, seg)) { return { found: false, value: undefined }; } cursor = obj[seg]; } } return { found: true, value: cursor };}
function applyAdd(doc: unknown, segments: string[], value: unknown): unknown { if (segments.length === 0) return clone(value); const { parent, leaf } = resolveParent(doc, segments); if (Array.isArray(parent)) { const idx = parseArrayIndex(leaf, parent.length); if (idx === null || idx > parent.length) { throw new Error(`array index "${leaf}" out of range for add (length ${parent.length})`); } parent.splice(idx, 0, clone(value)); } else { parent[leaf] = clone(value); } return doc;}
function applyReplace(doc: unknown, segments: string[], value: unknown): unknown { if (segments.length === 0) return clone(value); const { parent, leaf } = resolveParent(doc, segments); if (Array.isArray(parent)) { const idx = parseArrayIndex(leaf, parent.length); if (idx === null || idx >= parent.length) { throw new Error(`array index "${leaf}" out of range for replace (length ${parent.length})`); } parent[idx] = clone(value); } else { if (!Object.prototype.hasOwnProperty.call(parent, leaf)) { throw new Error(`path target does not exist for replace: "${leaf}"`); } parent[leaf] = clone(value); } return doc;}
function applyRemove(doc: unknown, segments: string[]): unknown { if (segments.length === 0) { throw new Error("cannot remove the document root"); } const { parent, leaf } = resolveParent(doc, segments); if (Array.isArray(parent)) { const idx = parseArrayIndex(leaf, parent.length); if (idx === null || idx >= parent.length) { throw new Error(`array index "${leaf}" out of range for remove (length ${parent.length})`); } parent.splice(idx, 1); } else { if (!Object.prototype.hasOwnProperty.call(parent, leaf)) { throw new Error(`path target does not exist for remove: "${leaf}"`); } delete parent[leaf]; } return doc;}
/** * Apply an RFC 6902 patch to `target`. The input is never mutated; the result * is a fresh structure. Throws `JsonPatchError` (with the failing op's index) * on any failure: missing path, out-of-range index, failed test op, etc. */export function applyJsonPatch(target: unknown, ops: JsonPatchOp[]): unknown { let doc: unknown = clone(target); for (let i = 0; i < ops.length; i++) { const op = ops[i]!; try { switch (op.op) { case "add": doc = applyAdd(doc, resolveOpPath(op.path, doc), op.value); break; case "replace": doc = applyReplace(doc, resolveOpPath(op.path, doc), op.value); break; case "remove": doc = applyRemove(doc, resolveOpPath(op.path, doc)); break; case "move": { const fromSegments = resolveOpPath(op.from, doc); const found = resolveValue(doc, fromSegments); if (!found.found) { throw new Error(`move source "${op.from}" does not exist`); } const moved = clone(found.value); doc = applyRemove(doc, fromSegments); // Resolve destination AFTER the remove so a JSONPath `path` sees // the post-remove doc state (array indices may have shifted). doc = applyAdd(doc, resolveOpPath(op.path, doc), moved); break; } case "copy": { const fromSegments = resolveOpPath(op.from, doc); const found = resolveValue(doc, fromSegments); if (!found.found) { throw new Error(`copy source "${op.from}" does not exist`); } doc = applyAdd(doc, resolveOpPath(op.path, doc), clone(found.value)); break; } case "test": { const segments = resolveOpPath(op.path, doc); const found = resolveValue(doc, segments); if (!found.found) { throw new Error(`test path "${op.path}" does not exist`); } if (!deepEqual(found.value, op.value)) { throw new Error(`test failed at "${op.path}"`); } break; } } } catch (err) { throw new JsonPatchError(i, err instanceof Error ? err.message : String(err)); } } return doc;}
/** * Lightweight save-time check: the input is a non-empty array of objects with * a known `op` discriminator. Path/value/from field checks are intentionally * skipped — the template validator strips `{{placeholders}}` to sentinel * strings before this runs, which would otherwise wrongly reject paths like * `/layers/{{idx}}/equipped` (the leading slash is lost in the collapse). * Use `validateJsonPatchOps` at render time, against the resolved value. */export function validateJsonPatchOpsShape( ops: unknown,): { ok: true } | { ok: false; error: string } { if (!Array.isArray(ops)) { return { ok: false, error: "JSON Patch template must render to a JSON array" }; } if (ops.length === 0) { return { ok: false, error: "JSON Patch template must contain at least one op" }; } for (let i = 0; i < ops.length; i++) { const op = ops[i]; if (op === null || typeof op !== "object" || Array.isArray(op)) { return { ok: false, error: `op ${i}: must be an object` }; } const o = op as Record<string, unknown>; if (typeof o.op !== "string" || !KNOWN_OPS.has(o.op)) { return { ok: false, error: `op ${i}: unknown op "${String(o.op)}" (expected add/remove/replace/move/copy/test)`, }; } } return { ok: true };}
/** Validate that a `path`/`from` field is a JSON Pointer ("/...", or empty) * or a JSONPath expression ("$..."). JSONPath strings are compile-checked * here so syntax errors surface at template save time rather than later when * the action fires. Returns an error message on failure, or null on success. */function validatePatchPathField( value: unknown, field: "path" | "from", opIndex: number,): string | null { if (typeof value !== "string") return `op ${opIndex}: "${field}" must be a string`; if (value.startsWith("$")) { try { compile(value); return null; } catch (err) { if (err instanceof JSONPathError) { return `op ${opIndex}: invalid JSONPath in "${field}": ${err.message}`; } throw err; } } if (value !== "" && !value.startsWith("/")) { return `op ${opIndex}: "${field}" must start with "/", "$", or be empty`; } return null;}
/** * Full check: validates op array structure AND each op's fields (path is a * JSON Pointer or JSONPath; add/replace/test carry a value; move/copy carry * a "from" string). Used at render time once placeholders have been resolved, * so any bad shape from a PDS-side record fails cleanly instead of throwing * deep inside `applyJsonPatch`. */export function validateJsonPatchOps( ops: unknown,): { ok: true; ops: JsonPatchOp[] } | { ok: false; error: string } { const shape = validateJsonPatchOpsShape(ops); if (!shape.ok) return shape; const arr = ops as unknown[]; for (let i = 0; i < arr.length; i++) { const o = arr[i] as Record<string, unknown>; const pathErr = validatePatchPathField(o.path, "path", i); if (pathErr) return { ok: false, error: pathErr }; if (o.op === "add" || o.op === "replace" || o.op === "test") { if (!Object.prototype.hasOwnProperty.call(o, "value")) { return { ok: false, error: `op ${i}: "${o.op as string}" requires a "value" field` }; } } if (o.op === "move" || o.op === "copy") { if (typeof o.from !== "string") { return { ok: false, error: `op ${i}: "${o.op as string}" requires a "from" string field`, }; } const fromErr = validatePatchPathField(o.from, "from", i); if (fromErr) return { ok: false, error: fromErr }; } } return { ok: true, ops: ops as JsonPatchOp[] };}