How a terminal habit became a realtime web dashboard — and accidentally became the first real JSS plugin, with a keypair for an identity and zero inotify watches.
For ages I've had this in a terminal:
hours today --all --watch
A little stacked-bar chart of points earned per hour — work, chores — refreshing every two seconds. I use it constantly. And for ages I've wanted it in a browser. Not "a webapp with a backend and a login" — just the same thing, on a URL, updating live.
This is the story of getting there, because the destination turned out to be more interesting than the dashboard.
The data model is almost insultingly simple: one JSON file per hour.
~/.nosdav/data/gamestr/hour/2026071119.json → { "work": 2500, "chores": 30000 }
My pod server (jspod, which wraps JSS) already serves files over HTTP with WebSocket notifications. So: symlink the data into the pod, subscribe, done?
Two rude surprises:
fs.watch(recursive)doesn't follow symlinked directories. The fix was to reverse the arrow — move the real directory into the pod and symlink the old path back at it. Every existing script keeps working; the watcher can finally see the writes.- Node's recursive watcher registers a watch per file, not per directory. My 18,806
hour files plus 555 directories came to 19,361 inotify watches — nearly a third of the
entire per-user budget (65,536 — which, it turns out, isn't even a kernel default: a desktop
search package pins it in
/usr/lib/sysctl.d/). And it grows by 24 files a day, forever.
It worked. The dashboard went live, updates flowed. But surveilling nineteen thousand files to notice one small write per hour is the wrong shape for the problem.
The realization: the pod isn't a file mirror, it's an API. If writes go through the pod
— an HTTP PUT instead of a filesystem write — the server fires the notification itself,
by design. No watcher at all.
The catch is always auth. Nobody wants a shell script doing an OIDC dance, refreshing tokens
at 3am. This is where it gets good: JSS ACLs accept a bare did:nostr pubkey as an agent,
and requests authenticate with NIP-98 — a one-shot signed
event in the Authorization header. Verified locally with a Schnorr signature. No DID
resolution, no network round-trip, no session, no expiry, no token store.
So the entire security model for a write-capable script is:
1. Generate a keypair. The secret is 32 bytes in a file:
~/.config/gamestr/secret (chmod 600)
2. Grant its DID write access — one entry in the container's .acl:
{
"@id": "#writer",
"@type": "acl:Authorization",
"acl:agent": { "@id": "did:nostr:3a29b862a31fc2a9416e951912859a0280b54dca67c318080ea9cc342fea0346" },
"acl:accessTo": { "@id": "./" },
"acl:default": { "@id": "./" },
"acl:mode": [ { "@id": "acl:Read" }, { "@id": "acl:Write" } ]
}3. Sign each request (kind 27235, tags u, method, payload = sha256 of body,
±60s tolerance):
Authorization: Nostr <base64(signed event)>
Revocation is deleting one JSON block. Least privilege is which container you put it on. That's the whole thing.
Inotify watches after this step: 0.
JSS grew a plugin model: a plugin is one file
exporting activate(api), and the api hands you the parts that are normally your problem —
routing, authentication (api.auth.getAgent speaks NIP-98, Solid-OIDC, WebID-TLS),
WebSockets, private storage:
export async function activate(api) {
api.fastify.post(api.prefix + '/bump', async (req, reply) => {
const agent = await api.auth.getAgent(req) // "did:nostr:3a29…" — verified
if (!agent) return reply.code(401).send({ error: 'unauthorized' })
// …one atomic read-modify-write, guarded by the same .acl…
})
await api.ws.route(api.prefix + '/stream', handleSocket)
}The gamestr plugin is ~160 lines and most of them are gamestr logic, not plumbing:
| Route | Does |
|---|---|
POST /gamestr/bump |
atomic server-side {category, points} increment — kills the read-modify-write race clients always had |
GET /gamestr/day/YYYYMMDD.json |
one request instead of 24 per refresh |
WS /gamestr/stream |
pushes {day, stamp, cats} the instant anything lands |
Crucially, storage stays plain files. The plugin writes the same hour/*.json the
terminal tool has always read. Delete the plugin tomorrow and nothing needs migrating —
it's convenience layered on protocol, not a new silo.
Everything degrades gracefully in both directions: the writer tries bump → falls back to a
raw LDP PUT → falls back to a direct disk write (points are never lost, even with the pod
down). The dashboard tries the stream → falls back to Solid notifications → falls back to
polling. Every layer is optional; the files are the truth.
(vector version: diagram.svg — GitHub doesn't preview SVGs in gists, but the raw file is fine)
text version (mermaid)
flowchart LR
subgraph writers [writers]
S["chores.sh · scripts"] --> A["addhour.js<br/>signs NIP-98 with<br/>~/.config/gamestr/secret"]
end
subgraph pod ["jspod → JSS"]
W["WAC: sig → did:nostr:pk<br/>matched against .acl"]
P["gamestr plugin<br/>bump · day · stream"]
L["LDP + solid-0.1 notifications"]
end
F[("hour/YYYYMMDDHH.json<br/>plain files — the truth")]
subgraph readers [readers]
B["browser dashboard<br/>● live (stream)"]
T["hours CLI<br/>(unchanged)"]
end
A -- "1: POST bump" --> W --> P
A -. "2: PUT file" .-> L
A -. "3: disk write" .-> F
P --> F
L --> F
P == "WS push" ==> B
B -- "GET day.json" --> P
L -. "fallback: pub/sub" .-> B
F --> T
| before | after | |
|---|---|---|
| inotify watches | 19,361 | 0 |
| HTTP requests per refresh | 24 | 1 |
| update latency | 2s poll | push, ~instant |
| concurrent-write race | yes (client RMW) | no (serialized in plugin) |
| credentials to manage | — | a 32-byte file |
| auth infrastructure | — | one .acl entry |
The dashboard is nice. The pattern is the point:
Any script — or any agent — can be granted a scoped, revocable slice of your pod with one ACL entry, authenticated by nothing but a signature.
No account creation. No OAuth app registration. No API-key dashboard. An LLM agent working in
my repo has a did:nostr identity file sitting right next to the code; giving it write access
to one corner of the pod is the same one-line grant as the shell script got. The pod becomes
the API surface for everything on the machine — humans, cron jobs, agents — with capability
granularity that URL-shaped storage gives you for free.
The pieces have cousins — Nextcloud apps, Community Solid Server components — but I don't
know of anything else where pluggable personal server + standards ACLs + keypair identity
lands this light. The test that matters: how long from "I want a thing on my pod" to the thing
existing? First time: one evening, including building the road (jspod ≥ 0.0.49 mounts
plugins: jspod --plugin ./gamestr/plugin.js@/gamestr). Next time: minutes.
Stack: jspod 0.0.49 · JSS 0.0.219 · JSS plugins · NIP-98 · did:nostr · Web Access Control

