The public page's write access is resolved: it reads through viewer(). 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— resolved: it reads only through the read-onlyviewer()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:
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).