Skip to content

Instantly share code, notes, and snippets.

@melvincarvalho
Last active July 11, 2026 20:02
Show Gist options
  • Select an option

  • Save melvincarvalho/9f2f56975a87426869e5337b2f63cc4e to your computer and use it in GitHub Desktop.

Select an option

Save melvincarvalho/9f2f56975a87426869e5337b2f63cc4e to your computer and use it in GitHub Desktop.
A plugin in 160 lines, an agent in one ACL entry — gamestr on a JSS pod (did:nostr + NIP-98 + plugins)

A plugin in 160 lines, an agent in one ACL entry

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.

Attempt 1: watch the files

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:

  1. 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.
  2. 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.

Attempt 2: stop watching, start writing through

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.

Attempt 3 (final form): make it a plugin

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.

The diagram

architecture: writers sign NIP-98 requests into the pod, the gamestr plugin writes plain hour files and pushes to subscribers

(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
Loading

The scoreboard

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

Why I think this matters

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

Display the source blob
Display the rendered blob
Raw
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 940 664" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">
<defs>
<marker id="a-ink" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#52514e"/>
</marker>
<marker id="a-aqua" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1baf7a"/>
</marker>
<marker id="a-muted" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#898781"/>
</marker>
</defs>
<!-- canvas -->
<rect x="1" y="1" width="938" height="662" rx="14" fill="#fcfcfb" stroke="#e1e0d9"/>
<!-- caption -->
<text x="26" y="38" font-size="17" font-weight="700" fill="#0b0b0b">gamestr on a pod</text>
<text x="26" y="58" font-size="12" fill="#898781">write through the pod · push out to subscribers · plain files as the source of truth</text>
<!-- ============ WRITERS ============ -->
<text x="30" y="96" font-size="10.5" letter-spacing="2" fill="#898781">WRITERS</text>
<rect x="30" y="106" width="190" height="42" rx="8" fill="#ffffff" stroke="#c3c2b7"/>
<text x="125" y="132" font-size="12.5" text-anchor="middle" fill="#0b0b0b" font-family="ui-monospace, Menlo, Consolas, monospace">chores.sh · scripts</text>
<line x1="125" y1="148" x2="125" y2="174" stroke="#52514e" stroke-width="1.6" marker-end="url(#a-ink)"/>
<rect x="30" y="178" width="190" height="94" rx="8" fill="#ffffff" stroke="#4a3aa7"/>
<text x="125" y="202" font-size="13.5" font-weight="700" text-anchor="middle" fill="#0b0b0b">addhour.js</text>
<text x="125" y="221" font-size="11" text-anchor="middle" fill="#52514e">signs every request · NIP-98</text>
<text x="125" y="240" font-size="10.5" text-anchor="middle" fill="#4a3aa7" font-family="ui-monospace, Menlo, Consolas, monospace">~/.config/gamestr/secret</text>
<text x="125" y="256" font-size="10.5" text-anchor="middle" fill="#4a3aa7" font-family="ui-monospace, Menlo, Consolas, monospace">did:nostr:3a29b8…</text>
<!-- tier 1: bump -->
<line x1="220" y1="206" x2="308" y2="248" stroke="#52514e" stroke-width="2" marker-end="url(#a-ink)"/>
<circle cx="252" cy="212" r="9" fill="#ffffff" stroke="#52514e"/>
<text x="252" y="215.5" font-size="10" font-weight="700" text-anchor="middle" fill="#0b0b0b">1</text>
<text x="236" y="196" font-size="10.5" fill="#52514e" font-family="ui-monospace, Menlo, Consolas, monospace">POST /bump</text>
<!-- tier 2: PUT -->
<line x1="220" y1="240" x2="308" y2="366" stroke="#898781" stroke-width="1.6" stroke-dasharray="6 4" marker-end="url(#a-muted)"/>
<circle cx="252" cy="286" r="9" fill="#ffffff" stroke="#898781"/>
<text x="252" y="289.5" font-size="10" font-weight="700" text-anchor="middle" fill="#52514e">2</text>
<text x="248" y="310" font-size="10.5" fill="#898781" font-family="ui-monospace, Menlo, Consolas, monospace">PUT file</text>
<!-- tier 3: disk -->
<path d="M 90 272 V 522 H 302" fill="none" stroke="#898781" stroke-width="1.4" stroke-dasharray="2 4" marker-end="url(#a-muted)"/>
<circle cx="90" cy="380" r="9" fill="#ffffff" stroke="#898781"/>
<text x="90" y="383.5" font-size="10" font-weight="700" text-anchor="middle" fill="#52514e">3</text>
<text x="104" y="384" font-size="10.5" fill="#898781">disk write</text>
<!-- ============ POD ============ -->
<rect x="310" y="106" width="350" height="330" rx="12" fill="#f5f8fd" stroke="#2a78d6" stroke-width="1.6"/>
<text x="485" y="130" font-size="13.5" font-weight="700" text-anchor="middle" fill="#0b0b0b">jspod → jss 0.0.219 <tspan fill="#898781" font-weight="400">(pm2)</tspan></text>
<!-- WAC strip -->
<rect x="330" y="144" width="310" height="52" rx="8" fill="#fdf6e4" stroke="#eda100"/>
<text x="485" y="164" font-size="12" font-weight="700" text-anchor="middle" fill="#0b0b0b">WAC · NIP-98 auth</text>
<text x="485" y="182" font-size="10.5" text-anchor="middle" fill="#52514e" font-family="ui-monospace, Menlo, Consolas, monospace">schnorr sig → did:nostr:&lt;pk&gt; ∈ .acl</text>
<!-- plugin box -->
<rect x="330" y="212" width="310" height="118" rx="8" fill="#e5eefb" stroke="#2a78d6"/>
<text x="485" y="234" font-size="12.5" font-weight="700" text-anchor="middle" fill="#0b0b0b">gamestr plugin <tspan fill="#898781" font-weight="400">· ~160 lines · /gamestr</tspan></text>
<text x="346" y="258" font-size="11" fill="#0b0b0b" font-family="ui-monospace, Menlo, Consolas, monospace">POST /bump <tspan fill="#52514e">— atomic increment</tspan></text>
<text x="346" y="280" font-size="11" fill="#0b0b0b" font-family="ui-monospace, Menlo, Consolas, monospace">GET /day/YYYYMMDD.json <tspan fill="#52514e">— 1 req</tspan></text>
<text x="346" y="302" font-size="11" fill="#0b0b0b" font-family="ui-monospace, Menlo, Consolas, monospace">WS /stream <tspan fill="#52514e">— push on every write</tspan></text>
<text x="346" y="320" font-size="10" fill="#898781">auth inherited: api.auth.getAgent()</text>
<!-- LDP strip -->
<rect x="330" y="346" width="310" height="52" rx="8" fill="#e9f6f0" stroke="#1baf7a"/>
<text x="485" y="366" font-size="12" font-weight="700" text-anchor="middle" fill="#0b0b0b">LDP · PUT / GET</text>
<text x="485" y="384" font-size="10.5" text-anchor="middle" fill="#52514e" font-family="ui-monospace, Menlo, Consolas, monospace">solid-0.1 · WS /.notifications</text>
<!-- pod -> storage -->
<line x1="485" y1="436" x2="485" y2="478" stroke="#52514e" stroke-width="2" marker-end="url(#a-ink)"/>
<text x="497" y="462" font-size="10.5" fill="#898781">writes</text>
<!-- ============ STORAGE ============ -->
<text x="310" y="474" font-size="10.5" letter-spacing="2" fill="#898781">PLAIN FILES — THE SOURCE OF TRUTH</text>
<rect x="310" y="482" width="350" height="92" rx="10" fill="#ffffff" stroke="#898781"/>
<text x="485" y="508" font-size="11.5" text-anchor="middle" fill="#0b0b0b" font-family="ui-monospace, Menlo, Consolas, monospace">~/pod-data/public/gamestr/hour/*.json</text>
<text x="485" y="530" font-size="11" text-anchor="middle" fill="#52514e" font-family="ui-monospace, Menlo, Consolas, monospace">{ "work": 2500, "chores": 30000 }</text>
<text x="485" y="556" font-size="10.5" text-anchor="middle" fill="#898781">symlinked from ~/.nosdav/… — every old reader still works</text>
<!-- ============ READERS ============ -->
<text x="710" y="96" font-size="10.5" letter-spacing="2" fill="#898781">READERS</text>
<rect x="710" y="142" width="200" height="118" rx="8" fill="#ffffff" stroke="#c3c2b7"/>
<text x="810" y="166" font-size="13" font-weight="700" text-anchor="middle" fill="#0b0b0b">browser dashboard</text>
<text x="810" y="188" font-size="12" text-anchor="middle" fill="#1baf7a" font-family="ui-monospace, Menlo, Consolas, monospace">● live (stream)</text>
<text x="810" y="210" font-size="10" text-anchor="middle" fill="#898781" font-family="ui-monospace, Menlo, Consolas, monospace">localhost:5444/public/gamestr/</text>
<text x="810" y="230" font-size="10.5" text-anchor="middle" fill="#52514e">bars · totals · ←/→ days · 🎉</text>
<text x="810" y="248" font-size="10" text-anchor="middle" fill="#898781">fallback: solid-0.1 → polling</text>
<!-- GET day -->
<line x1="710" y1="172" x2="662" y2="238" stroke="#52514e" stroke-width="1.4" marker-end="url(#a-ink)"/>
<text x="695" y="182" font-size="10" fill="#52514e" text-anchor="end" font-family="ui-monospace, Menlo, Consolas, monospace">GET day.json</text>
<!-- stream push -->
<line x1="662" y1="300" x2="710" y2="225" stroke="#1baf7a" stroke-width="2.4" marker-end="url(#a-aqua)"/>
<text x="700" y="290" font-size="11" font-weight="700" fill="#1baf7a" font-family="ui-monospace, Menlo, Consolas, monospace">WS push</text>
<!-- solid-0.1 fallback -->
<line x1="662" y1="376" x2="716" y2="262" stroke="#898781" stroke-width="1.4" stroke-dasharray="6 4" marker-end="url(#a-muted)"/>
<text x="705" y="336" font-size="10" fill="#898781">pub/sub</text>
<!-- terminal -->
<rect x="710" y="486" width="200" height="80" rx="8" fill="#ffffff" stroke="#c3c2b7"/>
<text x="810" y="512" font-size="13" font-weight="700" text-anchor="middle" fill="#0b0b0b" font-family="ui-monospace, Menlo, Consolas, monospace">hours --watch</text>
<text x="810" y="532" font-size="10.5" text-anchor="middle" fill="#52514e">the original terminal tool</text>
<text x="810" y="550" font-size="10.5" text-anchor="middle" fill="#898781">unchanged — reads the files</text>
<line x1="662" y1="526" x2="708" y2="526" stroke="#52514e" stroke-width="1.6" marker-end="url(#a-ink)"/>
<text x="685" y="518" font-size="10.5" fill="#898781" text-anchor="middle">read</text>
<!-- footer -->
<text x="26" y="612" font-size="11" fill="#52514e">write tiers: <tspan font-weight="700">① plugin bump</tspan> (NIP-98) → <tspan font-weight="700">② LDP PUT</tspan> → <tspan font-weight="700">③ direct disk</tspan> — points are never lost, even with the pod down</text>
<text x="26" y="632" font-size="11" fill="#52514e">identity = a keypair · authorization = one .acl entry · revocation = delete the entry · storage = files you can cat</text>
<text x="26" y="652" font-size="10.5" fill="#898781">jspod ≥ 0.0.49: jspod --plugin ./gamestr/plugin.js@/gamestr</text>
</svg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment