n8n-nodes-atproto 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,6 +97,22 @@ Worked examples of common workflows. Each one is a chain of nodes — names in *
97
97
 
98
98
  **Convenience fields:** the upload also exposes `cid` / `mimeType` / `size` at the top level, so `{{ $json.cid }}` works without drilling into `blob.ref.$link`.
99
99
 
100
+ ### Post a Bluesky link with an embed card
101
+
102
+ The **Bluesky** node's Post → Create operation builds an `app.bsky.embed.external` link card for you. Set **External Link URL** under Options; with **Auto-Scrape Link Metadata** on (the default) it fetches the page's OpenGraph title, description and thumbnail.
103
+
104
+ ```
105
+ [Bluesky: Post → Create]
106
+ Text: Great read 👇
107
+ Options:
108
+ External Link URL: https://example.com/article
109
+ Auto-Scrape Link Metadata: ✅ ← fetches og:title / og:description / og:image
110
+ ```
111
+
112
+ - **Overrides:** set **Link Title** / **Link Description** to replace the scraped text, or **Link Thumbnail Binary Property** to use your own image instead of the scraped one.
113
+ - **Scrape failures are fatal:** if auto-scrape is on and the page can't be fetched, the item errors — enable *Continue On Fail* to tolerate it. The thumbnail itself is best-effort: the card still posts if the image is missing or exceeds Bluesky's 1 MB blob limit.
114
+ - A post carries **either** an image embed **or** a link card, not both.
115
+
100
116
  ### Round-trip a blob (download, transform, re-upload)
101
117
 
102
118
  Mirror a blob from another user's repo into your own. Useful for archival, format conversion, or re-hosting.
@@ -37867,6 +37867,12 @@ async function searchCollections(filter) {
37867
37867
  }
37868
37868
  }
37869
37869
  //#endregion
