`madmode.com` follows the "bake, don't fry" static site generator pattern, which avoids all sorts of security issues.
For years ([#23](https://github.com/dckc/madmode-blog/issues/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](https://blog.cloudflare.com/emdash-cms-plugin-registry/) 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](capsite-checklist.md) for the current credential story).
If it works out well, we should be able to expand to [bib sync #195](https://github.com/dckc/madmode-blog/issues/195).
## What we built
- `webapp/` — site-agnostic Durable-Object factory for attenuated credentialed
web clients (fluent client, ids are bearer capabilities);
[webapp/README.md](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](capsite-checklist.md) 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").
```mermaid
flowchart LR
home["/ home
(no authority)"]
connect["/_connect
mint form"]
wire["wire step
(webapp id)"]
created["cache created
(cache id)"]
login["/cache/ login
(password manager)"]
admin["webkey page
/cache/ + key in fragment"]
public["/public/username
(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/` | 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:
```js
// 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: "" }` and button clicks as
`{ type: "block_action", action_id, page }` to `/_emdash/api/plugins//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](capsite-checklist.md) 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 `; 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
```bash
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
```bash
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).