Authentication

CallerCredentialBoundary
Browser UIAuthelia one_factor session cookieUI and /api/; the browser sends the cookie automatically
Agent, script, CLIAuthorization: Bearer <token>/api/ only; non-browser callers cannot complete the interactive SSO redirect
Missing or wrong credentialnonenginx answers 302 toward Authelia, not 401; a redirect-following client receives HTML

The bearer token lives at /home/loca/dev/tandem-audit/.api-token, is gitignored, and has mode 600. Read it into a request header; never print, log, paste, or commit its value. The nginx edge consumes both credentials, while the application assigns no identity or per-route permissions. See private page (sign-in required) for the boundary and private page (sign-in required) for rotation.

The 2026-08-21 evidence pass observed 200 for authenticated deck and SSE requests and 302 without a valid credential. Browser EventSource cannot add the bearer header, so the cookie path remains necessary.

Agent request flow

sequenceDiagram
    participant A as Agent
    participant E as Authelia/nginx edge
    participant API as TNDM API
    participant B as Event bus
    A->>E: GET /api/decks/:id + Bearer token
    E->>API: Forward authenticated request
    API-->>A: FrameDeck
    A->>E: GET /api/sessions/:id/stream?after=n + Bearer token
    E->>B: Open SSE subscription
    B-->>A: Replay and live BusEvent frames
    A->>E: POST verdict + Bearer token
    E->>B: Validate, serialize, append
    B-->>A: {ok, seq}
    B-->>A: SSE echo with server seq and ts

Endpoint index

Every row accepts the Authelia session cookie or bearer header.

