Files
captoys/capsite-notes.md
T
dckc d0a9b899f2 docs: goal tree and rules track the 1c cache
- checklist: 1c (diigo-dump evolves into a bookmark cache) with its
  create-flow, id-wiring, and verification subgoals
- notes: diigo-cache replaces diigo-dump in What-we-built
- AGENTS: no worker secret since 1c; durable state — be LOUD and
  conservative about breaking changes to stored DB state; pointer to
  the workspace skills for commit trailers

Generated-by: opencode (GLM)
Generated-by: Claude Opus 5.5
2026-10-07 04:19:54 -05:00

6.1 KiB
Raw 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).

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).