capsite-notes gains a map of what a person can do in diigo-cache, where, by which transport (HTTP route or Cap'n Web RPC), and with what authority — a flowchart of the pages plus a table — marking the misalignments: publish and dump are cache-key actions on HTTP forms, not the RPC facet; the public page can write (a miss fills from diigo). Input to aligning the admin UX with the RPC interface. Generated-by: Claude Opus 5.5
8.3 KiB
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:
- EmDash 1.0: the stable CMS with a secure plugin registry | Cloudflare Blog September 28, 2026
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.mddiigo-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 viapnpm dev.diigo-bookmarks/— native EmDash plugin (converted from sandboxed):src/index.tsexportscreatePlugin()which returnsdefinePlugin({...}).- Declares
capabilities: ["network:request"],allowedHosts: ["secure.diigo.com", "www.diigo.com"](Diigo 301ssecure.diigo.com→www.diigo.com; the host gate re-validates every redirect hop). - Fetches
https://www.diigo.com/api/v2/bookmarkswith HTTP Basic auth into plugin storage, serves a public JSON route, and has a Block Kit admin page ("Sync now" button + table).
- Site
/bookmarkspage 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: a miss fills from diigo through the owner's session (to become read-only: the reader facet only)
- 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:
PluginContexthas noenv. Worker secrets are read via dynamicimport("virtual:emdash/env")(generated module re-exportscloudflare:workersenvunder @astrojs/cloudflare;undefinedon other adapters).- GET plugin routes get query params in
ctx.input(aPluginRouteQueryrecord), notctx.queryorctx.request.urlparsing. - 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 theX-EmDash-Request: 1header (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'sadminPagesis NOT merged into the manifest; the admin sidebar readsplugin.admin.pagesfrom the entrypoint's ResolvedPlugin. Withadmin.pagesset, the manifest reportsadminMode: "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 aplugin:activaterun by default).- Local
.pnpmhardlinks mean source edits reach the site only after a full dev-server restart; if you delete a stalenode_modules/.vite/deps_ssr/*.jsby hand the server 500s untilrm -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).