Skip to content

Alap 3.2.0 release notes

Released 2026-04-26

A substantial release. Three new generate protocols, a redesigned :location: protocol, an extensive pass on the async and rendering lifecycle, a new expression sigil ($preset), a security sweep, and a full authoring-side toolkit for converting external markdown into Obsidian vaults.

The headline items:

  • HIGH-severity URL sanitization fix for protocol-sourced links — all 3.1.x users should upgrade.
  • Three new generate protocols: :hn: (Hacker News), :obsidian:core: (local vault via filesystem), :obsidian:rest: (Obsidian Local REST API).
  • :location: redesigned — sub-mode verbs (radius, bbox), a new src/geo/ peer module for spatial primitives. Breaking change for the old positional shape; see Upgrading.
  • $preset sigil — protocol-local named argument bundles, peer to @macro at the expression level.
  • Progressive async everywhere — one ProgressiveRenderer under menu, lens, lightbox, DOM, and Alpine. In-flight dedup, timeout, concurrency cap, refcounted cancel-on-dismiss.
  • Renderer coordinators — menu, lens, and lightbox sharing one engine dismiss cleanly; Escape cascades through the stack.
  • Placement grammar parity across menu, lens, lightbox, and <alap-link>.
  • Security sweep — trust model for different data sources, frozen config, handler registry, hooks allowlist, outbound rel="noopener noreferrer", two-tier handler size warning.
  • Markdown → Obsidian vault converter with SSG plugins (Hugo, Jekyll, MkDocs, Docusaurus) and trust-model-by-default sanitisation.
  • Feeds → markdown adjacent utility for RSS 2.0 / Atom 1.0 archives.
  • Shared protocol helperspathSafety, localhostGuard, uriTemplate, dynamicImport — reusable building blocks for filesystem and localhost-facing protocols.

Versions 3.0 and 3.1 are unsupported

The security work in 3.2 isn't practically backportable to the earlier versions, and adoption was low enough that I'd rather direct effort forward than maintain parallel surfaces. Upgrade to 3.2 for security fixes.

A note on scope

Alap is a single-maintainer open source project that hasn't been through a third-party audit. Please do your own due diligence — especially when wiring up protocols on servers with local network access. See SECURITY.md and the threat model for the full picture.

Security

Fixed a HIGH-severity URL sanitization bypass affecting protocol-sourced links that could lead to cross-site scripting. All 3.1.x users should upgrade to 3.2.0.

SSRF guard hardened against DNS rebinding

The syntactic isPrivateHost check on a URL's hostname runs before the socket connect, so it can't reflect what the OS resolver returns at connect time — a public-looking name whose resolution changes between check and connect would slip past. Server-side fetches — :web:, :json:, :hn:, :atproto: — now re-validate the resolved IP against the private-address blocklist at socket-open time via a new guardedFetch helper, moving the check to the moment the connection is made.

:obsidian:rest: keeps its explicit allowedHosts gate; loopback is the intended destination there, and a socket-level deny would break the default config. The residual scenario for that protocol is documented inline in src/protocols/obsidian/restClient.ts.

3.2 security sweep

Layered hardening across validation, rendering, and the async fetch lifecycle:

  • Trust model. How Alap handles data from different sources (local, external, and stored) to ensure flexibility for developers while providing a high "defense floor" for untrusted input.
  • Frozen, data-only config. validateConfig deep-freezes configs and deep-clones on ingest. Handler functions move out of the config into a runtime registry passed to new AlapEngine(config, { handlers }).
  • Hooks allowlist. settings.hooks doubles as an allowlist for hooks on non-author-tier links.
  • Outbound link hardening. Menu, lens, and lightbox anchors all carry rel="noopener noreferrer" regardless of tier.
  • Refcounted fetch cancellation. Multi-renderer setups sharing one engine can dismiss independently without cancelling each other's fetches.
  • Two-tier handler size warning. Handlers returning more than 10× the 200-link cap trigger a louder operator-facing warning; the engine still slice-caps as a backstop.
  • Link metadata key blocklist. validateConfig strips a reserved set of metadata keys so config authors can't shadow handler-meaningful keys with caller-controlled meta.

The per-feature breakdown lives in api-reference/security.md.

New protocols

:hn: — Hacker News

Top, new, best, Ask HN, Show HN, jobs, user submissions, Algolia search, and specific items by id. Zero auth, zero server — both backends (Firebase for listings and items, Algolia for search) serve CORS headers, so the browser fetches directly.

:hn:top:                          → front-page top stories
:hn:new:limit=5:                  → newest stories (5)
:hn:search:$ai_papers:limit=10:   → Algolia search via named preset

Composes with :time: for recency filtering — meta.timestamp is populated so :hn:new:limit=30: + :time:6h: works out of the box. Shipped as a worked tutorial for writing custom protocols — see writing-protocols.md.

:obsidian:core: — local vault, filesystem-backed

Turns an Obsidian vault on disk into a link source. Queries match note titles, tags, bodies, and paths; resolved links open notes in the desktop app via obsidian:// URIs. Node-only, opt-in subpath import (alap/protocols/obsidian).

:obsidian:core:bridges:           → notes mentioning "bridges"
:obsidian:core:music:$meta:       → titles/tags only, via named preset

Inline #tag tokens in note bodies are picked up alongside the frontmatter tags: array — the primary Obsidian tagging idiom for many users. Tag aliases (tagAliases: { inbox_now: 'inbox/now' }) bridge Alap-safe handles to tag shapes with slashes or hyphens that the expression grammar can't accept literally.

:obsidian:rest: — via Obsidian's Local REST API plugin

Same query surface as :obsidian:core:, but backed by Obsidian's Local REST API plugin. Useful when filesystem access is inconvenient — iOS-only vaults, Windows setups, or an existing REST workflow. Host allowlist defaults to loopback; non-loopback hosts require explicit opt-in via rest.allowedHosts. API keys are redacted from log output.

Protocol redesign

:location: — sub-mode verbs

The bare :loc: protocol was renamed to :location: and reorganised around explicit sub-mode verbs. The old positional shape — :location:lat,lng:5mi: — dispatched on argument arity, which didn't extend cleanly. Verbs make intent explicit and leave room for richer queries to land later.

Sub-modes shipped:

  • :location: — existence (does the item have coordinates?)
  • :location:radius:lat,lng:5mi: — within radius
  • :location:bbox:lat,lng:lat,lng: — inside a bounding box

A new src/geo/ peer module holds the spatial primitives (haversine first; polygon, geojson, and route buffers to follow) so future refiners like *sort:near:* can share them without depending on the location protocol.

Expression grammar

$preset sigil

New sigil for named argument bundles inside protocol segments, distinct from @macro expansion at the expression level.

:obsidian:core:music:$meta:       # $meta expands inside the segment
:hn:search:$ai_papers:limit=10:   # preset plus inline override

Precedence: inline key=value beats any preset; a later preset beats an earlier one. Same principle as /pattern/ for regex atoms — protocol-local syntax stays inside the protocol's bracket rather than leaking into the expression grammar.

Async and rendering

Progressive renderer consolidation

DOM, Alpine, lens, and lightbox share one ProgressiveRenderer underneath. In-flight dedup, timeout, concurrency cap, and cancel-on-dismiss live in one place. Each adapter dropped ~40 lines of duplicated lifecycle logic.

Renderer coordinators

Menu, lens, and lightbox sharing one engine now coordinate dismissal. Opening any one dismisses the others; Escape cascades through the stack. Works consistently across the web component, DOM, and every framework adapter (React, Vue, Svelte, Solid, Qwik, Alpine).

Framework-agnostic menu-dismiss helper

Click-outside, Escape, and timer handling moved into installMenuDismiss({ getTrigger, getMenu, close, mode, timeoutMs }). React, Vue, Svelte, and Qwik adapters are now thin wrappers over it — one source of truth for menu lifecycle instead of four.

Placement grammar parity

placement="SE,clamp" (and other strategy-bearing forms) now work consistently on menu, lens, lightbox, and <alap-link>. Previously lens and lightbox accepted a compass direction but silently dropped the strategy token — that's fixed.

all-together example site

End-to-end example at examples/sites/all-together/ demonstrating all three renderers — menu, lens, lightbox — on one page, sharing one config, coordinating cross-dismissal via RendererCoordinator.

