Commit Graph

56 Commits

Author SHA1 Message Date
Ricardo
cf284e8633 feat: add fediverse URL/handle lookup input to reader
Adds a search box at the top of the reader page where users can paste
any fediverse URL or @user@domain handle. Uses Fedify's lookupObject()
which natively resolves URLs, handles, and acct: URIs, then redirects
to the internal post detail or remote profile view.
2026-02-21 21:33:08 +01:00
Ricardo
0cf49e037c fix: remove duplicate page headings across all AP templates
document.njk already renders title as h1 via the heading macro.
All 14 AP templates were also calling heading() with level 1 inside
their content block, producing two h1 elements per page. Removed
the redundant calls and moved dynamic count prefixes into the title
variable in followers/following controllers.
2026-02-21 21:32:56 +01:00
Ricardo
81373d662f chore: bump version to 1.1.16 2026-02-21 20:29:25 +01:00
Ricardo
31418310d2 fix: pagination, headers, avatars, tab order, and notification UI
- Fix cursor pagination: use string comparison (not Date objects) for
  published field queries in both timeline and notifications
- Fix "Older" cursor to use oldest item's date, not newest
- Remove redundant parent breadcrumb from all AP page headings
- Reorder tabs: Notes first, All last
- Fix avatar loading: non-destructive hide/show with lazy loading
- Add actor avatars with type badge overlay to notification cards
- Add Fediverse navigation group in sidebar
2026-02-21 20:28:40 +01:00
Ricardo
937c0a8226 chore: bump version to 1.1.15 2026-02-21 20:00:20 +01:00
Ricardo
d20dea2dc8 feat: notification management — clear, mark read, dismiss, TTL retention
- Add "Mark all read" and "Clear all" toolbar buttons on notifications page
- Add per-notification dismiss (×) button
- Remove auto-mark-all-as-read on page load (explicit action only)
- Add 30-day TTL index on createdAt for automatic notification cleanup
- New config option: notificationRetentionDays (default 30)
2026-02-21 20:00:05 +01:00
Ricardo
5ff3197493 feat: add internal AP link resolution and OpenGraph card unfurling (v1.1.14)
Reader now resolves ActivityPub links internally instead of navigating
to external instances. Actor links open the profile view, post links
open a new post detail view with thread context (parent chain + replies).

External links in post content get rich preview cards (title, description,
image, favicon) fetched via unfurl.js at ingest time with fire-and-forget
async processing and concurrency limiting.

New files: post-detail controller, og-unfurl module, lookup-cache,
link preview template/CSS, client-side link interception JS.
Includes SSRF protection for OG fetching and GoToSocial URL support.
2026-02-21 18:32:12 +01:00
Ricardo
313d5d414c fix: reader UI fixes and correct Fedify API usage (v1.1.8→1.1.12)
- Fix Unknown authors by adding multi-strategy fallback chain in
  extractObjectData (getAttributedTo → actorFallback → attributionIds)
- Fix empty boosts from Lemmy/PieFed by checking content before storing
- Fix @mention/hashtag styling to stay inline instead of breaking layout
- Fix compose reply to show sanitized HTML blockquote instead of raw text
- Add default-checked syndication targets for AP and Bluesky
- Use authenticated document loader for all lookupObject calls
  (fixes 401 errors on servers requiring Authorized Fetch)
- Fix like handler 404 by using canonical AP uid for interactions
  instead of display URLs; add data-item-uid to card template
- Fix profile bio showing Nunjucks macro source code by renaming
  summary→bio to avoid collision with Indiekit's summary macro
- Fix Fedify API misuse in timeline-store.js: use instanceof Article
  (not string comparison), replyTargetId (not inReplyTo), getTags()
  and getAttachments() async methods (not sync property access)
- Fix inbox-listeners.js: use replyTargetId instead of non-existent
  getInReplyTo(), use instanceof Article for Update handler
- Add error logging to interaction catch blocks
2026-02-21 17:08:28 +01:00
Ricardo
b81ecbcaa4 docs: add CLAUDE.md for AI agents and README.md for humans
CLAUDE.md covers architecture, 18 critical gotchas distilled from
bug fixes (Fedify bridge, objectId vs getObject, template collisions,
Express 5 redirect, date handling, author fallback chain, etc.),
MongoDB collections, route table, and publishing workflow.

README.md covers features, installation, configuration, nginx setup,
how syndication/inbox/content negotiation work, Mastodon migration,
admin UI reference, and known limitations.
2026-02-21 17:06:11 +01:00
Ricardo
348a183e46 fix: profile links lost on save due to qs body parser key mismatch
express.urlencoded({ extended: true }) uses qs which strips [] from
field names, so link_name[] arrives as request.body.link_name — not
request.body["link_name[]"]. The old lookup always got undefined,
producing an empty attachments array that overwrote existing links.
2026-02-21 15:04:36 +01:00
Ricardo
94844d5b4d fix: extract correct username from /users/name URL pattern
The attributionIds fallback was matching "users" from /users/NatalieDavis
instead of the actual username. Now handles /@name, /users/name, and
/ap/users/id patterns correctly.
2026-02-21 15:00:29 +01:00
Ricardo
d395a1cc24 fix: resolve Unknown authors, filter empty boosts, style mentions
- Add actorFallback option to extractObjectData() so the activity's
  actor is used when object.getAttributedTo() fails (Authorized Fetch,
  unreachable servers). Falls back to attributionIds for URL-based info.
- Pass create.getActor() as actorFallback in Create inbox listener.
- Skip storing boosts with no content (Lemmy/PieFed activity IDs).
- Add template guard to hide empty cards already in the database.
- Style @mention and hashtag links distinctly from prose content.
- Handle Mastodon's invisible/ellipsis URL span classes.
2026-02-21 14:54:10 +01:00
Ricardo
7e97ab7fbf style: rewrite CSS to use Indiekit theme system
Replace all nonexistent CSS variable references with Indiekit's actual
custom properties. This enables automatic dark mode support (variables
swap via prefers-color-scheme) and visual consistency with the rest of
the admin UI.

Key changes:
- Map --color-text → --color-on-background, --color-text-muted →
  --color-on-offset, --border-radius → --border-radius-small, etc.
- Add post-type differentiation via colored left borders: purple for
  notes, green for articles, yellow for boosts, primary for replies
- Replace hardcoded hex colors (#e11d48, #16a34a) with Indiekit's
  palette variables (--color-red45, --color-green50, etc.)
- Use Indiekit's border-width tokens for consistent border sizing
- Add background/color to form inputs for dark mode compatibility
2026-02-21 14:22:28 +01:00
Ricardo
978aeb45ae fix: rename reader layout to ap-reader.njk to avoid microsub collision
Nunjucks resolves template names across all registered plugin view
directories. Both @rmdes/indiekit-endpoint-microsub and this plugin
had views/layouts/reader.njk, causing the microsub layout to be
loaded instead — which meant Alpine.js, reader CSS, and all timeline
content were missing from the rendered page.
2026-02-21 14:08:05 +01:00
Ricardo
3ad86ffb39 fix: reader UI — navigation, Alpine.js loading, avatar fallback, Temporal dates
- Return multiple navigation items (ActivityPub, Reader, Notifications, Moderation)
  so all AP sub-pages are accessible from the sidebar
- Fix Alpine.js not loading: `{% block head %}` was silently discarded because
  the parent template chain has no such block — moved script/css into content block
- Pin Alpine.js to exact version 3.14.9 to prevent CDN resolution issues
- Add fallback avatar (first letter) when author photo is missing
- Guard empty author URLs to prevent broken links
- Fix Temporal.Instant TypeError: use String() instead of new Date() for
  Fedify published timestamps in inbox-listeners and timeline-store
- Link author names to remote profile view instead of raw AP URLs
- Bump to 1.1.3
2026-02-21 13:31:52 +01:00
Ricardo
55e9311c4a feat: broadcast Update(Person) on profile/featured/tags changes, fix rel=me
- Add broadcastActorUpdate() method that sends Update(Person) to all
  followers so remote servers re-fetch the actor object
- Profile, featured pin/unpin, and featured tags add/remove controllers
  now trigger the broadcast after changes
- Wrap URL attachment values in <a rel="me"> HTML for Mastodon rel=me
  verification; plain text values pass through unchanged
- Bump version to 1.1.1
2026-02-21 12:19:22 +01:00
Ricardo
4e514235c2 feat: ActivityPub reader — timeline, notifications, compose, moderation
Add a dedicated fediverse reader view with:
- Timeline view showing posts from followed accounts with threading,
  content warnings, boosts, and media display
- Compose form with dual-path posting (quick AP reply + Micropub blog post)
- Native AP interactions (like, boost, reply, follow/unfollow)
- Notifications view for likes, boosts, follows, mentions, replies
- Moderation tools (mute/block actors, keyword filters)
- Remote actor profile pages with follow state
- Automatic timeline cleanup with configurable retention
- CSRF protection, XSS prevention, input validation throughout

Removes Microsub bridge dependency — AP content now lives in its own
MongoDB collections (ap_timeline, ap_notifications, ap_interactions,
ap_muted, ap_blocked).

Bumps version to 1.1.0.
2026-02-21 12:13:10 +01:00
Ricardo
81a28ef086 feat: add actor type selector and profile links to admin UI
- Actor type radio buttons (Person/Service/Organization) in Profile page,
  stored in ap_profile and read by federation-setup actor dispatcher
- Profile links (attachments) section with add/remove for rel="me"
  verification links, rendered as PropertyValue on the ActivityPub actor
- New locale strings for all new UI elements
2026-02-21 10:07:03 +01:00
Ricardo
d46aca0c93 fix: replace redirect("back") with explicit paths for Express 5
Express 5 removed the "back" magic keyword from response.redirect().
It was treated as a literal URL, causing 404s at /admin/featured/back
and /admin/tags/back. Now redirects to the correct parent pages.
2026-02-21 00:17:56 +01:00
Ricardo
71851eba9b fix: use .objectId accessors to prevent fetch errors from remote servers
Inbox handlers used await activity.getObject() which HTTP-fetches remote
objects. This fails when remote servers have Authorized Fetch enabled or
are unavailable, causing Fedify to retry ~10 times per activity.

Replaced with .objectId/.actorId accessors (zero network requests) for
Like, Announce, Undo, and Delete handlers. Wrapped remaining getObject()
and getActor() calls in try-catch with fallback to ID accessors.

Also adds Pinned Posts and Featured Tags cards to the admin dashboard.
2026-02-20 23:44:18 +01:00
Ricardo
4827a98614 feat: Fedify feature completeness — collections, dispatchers, delivery hardening
Implement all missing Fedify features for full ActivityPub compliance:

- Liked, Featured, Featured Tags collection dispatchers with admin UIs
- Object dispatcher for Note/Article dereferencing at AP URIs
- Instance actor (Application type) for domain-level federation
- Handle aliases (.mapAlias) for profile URL and /@handle resolution
- Configurable actor type (Person/Service/Organization/Group)
- Dynamic NodeInfo version from @indiekit/indiekit package.json
- Context data propagation (handle + publication URL)
- ParallelMessageQueue wrapping RedisMessageQueue (5 workers)
- Collection sync (FEP-8fcf) and ordering keys on sendActivity
- Permanent failure handler stub (deferred to Fedify 2.0)
- Profile attachments (PropertyValue) and alsoKnownAs support
- Strip invalid "type":"as:Endpoints" from actor JSON (Fedify #576)
- Fix .mapAlias() return type ({identifier} not bare string)
- Remove .authorize() predicate (causes 401 loops without auth doc loader)
- Narrow content negotiation router to /nodeinfo/ only

22/22 compliance tests pass (Grade A+). Version 1.0.26.
2026-02-20 22:57:41 +01:00
Ricardo
be369b1677 feat: federation hardening — persistent keys, Redis queue, indexes
- Persist Ed25519 key pair to ap_keys collection via exportJwk/importJwk
  instead of regenerating on every request (fixes OIP verification failures)
- Use assertionMethods (plural array) per Fedify spec
- Add @fedify/redis + ioredis for persistent message queue that survives
  process restarts (falls back to InProcessMessageQueue when no Redis)
- Add Reject inbox listener to mark rejected Follow requests
- Add performance indexes on ap_followers, ap_following, ap_activities
- Wire storeRawActivities flag through to activity logging
- Bump version to 1.0.21
2026-02-20 16:33:13 +01:00
Ricardo
5604771c69 feat: deliver replies to original post author's inbox
Replies syndicated via ActivityPub were only sent to followers.
Remote servers (e.g. Mastodon) never received the Create(Note) activity,
so replies didn't appear under the original post.

Changes:
- Resolve the reply-to post author via ctx.lookupObject() + getAttributedTo()
- Include the original author in CC addressing (ccs) on the Note
- Add a Mention tag for the original author
- Deliver the activity to the author's inbox via a second sendActivity() call
- Log reply delivery with targetUrl for debugging

Also includes: following list badge fix from refollow work, version bump to 1.0.20
2026-02-20 14:00:00 +01:00
Ricardo
06b8509d8a fix: refollow UI - progress bar, pause button, status endpoint
Three issues fixed:

1. Progress bar invisible: used --color-accent (doesn't exist in
   Indiekit theme). Changed to --color-primary.

2. Pause/resume buttons non-functional: the /admin/refollow/status
   GET endpoint was intercepted by Fedify middleware (content
   negotiation routes) returning 404 before Express saw it. Added
   /admin path skip to content negotiation middleware. Also made
   buttons toggle dynamically via Alpine.js x-show instead of
   server-rendered {% if %}.

3. Status badge static: replaced Nunjucks badge macro with Alpine.js
   x-text bound to a computed statusLabel property.
2026-02-20 10:29:04 +01:00
Ricardo
505daa68c5 fix: auto-created Microsub channel invisible in reader UI
The AP plugin auto-created the Fediverse channel with userId: null,
but the Microsub reader UI filters channels by userId: "default".
The channel existed in MongoDB but was invisible to users.

getApChannelId() now:
- Auto-creates with userId: "default" if no channel exists
- Fixes existing channels missing userId (from earlier versions)
- Uses proper field pattern matching Microsub plugin conventions
2026-02-20 10:00:01 +01:00
Ricardo
bd87769ad0 fix: Accept handler skipping all accepts due to inner object type
Fedify resolves accept.getObject() to a Person (the Follow target actor)
rather than the Follow activity itself. The instanceof Follow check
rejected every Accept. Removed inner object inspection entirely — instead
match directly against ap_following where source is refollow:sent. If we
have a pending follow for this actor, any Accept from them confirms it.
2026-02-20 09:47:32 +01:00
Ricardo
13d939c146 fix: Accept(Follow) handler not matching incoming accepts
Two issues prevented Accept activities from transitioning ap_following
docs from refollow:sent to federation:

1. accept.getObject() often returns null because remote servers reference
   our outgoing Follow by URL, which Fedify can't resolve back. The strict
   instanceof Follow check caused early return on every Accept. Now we
   proceed to the MongoDB match if getObject() returns null or throws.

2. Batch processor sent Follow to entry.actorUrl but never updated the
   stored URL to the canonical form after resolving the remote actor.
   Now updates actorUrl to remoteActor.id.href so Accept handler matches.
2026-02-20 09:40:20 +01:00
Ricardo
baac0a60bc fix: filter Like and Announce to only log reactions to our content
After following remote actors, their servers deliver all activities
to our inbox — including likes/boosts of other people's posts. Only
log Likes and Announces where the objectUrl starts with our
publication URL, filtering out unrelated activity.
2026-02-20 09:28:08 +01:00
Ricardo
432bb7a64a fix: include federated accounts in progress bar calculation
When Accept(Follow) arrives, source transitions from refollow:sent
to federation. Without counting federated in the total, those
accounts drop out of both numerator and denominator, making the
progress bar stay flat or go backwards.
2026-02-20 08:32:51 +01:00
Ricardo
473624c709 fix: guard getInReplyTo call for partial Fedify objects
Some resolved Note objects from Create activities don't have
getInReplyTo as a function (Fedify stub/partial resolution).
Add typeof check and try-catch to prevent inbox processing crash.
2026-02-20 08:22:43 +01:00
Ricardo
84122cc470 feat: batch re-follow system for imported AP accounts
After Mastodon migration, imported accounts exist only locally — no
Follow activities were sent. This adds a gradual background processor
that sends Follow activities to all source:"import" accounts so remote
servers start delivering Create activities to our inbox.

- New batch engine (lib/batch-refollow.js) processes 10 accounts per
  batch with 3s between follows and 30s between batches
- Accept(Follow) inbox listener transitions source to "federation"
  and cleans up tracking fields
- Admin API: pause, resume, and status JSON endpoints
- Dashboard progress bar with Alpine.js polling (10s interval)
- Following list badges for refollow:sent and refollow:failed states
- Restart recovery resets stale refollow:pending back to import
- 3 retries with 1-hour cooldown before permanent failure
2026-02-20 08:10:45 +01:00
Ricardo
656b66c780 fix: add WebFinger handle mapper and Ed25519 key pair
1. mapHandle() — tells Fedify how to resolve WebFinger usernames to
   actor identifiers, suppressing the "No actor handle mapper is set"
   warning on every WebFinger lookup.

2. Ed25519 key pair — generated alongside the legacy RSA pair so Fedify
   can create Object Integrity Proofs on outbound activities. RSA is
   kept for HTTP Signatures backward compatibility.
2026-02-19 20:18:16 +01:00
Ricardo
8a03dc9c9d chore: bump version to 1.0.9 2026-02-19 19:52:27 +01:00
Ricardo
53db99400a fix: suppress LogTape context-local storage warning
Add AsyncLocalStorage to LogTape configure() to fix the repeated
"Context-local storage is not configured" warning that appeared
before every Fedify log entry. Also remove unused getLogger import.
2026-02-19 19:52:05 +01:00
Ricardo
420973e5ec fix: start delivery queue and enable authenticated shared inbox fetches
Two critical fixes for ActivityPub federation:

1. Call federation.startQueue() — without this, ctx.sendActivity() enqueues
   delivery tasks but the InProcessMessageQueue never processes them, so
   activities are never actually POSTed to follower inboxes.

2. Add setSharedKeyDispatcher on the shared inbox — enables Fedify to make
   signed/authenticated GET requests when verifying incoming HTTP Signatures.
   Servers with authorized fetch (e.g. hachyderm.io) return 401 on unsigned
   requests, which prevented Fedify from fetching sender public keys and
   caused all incoming activities to be rejected.
2026-02-19 19:34:53 +01:00
Ricardo
fb8d90232b feat: add Fedify LogTape logging for delivery visibility
Configure LogTape to route Fedify's internal logs (federation, vocab,
delivery) to console at info level. This makes activity delivery
attempts, HTTP signature issues, and queue processing visible in
container logs.
2026-02-19 19:19:15 +01:00
Ricardo
78f6d8b34a chore: bump version to 1.0.6 2026-02-19 18:12:42 +01:00
Ricardo
e40ffbf61d feat: add outbound follow/unfollow, activity logging, and Microsub timeline integration
- Add followActor() and unfollowActor() methods for sending Follow/Undo(Follow) activities
- Add shared activity-log.js utility for logging to ap_activities collection
- Log all outbound activities (syndication, follow, unfollow) with success/failure details
- Update inbox Create listener to store timeline items from followed accounts
- Add Microsub collection accessors for cross-plugin timeline integration
- Refactor inbox-listeners to use shared activity logging utility
2026-02-19 18:11:28 +01:00
Ricardo
d055408aad feat: append permalink to syndicated AP content + bump to 1.0.5
Notes and articles syndicated to ActivityPub now include a clickable
link back to the canonical post URL at the end of the content body.
This ensures fediverse clients display a visible permalink, since the
Note url property alone is not shown inline by most implementations.
2026-02-19 16:08:20 +01:00
Ricardo
b0b0605985 feat: add Reply content/targetUrl fields, fix followers card photo crash
- inbox-listeners.js: Store `targetUrl` (inReplyTo) and `content` (HTML)
  on Reply activities for the conversations plugin AP adapter
- activitypub-followers.njk: Fix photo property name (`src` → `url`)
  to match the card component's expected interface, fixing TypeError
  crash on followers page when avatars are present
- Bump to v1.0.4
2026-02-19 15:10:34 +01:00
Ricardo
06e521cfa7 fix: skip Fedify middleware for non-GET requests in contentNegotiationRoutes
The contentNegotiationRoutes getter is mounted at root / and was passing
ALL requests through Fedify, including POST requests to admin routes.
fromExpressRequest() calls Readable.toWeb(req) which consumes the body
stream, causing "response body object should not be distributed or locked"
errors when admin controllers try to read req.body.

The v1.0.2 fix only protected routesPublic (mounted at /activitypub).
This fixes the actual culprit by skipping non-GET/HEAD methods in
contentNegotiationRoutes, since content negotiation and NodeInfo are
both GET-only concerns.
2026-02-19 12:52:02 +01:00
Ricardo
e461291178 fix: skip Fedify middleware for admin UI routes
POST to /admin/migrate was going through Fedify's federation.fetch()
which consumed the already-parsed request body stream, causing
"response body object should not be distributed or locked" errors.

Admin routes (/admin/*) are UI routes handled by authenticated
Express handlers, not federation endpoints.
2026-02-19 12:35:01 +01:00
Ricardo
599f15e8b4 fix: handle inbox GET, add missing activity type handlers
- Return 405 for GET on inbox endpoints instead of falling through
  to Indiekit's auth middleware (which redirects to login)
- Add handlers for Update (refresh follower data), Block (remove
  follower), Add and Remove (Mastodon pin/unpin — ignored)
- Bump to 1.0.1
2026-02-19 12:27:47 +01:00
Ricardo
f81b9212f7 chore: bump version to 1.0.0 for Fedify migration 2026-02-19 12:00:07 +01:00
Ricardo
eaf0f1d126 feat: migrate to Fedify for ActivityPub federation (v0.2.0)
Replace hand-rolled federation code with Fedify's battle-tested
implementation. This gives us proper HTTP Signatures, WebFinger,
NodeInfo, typed inbox listeners, and collection dispatchers out
of the box.

New modules:
- lib/federation-setup.js — Fedify Federation configuration
- lib/federation-bridge.js — Express↔Fedify middleware bridge
- lib/inbox-listeners.js — typed inbox handlers (Follow, Undo, etc.)
- lib/kv-store.js — MongoDB-backed KvStore adapter
- lib/controllers/profile.js — admin profile management
- views/activitypub-profile.njk — profile editing form

Removed hand-rolled modules:
- lib/actor.js, lib/federation.js, lib/inbox.js
- lib/keys.js, lib/webfinger.js

Key changes:
- Actor, inbox, outbox, followers, following all delegate to Fedify
- Syndication uses ctx.sendActivity() instead of manual delivery
- Profile managed via admin UI, stored in ap_profile collection
- Legacy PKCS#8 keys imported via Web Crypto API
- Custom bridge preserves Express mount path (req.originalUrl)
2026-02-19 11:59:23 +01:00
Ricardo
c522989d38 fix: parse CSV client-side to avoid 413 payload too large
Express's app-level body parser has a 100KB default limit that
runs before any route-level overrides. A 3K-line CSV at 113KB
exceeds this. Instead of sending raw CSV, the client now extracts
handles (first column only) and sends just the array — typically
under 90KB for 3000 accounts.
2026-02-19 10:41:20 +01:00
Ricardo
d159e79998 fix: switch CSV import to JSON fetch to bypass body size limit
The app-level Express urlencoded parser (100KB limit) runs before
route-level middleware, so overriding the limit on the route doesn't
help. Solution: POST the CSV as JSON via fetch() to a dedicated
/admin/migrate/import endpoint with its own express.json({ limit: '5mb' }).

- Import button now shows "Importing..." while working
- Results appear inline without page reload
- Failed handles shown in a collapsible details element
- Import button disabled until a file is selected
- Alias form remains a regular POST (small payload, no issue)
2026-02-19 10:22:34 +01:00
Ricardo
de00d3a16c fix: payload too large error and add migration logging
- Add express.urlencoded({ limit: '5mb' }) to migration POST route
  to handle large CSV files (default 100KB was too small)
- Add per-handle progress logging to console for monitoring imports
- Log failed handles with reasons (WebFinger failure, no AP link, etc.)
- Show failed handles in the UI result notification
- Use error notification type when all imports fail
2026-02-19 10:14:23 +01:00
Ricardo
fec4b1f242 fix: use client-side FileReader for CSV upload
Multipart form uploads fail because Indiekit has no multipart parsing
middleware. Instead, read the CSV file client-side with FileReader and
submit the text content as a hidden form field. Shows file name and
line count after selection for user confidence.
2026-02-19 09:50:43 +01:00
Ricardo
334f71d601 fix: improve migration page UX
- Show current alias value on the page (persists across GET/POST)
- Pre-fill alias input with current value
- Add fieldset legend and per-item hints to import checkboxes
- Add intro paragraph explaining the migration flow
- Rewrite copy to be clearer and more reassuring
- Note irreversibility of step 3 explicitly
2026-02-19 09:27:35 +01:00