Skip to content

Plugin API v1.2: unlock #70 / #71 / #72 / #73 as plugins (closes #112, #113)#141

Merged
thetechjon merged 6 commits into
devfrom
feat/plugins-v1.2
Jun 7, 2026
Merged

Plugin API v1.2: unlock #70 / #71 / #72 / #73 as plugins (closes #112, #113)#141
thetechjon merged 6 commits into
devfrom
feat/plugins-v1.2

Conversation

@thetechjon
Copy link
Copy Markdown
Collaborator

Merges the v1.2 work that landed across PRs #116, #117, #118, #119, #132, #139 onto a long-running branch, into dev.

Closes #112 (graph plugin gaps) and #113 (importer plugin gaps) since the API now supports them.

What v1.2 ships

Reference plugins added

  • noteser-vnode-demo (exercises VNodes + fullscreen)
  • noteser-vault-read-demo
  • noteser-write-demo
  • noteser-folder-demo
  • noteser-event-demo

Verification

  • Final test suite at PR D close: 2527 pass, 17 skipped.
  • All 6 sub-PRs Vercel preview deploys passed.
  • Implementation notes for every deviation in docs/plugins-v1.2-impl-notes.md.

Post-merge follow-ups

thetechjon and others added 6 commits June 6, 2026 23:03
Extends the curated VNode renderer with the seven new shapes
required by every later v1.2 PR: button, input, list, link, radio,
svg, and box. Lays down the shared event-handler record (`VNodeEvent`)
and the wire envelope (`host:vnodeEvent`) so PR B (fullscreen) and
the capability PRs can ship their adapters without churning the
protocol again.

Security: every plugin-supplied string lands in a React children
slot — the renderer never reaches React's raw-HTML escape hatch (a
static-source guard pins the rule). Numeric props are coerced via
Number() and rejected on NaN / Infinity. Link hrefs use a
discriminated union (note id or anchor fragment); plugins cannot
produce a raw href string, so javascript: and data: are structurally
impossible. The named `isSafePluginHref` guard covers the
belt-and-braces case. SVG ships only the five primitives in the plan
(line / circle / rect / text / path); unknown svg child tags drop.
List / box recursion shares one depth budget (`MAX_LIST_DEPTH = 8`).

Tests: 32 unit tests under `src/plugins/__tests__/PluginVNode.test.tsx`
cover the rendered DOM for every new shape, the sanitiser path,
javascript: link rejection, and the event-shape round-trip through
the dispatcher.

Reference plugin: `public/plugins/noteser-vnode-demo/` exercises
every new shape inside a sidebar panel for manual smoke. The plugin
loads via the existing vault-scan / URL install paths.

Impl notes: `docs/plugins-v1.2-impl-notes.md` documents the
deviations from the plan (link discriminated union over raw-href
guard, fullscreen source variant pre-shipped in protocol.ts for
PR B, list / box sharing one depth budget).

References docs/plugins-v1.2-plan.md section 2.

Co-authored-by: noteser-agent <claude-agent@noteser.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Lands the first v1.2 plugin capability: read every note's body +
frontmatter from the worker. Required by backlinks, unlinked-mentions,
graph derivation, and AI-RAG plugins (issues #70, #71, #72).

Adds three SDK methods under `ctx.vault.read`:
  - getAllNotes() returns a snapshot of every non-deleted note. Host
    caches by a cheap rolling SHA so a second call within the same
    snapshot is free; rejects with "Vault too large; use stream()."
    above the 4 MiB projected payload guard.
  - getNote(id) returns one note or null.
  - stream({ chunkSize? }) paginates over the vault, defaulting to
    100 notes per chunk (max 500 to stay under MAX_ENVELOPE_BYTES).
    Yields to the main thread between chunks; the perf budget caps
    a 5000-note walk well under the 50ms-per-task target from #79.

Wire-protocol additions in src/plugins/protocol.ts:
  - worker:requestVaultRead carries a 'all' / 'one' / 'stream' mode.
  - host:vaultReadResult answers the first two; host:vaultStreamChunk
    answers stream mode, with notes:[] + non-empty error signalling
    mid-flight permission revocation.

Manifest validator accepts the new vault.read.all permission and the
install-preview modal renders an informational bullet describing it
(non-destructive — vault.write's red bullet is PR D's scope).

Settings -> Plugins gains a per-installed-plugin permissions block
that lets the user revoke vault.read.all at runtime. Revocation is
persisted on the install record and re-applied on every boot; the
PluginHost rejects subsequent calls with "Permission ... was revoked."
immediately, including chunks of an already-running stream.

Reference plugin at public/plugins/noteser-vault-read-demo/ exercises
both getAllNotes() and stream({ chunkSize: 50 }) from palette commands,
notifying the count on success or the rejection on revoke.

Tests under src/plugins/__tests__/vaultRead.test.ts cover the manifest
acceptance, the host snapshot + cache, the chunked stream's slice
counts at chunkSize 1 / 100 / above-cap, mid-flight revocation, and
the PluginHost wire-protocol gate (declared / revoked / restore).

Spec: docs/plugins-v1.2-plan.md sections 4.1, 5, 6, 7, 8. Per-PR
deviations recorded in the new docs/plugins-v1.2-impl-notes.md (PR A
will reconcile its own section against the TODO stub at the top).

Co-authored-by: noteser-agent <claude-agent@noteser.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds the `vault.events` capability per docs/plugins-v1.2-plan.md
section 4.4. Plugins now subscribe to `onVaultChange`,
`onNoteSaved(noteId)`, and `onActiveNoteChange(noteId)` and receive a
debounced (250 ms) event stream the host fans out from the noteStore
+ workspaceStore + folderStore subscriptions. Required for the graph
view / backlinks plugin (#71) and AI cache-invalidation plugins (#70).

Wire protocol gains worker:subscribeVault / worker:unsubscribeVault
and host:vaultChanged / host:noteSaved / host:activeNoteIdChanged
envelopes; every host event carries the worker-minted
subscriptionId so the in-worker handler dispatcher pairs correctly.

PluginHost auto-clears every subscription on unload (disable,
uninstall, page unload), with an explicit leak test that mounts +
unmounts a plugin 10 times and asserts the subscriber count returns
to zero each iteration.

Settings → Plugins adds a per-permission toggle. Revoked permissions
re-check at every dispatch, so toggling off mid-stream silences
delivery without crashing existing subscribers — they keep their
Unsubscribe thunk and start receiving again on re-grant.

Reference plugin at public/plugins/noteser-event-demo subscribes to
all three events and toasts on each fire, useful for verifying the
250 ms debounce manually.

Co-authored-by: noteser-agent <noteser-agent@local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds the v1.2 fs.open-directory permission so plugins can pop the
native directory picker, walk the chosen folder, and read each file
lazily via a Blob handle. Required for Obsidian / Logseq importers
(see plugins-v1.2-plan.md section 4.3).

- Wire envelopes worker:requestDirectoryOpen / host:directoryOpenResult
  added to src/plugins/protocol.ts with a 50,000-entry cap constant.
- ctx.fs.openDirectory({ extensions? }) on the SDK ctx, mirrored in
  the published @noteser/plugin-sdk package.
- Host handler prefers showDirectoryPicker (Chromium) and falls back
  to <input type=file webkitdirectory> (Safari / Firefox). Walker
  lives in src/plugins/directoryPickerHelpers.ts so it unit-tests
  without dragging in the singleton wiring.
- Settings → Plugins gains a per-permission revocation checkbox
  backed by InstalledPluginRecord.revokedPermissions; the directory
  handler honours it on every call.
- Reference plugin public/plugins/noteser-folder-demo counts .md /
  .markdown files in a picked folder, exercising both code paths.
- Manifest-preview modal line "Open folders to read files into the
  plugin" surfaces automatically via PERMISSION_DESCRIPTIONS.
- Unit tests cover manifest acceptance, permission gate, cap, filter,
  the webkitdirectory cancel path, and the new revocation store
  action.

Co-authored-by: noteser-agent <noteser-agent@claude>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds the `surfaces.fullscreenViews` manifest field, the host modal at
`src/components/plugins/PluginFullscreenView.tsx`, the wire envelopes
for open/close/setContent + lifecycle, and the
`ctx.openFullscreen` / `ctx.closeFullscreen` /
`ctx.setFullscreenContent` SDK methods. Follows
`docs/plugins-v1.2-plan.md` section 3.1.

Lifecycle choices documented in `docs/plugins-v1.2-impl-notes.md`:
- Reject (do not replace) on a second openFullscreen call.
- Modal persists across active-note changes; plugin stays in control.
- Open response and mount notification split into two envelopes so a
  plugin's `await openFullscreen` resolves before the mount handler
  starts pushing content.
- Esc bound at document-level capture phase so a plugin handler
  cannot trap it.
- Focus trap + body scroll lock + focus snapshot/restore mirror the
  existing Modal.tsx contract from #104.

Unit tests under `src/plugins/__tests__/PluginFullscreenView.test.tsx`
cover mount/unmount, Esc + X close, focus-trap wrap, body scroll
lock, content updates, and the singleton dismiss helper. Manifest
validator tests gain a `surfaces.fullscreenViews` block. PluginHost
tests gain a PR-B describe block covering the open / close /
setContent fan-out.

Reference plugin `noteser-vnode-demo` extended (v0.2.0) to add a
"VNode demo: show fullscreen" command that opens a modal with a
callout + button + SVG so a human can confirm the lifecycle.

Co-authored-by: noteser-agent <noteser-agent@claude>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Lands the v1.2 vault.write capability per docs/plugins-v1.2-plan.md
section 4.2, plus the destructive-permission UI flow (section 8) and
a persistent audit log of every plugin-initiated vault mutation.

Wire protocol additions (src/plugins/protocol.ts):
- WorkerRequestVaultWrite carries a discriminated `op` union over
  create / update / delete / createFolder.
- HostVaultWriteResult carries `ok`, `requestSeq`, `id` +
  `conflictResolved` on successful create, `error` on failure.

SDK (src/plugins/sdk.ts + packages/noteser-plugin-sdk/src/sdk.ts):
- ctx.vault.write.createNote / updateNote / deleteNote / createFolder.
- Sits alongside PR C's `ctx.vault.read` namespace under the same
  `ctx.vault` root.

Host implementation (src/plugins/pluginHostSingleton.ts):
- handleVaultWriteRequest writes through useNoteStore.addNote /
  updateNote / deleteNote and useFolderStore.ensureFolderPath. Same
  paths the user-driven flows use — sync, indexing, and undo behave
  identically.
- Title-collision resolver appends " (imported)" (chains to
  "(imported 2)" on repeat). Host returns the conflictResolved flag
  so the importer plugin (#73) plumbs it straight into its progress
  log.
- Per plan §4.2: title 1-200 chars, body <= 1 MiB, folderPath regex.
  Hard-delete intentionally absent — recovery stays in the Trash UI.

Permission infrastructure (src/plugins/PluginHost.ts):
- vault.write joins file-save / file-open in the per-message gate
  inside handleWorkerMessage. Two-layer check: declared in manifest
  AND not currently revoked. Reuses PR C's revokePermission /
  hasPermission / revokedPermissions Set on InstalledPlugin.
- New respondVaultWrite() carries the host's reply envelope.
- File-save / file-open paths now also honour the revocation Set
  (previously only the declared-permissions list).

Manifest (src/plugins/manifest.ts):
- PERMISSIONS gains `vault.write` (after PR C's `vault.read.all`).
- New DESTRUCTIVE_PERMISSIONS set + isDestructivePermission helper.
  Modal UI uses this to render a red-bordered section with a per-
  permission opt-in checkbox; Install button stays disabled until
  every destructive entry is acknowledged.

Settings UI (src/components/modals/PluginsSettingsPanel.tsx):
- Persistent red "Destructive" badge on plugin rows whose manifest
  declares any destructive permission — survives revocation so the
  user always sees a historical trace.
- Permission rows for destructive perms render in red and reuse
  PR C's setPluginPermissionRevoked toggle.
- New PluginAuditPanel renders the last 50 audit entries.

Audit trail (src/utils/pluginAudit.ts — NEW):
- 500-entry in-memory ring buffer, debounced flush to localStorage
  every 250 ms. Survives reloads, doesn't depend on the noteser
  Zustand stores being hydrated. Entries: {ts, pluginId, op, target,
  ok, error?, conflictResolved?}. Every accepted op and every
  validation rejection records.

Reference plugin (public/plugins/noteser-write-demo/):
- One command "Plugin demo note" that calls
  ctx.vault.write.createNote. Running it twice exercises the
  collision suffix path.

Tests:
- src/__tests__/plugins/vaultWrite.test.ts — 15 tests: manifest
  acceptance, isDestructivePermission flag, permission gate
  (undeclared + revoked), respondVaultWrite envelope, end-to-end
  round-trip per op, chained title collision (Twin + (imported) +
  (imported 2)), validation failure surfaces ok=false + audit entry.
- src/__tests__/plugins/pluginAudit.test.ts — 6 tests: ordering,
  MAX_ENTRIES rollover, per-plugin filtering, error capture, and
  the localStorage flush.

Spec: docs/plugins-v1.2-plan.md §4.2, §5, §6, §7, §8.
Impl notes: docs/plugins-v1.2-impl-notes.md "PR D" section.

Co-authored-by: noteser-agent <noteser-agent@claude>
Co-authored-by: noteser-agent <claude-agent@noteser.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@thetechjon thetechjon merged commit 13e79ab into dev Jun 7, 2026
5 of 7 checks passed
thetechjon added a commit that referenced this pull request Jun 7, 2026
…142)

Plugin API v1.2 (PRs A-F + integration #141) shipped the VNode event
envelope and the VNode shapes that emit events, but the dispatch +
routing pipeline was never wired. Four feature plugins (#70 / #71 /
#72 / #73) hit the same wall trying to use the API. Two gaps closed
here:

Gap 1 — VNode event delivery. Added PluginHost.sendVNodeEvent which
constructs the host:vnodeEvent envelope and posts to the worker,
rate-limited per plugin at MAX_VNODE_EVENTS_PER_SECOND (16/s/plugin).
workerEntry handles host:vnodeEvent by fanning out to handlers
registered through the new ctx.onVNodeEvent SDK method. PluginsPanel,
PluginFullscreenView, and PluginCodeBlock now wrap PluginNode's
onEvent with the right source descriptor.

Gap 2 — wikilink:// click intercept. The renderer's renderLink now
attaches an onClick that calls workspaceStore.openNote(noteId) when
the href starts with wikilink://, taking the noteId off the typed
VNodeLink.href shape (not URL parsing). Modifier-clicks and fragment
anchors pass through to native browser behaviour.

Reference plugin noteser-vnode-demo bumped to 0.3.0 — subscribes to
every VNode event via the new API and renders a wikilink to the
active note so the intercept can be smoke-tested manually.

Backwards compatible: plugins that never call ctx.onVNodeEvent see
no behaviour change.

Docs appended to docs/plugins-v1.2-impl-notes.md under "Post-v1.2:
VNode event delivery + wikilink intercept" with the rate-limit
rationale and a deviation note on the (event, cb) vs
(handler) SDK shape.

Co-authored-by: noteser-agent <noteser-agent@claude>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant