Files
captoys/capsite-notes.md
dckc d857018e4b fixup! docs: diigo-cache UX map
The public page's write access is resolved: it reads through viewer().

Generated-by: Claude Opus 5.5
2026-10-07 09:26:09 -05:00

8.3 KiB
Raw Permalink Blame History

madmode.com follows the "bake, don't fry" static site generator pattern, which avoids all sorts of security issues.

For years (#23 was created in 2016) I have been wishing for a way to exercise read capabilities to get things like bookmarks as well as write access to put it on my site.

Let's give this a try:

Goal: confine the code that has access to the diigo.com secret to a small, auditable piece behind an attenuated interface (originally "the API key held by the host, never inside the plugin"; the API-key path turned out dead — see the goal tree for the current credential story). If it works out well, we should be able to expand to bib sync #195.

What we built

  • webapp/ — site-agnostic Durable-Object factory for attenuated credentialed web clients (fluent client, ids are bearer capabilities); webapp/README.md
  • diigo-cache/ — the webapp consumer, evolved from the diigo-dump worker: bookmark dumps cached in DO SQLite storage (private cache by unique id, public-only cache by username-in-path); the mint call carries all diigo knowledge; the cookie lives in the DO's storage (see the checklist's goal tree for the verified/not-yet split)
  • madmode/ — Cloudflare Workers EmDash site (Starter template), local dev via pnpm dev.
  • diigo-bookmarks/ — native EmDash plugin (converted from sandboxed):
    • src/index.ts exports createPlugin() which returns definePlugin({...}).
    • Declares capabilities: ["network:request"], allowedHosts: ["secure.diigo.com", "www.diigo.com"] (Diigo 301s secure.diigo.com → www.diigo.com; the host gate re-validates every redirect hop).
    • Fetches https://www.diigo.com/api/v2/bookmarks with HTTP Basic auth into plugin storage, serves a public JSON route, and has a Block Kit admin page ("Sync now" button + table).
  • Site /bookmarks page renders the collected bookmarks (linked from the primary menu).

diigo-cache UX map (2026-10-07)

What a person can do, where, and with what authority. Input to aligning the admin UX with the RPC interface (checklist: the store refactor and "align admin UX and RPC").

flowchart LR
  home["/ home<br/>(no authority)"]
  connect["/_connect<br/>mint form"]
  wire["wire step<br/>(webapp id)"]
  created["cache created<br/>(cache id)"]
  login["/cache/ login<br/>(password manager)"]
  admin["webkey page<br/>/cache/ + key in fragment"]
  public["/public/username<br/>(anyone)"]

  home -->|create| connect
  connect -->|"POST /_mint (cookie)"| wire
  home -->|"POST /_wire (webapp id)"| created
  wire -->|"POST /_wire"| created
  created -->|"POST /_publish (cache id)"| created
  created -->|"open this cache"| admin
  home -->|"open a cache"| login
  login -->|"cache id"| admin
  home -->|"dump: POST / (cache id + page)"| home
  admin -->|"api.cache(key) …"| admin
  created -->|"link (public mode)"| public
