sentinel/relay
Public: anyone can read this; nobody can change it.
An event hub for the apps on this forge: apps post signed events, people and agents subscribe, one live timeline.
forked from trevato/relay
This repository has no app yet.
following trevato/relay in step
Everything here is up to date with trevato/relay.
Proposes updates from trevato/relay.
main
- client
- docs
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 4.1 KB · ~1.1k tok
- forge.json 111 B · ~37 tok
- package.json 276 B · ~92 tok
- README.md 12.1 KB · ~3.1k tok
README.md
relay
An event hub for the apps on this forge: apps post signed events, people and agents subscribe, one live timeline.
This is an app: the forge runs it. Production follows the default branch;
every open pull gets a preview at its own address with its own copy of the
data. Everything the app keeps lives under FORGE_APP_DATA, which is what
makes "production data" and "preview data" two different things.
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
bun test # the check every pull runs
FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
Posting events
Another app posts a signed event with /client/relay.ts — one file, no
build step, no dependency, pasted straight into a Bun or Node 20 app with
curl https://<app>/client/relay.ts. See /adopting on a running relay
for the fields, how to get a key, and the curl equivalent.
The shape
One implementation, many doors. src/actions.ts is every operation as a
plain function — (db, who, input) in, data or a named failure out — and
the page, the JSON API, the MCP server and the terminal all call it and
decide nothing themselves. AGENTS.md says how to add to it.
| file | what it is |
|---|---|
src/actions.ts | post, list, get an event — and who may |
src/apps.ts | an app's key, derived from the identity key, never stored |
src/who.ts | who is asking, from the forge's signed headers |
src/db.ts | SQLite under FORGE_APP_DATA, WAL, forward-only migrations |
src/live.ts | the in-process bus and GET /events |
src/mcp.ts | the actions as MCP tools at POST /mcp |
src/routes.ts | every path, in one table |
src/page.ts | the HTML, rendered on the server |
src/http.ts | the floor: headers, body cap, same-origin, files |
src/ration.ts | how much a caller may write, and exactly when to retry |
src/server.ts | boot: open the database, serve |
src/tty.ts | the terminal, over the same actions |
src/env.ts | what the forge told this process |
static/app.css, static/live.js | the look, and the list that stays current |
An event
One table, event, and one row an app posts and anyone reads:
| column | meaning |
|---|---|
source | who posted it: lowercase letters, digits, hyphens, up to 32 chars |
kind | what happened: dotted lowercase segments, e.g. order.placed |
subject | the one-line headline, 1–120 characters |
summary | the detail, 1–200 characters |
link | optional, an absolute http(s) URL, up to 512 characters |
occurred_at | when it happened, ISO 8601, defaults to now |
received_at | when relay stored it |
dedupe | optional idempotency key, unique per source: post it twice, get the same row back |
subject and summary refuse control characters and newlines rather than
stripping them, and a link's scheme is checked here so no other door has
to remember.
An app's identity
Registering an app (registerApp in src/actions.ts, admin only) makes a
row in app; its posting key is never stored, only derived from the id,
a generation and the forge's identity key (src/apps.ts), and shown once,
at the moment it's issued. Rotating (rotateAppKey) bumps the generation,
which is what makes the previous key stop verifying — there is no key to
delete. Revoking (revokeApp) sets a column rather than removing the row,
because a derived key stays computable forever and the check that matters
is whether it's still current, not whether the row exists. relay itself
is a reserved id — nothing can register an app under it — because relay is
a poster on its own timeline (below), and its source must stay
unforgeable. The terminal (src/tty.ts, below) reaches all three; no page
or JSON route does yet (issue #71), so today only the terminal or a script
working directly against src/actions.ts can register, rotate or revoke.
relay posts its own events
An event hub with an empty timeline has nothing to show. relay is an app
on this forge like any other, and it posts about its own actions the same
way anything else does — through postEvent, with an identity it builds
for itself (RELAY in src/actions.ts), never a bare INSERT. Two kinds:
app.registered,app.rotated,app.revoked— one each time an admin
registers, rotates or revokes an app (src/actions.ts). subject is the
app's id; summary is one line naming the app and who did it, e.g.
"juniper registered bazaar". Never the key, the secret or anything
derived from either — only the id and the two names ever reach the row.
relay.deployed— one per boot, posted in the web process only (never
the terminal), after the listener is up (src/server.ts). subject is
the short commit; summary names the environment, e.g. "production,
commit bbf8ef9"; dedupe is the commit, so a restart, a crash loop or a
second process never repeats it. A local run with no commit configured
posts nothing.
Posting is best-effort: if it fails, the failure is logged and the write it followed — a registration, a rotation, a revocation — still succeeds. An event on a write path is a second thing that can fail, and it must never be the reason the first thing does.
Naming the admin who acted puts a forge username on a public timeline. That's deliberate, not an oversight: it's the same username the forge already shows next to "Sign out" on relay's own page, nothing more identifying, and it's what makes the timeline a true account of who did what.
Who you are
The forge says who is asking on every request it proxies, and the app
verifies the signature (src/who.ts); nothing else is trusted. Four tiers:
- anon — anyone. Reads.
- user — signed in to the forge and able to see this app. Reads, same as
anon; posting an event is not for a person.
- admin — able to write this repository. Posts an event under any
source it names.
- app — an app on the forge whose signed request this app has verified
(its own issue); posts under its own source only, and cannot claim
another's.
"Sign in" is /__forge/signin?to=<path> and "Sign out" is
/__forge/signout; the forge answers both on the app's own host.
Any interface
The same actions, the same rules, at every door.
curl https://<app>/api/events
curl https://<app>/api/events?source=shop&kind=order&limit=20
curl https://<app>/api/events/1
curl -X POST https://<app>/api/events -H 'content-type: application/json' \
-d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}'
curl https://<app>/api/sources
GET /api/sources is the directory: one entry per source that has ever
posted, newest activity first, each with its busiest kinds — at most 50
sources and 10 kinds per source, so it stays cheap no matter how much a
source invents. A source with more kinds than that carries more_kinds
rather than lying about its total. The same directory sits above the
timeline on /, each source and kind a link that narrows it.
Writes are rationed per person, twenty in hand and two a second (per
address for a stranger, since a person is their own bucket — and behind
the forge every anonymous caller shares its one address, so a busy
anonymous agent shares its bucket with every other stranger; one that
wants its own signs in, or, once apps can post as themselves (#8), posts
as an app instead). A read is rationed apart from a write, on its own
larger bucket — 120 in hand and 20 a second, since an agent paging a
timeline in a burst is not a person typing a form. /mcp costs whichever
its JSON-RPC method does: initialize, ping, tools/list and a
tools/call of a read-only tool are reads; post_event and any other
writing tool are writes, decided from the tool's own declaration, never
from its arguments. A write over MCP costs both buckets — the read floor
/mcp always pays before its body is parsed, and the write bucket on top
once the method is known — so an agent that has burnt its read bucket
cannot post even with write tokens still in hand. Over either ceiling, a
call is refused with 429 and
a retry-after in whole seconds; the JSON doors (/api/* and /mcp)
also carry the exact wait, so a script can sleep precisely rather than a
rounded second too long:
{ "error": "too_many_requests", "message": "...", "retry_after_ms": 500 }
As an MCP server, for Claude Code or Claude Desktop — list_events,
post_event, list_sources, get_event:
claude mcp add --transport http __TITLE__ https://<app>/mcp
{
"mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
}
The page stays current over /events: an event another app posts appears
without a reload. Without JavaScript it is the same page, one reload behind.
The terminal opens with the exact command in FORGE_APP_SSH when the app
runs (ssh -p 2222 <name>@<forge> — the app is the login name, the forge
the host; <name>--<pull>@<forge> for a preview's). It is src/tty.ts on
a real pty over the same data, as the account at the keyboard: j/k move,
q leaves, p switches between the timeline and the app registry — any key
redraws with fresh data, so there is no separate refresh key. In the
registry an admin presses n to register (id, then name, the same two
fields any door would ask for), r to rotate the app under the cursor, and
x to revoke it — the same registerApp, rotateAppKey and revokeApp as
any other door, refusing a guest or a plain user exactly as they would
anywhere else, immediately and without opening a prompt for a key the
footer does not even offer them. Rotating and revoking ask first, naming
the app (Revoke Beacon (beacon)? y/n), because both are one-way on a
single keypress; the app under the cursor is tracked by id, not by row
position, so registering a new one — which sorts to the top — never
silently moves the cursor onto a different app. A freshly issued key
prints once, its own screen, and is gone the moment anything else is
drawn — it is never held in the state the status line redraws.
The terminal is its own process, so a write made there does not reach an
open page over /events the way a write from another process's own door
would (src/live.ts); it shows up the next time that page loads, until
issue #65 gives every process a database watcher instead of an in-process
bus. Issue #71 tracks the other side of the asymmetry this leaves: the
page and the JSON API still cannot register, rotate or revoke — only the
terminal (and a script working directly against src/actions.ts) can.
The footer prints that command verbatim, quoted, never a guessed form.
The forge sits behind a proxy that rewrites anything shaped like an email
address, so the command is wrapped in <!--email_off--> and its host sits
in its own element — readable and copyable without JavaScript, even
behind the rewrite. With FORGE_APP_SSH unset the footer says the
terminal is off.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or
preview), FORGE_APP_URL, FORGE_APP_COMMIT, FORGE_APP_SSH and
FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.