**Note** from Bead: Android Photo Sync · [canonical source](https://redfish.acequia.io/guerin/.agents/06b7373f-2ba9-46c5-88bb-31bd93ce453f/2026-06-27/notes/01-filesystem-router.md) · session 2026-06-27 · discussion: Talk: Android Photo Sync
Stephen asked: *"isn't there a way to hot plugin new handlers… eg a config.json… an index.js at stephenguerin.live/camera/ that would handle processing the subpath unless there's another script lower down. something close to index.php or asp."* Yes — this is a well-trodden pattern. We added it to [`C:\caddy\origins\stephenguerin.live\server.js`](C:/caddy/origins/stephenguerin.live/server.js).
## The norms this follows - **Front controller / index file** (`index.php`, classic ASP, `DirectoryIndex`): the file at a path handles it; per-dir `.htaccess` overrides downward. - **CGI/FastCGI per directory** (Caddy `php_fastcgi`, nginx `try_files`). - **File-based routing** (Next.js App Router `route.js`, SvelteKit `+server.js`, Remix): nested, **deepest segment wins**, dynamic `[param]` folders. - **Sub-app mounting** (Express `app.use('/camera', router)`). - **Plan 9 / 9P + per-process namespaces** — our project_uri-bind-mount / agent-as-file ducktyping. - **Hot reload**: re-read declarative config (Caddy `caddy reload` / admin API), or dynamically `import()` a module and bust its cache. We use the latter (mtime cache-bust).
## What we built (deepest handler wins, hot-loaded) For a request path, the router walks dirs **deepest → shallowest** under `WEB_ROOT` (default `c:/Users/steph/Documents/sites/stephenguerin.live`, set in the service via swap-to-server.ps1). The first dir containing `handler.mjs` (or `index.mjs`) owns the subtree and receives `req.subpath` (the remainder below its mount). No handler along the path → the request falls through to the **bead root handler** (agent-face) exactly as before. The router is **additive**: existing bead behaviour is untouched (verified: `GET /` still serves the bead landing, `OPTIONS /` the capability card). Modules are cached by `{absFile → mtimeMs}`; each request re-`stat`s and re-`import()`s with `?v=<mtime>` when the file changed, so **adding or editing a handler is live with no service restart** (verified: dropped a new `/hottest` endpoint into a running server, then edited it — `v1` → `v2` with zero restart). ### Handler contract ```js export async function handle(req, ctx) { /* return res | { passthrough:true } */ } // req = { verb, path, base, subpath, headers, rawBody, query, caller } // ctx = { dir, base, subpath, config, readFile, readText, writeFile, statFile, mime, notify, now } ``` - `ctx.readFile/readText/statFile/writeFile` are **scoped to the handler's own dir** (path-escape → null/throw). - `{ passthrough:true }` → the runtime serves a static file from the handler dir (`/` → `index.html`), binary-safe with a mime type. - `ctx.config` = the dir's optional `config.json`. If it has `auth.tokenFile` (an absolute creds path), the runtime resolves it to `auth.token` so handlers never read outside their own dir for secrets. - `res.defer` (or `subpath === '/ask'`) still escalates to the WebSocket cognition client, same as the bead.
## Tradeoffs / boundaries - mtime `import()` versions are not GC'd (small leak) — standard dev-grade hot reload; fine here. - Per-request `stat` walk along the path (a few syscalls) — cheap; could be cached/`fs.watch`ed later. - Auth **enforcement** currently lives in each handler (reading `ctx.config.auth.token`); the runtime only *resolves* the token. A future step could let a code-free `config.json` (static + `write.auth`) enforce bearer auth with no `handler.mjs` at all — i.e. a purely declarative endpoint, mirroring the `.site` split.
## Worked example [`stephenguerin.live/photos/handler.mjs`](c:/Users/steph/Documents/sites/stephenguerin.live/photos/handler.mjs) + [`config.json`](c:/Users/steph/Documents/sites/stephenguerin.live/photos/config.json): public GET/HEAD (serves the gallery + uploaded files via passthrough), bearer-auth PUT (binary-safe write into the subtree, refuses to clobber `index.html`/`handler.mjs`/`config.json`). Replaces the inline `/photos` lane. **Live cutover:** the router itself is a `server.js` change, so it needs **one** elevated `Restart-Service stephenguerin-live-origin`. After that, new endpoints are drop-in files.