action where transport authority
mint the diigo session /_connect HTTP POST /_mint (relays webapp's form) the cookie
wire a cache (private/public) wire step, home HTTP POST /_wire webapp id
save the ids created page browser (copy, password manager) —
publish later created page HTTP POST /_publish ⚠ cache id
dump a page (raw rows) home HTTP POST / ⚠ cache id
fingerprint, has-public webkey page RPC fingerprint(), hasPublic() cache key
fill / stop / status webkey page RPC startFill(), stopFill(), fillStatus() cache key
…of the public companion webkey page RPC publicCache().… cache key
invalidate the last N days webkey page RPC invalidateRecent(days) cache key
read public bookmarks /public/<u> HTTP GET (HTML or JSON) ⚠ none
bulk dump for another worker — service binding DiigoBridge.dumpNdjson worker config

⚠ misalignments:

  • publish and dump are admin actions held by the cache key, but live on HTTP form routes rather than the RPC facet the webkey page uses
  • the public page can write — resolved: it reads only through the read-only viewer() facet; unfetched pages say so
  • the webkey page has no dump or publish; the home page has no fill

Native-plugin wiring (the part that took a while)

emdash()'s plugins: [] takes descriptors, not plugin objects — the Astro integration needs a module specifier it can statically import at build time:

// astro.config.mjs
plugins: [{ id: "diigo-bookmarks", version: "0.1.0", entrypoint: "diigo-bookmarks" }]

The generated runtime module does import { createPlugin } from "diigo-bookmarks" and calls it. definePlugin(...) results have no entrypoint and are rejected with a build-time error. An in-config factory call also fails ("has no entrypoint").

Key facts verified from emdash source:

  • PluginContext has no env. Worker secrets are read via dynamic import("virtual:emdash/env") (generated module re-exports cloudflare:workers env under @astrojs/cloudflare; undefined on other adapters).
  • GET plugin routes get query params in ctx.input (a PluginRouteQuery record), not ctx.query or ctx.request.url parsing.
  • Admin (Block Kit) route: the admin frontend POSTs JSON { type: "page_load", page: "<declared page path>" } and button clicks as { type: "block_action", action_id, page } to /_emdash/api/plugins/<id>/admin; the handler returns { blocks, toast? }. CSRF requires the X-EmDash-Request: 1 header (the frontend sends it; curl must add it explicitly).
  • Admin pages/widgets for native plugins must be declared in the plugin's own definePlugin({ admin: { pages: [...] } }) — the build descriptor's adminPages is NOT merged into the manifest; the admin sidebar reads plugin.admin.pages from the entrypoint's ResolvedPlugin. With admin.pages set, the manifest reports adminMode: "blocks" and the sidebar gains Plugins → Diigo Sync at /_emdash/admin/plugins/diigo-bookmarks/sync.
  • ctx.cron.schedule() upserts, so calling it on every sync is a safe bootstrap for configured plugins (which don't get a plugin:activate run by default).
  • Local .pnpm hardlinks mean source edits reach the site only after a full dev-server restart; if you delete a stale node_modules/.vite/deps_ssr/*.js by hand the server 500s until rm -rf node_modules/.vite + full restart.

Where the Diigo credentials go

The credential story moved twice; the goal tree is authoritative. Current shape: the session cookie lives only in the webapp DO's storage; madmode/.dev.vars still holds placeholder DIIGO_API_KEY / DIIGO_USERNAME for the (dead) API-key path — both documented schemes return 401 unconditionally, verified against both known real keys (opencode session ses_eec320fe4ffehaMAn44U1nTKJU: key= param, key+user=, and HTTP Basic, on both /api/v2/bookmarks and /api/v2/notes, though the same creds downloaded 13.5k bookmarks in 2024–2025; diigo evidently revoked v2 key auth). Deployed secrets: cd madmode && pnpm wrangler secret put <NAME>; the sync rides the cookie path since the API path died.

EMDASH_ENCRYPTION_KEY (in madmode/.env locally) is only needed for sandboxed plugins' secret settings; the native plugin doesn't use it, but keep it backed up for the site.

How to run it

cd madmode
pnpm dev          # → http://localhost:4321, admin at /_emdash/admin
# one-time auth helper (skips passkey):
#   http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
  • Admin page: /_emdash/admin → plugins → Diigo Sync → "Sync now"
  • Public JSON: /_emdash/api/plugins/diigo-bookmarks/bookmarks?limit=20
  • Site page: /bookmarks
  • Sync also self-schedules via cron (0 */6 * * *), stored in _emdash_cron_tasks.

Note: private Diigo bookmarks need the session-cookie channel (which is what the sync will use after goal 2; the API-key path is dead).

Plugin dev loop

cd diigo-bookmarks
pnpm typecheck    # tsc --noEmit

Then restart pnpm dev in madmode/ (full stop + start; config/plugin changes are not hot-reloaded through the .pnpm hardlink path).