MethodPathPurposeAuthNotable response
GET/api/decksList imported decksCookie or bearerDeckSummary[]; unexpected failure is 500
POST/api/decks/importImport md_path or md_url; optional id, copy_from, base_urlCookie or bearer{ok, id, sections, frames, imagesRewritten, assets}; invalid input is 400, oversize remote response is 413 (src/lib/server/importer.ts:225-)
GET/api/decks/:idRead one FrameDeckCookie or bearer400 malformed id, 404 absent deck, 500 corrupt deck
GET/api/decks/:id/assets/*Serve one imported assetCookie or bearerRaw bytes with derived content type and max-age=300; escape is 400, non-file is 404
POST/api/sessionsCreate a tandem session from {deckId}Cookie or bearerSessionMeta; bad body/id is 400, absent deck is 404
GET/api/sessionsList sessions; optional ?deckId= filterCookie or bearerSessionRow[] with lastSeq, lastEventTs, doneSections
GET/api/sessions/:idAttach by reading the session and deck togetherCookie or bearer{session, deck}; unknown id is 404
GET/api/sessions/:id/eventsPoll the bus; optional exclusive ?after= cursorCookie or bearerBusEvent[]; bad cursor is 400, unknown session is 404
POST/api/sessions/:id/eventsAppend one event inputCookie or bearer{ok, seq}; invalid event is 400, unknown session is 404
GET/api/sessions/:id/streamReplay after a cursor, then follow live events over SSECookie or bearertext/event-stream; bad cursor is 400, unknown session is JSON 404
POST/api/sessions/:id/scribbleDecode {frame, dataUrl}, save PNG, append scribble eventCookie or bearer{ok, seq, file}; invalid frame/data URL is 400
GET/api/sessions/:id/export.mdRender the session decision logCookie or bearertext/markdown: YAML stats header plus one bullet per exported decision
POST/api/sessions/:id/collabValidate and attach {link} to the sessionCookie or bearer{ok, link, webUrl}; unrecognizable link is opaque 400
GET/api/sessions/:id/collabRead the attached collab roomCookie or bearer{link, webUrl} or {link: null}
DELETE/api/sessions/:id/collabClear the attached collab roomCookie or bearer{ok: true}

All JSON-body endpoints in this table — import, session creation, event append, collab POST, and scribble — return 413 request body exceeds the maximum size once a body exceeds 1 MiB (src/lib/server/http.ts:4,71-).

The scribble POST uses a 5,657,944-byte per-route body budget so its effective caller-facing ceiling is a 4 MiB decoded PNG; exceeding it returns 413 scribble image exceeds the maximum size (src/lib/server/store.ts:382,388,408-410; src/routes/api/sessions/[id]/scribble/+server.ts:14).

Request shapes worth copying

Set the shared values without printing the token:

BASE='https://tndm.loca.zone'
AUTH="Authorization: Bearer $(cat /home/loca/dev/tandem-audit/.api-token)"
S='replace-with-session-id'

Import a local document and materialize its images:

curl -sS -X POST "$BASE/api/decks/import" -H "$AUTH" \
  -H 'content-type: application/json' \
  -d '{"md_path":"/home/loca/dev/project/audit.md","copy_from":"/home/loca/dev/project","id":"audit-deck"}'
# {"ok":true,"id":"audit-deck","sections":...,"frames":...,"imagesRewritten":...,"assets":{...}}

Append a verdict; omit seq and ts:

curl -sS -X POST "$BASE/api/sessions/$S/events" -H "$AUTH" \
  -H 'content-type: application/json' \
  -d '{"actor":"agent","kind":"verdict","frame":"section-id/1","section":"section-id","verdict":"keep"}'
# {"ok":true,"seq":...}

Replay and follow the SSE stream:

curl -sN -H "$AUTH" "$BASE/api/sessions/$S/stream?after=0"
# : connected
# data: {"seq":1,"ts":"...","actor":"agent","kind":"verdict",...}

Read the markdown export:

curl -sS -H "$AUTH" "$BASE/api/sessions/$S/export.md"
# YAML stats header, then "## Tandem audit ..." and decision bullets

Event schema

Server ownership: clients send neither seq nor ts. Inside the per-session lock, the server assigns the next monotonic seq and a commit-time ISO ts, appends one JSON object to events.jsonl, updates session metadata, then publishes the same event. Extra client values for those fields are discarded.

Every generic event POST requires actor (user or agent) and kind. Only a verdict has another API-enforced field. The API does not enforce a kind-to-actor pairing; the emitter column records the built-in or conventional producer.

KindRequired fields and normal payloadWho emits itMeaning
noteNone beyond shared fields; UI sends frame, section, text, tagsBrowser as user; agents may POSTReview note; becomes an export bullet
taskNone beyond shared fields; UI sends frame, section, text, tagsBrowser as user; agents may POSTFollow-up task; becomes an export bullet
verdictverdict is required; UI also sends frame, sectionBrowser as user or attached agentFrame ruling: keep, recapture, or kill; becomes an export bullet
speechNone enforced; voice helper sends textBrowser voice helper as userHuman transcript; becomes an export bullet
sayNone enforced; agents conventionally send textAttached agentDialog-rail message; live agent speech may be voiced; no export bullet
scribbleScribble route requires frame, dataUrl; stored event has frame, fileServer for browser scribble, as userSaved PNG reference; becomes an export bullet
navNone enforced; browser sends section and optional frameBrowser as userScroll-position telemetry; no export bullet
route_doneNone enforced; browser sends sectionBrowser as user; agents may POSTMarks a section reviewed; counted by sections_done, no bullet
statusNone enforcedExternal agent/client; no built-in UI emitterHidden transport status; no export bullet
Shared field ruleContract
frame, section, text, fileOptional strings, each at most 16,384 characters
tagsOptional string array; values are trimmed, empties removed, first 32 retained
verdictOne of keep, recapture, kill; mandatory only for kind: verdict
Export histogramby_kind counts all nine kinds; bullets include only note, task, verdict, speech, scribble

Nav client policy changed in the 2026-08-21 polish pass, not on the wire: loading or reloading a session no longer emits nav; the first genuine section change still emits one. See private page (sign-in required).

Import safety

The realpath guard is the core rule: a local source is resolved before containment is checked, so symlink escapes fail. Containment compares the approved root plus a path separator, preventing sibling-prefix bypasses. private page (sign-in required) owns the broader trust boundary.

Input or phaseContract
Source selectionSupply exactly one of md_path or md_url
md_pathMust resolve to a regular file inside /home/loca/dev
md_urlMust parse as http: or https:; fetch timeout is 20 seconds and non-OK becomes 400. The hostname is resolved and every DNS answer must be public or the request is refused with 400 md_url host must resolve only to public addresses; redirects are followed manually with every hop re-validated and capped at 5; the response is capped at 8 MiB with 413 md_url response exceeds the maximum size (src/lib/server/importer.ts:32-33,186-,225-,256-)
idSlugified, then checked against the deck-id pattern
copy_fromExisting directory inside /home/loca/dev or DATA_DIR; copies the longest resolvable image-path suffix, preserves subpaths, copies repeats once, skips absolute/protocol-relative URLs, reports up to 20 misses
base_urlUsed only when copy_from is absent; prefixes root-relative sources, leaves absolute URLs unchanged, copies nothing
Neither image optionImage src values remain exactly as written
Asset pathDrops . and .. segments during copy; the serving route rechecks containment against the deck asset directory
Re-importRewrites deck.json and recopies assets without pruning; existing sessions and their event logs remain untouched
Shared errors, cursors, storage, and export rules
AreaContract
Error JSONEvery non-2xx application response is {ok: false, error: string}; known HttpError status/message survive, unexpected errors become opaque 500 and are logged server-side
JSON bodiesMust be objects; arrays and non-objects are rejected
afterMissing or empty means 0; otherwise a non-negative integer; reads are exclusive (seq > after)
SSEStarts with : connected, replays past the cursor, stays live, and sends : ping every 25 seconds
Durable busOne complete JSON object per line; publish occurs after append; garbage/torn lines are skipped on read rather than poisoning the log
Export countsevents counts decision bullets, not all bus events; frames_touched counts distinct frames on bullets; sections_done counts distinct route_done targets
Export textBullet shape is [kind] target — pieces; missing target falls back to deck, tags become hash chips, scribbles use relative links, and untrusted text is collapsed to one line
Collab storageLink stays on the owning session record, outside events.jsonl and session-list rows; writes use the session lock; errors never echo submitted link
Collab normalization contract
Input family or ruleResult
Bare dotted form, legacy fragment form, or encoded-fragment variantNormalized to the canonical stored form
Schemeless host route, ws:, wss:, http:, or https: routeNormalized host/room data plus a derived browser webUrl
Wrapper URL with a valid room link in its fragmentRecursively parsed, capped at four nesting levels
Cleartext transporthttp: and ws: accepted only for localhost origins
IdentifiersRoom id is 8–128 base64url characters; secret is base64url with exactly 43 or 64 characters
Read degradationNo link returns {link: null}; an old stored link that cannot derive a browser URL returns {link, webUrl: null} rather than 500

The default derived browser origin is https://my.omp.sh. The collab room is a session secret; handling guidance stays in private page (sign-in required).

Source and dated evidence map
ContractSource or observation
Shared HTTP parsing/errorssrc/lib/server/http.ts:8-28,46-54; src/lib/server/errors.ts:2-9
Event types, validation, append ordersrc/lib/types.ts:4-36; src/lib/server/store.ts:260-357
Scribble persistencesrc/lib/server/store.ts:381-417
Import and asset containmentsrc/lib/server/importer.ts:25-29,52-59,78-105,109-278; src/lib/server/store.ts:51-81
Export selection and counterssrc/lib/server/export.ts:5-35,49-70,89-124
Collab parsing and storagesrc/routes/api/sessions/[id]/collab/+server.ts:17-200; src/lib/server/store.ts:360-379
2026-08-21 evidence passBearer deck/SSE requests returned 200; missing or wrong bearer returned 302; encoded traversal returned 400; a legitimate PNG returned image/png, 329,992 bytes and max-age=300; collab GET returned null, junk POST returned opaque 400, and DELETE cleared it

Read agent-cookbook for six copy-paste agent recipes, md-parser for deck shape, or private page (sign-in required) for dated receipts.