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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment