NotReliable

Running the server · How it is built · The tz command line · The extension · Technical notes

Running the shadow server

The plan is in [DESIGN.md](/docs/design) — read that first. This is the part you need to have one running in front of you.

Phases 0 to 9 are in, the extension half of mirroring excepted. It authenticates, it is a working Trello — boards, lists, cards, labels, members, checklists, comments, search, the batch endpoint — it keeps a change history that says what a value was before, it is live (every change goes out over a WebSocket to whoever is watching that board), and it has a web UI. Open http://localhost:3000/loginz and sign in.

What the web UI does:

  • The flat table, with everything in the query string: filter chips, search, sort, grouping (board, list, member, due, label), which columns to show, and which card is open beside it. Every one of those is a link you can paste to someone.
  • A quick view — name, due, list, members, labels, checklists — beside the table rather than instead of it, so reading the list and editing a card are not different pages.
  • Exports: /export.csv and /export.yaml take the same filters as the table and are produced from the same model, so they cannot disagree with what you were looking at.
  • Doc cards: a card whose text leads, read as a page, written in a full-page Markdown editor (edit/preview as real forms, a WYSIWYG layered on top when the round trip is provably lossless), linking to other cards with [[WEB-12]] or [[their name]].
  • History with a way back: /c/KEY/history is every version the text has had, what each edit changed, and a restore. A restore is an edit, so the version you left stays in the list.
  • Attachments: upload a file from the card page, or bookmark a link. Uploads take multipart from a browser and raw bytes from anything else (POST /1/cards/KEY/attachments?name=x.png), and download with the mime type they were stored under. Bytes live on disk under a minted UUID; the database keeps the name, size and sha256 and not one byte of the file.
  • Moving without dragging: every card takes a typed position in its list, and board/list moves are dropdowns. There is no drag-only anything.
  • Sharing: publish a board, list or card as a read-only link that needs no account, or as a status badge for a README — /s/<token>/badge.svg, and the same numbers as shields-shaped JSON. A shared page has no form on it anywhere.
  • Webhooks, both ways: outbound on matching events, HMAC-signed, as JSON or as Slack blocks or a Teams card; inbound as an address another system can POST a card to. Both are managed from the board page by its admins.
  • Workflows: say which lists are steps and in what order. A move that skips or reverses still happens — the card is nudged, not blocked — and the verdict rides on the event, so the page, the socket and a webhook agree.
  • Mirroring in: PUT /1/mirror/boards/<their-id> with a Trello board-detail payload. Idempotent, tombstones what has gone, and brings their members over as placeholder people who cannot log in.
  • Boards, groups, admin (users, groups, tokens), invite links, notifications.

None of it needs JavaScript. The two enhancements — the live-update nudge and the WYSIWYG editor — are additions to pages that already work without them.

In a container

docker compose up -d
docker compose exec notreliable node server/server.mjs user add you --admin --password "…"

Then open <http://localhost:3000/loginz> and sign in. The front page at / is for everyone: the extension, and how to install and update it, in a few lines. It does not link to the sign-in page — /loginz is an address you tell people who have accounts. The sign-in page also hands out the CLI. Both are packaged from the files in the image, so a client can never be a version out of step with the server that gave it to you, and the extension's footer says so when it is.

The database and the attachment bytes live in the notreliable-data volume, and that is the only thing worth keeping when the container is replaced. The compose file publishes on loopback only; change that deliberately.

Or without one

cd server
npm install        # better-sqlite3 and ws, and nothing else

The extension at the repo root still has zero dependencies. These two live here, and test/wiring.test.mjs fails if a third one appears.

Make yourself a user, then a token

node server.mjs user add mmarc --name "Michael March" --admin --password "…"
node server.mjs token add mmarc --name "curl on this laptop"   # prints the token, once

The token is shown exactly once, because only a SHA-256 of it is stored. You can hold as many as you like — one per machine, per client, per script — each named, each revocable on its own:

node server.mjs token list mmarc
node server.mjs token revoke mmarc <id>

A password comes from --password, from TZ_PASSWORD, or from standard input if you pipe one in. Never from a prompt: this runs in scripts as often as in a shell.

Start it

node server.mjs                       # http://127.0.0.1:3000, data in server/data
TZ_PORT=8080 TZ_HOST=0.0.0.0 node server.mjs
VariableMeans
TZ_PORT (or PORT)Port. Default 3000.
TZ_HOSTInterface. Default 127.0.0.1 — loopback, until you say otherwise.
TZ_DATAWhere app.db and attachments live. Default server/data.
TZ_DBAn explicit database path, :memory: included. Beats TZ_DATA.
TZ_NAMEWhat the server calls itself in GET /1/server.
TZ_BASE_URLWhere the server says it lives, for links that leave the building — share links, invites, webhooks. Unset, links derive from each request's Host header.
TZ_BREAK_GLASSEmergency access: this secret, sent as a bearer token, authenticates as the server-admin account break_glass — accounts and tokens only, no sight of boards. 16 characters minimum or the server refuses to start. Set it when locked out, unset it after.

Tokens and scopes

POST /1/tokens with {username, password} is the login and answers a full token — everything you can do. Already authenticated, the same endpoint mints a scoped one:

curl -X POST localhost:3000/1/tokens -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"name":"wiki badge","access":"r","boards":["<board id>"]}'

access is r or rw; idBoards and idOrganizations say what it reaches, and naming nothing scopes it to nothing — the safe default. A scoped token reads as an observer (rw writes as a member), and is refused by everything that grants access: tokens, invites, share links, webhooks, seats, groups. It can never mint its way wider.

Both clients offer the same choices as a form: ⚙ Settings → Your API tokens in the app, and the Account page (/me) without JavaScript. The credential the request arrived on is marked current, so a list of them can say which one you are holding.

Boards and workspaces can own tokens themselves — service tokens, minted by their admins at POST /1/boards/:id/tokens and POST /1/organizations/:id/tokens ({name, access}). Each is carried by a service account rather than a person, so a badge or a CI job survives whoever set it up; revoking the token (DELETE …/tokens/:tokenId) retires the account with it.

Talk to it

Everything except discovery and logging in wants Authorization: Bearer <token>.

curl localhost:3000/1/server                                    # what am I talking to?
curl -H "Authorization: Bearer $TOKEN" localhost:3000/1/members/me
curl -X POST localhost:3000/1/tokens \
  -H 'content-type: application/json' \
  -d '{"username":"mmarc","password":"…","name":"from curl"}'   # log in, get a token

GET /1/server needs no token and lists every path the server answers, with a sentence each — so a person with curl, or a model with a context window, can find their way around without reading any of this.

Card keys

Every board gets a short key (Web AppWA) and every card the next number on that board, so a card is WA-1 as well as a 24-character id. The key works anywhere an id does:

curl -H "Authorization: Bearer $TOKEN" localhost:3000/1/cards/WA-1
curl -X PUT localhost:3000/1/cards/WA-1 -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"name":"Renamed through its key"}'

Numbers are handed out once and never reused — archiving WA-3 does not give 3 to the next card — and a card that moves to another board takes that board's key, because the key belongs to the board. The id never changes.

Keys are never optional: every board gets one when it is made, mirrored boards included, and every card carries KEY-N from its first moment. Showing them is the board's own decision and is off until asked, because a board of groceries does not want a ticket number on every row:

curl -X PUT localhost:3000/1/boards/WA -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"showKey":true}'

Board admins only, since it changes how the board reads for everyone on it. In the app it is a tick-box on the board's ⚙ Settings; without JavaScript it is on the board's own page. Hiding them changes nothing but the drawing — /c/WA-1 resolves either way.

A card's history

