`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-dump/` — the webapp consumer: its 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). ## 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 (see checklist goal 2). 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).