Menu items now close the menu on click. Same-tab http/https navigation already unloaded the page, but external schemes (obsidian://, mailto:, tel:, slack://…) and new-tab clicks left the menu lingering — explicit dismissal matches expectation.

Authoring tooling

Markdown → Obsidian vault converter

scripts/vault_convert.py turns any markdown tree into an Obsidian vault, with SSG-aware shortcode rewriting and trust-model-by-default sanitisation.

  • SSG plugins for Hugo, Jekyll, MkDocs, and Docusaurus. Shortcodes rewrite to Obsidian callouts, fenced code blocks, figure embeds, and the like. Lazy-loaded: --ssg hugo never imports Docusaurus code. Auto-detection hint in the preview when the SSG's config file is present at the source root; explicit opt-in preserved, nothing auto-applies.
  • Trust-model-by-default. Sources are treated as untrusted. HTML body sanitiser, frontmatter HTML sanitiser, active-content detection (Dataview, Templater, Tasks, Excalidraw), per-file size cap (20 MB), total file-count cap (20,000), VCS exclusion expansion. Every guard has a matching --allow-* flag for when the source is yours.
  • CLI UX — preview with policy banner → y/N confirm → post-write policy summary with replay hints. Zero-count categories omitted so clean runs stay quiet.

Feeds → markdown

Adjacent utility at scripts/feed_to_md.py. Parses RSS 2.0 / Atom 1.0 feeds and emits one markdown file per item, ready to feed into the vault converter on a second pass. Feed XML is treated as untrusted by default; the same sanitiser flags are available.

Shared protocol helpers

Under src/protocols/shared/, reusable building blocks for filesystem and localhost-facing protocols:

  • pathSafety.tsisWithin(base, target) + realpathWithin(base, target)
  • localhostGuard.tsisLocalhost(host) covering 127.0.0.0/8, ::1, literal localhost
  • uriTemplate.tsexpandTemplate(template, vars) with URL-encoding by default and a raw opt-out
  • dynamicImport.ts — memoised loadOptional(pkg) for optional dependencies with graceful fallback

Node-only helpers aren't re-exported from protocols/shared/index.ts — deep-import them so browser bundles don't pull in node:fs/promises / node:path.

Documentation

  • Obsidian docs Setup (plugin install, API key handling, cert handling, server config) lives in docs/cookbook/obsidian-rest-setup.md; hardening (library-provided defenses, the layered checklist, auditing) lives in docs/cookbook/obsidian-hardening.md. The hardening page leads with a disclaimer that the guidance is layered defense, not a guarantee.
  • :hn: cookbook page at docs/cookbook/hn.md with a defense-floor table and the full operator-visible warning catalog.
  • Per-protocol security posture paragraphs in the :atproto: and :obsidian:core: sections of docs/core-concepts/protocols.md, and a matching section in docs/api-reference/embeds.md.
  • Security Trust Model orientation folded into the main Security reference, describing shared responsibility between the library, integrator, user agent, and end user.

Package exports

  • New subpath: alap/protocols/obsidian (Node-only).
  • @types/node added as a workspace dev dependency.
  • Vite build externalises node:*, yaml, and fast-glob.

Upgrading

A few shape changes worth knowing about.

Node 22 minimum

Alap 3.2 requires Node 22+. Node 20 reached end-of-life in April 2026; the SSRF guard hardening uses undici.Agent's connect.lookup callback, which is available in Node 22 onward. If you're still on Node 20 or earlier, upgrade before taking 3.2.

:location: protocol — sub-mode verbs required

The bare :loc: name is gone, and the old positional radius shape is no longer accepted:

:loc:40.7,-74.0:5mi:                  # 3.1 — no longer recognised
:location:40.7,-74.0:5mi:             # old positional — no longer recognised
:location:radius:40.7,-74.0:5mi:      # 3.2 — sub-mode verb

Scan your expressions (and any :location: usage inside macros) before upgrading. Bounding-box queries move to the bbox verb:

:location:bbox:40.7,-74.0:40.8,-73.9:

Security hardening — bare @ removed

Bare @ expansion — which resolved to a macro matching the trigger's DOM id — is no longer supported. Use explicit @macroname:

<a id="nycbridges" data-alap-linkitems="@">              # no longer expands
<a id="nycbridges" data-alap-linkitems="@nycbridges">    # explicit, works

The parser emits an operator-facing warning on any bare @ it sees, so stray usages are easy to spot in dev.

The anchorId parameter on engine.query/resolve/resolveAsync/resolveProgressive is a no-op from 3.2 forward and is marked @deprecated. The anchorId prop on <AlapLink> components continues to flow through to onTriggerHover / onTriggerContext event details for trigger identification; only the parser fallback is gone.

Handler functions in a runtime registry

The other shift is where handler functions live. The preferred form is a runtime registry passed to new AlapEngine:

javascript
// 3.1 form — handlers inside config.protocols.*
config.protocols.web = { generate: webHandler, allowedOrigins: [...] };

// 3.2 form — handlers in a runtime registry
const engine = new AlapEngine(config, {
  handlers: { web: webHandler, atproto: atprotoHandler },
});

The 3.1 form still works for one more release, with a deprecation warning. Migration detail lives in migration-3_2-handlers-out-of-config.md.

Full changelog

The per-commit detail is in the repository CHANGELOG.