Every change writes a row saying who did it, when, and what the value was before. Nothing is compacted away. That is every change, not only a card's: a list renamed or archived, a checklist added, an item ticked, a label made or deleted, the board itself renamed — each is a row in Trello's own shape (createList, updateCheckItemStateOnCard, updateBoard, …), written in the same transaction as the event that announces it, so a client reading Trello and a client reading this server see the same kinds of sentence.

curl -H "Authorization: Bearer $TOKEN" localhost:3000/1/cards/WA-1/actions
curl -H "Authorization: Bearer $TOKEN" "localhost:3000/1/cards/WA-1/actions?filter=commentCard"
# and a whole board's, newest first, from a moment on — the feed behind ⏱ Recent
curl -H "Authorization: Bearer $TOKEN" "localhost:3000/1/boards/WA/actions?since=2026-09-01T00:00:00Z"

The web UI

Everything at / is server-rendered, and every write is a real <form>, so the whole application works with JavaScript switched off. That is not nostalgia: it is what makes the UI operable by anything that can fetch and post — a script, a curl loop, a model.

PageWhat it is
/The flat table: every card you can see. Filters, search and sort live in the query string, so a view is a link.
/boards, /b/WEBYour boards; one board as columns of lists, each with a form that adds a card.
/c/WEB-12One card: fields, description (Markdown), checklists, comments, and its history.
/recentWhat changed on every board you can see in the last 48 hours (?hours=24, 168, 720), as sentences with the card and the board linked.
/meNotifications, password, API tokens.
/adminPeople, invite links, groups. Server admins only.
/invite/<token>Pick a username, set a password, and you are in — on the board the invite named.
/c/WEB-12/edit, /c/WEB-12/historyThe Markdown editor, and every version the text has had with a way back to any of them.
/export.csv, /export.yamlThe table you are looking at, taken away whole — same filters, same sort.
/s/<token>A read-only view for somebody with no account. /s/<token>/badge.svg is the README badge.

Every page has a JSON twin at the same URL — send Accept: application/json or add ?format=json. It returns the exact object the HTML was rendered from, so the two cannot drift:

curl -b cookies.txt "localhost:3000/?format=json"          # the table, as data
curl -b cookies.txt "localhost:3000/c/WEB-12?format=json"  # one card, as data
curl localhost:3000/llms.txt                               # the map, for an agent

Sessions are an ordinary token in an HttpOnly cookie — same mechanism as the API, carried differently — so a script cannot read it, and signing out revokes it rather than merely forgetting it. Form posts carry a CSRF value derived from the session; bearer-token requests do not need one, because nobody else's site can send a header.

Inviting someone

There is no mail transport yet, so the UI hands you the link and says so:

/admin  →  Invite someone  →  copy the link  →  send it yourself

The link carries what the person becomes — a role on a board, a workspace, or just an account — and it can expire, be single- or multi-use, and be revoked. Accepting one creates the account, sets the password, and grants the membership in a single transaction: there is no state where somebody has an account that can see nothing.

Live updates

Every mutation writes one event, in the same transaction as the change, and the event goes out to sockets watching that board once the transaction commits.

# wscat, websocat, or ten lines of node — the frames are in ../protocol.js
wscat -c "ws://localhost:3000/1/Session/socket?token=$TOKEN"
> {"type":"subscribe","modelType":"Board","idModel":"<board id>","reqid":1}
< {"reqid":1,"result":12}
< {"notify":{"event":"change","idBoard":"…","model":"card","verb":"update","delta":{"name":"…"}}}

The token rides in the query string because a browser cannot put a header on a WebSocket. The ack hands back the board's event index, which is the number you pass to the catch-up feed if your connection drops — or if you would rather poll than hold a socket open at all:

curl -H "Authorization: Bearer $TOKEN" "localhost:3000/1/boards/WA/events?since=12"

Same events, same indexes, whichever way you hear them. Nothing is missed between the two: ask for everything after the last index you saw.

Tests

npm test          # from the repo root: the extension's suites and the server's together
npm test          # from server/: just these

The server suites need npm install in server/ first; the extension's do not, and never will.