37870
+ Object.defineProperty(exports, "Agent", {
37871
+ enumerable: true,
37872
+ get: function() {
37873
+ return Agent;
37874
+ }
37875
+ });
37870
37876
  Object.defineProperty(exports, "AtUri", {
37871
37877
  enumerable: true,
37872
37878
  get: function() {
@@ -3,6 +3,16 @@ const require_shared = require("../../_chunks/shared.js");
3
3
  let n8n_workflow = require("n8n-workflow");
4
4
  //#region nodes/Atproto/operations.ts
5
5
  /**
6
+ * CRUD operations for AT Protocol records.
7
+ *
8
+ * Each function wraps an XRPC call via the authenticated Agent.
9
+ * - `$type` is auto-injected from the collection NSID.
10
+ * - `createdAt` is auto-injected as ISO string when the schema requires it
11
+ * (Phase 1: always inject; Phase 2 will make it schema-conditional).
12
+ * - `repo` defaults to the authenticated user's DID for write operations.
13
+ * Get/List accept an optional `repo` to read other users' public records.
14
+ */
15
+ /**
6
16
  * Injects `$type` if not already present.
7
17
  */
8
18
  function ensure$type(record, collection) {
@@ -34,6 +44,53 @@ async function resolveActorToDid(agent, actor) {
34
44
  return (await agent.com.atproto.identity.resolveHandle({ handle })).data.did;
35
45
  }
36
46
  /**
47
+ * Map a DID to the URL of its DID document. Supports did:plc (PLC directory)
48
+ * and did:web.
49
+ */
50
+ function didDocumentUrl(did) {
51
+ if (did.startsWith("did:plc:")) return `https://plc.directory/${did}`;
52
+ if (did.startsWith("did:web:")) {
53
+ const [host, ...path] = did.slice(8).split(":").map(decodeURIComponent);
54
+ return path.length === 0 ? `https://${host}/.well-known/did.json` : `https://${host}/${path.join("/")}/did.json`;
55
+ }
56
+ throw new Error(`Unsupported DID method: ${did}`);
57
+ }
58
+ /**
59
+ * Resolve a DID's hosting PDS endpoint from its DID document.
60
+ */
61
+ async function resolvePdsEndpoint(did) {
62
+ const res = await fetch(didDocumentUrl(did));
63
+ if (!res.ok) throw new Error(`Failed to resolve DID ${did}: HTTP ${res.status}`);
64
+ const endpoint = (await res.json()).service?.find((s) => s.id.endsWith("#atproto_pds"))?.serviceEndpoint;
65
+ if (typeof endpoint !== "string" || endpoint.length === 0) throw new Error(`No PDS endpoint found in DID document for ${did}`);
66
+ return endpoint;
67
+ }
68
+ /**
69
+ * Resolve the repo to read from into a DID plus an Agent pointed at the PDS
70
+ * that hosts it. Repo-hosting reads (getRecord, listRecords, getBlob,
71
+ * listBlobs) must hit that PDS — the authenticated session's PDS only serves
72
+ * its own repos and answers "Could not find repo" for any other.
73
+ *
74
+ * The session agent is reused for the user's own repo (already on the correct
75
+ * PDS); foreign repos get an unauthenticated Agent, since these reads are
76
+ * public.
77
+ */
78
+ async function resolveReadTarget(agent, actor) {
79
+ if (!actor || actor.trim() === "") return {
80
+ did: getOwnDid(agent),
81
+ agent
82
+ };
83
+ const did = await resolveActorToDid(agent, actor);
84
+ if (did === agent.did) return {
85
+ did,
86
+ agent
87
+ };
88
+ return {
89
+ did,
90
+ agent: new require_shared.Agent(await resolvePdsEndpoint(did))
91
+ };
92
+ }
93
+ /**
37
94
  * Walk schema properties and inject `const` values for any field the user
38
95
  * left empty. This ensures constant fields are always set correctly even if
39
96
  * the readOnly UI is bypassed (e.g. via JSON mode or expressions).
@@ -69,9 +126,9 @@ async function createRecord(agent, params) {
69
126
  * Optionally reads from a different repo (defaults to self).
70
127
  */
71
128
  async function getRecord(agent, params) {
72
- const repo = params.repo ?? getOwnDid(agent);
73
- const data = (await agent.com.atproto.repo.getRecord({
74
- repo,
129
+ const { did, agent: reader } = await resolveReadTarget(agent, params.repo);
130
+ const data = (await reader.com.atproto.repo.getRecord({
131
+ repo: did,
75
132
  collection: params.collection,
76
133
  rkey: params.rkey
77
134
  })).data;
@@ -119,9 +176,9 @@ async function deleteRecord(agent, params) {
119
176
  * Returns one page per execution; chain nodes or loop to paginate.
120
177
  */
121
178
  async function listRecords(agent, params) {
122
- const repo = params.repo ?? getOwnDid(agent);
123
- const data = (await agent.com.atproto.repo.listRecords({
124
- repo,
179
+ const { did, agent: reader } = await resolveReadTarget(agent, params.repo);
180
+ const data = (await reader.com.atproto.repo.listRecords({
181
+ repo: did,
125
182
  collection: params.collection,
126
183
  limit: params.limit,
127
184
  cursor: params.cursor
@@ -160,8 +217,9 @@ async function uploadBlob(agent, params) {
160
217
  * MIME type (read from the response headers).
161
218
  */
162
219
  async function getBlob(agent, params) {
163
- const response = await agent.com.atproto.sync.getBlob({
164
- did: params.did,
220
+ const { did, agent: reader } = await resolveReadTarget(agent, params.did);
221
+ const response = await reader.com.atproto.sync.getBlob({
222
+ did,
165
223
  cid: params.cid
166
224
  });
167
225
  const buffer = Buffer.from(response.data);
@@ -179,8 +237,8 @@ async function getBlob(agent, params) {
179
237
  * `did` defaults to the authenticated user.
180
238
  */
181
239
  async function listBlobs(agent, params = {}) {
182
- const did = params.did ?? getOwnDid(agent);
183
- const response = await agent.com.atproto.sync.listBlobs({
240
+ const { did, agent: reader } = await resolveReadTarget(agent, params.did);
241
+ const response = await reader.com.atproto.sync.listBlobs({
184
242
  did,
185
243
  limit: params.limit,
186
244
  cursor: params.cursor,