Commits
chore(main): release n8n-nodes-atproto 0.2.3
Repo reads can route to a foreign PDS, so an error (e.g. a rate limit)
could come from a server other than the user's own. Tag every XRPC call
with the host it was dispatched to and surface it in the message. Read the
rate-limit reset from the `ratelimit-reset`/`retry-after` headers instead
of scraping the message string, and use friendlyError on the throw path so
the helpful message shows outside continueOnFail mode too.
chore(main): release n8n-nodes-atproto 0.2.2
Repo-hosting reads (getRecord, listRecords, getBlob, listBlobs) were sent
to the authenticated session's PDS, which only serves its own repos and
answers "Could not find repo" for any other DID. Resolve the target DID's
hosting PDS from its DID document (did:plc via plc.directory, did:web via
well-known) and dispatch the read there via an unauthenticated agent. The
session agent is reused for the user's own repo.
chore(main): release n8n-nodes-atproto 0.2.1
Add app.bsky.embed.external support to the Bluesky node's Post > Create
operation, with OpenGraph auto-scrape.
- new external.ts: dependency-free OG scraper (parseOpenGraph) plus
fetchExternalMetadata / fetchThumbnail wrappers over global fetch
- createPost emits app.bsky.embed.external and rejects image+external
(a post's embed is a single union)
- node options: External Link URL, Auto-Scrape Link Metadata (default on),
Link Title/Description overrides, Link Thumbnail Binary Property
- scrape failure is fatal to the item; thumbnail is best-effort (skipped
on failure or above Bluesky's 1 MB blob limit)
chore(main): release n8n-nodes-atproto 0.2.0
refactor!: move sources to root layout + activate n8n verified ruleset
The collection resourceLocator field is hidden via displayOptions for
the blob operations (uploadBlob / getBlob / listBlobs). Reading it
unconditionally at the top of the execute loop triggered `Could not get
parameter` on n8n 2.23+ where hidden parameters without stored values
throw instead of returning a default.
Pass an empty resourceLocator (`{ mode: 'list', value: '' }`) as the
fallback so the call always resolves; the value is ignored by the blob
switch cases.
Caught while testing List Blobs against a live PDS on the dev server.
Moves all source files to the layout n8n's verification lints expect
(`credentials/`, `nodes/`, `index.ts` at the repo root). This re-enables
two rule sets that were silently inactive on the previous `src/` layout:
- @n8n/community-nodes/no-credential-reuse (security: catches a node
referencing a credential defined in another package)
- n8n-nodes-base/* (the full official community-node ruleset)
The previous workaround `no-credential-reuse: off` is dropped from
eslint.config.mjs.
Notable rule-driven changes:
- Inline the Jetstream endpoint options (no more PUBLIC_ENDPOINTS spread)
so node-param-default-wrong-for-options can statically verify the
default value. Default restored to the working US-West public URL.
- Sort the Jetstream endpoint options alphabetically.
- Rename two node files to match the convention
`<PascalCaseName>.node.ts`:
AtprotoJetstreamTrigger.node.ts -> AtprotoTrigger.node.ts
Bluesky.node.ts -> AtprotoBluesky.node.ts
- Rename the two classes to match:
AtprotoJetstreamTrigger -> AtprotoTrigger
Bluesky -> AtprotoBluesky
- Bluesky node internal `name` becomes `atprotoBluesky` (camelCase
required by node-class-description-name-miscased). User-facing
displayName stays 'Bluesky'.
- Convert two `throw new Error(...)` sites to ApplicationError /
NodeOperationError per node-execute-block-wrong-error-thrown.
- Various title-case fixes on displayName values + canonical phrasing
for the Return All / Limit descriptions (autofixed).
Build output (`dist/`) tree updated for the renamed files; `package.json`
`n8n.nodes` paths updated accordingly. No-op for the AT Protocol main
node; both renamed nodes are new in v0.1.2 (Bluesky) or unchanged in
identifier (Trigger keeps `name: 'atprotoTrigger'`).
Documentation updated: CONTRIBUTING.md and DESIGN.md path tables now
reflect the root layout; PLAN.md compacted (983 -> 71 lines) keeping the
Status table + Decisions Log (now 36 entries) and dropping the phase-by-
phase implementation walkthroughs (the code is the spec now).
Verification: 216/216 tests pass, lint clean (full ruleset), tsc clean,
vite build clean.
BREAKING CHANGE: The Bluesky node's internal `name` field changes from
`atproto-bluesky` (v0.1.2) to `atprotoBluesky`. Workflows saved against
v0.1.2 that reference the Bluesky node will need to re-add the node.
The AT Protocol main node and Jetstream trigger are unaffected.
The n8n-workflow type defines usableAsTool as `true | UsableAsToolDescription`
(no `false`), while the @n8n/community-nodes/node-usable-as-tool lint rule
requires the property be present. The previous code had `usableAsTool: false`
which failed tsc; omitting it failed lint.
Set `usableAsTool: true` with a comment explaining the trigger-vs-tool
mismatch. Trigger nodes can't actually be invoked by AI agents, but n8n
core treats the property as irrelevant for trigger-type nodes, so setting
true is harmless metadata that keeps both checks happy.
Also drops the stray `usableAsTool: true` line that `lint --fix` had
appended at the bottom of the description object with mismatched indentation.
Two more UX improvements requested as follow-ups:
1. Return All toggle on List Records and List Blobs
New boolean parameter that paginates internally until the cursor is
exhausted. When on, the Limit / Cursor fields are hidden and a hardcoded
per-page size is used (100 records / 500 blobs). A MAX_PAGES safety cap
(1000) prevents runaway loops.
- List Records (Return All): concatenates records across pages into the
existing single { records: [...] } output shape. Backwards compatible.
- List Blobs (Return All): emits one item per CID across all pages,
no cursor (since pagination is complete).
Without Return All, both ops behave exactly as before.
2. README recipes section
Three worked examples showing how to compose the blob ops into real
workflows:
- Compose a multi-image Bluesky post (the main thing the new ops unlock)
- Round-trip a blob (download \u2192 transform \u2192 re-upload)
- List every blob in your repo (audits / migrations)
Each recipe is laid out as an ASCII node graph with key parameters
inline, plus 'why this works' / 'tip' callouts for the non-obvious bits.
Tests: extends listBlobs tests with a manual-pagination case to lock in
the cursor-passthrough behavior. 216 tests pass; lint clean.
Four targeted improvements to the blob operations added in the previous commit:
1. List Blobs emits one item per CID (n8n list-op convention)
Instead of a single { cids: [...], cursor } output, the node now produces
one output item per CID with cursor attached to each. Filter / Loop / Set
nodes work without a Split In Batches detour.
2. Upload Blob outputs flat convenience fields
Adds cid / mimeType / size siblings to the full blob ref:
{ blob: {...}, cid: "bafkrei...", mimeType: "image/jpeg", size: 12345 }
So {{ $json.cid }} works instead of {{ $json.blob.ref.$link }}.
3. Smarter Blob Reference input on Download Blob
The CID field now accepts:
- bare CIDs (unchanged)
- BlobRef JSON: {"$link":"bafkrei..."} or full BlobRef objects
- bsky CDN URLs: https://cdn.bsky.app/img/.../plain/<did>/<cid>@jpeg
When a CDN URL is pasted, the embedded DID populates the Repo field
automatically, so Repo becomes optional in that case. Parsing logic
lives in src/nodes/Atproto/blobInput.ts and is unit-tested separately.
4. Friendly errors for blob endpoints
friendlyError() now maps:
- BlobNotFound -> "Blob not found at <did>/<cid>"
- PayloadTooLarge -> "Blob too large \u2014 the PDS rejected the upload
(bsky.social limits blobs to ~1 MB)"
Download Blob passes cid/did as context so the not-found message
includes the specific blob the user was looking for.
Tests: 18 new tests in tests/blobInput.test.ts covering all input shapes
and edge cases. 215 tests pass total; lint clean; build clean.
Adds three new operations to the AT Protocol node that wrap the raw PDS
blob endpoints, so users can manage blobs without going through the
lexicon-driven record flow:
- Upload Blob -> com.atproto.repo.uploadBlob
- Download Blob -> com.atproto.sync.getBlob
- List Blobs -> com.atproto.sync.listBlobs
Use case: composing records whose blobs aren't top-level lexicon fields,
e.g. multi-image Bluesky posts where the blob lives in embed.images[].image.
Workflow becomes: HTTP Request -> Upload Blob (xN) -> Create Record.
UI changes:
- Adds a Resource selector ('Record' / 'Blob') to group the now 8
operations. Silences the @n8n/community-nodes resource-operation-pattern
lint warning that kicked in at >5 operations.
- Operation *values* are unchanged (createRecord, getBlob, etc.) so
existing workflows keep working without migration.
- Download Blob resolves handles to DIDs via resolveHandle so users can
paste either form into the Repo field.
Tests: 13 new tests in tests/blobOps.test.ts, plus MSW handlers for
sync.getBlob, sync.listBlobs, and identity.resolveHandle in tests/setup.ts.
All 197 tests pass; lint clean; build clean.
The internal node name 'bluesky' conflicted with other community
packages (e.g. n8n-nodes-bluesky). Prefixing with the package name
('atproto-') keeps it unique and consistent with our package id.
User-facing displayName remains 'Bluesky'.
chore(main): release n8n-nodes-atproto 0.1.2
- AtprotoJetstreamTrigger: add explicit usableAsTool: false. Triggers
don't expose a callable surface to AI agents \u2014 they fire on
incoming events \u2014 so 'false' is the honest answer here.
- Bluesky: sort Resource (Follow/Like/Post/Repost) and Operation
(Create/Quote/Reply) options alphabetically as the rule requires.
Default selections unchanged (resource='post', operation='create').
chore(main): release n8n-nodes-atproto 0.1.1
Adds a task-shaped Bluesky node that hides NSIDs and lexicon details
for the most common app.bsky.* operations. The generic AT Protocol
node remains for everything else.
Resources & operations:
- Post \u2192 Create / Reply / Quote
- Like \u2192 Create
- Repost \u2192 Create
- Follow \u2192 Create
Affordances handled internally:
- Rich-text facet auto-detection (mentions / links / hashtags)
via RichText.detectFacets() from @atproto/api, with correct
UTF-8 byte offsets
- Handle \u2192 DID resolution for follows and mentions
- Parent \u2192 root walking when building reply refs (re-uses the
parent's reply.root if present)
- Both at:// URIs and https://bsky.app/profile/.../post/... URLs
accepted everywhere a post reference is requested
- Optional image embed via binary property + uploadBlob
Files:
- src/nodes/Bluesky/Bluesky.node.ts
- src/nodes/Bluesky/operations.ts
- src/nodes/Bluesky/postUri.ts
- src/nodes/Bluesky/bluesky.svg
- tests/bluesky.test.ts (8 new tests)
Node marker: usableAsTool: true so AI agents can call it as a tool.
176 \u2192 184 tests passing. Build clean.
Replaces the trigger's plain-string collection input with the same
searchable resourceLocator picker the action node uses. Each entry in
the Collections fixedCollection now has list/nsid modes, lets users
pick from their repo's known collections, and accepts wildcard
prefixes (e.g. app.bsky.feed.*) in free-text mode.
- Extract createAgent, extractCollectionNsid and searchCollections
into src/nodes/Atproto/shared.ts (both nodes now import from it)
- Register methods.listSearch.searchCollections on the trigger node
- Update wantedCollections extraction in trigger() to handle the new
{ mode, value } shape via extractCollectionNsid()
Side effect: bundle layout is healthier \u2014 @atproto/api now lives in
a shared chunk (1MB) instead of being duplicated in the action bundle,
so Atproto.node.js drops from 902KB to 48KB raw.
n8n's getNodeParameter throws 'Could not get parameter' when a field
is hidden by displayOptions (no stored value exists). This affected:
- customEndpoint (only visible when endpoint='custom')
- operations (only visible when eventKinds includes 'commit')
All getNodeParameter calls now pass safe defaults, so the trigger
works regardless of which optional sub-fields are visible.
n8n's node creator UI groups action+trigger pairs by stripping
'Trigger' from the trigger's description.name and matching against
the action's name.
Before:
trigger.name='atprotoJetstreamTrigger'.replace('Trigger','')
='atprotoJetstream' -> no match with action 'atproto'
After:
trigger.name='atprotoTrigger'.replace('Trigger','')
='atproto' -> matches action 'atproto' ✓
The displayName 'AT Protocol Jetstream Trigger' is preserved and
auto-stripped by n8n to 'AT Protocol Jetstream' when displayed
within the grouped 'AT Protocol' entry.
n8n's CustomDirectoryLoader uses fast-glob('**/*.node.js') to discover
nodes in the custom folder — it does NOT read package.json n8n.nodes[].
Additionally, the loadClass helper derives the class name from the
filename via path.parse(file).name.split('.')[0], so the class must
match the filename prefix.
- Rename AtprotoJetstream.trigger.ts -> AtprotoJetstreamTrigger.node.ts
- Update vite entry, package.json n8n.nodes, barrel export
- Class name AtprotoJetstreamTrigger now matches the file prefix
Typescript error: FlattenedJetstreamEvent lacks index signature
so it doesn't satisfy IDataObject. Cast with as unknown as IDataObject.
- Add JetstreamClient WebSocket client (jetstream.ts) with:
- Lazy-loaded zstd dictionary for decompression
- Exponential backoff reconnection (1s→2s→4s…30s)
- 3s cursor rewind for gapless playback
- Stop flag to prevent reconnect on teardown
- Add AtprotoJetstreamTrigger node with:
- 7 parameters: endpoint, collections, DIDs, event kinds,
operations, compression, max message size
- Event kind + operation filtering
- Cursor persistence via getWorkflowStaticData
- Manual trigger support with 30s timeout
- Move zstd_dictionary to src/nodes/Atproto/ (from JetstreamTrigger/)
- Update vite.config.build.ts: entry point + dictionary copy
- Update package.json: n8n.nodes[] entry
- Update src/index.ts: barrel export
- Add 16 tests: event flattening all 3 kinds, filtering,
cursor tracking, zstd dictionary round-trip, WS integration
- Build clean, 176 tests passing
- Phase 5: enum/const/default mapping, constraint validation
(string length, graphemes, numeric range, array bounds, blob
accept/maxSize, closed unions), displayName hints
- Replace oxlint/oxfmt with n8n-required eslint config
- Add author, repository, peerDependencies, credential icon,
usableAsTool, NodeConnectionTypes.Main
- Add LICENSE (MIT), CI workflow, release-please workflow
- Update README: remove stale hex/color references
- 160 tests passing, lint clean, build clean
- README: collection picker, hex colors, validation, tips section,
architecture table
- CONTRIBUTING: new files, updated test count, execution pipeline
- DESIGN: type-only lexicons, execution-time processing pipeline,
special field handling, updated file structure
- PLAN: Phase 4 status, decisions 15-21, updated totals
- TODO: Phase 3+4 marked done, 125 tests
Execution-time validation (validation.ts):
- Required field checks (skips auto-injected $type/createdAt)
- Type correctness: string, integer, boolean, array, object
- Union $type discriminator: missing and invalid values
- Recursive validation into nested refs (depth-limited)
- Blob fields: detects unresolved binary property names
- Clear bullet-point error messages before PDS submission
Better descriptions for object/array fields (fieldMapping.ts):
- Multi-ref unions show valid $type options in displayName
- Array fields show item type hint (e.g. 'JSON array of ...')
- Blob fields hint about binary property names from input
Make rkey optional for get/put/delete. When empty, resolve the
lexicon schema and use the literal key if declared (e.g. literal:self).
Throws a clear error if the lexicon requires a user-provided key.
- Collection field → resourceLocator with searchable list (queries
the user's PDS via describeRepo) + free-text "By NSID" mode with
NSID format validation.
- Add subtitle showing the current operation on the canvas.
- Move Swap Commit into an Options collection so it doesn't clutter
the main view for the common case.
- Blob fields now show a hint in the displayName explaining that
the value should be a binary property name from the input.
Fix cryptic JSON editors shown for custom lexicon fields like
site.standard.publication's theme colors. Four issues addressed:
F1 – Single-ref unions (common ATProto pattern) are now resolved and
flattened like regular refs. Added getResolvableRef() helper.
F2 – Type-only lexicons (no main record, e.g. site.standard.theme.color)
now return a defs-only schema so cross-document fragment resolution
(#rgb, #rgba) works.
F3 – Descriptions from lexicon properties are propagated to displayName
on all flattened and fallback fields.
F4 – Nested $type injection at execution time via injectNestedTypes().
Hex color strings (#RRGGBB, #RGB) are expanded to {r, g, b} objects
with the correct $type discriminator. Wired into both createRecord
and putRecord flows.
RGB color refs are collapsed into single hex string fields instead of
three separate number inputs (4 fields vs 12 for a theme).
- Dependencies section: runtime → none, all bundled by Vite
- Tooling section: n8n-node build → vite build
- Decisions log: entries 13-14 (zero deps, uint8arrays patch)
- Distribution checklist: mark lint/test/build/dev as done
- File structure: add blob.ts, scripts/, vite config, CONTRIBUTING
- Bundle size table with per-chunk breakdown
n8n-node dev symlinks the project root, and n8n's glob picks up
*.node.js files from node_modules/uint8arrays (platform-specific
conditional exports). These aren't n8n nodes and crash the loader.
Add a postinstall script that rewrites the 'node' condition in
uint8arrays' exports/imports maps to point at the standard ESM
targets, then deletes the .node.js files. The ESM fallbacks are
functionally identical (Uint8Array vs Buffer — negligible diff).
Dev server now starts cleanly on http://localhost:5678.
n8n's verification guidelines require community nodes to have no
runtime dependencies. The dev symlink also caused crashes because
n8n's glob picked up uint8arrays' .node.js platform files.
Fix: use Vite (already installed via vitest) in library mode to
bundle @atproto/api and @atproto/lexicon-resolver into the dist
output. Only n8n-workflow and Node.js builtins are externalized.
- Move @atproto/* from dependencies to devDependencies
- Add vite.config.build.ts (CJS library mode, two entry points)
- Replace n8n-node build with vite build in the build script
- Remove peerDependencies (n8n-workflow is externalized by Vite)
- Copy icon via writeBundle plugin
- Build produces ~2.3 MB (460 KB gzipped) with zero npm deps
- Add src/nodes/Atproto/blob.ts with applyBlobUploads() that walks a
built record, uploads binary data for blob-typed fields via
com.atproto.repo.uploadBlob, and substitutes BlobRef values.
- Wire blob upload into execute path for createRecord and putRecord
(resolves lexicon schema to identify blob fields, then applies uploads
before the XRPC call).
- MIME type read from n8n binary metadata, falls back to
application/octet-stream.
- Top-level blob fields only; rollback on first failure.
- 11 new tests covering upload, field discrimination, error paths,
MIME fallback, and record immutability.
- Update mock server with uploadBlob handler (binary body, not JSON).
- Update PLAN.md with Phase 3 completion status and decisions.
- Added Status table at top: Phase 0/1/2 done, Phase 3 next, 92 tests passing
- Added decision log entries 10-12 capturing surprises from Phase 2:
10. XRPC validation rejects record type \u2014 catch error, use responseBody
11. Dotted-key un-flattening required before sending records to PDS
12. resolveRefProperties returns { properties, required } for accurate
required propagation through ref-flattened sub-fields
- Marked Phase 0/1/2 sections as done with commit refs
- Added a 'Deviations from the plan' note under Phase 2 explaining the
five things that ended up different from the original design
- Expanded Phase 3 with what Phase 2 already set up for it and the
open question about where async blob upload post-processing fits
Critical bugs fixed:
1. Dotted keys produced by ref/object field flattening (e.g. reply.root,
reply.parent) were sent to the PDS as literal flat keys, producing
records that would always fail lexicon validation. Added
unflattenDottedKeys() to convert them back to nested objects before
the XRPC call. Also drops empty optional values so they don't get sent
as empty strings.
2. resolveLocalDef extracted the required-fields array but discarded it,
so the required status of ref-resolved sub-fields was lost. Changed
resolveRefProperties to return { properties, required } (new ResolvedRef
type) and updated flattenRefProperties to AND the schema's required
list with the parent's required status — so optional ref sub-fields
stay optional even when the schema would require them if the ref were
present.
Resource mapper UX improvements:
- Added loadOptionsDependsOn: ['collection'] so getRecordFields is only
called when the NSID changes, not on every editor keystroke.
- Added supportAutoMap: true so users can auto-map input data to fields.
- Added noFieldsError with a helpful message when lexicon resolution fails.
Cleanup:
- Removed unused INodePropertyOptions and ResourceMapperField imports.
- Removed unused 'collection' parameter from buildRecordFromNodeParams.
- Exported buildRecordFromNodeParams + unflattenDottedKeys for testing.
New tests (21 added, 92 total):
- tests/buildRecord.test.ts — 20 tests covering raw JSON, resourceMapper
values, dotted key un-flattening, empty value handling, deep nesting,
and plain object input (from expressions).
- tests/fieldMapping.test.ts — 1 test for the optional-ref-sub-field
required propagation fix.
New modules:
- src/nodes/Atproto/lexicon.ts — Lexicon resolution from PDS endpoint
(with XRPC validation bypass via error.responseBody) or DNS-based
@atproto/lexicon-resolver fallback. In-memory cache, full lexicon
document parsing with type inference, and ref resolution helpers.
- src/nodes/Atproto/fieldMapping.ts — Lexicon schema → ResourceMapperField[]
conversion with type mapping, createdAt auto-default, recursive ref
flattening (depth cap 3), inline object unwrapping with dotted prefix.
- tests/mockLexicons.ts — 5 realistic mock lexicon documents covering
records, primitives, inline objects, deeply nested refs, and queries.
Updated modules:
- src/nodes/Atproto/Atproto.node.ts — Added resourceMapper property for
Record Data (Create/Put), methods.resourceMapping.getRecordFields,
and buildRecordFromNodeParams helper for both resourceMapper and raw JSON.
- tests/setup.ts — Added com.atproto.lexicon.resolveLexicon mock handler
and catch-all bypass for unhandled requests (DID resolution, etc.).
- tsconfig.json — Added skippedLibCheck (already set) for ESM package compat.
- TODO.md — Marked Phase 0, 1, and 2 items as complete.
Tests: 71 total (35 Phase 1 + 36 Phase 2), all passing.
Phase 0:
- Initialize project structure with n8n community node conventions
- Configure package.json with scripts, dependencies, and n8n metadata
- Set up tsconfig.json (ES2022, strict, commonjs)
- Set up oxlint.json with n8n eslint plugin via jsPlugins
- Set up vitest.config.ts and .oxfmtrc.json
- Add AT Protocol butterfly icon
- Add .gitignore (node_modules, dist)
Phase 1:
- Credential definition (AtprotoApi): identifier, appPassword, serviceUrl
with test block that validates via createSession
- TID generation (tid.ts): 13-char base32-sortable, top bit 0,
53-bit microsecond timestamp, 10-bit clock ID
- Node description (Atproto.node.ts): all 5 operations with complete
property definitions (Collection, Repo, Record Key, Record Data,
Swap Commit, Limit, Cursor)
- Operations (operations.ts): createRecord, getRecord, putRecord,
deleteRecord, listRecords — with /createdAt auto-injection
- Execute method: session management via CredentialSession,
per-item processing, continueOnFail pattern
- Error handling: friendlyError maps XRPC errors to n8n messages
- Tests: tid.test.ts (format, uniqueness, sortability),
operations.test.ts (all 5 CRUD ops + error paths),
type-injection.test.ts (/createdAt behavior),
setup.ts (msw mock XRPC server)
- README.md: project overview, use cases, quick start
- DESIGN.md: architecture, all 9 resolved ambiguities, dependency analysis
- TODO.md: 46 user stories across 3 phases + setup/testing/distribution
- PLAN.md: step-by-step implementation guide with dev loop, tooling, and scaffolding
chore(main): release n8n-nodes-atproto 0.2.3
Repo reads can route to a foreign PDS, so an error (e.g. a rate limit)
could come from a server other than the user's own. Tag every XRPC call
with the host it was dispatched to and surface it in the message. Read the
rate-limit reset from the `ratelimit-reset`/`retry-after` headers instead
of scraping the message string, and use friendlyError on the throw path so
the helpful message shows outside continueOnFail mode too.
chore(main): release n8n-nodes-atproto 0.2.2
Repo-hosting reads (getRecord, listRecords, getBlob, listBlobs) were sent
to the authenticated session's PDS, which only serves its own repos and
answers "Could not find repo" for any other DID. Resolve the target DID's
hosting PDS from its DID document (did:plc via plc.directory, did:web via
well-known) and dispatch the read there via an unauthenticated agent. The
session agent is reused for the user's own repo.
chore(main): release n8n-nodes-atproto 0.2.1
Add app.bsky.embed.external support to the Bluesky node's Post > Create
operation, with OpenGraph auto-scrape.
- new external.ts: dependency-free OG scraper (parseOpenGraph) plus
fetchExternalMetadata / fetchThumbnail wrappers over global fetch
- createPost emits app.bsky.embed.external and rejects image+external
(a post's embed is a single union)
- node options: External Link URL, Auto-Scrape Link Metadata (default on),
Link Title/Description overrides, Link Thumbnail Binary Property
- scrape failure is fatal to the item; thumbnail is best-effort (skipped
on failure or above Bluesky's 1 MB blob limit)
chore(main): release n8n-nodes-atproto 0.2.0
The collection resourceLocator field is hidden via displayOptions for
the blob operations (uploadBlob / getBlob / listBlobs). Reading it
unconditionally at the top of the execute loop triggered `Could not get
parameter` on n8n 2.23+ where hidden parameters without stored values
throw instead of returning a default.
Pass an empty resourceLocator (`{ mode: 'list', value: '' }`) as the
fallback so the call always resolves; the value is ignored by the blob
switch cases.
Caught while testing List Blobs against a live PDS on the dev server.
Moves all source files to the layout n8n's verification lints expect
(`credentials/`, `nodes/`, `index.ts` at the repo root). This re-enables
two rule sets that were silently inactive on the previous `src/` layout:
- @n8n/community-nodes/no-credential-reuse (security: catches a node
referencing a credential defined in another package)
- n8n-nodes-base/* (the full official community-node ruleset)
The previous workaround `no-credential-reuse: off` is dropped from
eslint.config.mjs.
Notable rule-driven changes:
- Inline the Jetstream endpoint options (no more PUBLIC_ENDPOINTS spread)
so node-param-default-wrong-for-options can statically verify the
default value. Default restored to the working US-West public URL.
- Sort the Jetstream endpoint options alphabetically.
- Rename two node files to match the convention
`<PascalCaseName>.node.ts`:
AtprotoJetstreamTrigger.node.ts -> AtprotoTrigger.node.ts
Bluesky.node.ts -> AtprotoBluesky.node.ts
- Rename the two classes to match:
AtprotoJetstreamTrigger -> AtprotoTrigger
Bluesky -> AtprotoBluesky
- Bluesky node internal `name` becomes `atprotoBluesky` (camelCase
required by node-class-description-name-miscased). User-facing
displayName stays 'Bluesky'.
- Convert two `throw new Error(...)` sites to ApplicationError /
NodeOperationError per node-execute-block-wrong-error-thrown.
- Various title-case fixes on displayName values + canonical phrasing
for the Return All / Limit descriptions (autofixed).
Build output (`dist/`) tree updated for the renamed files; `package.json`
`n8n.nodes` paths updated accordingly. No-op for the AT Protocol main
node; both renamed nodes are new in v0.1.2 (Bluesky) or unchanged in
identifier (Trigger keeps `name: 'atprotoTrigger'`).
Documentation updated: CONTRIBUTING.md and DESIGN.md path tables now
reflect the root layout; PLAN.md compacted (983 -> 71 lines) keeping the
Status table + Decisions Log (now 36 entries) and dropping the phase-by-
phase implementation walkthroughs (the code is the spec now).
Verification: 216/216 tests pass, lint clean (full ruleset), tsc clean,
vite build clean.
BREAKING CHANGE: The Bluesky node's internal `name` field changes from
`atproto-bluesky` (v0.1.2) to `atprotoBluesky`. Workflows saved against
v0.1.2 that reference the Bluesky node will need to re-add the node.
The AT Protocol main node and Jetstream trigger are unaffected.
The n8n-workflow type defines usableAsTool as `true | UsableAsToolDescription`
(no `false`), while the @n8n/community-nodes/node-usable-as-tool lint rule
requires the property be present. The previous code had `usableAsTool: false`
which failed tsc; omitting it failed lint.
Set `usableAsTool: true` with a comment explaining the trigger-vs-tool
mismatch. Trigger nodes can't actually be invoked by AI agents, but n8n
core treats the property as irrelevant for trigger-type nodes, so setting
true is harmless metadata that keeps both checks happy.
Also drops the stray `usableAsTool: true` line that `lint --fix` had
appended at the bottom of the description object with mismatched indentation.
Two more UX improvements requested as follow-ups:
1. Return All toggle on List Records and List Blobs
New boolean parameter that paginates internally until the cursor is
exhausted. When on, the Limit / Cursor fields are hidden and a hardcoded
per-page size is used (100 records / 500 blobs). A MAX_PAGES safety cap
(1000) prevents runaway loops.
- List Records (Return All): concatenates records across pages into the
existing single { records: [...] } output shape. Backwards compatible.
- List Blobs (Return All): emits one item per CID across all pages,
no cursor (since pagination is complete).
Without Return All, both ops behave exactly as before.
2. README recipes section
Three worked examples showing how to compose the blob ops into real
workflows:
- Compose a multi-image Bluesky post (the main thing the new ops unlock)
- Round-trip a blob (download \u2192 transform \u2192 re-upload)
- List every blob in your repo (audits / migrations)
Each recipe is laid out as an ASCII node graph with key parameters
inline, plus 'why this works' / 'tip' callouts for the non-obvious bits.
Tests: extends listBlobs tests with a manual-pagination case to lock in
the cursor-passthrough behavior. 216 tests pass; lint clean.
Four targeted improvements to the blob operations added in the previous commit:
1. List Blobs emits one item per CID (n8n list-op convention)
Instead of a single { cids: [...], cursor } output, the node now produces
one output item per CID with cursor attached to each. Filter / Loop / Set
nodes work without a Split In Batches detour.
2. Upload Blob outputs flat convenience fields
Adds cid / mimeType / size siblings to the full blob ref:
{ blob: {...}, cid: "bafkrei...", mimeType: "image/jpeg", size: 12345 }
So {{ $json.cid }} works instead of {{ $json.blob.ref.$link }}.
3. Smarter Blob Reference input on Download Blob
The CID field now accepts:
- bare CIDs (unchanged)
- BlobRef JSON: {"$link":"bafkrei..."} or full BlobRef objects
- bsky CDN URLs: https://cdn.bsky.app/img/.../plain/<did>/<cid>@jpeg
When a CDN URL is pasted, the embedded DID populates the Repo field
automatically, so Repo becomes optional in that case. Parsing logic
lives in src/nodes/Atproto/blobInput.ts and is unit-tested separately.
4. Friendly errors for blob endpoints
friendlyError() now maps:
- BlobNotFound -> "Blob not found at <did>/<cid>"
- PayloadTooLarge -> "Blob too large \u2014 the PDS rejected the upload
(bsky.social limits blobs to ~1 MB)"
Download Blob passes cid/did as context so the not-found message
includes the specific blob the user was looking for.
Tests: 18 new tests in tests/blobInput.test.ts covering all input shapes
and edge cases. 215 tests pass total; lint clean; build clean.
Adds three new operations to the AT Protocol node that wrap the raw PDS
blob endpoints, so users can manage blobs without going through the
lexicon-driven record flow:
- Upload Blob -> com.atproto.repo.uploadBlob
- Download Blob -> com.atproto.sync.getBlob
- List Blobs -> com.atproto.sync.listBlobs
Use case: composing records whose blobs aren't top-level lexicon fields,
e.g. multi-image Bluesky posts where the blob lives in embed.images[].image.
Workflow becomes: HTTP Request -> Upload Blob (xN) -> Create Record.
UI changes:
- Adds a Resource selector ('Record' / 'Blob') to group the now 8
operations. Silences the @n8n/community-nodes resource-operation-pattern
lint warning that kicked in at >5 operations.
- Operation *values* are unchanged (createRecord, getBlob, etc.) so
existing workflows keep working without migration.
- Download Blob resolves handles to DIDs via resolveHandle so users can
paste either form into the Repo field.
Tests: 13 new tests in tests/blobOps.test.ts, plus MSW handlers for
sync.getBlob, sync.listBlobs, and identity.resolveHandle in tests/setup.ts.
All 197 tests pass; lint clean; build clean.
chore(main): release n8n-nodes-atproto 0.1.2
- AtprotoJetstreamTrigger: add explicit usableAsTool: false. Triggers
don't expose a callable surface to AI agents \u2014 they fire on
incoming events \u2014 so 'false' is the honest answer here.
- Bluesky: sort Resource (Follow/Like/Post/Repost) and Operation
(Create/Quote/Reply) options alphabetically as the rule requires.
Default selections unchanged (resource='post', operation='create').
chore(main): release n8n-nodes-atproto 0.1.1
Adds a task-shaped Bluesky node that hides NSIDs and lexicon details
for the most common app.bsky.* operations. The generic AT Protocol
node remains for everything else.
Resources & operations:
- Post \u2192 Create / Reply / Quote
- Like \u2192 Create
- Repost \u2192 Create
- Follow \u2192 Create
Affordances handled internally:
- Rich-text facet auto-detection (mentions / links / hashtags)
via RichText.detectFacets() from @atproto/api, with correct
UTF-8 byte offsets
- Handle \u2192 DID resolution for follows and mentions
- Parent \u2192 root walking when building reply refs (re-uses the
parent's reply.root if present)
- Both at:// URIs and https://bsky.app/profile/.../post/... URLs
accepted everywhere a post reference is requested
- Optional image embed via binary property + uploadBlob
Files:
- src/nodes/Bluesky/Bluesky.node.ts
- src/nodes/Bluesky/operations.ts
- src/nodes/Bluesky/postUri.ts
- src/nodes/Bluesky/bluesky.svg
- tests/bluesky.test.ts (8 new tests)
Node marker: usableAsTool: true so AI agents can call it as a tool.
176 \u2192 184 tests passing. Build clean.
Replaces the trigger's plain-string collection input with the same
searchable resourceLocator picker the action node uses. Each entry in
the Collections fixedCollection now has list/nsid modes, lets users
pick from their repo's known collections, and accepts wildcard
prefixes (e.g. app.bsky.feed.*) in free-text mode.
- Extract createAgent, extractCollectionNsid and searchCollections
into src/nodes/Atproto/shared.ts (both nodes now import from it)
- Register methods.listSearch.searchCollections on the trigger node
- Update wantedCollections extraction in trigger() to handle the new
{ mode, value } shape via extractCollectionNsid()
Side effect: bundle layout is healthier \u2014 @atproto/api now lives in
a shared chunk (1MB) instead of being duplicated in the action bundle,
so Atproto.node.js drops from 902KB to 48KB raw.
n8n's getNodeParameter throws 'Could not get parameter' when a field
is hidden by displayOptions (no stored value exists). This affected:
- customEndpoint (only visible when endpoint='custom')
- operations (only visible when eventKinds includes 'commit')
All getNodeParameter calls now pass safe defaults, so the trigger
works regardless of which optional sub-fields are visible.
n8n's node creator UI groups action+trigger pairs by stripping
'Trigger' from the trigger's description.name and matching against
the action's name.
Before:
trigger.name='atprotoJetstreamTrigger'.replace('Trigger','')
='atprotoJetstream' -> no match with action 'atproto'
After:
trigger.name='atprotoTrigger'.replace('Trigger','')
='atproto' -> matches action 'atproto' ✓
The displayName 'AT Protocol Jetstream Trigger' is preserved and
auto-stripped by n8n to 'AT Protocol Jetstream' when displayed
within the grouped 'AT Protocol' entry.
n8n's CustomDirectoryLoader uses fast-glob('**/*.node.js') to discover
nodes in the custom folder — it does NOT read package.json n8n.nodes[].
Additionally, the loadClass helper derives the class name from the
filename via path.parse(file).name.split('.')[0], so the class must
match the filename prefix.
- Rename AtprotoJetstream.trigger.ts -> AtprotoJetstreamTrigger.node.ts
- Update vite entry, package.json n8n.nodes, barrel export
- Class name AtprotoJetstreamTrigger now matches the file prefix
- Add JetstreamClient WebSocket client (jetstream.ts) with:
- Lazy-loaded zstd dictionary for decompression
- Exponential backoff reconnection (1s→2s→4s…30s)
- 3s cursor rewind for gapless playback
- Stop flag to prevent reconnect on teardown
- Add AtprotoJetstreamTrigger node with:
- 7 parameters: endpoint, collections, DIDs, event kinds,
operations, compression, max message size
- Event kind + operation filtering
- Cursor persistence via getWorkflowStaticData
- Manual trigger support with 30s timeout
- Move zstd_dictionary to src/nodes/Atproto/ (from JetstreamTrigger/)
- Update vite.config.build.ts: entry point + dictionary copy
- Update package.json: n8n.nodes[] entry
- Update src/index.ts: barrel export
- Add 16 tests: event flattening all 3 kinds, filtering,
cursor tracking, zstd dictionary round-trip, WS integration
- Build clean, 176 tests passing
- Phase 5: enum/const/default mapping, constraint validation
(string length, graphemes, numeric range, array bounds, blob
accept/maxSize, closed unions), displayName hints
- Replace oxlint/oxfmt with n8n-required eslint config
- Add author, repository, peerDependencies, credential icon,
usableAsTool, NodeConnectionTypes.Main
- Add LICENSE (MIT), CI workflow, release-please workflow
- Update README: remove stale hex/color references
- 160 tests passing, lint clean, build clean
- README: collection picker, hex colors, validation, tips section,
architecture table
- CONTRIBUTING: new files, updated test count, execution pipeline
- DESIGN: type-only lexicons, execution-time processing pipeline,
special field handling, updated file structure
- PLAN: Phase 4 status, decisions 15-21, updated totals
- TODO: Phase 3+4 marked done, 125 tests
Execution-time validation (validation.ts):
- Required field checks (skips auto-injected $type/createdAt)
- Type correctness: string, integer, boolean, array, object
- Union $type discriminator: missing and invalid values
- Recursive validation into nested refs (depth-limited)
- Blob fields: detects unresolved binary property names
- Clear bullet-point error messages before PDS submission
Better descriptions for object/array fields (fieldMapping.ts):
- Multi-ref unions show valid $type options in displayName
- Array fields show item type hint (e.g. 'JSON array of ...')
- Blob fields hint about binary property names from input
- Collection field → resourceLocator with searchable list (queries
the user's PDS via describeRepo) + free-text "By NSID" mode with
NSID format validation.
- Add subtitle showing the current operation on the canvas.
- Move Swap Commit into an Options collection so it doesn't clutter
the main view for the common case.
- Blob fields now show a hint in the displayName explaining that
the value should be a binary property name from the input.
Fix cryptic JSON editors shown for custom lexicon fields like
site.standard.publication's theme colors. Four issues addressed:
F1 – Single-ref unions (common ATProto pattern) are now resolved and
flattened like regular refs. Added getResolvableRef() helper.
F2 – Type-only lexicons (no main record, e.g. site.standard.theme.color)
now return a defs-only schema so cross-document fragment resolution
(#rgb, #rgba) works.
F3 – Descriptions from lexicon properties are propagated to displayName
on all flattened and fallback fields.
F4 – Nested $type injection at execution time via injectNestedTypes().
Hex color strings (#RRGGBB, #RGB) are expanded to {r, g, b} objects
with the correct $type discriminator. Wired into both createRecord
and putRecord flows.
RGB color refs are collapsed into single hex string fields instead of
three separate number inputs (4 fields vs 12 for a theme).
- Dependencies section: runtime → none, all bundled by Vite
- Tooling section: n8n-node build → vite build
- Decisions log: entries 13-14 (zero deps, uint8arrays patch)
- Distribution checklist: mark lint/test/build/dev as done
- File structure: add blob.ts, scripts/, vite config, CONTRIBUTING
- Bundle size table with per-chunk breakdown
n8n-node dev symlinks the project root, and n8n's glob picks up
*.node.js files from node_modules/uint8arrays (platform-specific
conditional exports). These aren't n8n nodes and crash the loader.
Add a postinstall script that rewrites the 'node' condition in
uint8arrays' exports/imports maps to point at the standard ESM
targets, then deletes the .node.js files. The ESM fallbacks are
functionally identical (Uint8Array vs Buffer — negligible diff).
Dev server now starts cleanly on http://localhost:5678.
n8n's verification guidelines require community nodes to have no
runtime dependencies. The dev symlink also caused crashes because
n8n's glob picked up uint8arrays' .node.js platform files.
Fix: use Vite (already installed via vitest) in library mode to
bundle @atproto/api and @atproto/lexicon-resolver into the dist
output. Only n8n-workflow and Node.js builtins are externalized.
- Move @atproto/* from dependencies to devDependencies
- Add vite.config.build.ts (CJS library mode, two entry points)
- Replace n8n-node build with vite build in the build script
- Remove peerDependencies (n8n-workflow is externalized by Vite)
- Copy icon via writeBundle plugin
- Build produces ~2.3 MB (460 KB gzipped) with zero npm deps
- Add src/nodes/Atproto/blob.ts with applyBlobUploads() that walks a
built record, uploads binary data for blob-typed fields via
com.atproto.repo.uploadBlob, and substitutes BlobRef values.
- Wire blob upload into execute path for createRecord and putRecord
(resolves lexicon schema to identify blob fields, then applies uploads
before the XRPC call).
- MIME type read from n8n binary metadata, falls back to
application/octet-stream.
- Top-level blob fields only; rollback on first failure.
- 11 new tests covering upload, field discrimination, error paths,
MIME fallback, and record immutability.
- Update mock server with uploadBlob handler (binary body, not JSON).
- Update PLAN.md with Phase 3 completion status and decisions.
- Added Status table at top: Phase 0/1/2 done, Phase 3 next, 92 tests passing
- Added decision log entries 10-12 capturing surprises from Phase 2:
10. XRPC validation rejects record type \u2014 catch error, use responseBody
11. Dotted-key un-flattening required before sending records to PDS
12. resolveRefProperties returns { properties, required } for accurate
required propagation through ref-flattened sub-fields
- Marked Phase 0/1/2 sections as done with commit refs
- Added a 'Deviations from the plan' note under Phase 2 explaining the
five things that ended up different from the original design
- Expanded Phase 3 with what Phase 2 already set up for it and the
open question about where async blob upload post-processing fits
Critical bugs fixed:
1. Dotted keys produced by ref/object field flattening (e.g. reply.root,
reply.parent) were sent to the PDS as literal flat keys, producing
records that would always fail lexicon validation. Added
unflattenDottedKeys() to convert them back to nested objects before
the XRPC call. Also drops empty optional values so they don't get sent
as empty strings.
2. resolveLocalDef extracted the required-fields array but discarded it,
so the required status of ref-resolved sub-fields was lost. Changed
resolveRefProperties to return { properties, required } (new ResolvedRef
type) and updated flattenRefProperties to AND the schema's required
list with the parent's required status — so optional ref sub-fields
stay optional even when the schema would require them if the ref were
present.
Resource mapper UX improvements:
- Added loadOptionsDependsOn: ['collection'] so getRecordFields is only
called when the NSID changes, not on every editor keystroke.
- Added supportAutoMap: true so users can auto-map input data to fields.
- Added noFieldsError with a helpful message when lexicon resolution fails.
Cleanup:
- Removed unused INodePropertyOptions and ResourceMapperField imports.
- Removed unused 'collection' parameter from buildRecordFromNodeParams.
- Exported buildRecordFromNodeParams + unflattenDottedKeys for testing.
New tests (21 added, 92 total):
- tests/buildRecord.test.ts — 20 tests covering raw JSON, resourceMapper
values, dotted key un-flattening, empty value handling, deep nesting,
and plain object input (from expressions).
- tests/fieldMapping.test.ts — 1 test for the optional-ref-sub-field
required propagation fix.
New modules:
- src/nodes/Atproto/lexicon.ts — Lexicon resolution from PDS endpoint
(with XRPC validation bypass via error.responseBody) or DNS-based
@atproto/lexicon-resolver fallback. In-memory cache, full lexicon
document parsing with type inference, and ref resolution helpers.
- src/nodes/Atproto/fieldMapping.ts — Lexicon schema → ResourceMapperField[]
conversion with type mapping, createdAt auto-default, recursive ref
flattening (depth cap 3), inline object unwrapping with dotted prefix.
- tests/mockLexicons.ts — 5 realistic mock lexicon documents covering
records, primitives, inline objects, deeply nested refs, and queries.
Updated modules:
- src/nodes/Atproto/Atproto.node.ts — Added resourceMapper property for
Record Data (Create/Put), methods.resourceMapping.getRecordFields,
and buildRecordFromNodeParams helper for both resourceMapper and raw JSON.
- tests/setup.ts — Added com.atproto.lexicon.resolveLexicon mock handler
and catch-all bypass for unhandled requests (DID resolution, etc.).
- tsconfig.json — Added skippedLibCheck (already set) for ESM package compat.
- TODO.md — Marked Phase 0, 1, and 2 items as complete.
Tests: 71 total (35 Phase 1 + 36 Phase 2), all passing.
Phase 0:
- Initialize project structure with n8n community node conventions
- Configure package.json with scripts, dependencies, and n8n metadata
- Set up tsconfig.json (ES2022, strict, commonjs)
- Set up oxlint.json with n8n eslint plugin via jsPlugins
- Set up vitest.config.ts and .oxfmtrc.json
- Add AT Protocol butterfly icon
- Add .gitignore (node_modules, dist)
Phase 1:
- Credential definition (AtprotoApi): identifier, appPassword, serviceUrl
with test block that validates via createSession
- TID generation (tid.ts): 13-char base32-sortable, top bit 0,
53-bit microsecond timestamp, 10-bit clock ID
- Node description (Atproto.node.ts): all 5 operations with complete
property definitions (Collection, Repo, Record Key, Record Data,
Swap Commit, Limit, Cursor)
- Operations (operations.ts): createRecord, getRecord, putRecord,
deleteRecord, listRecords — with /createdAt auto-injection
- Execute method: session management via CredentialSession,
per-item processing, continueOnFail pattern
- Error handling: friendlyError maps XRPC errors to n8n messages
- Tests: tid.test.ts (format, uniqueness, sortability),
operations.test.ts (all 5 CRUD ops + error paths),
type-injection.test.ts (/createdAt behavior),
setup.ts (msw mock XRPC server)