Authentication
| Caller | Credential | Boundary |
|---|---|---|
| Browser UI | Authelia one_factor session cookie | UI and /api/; the browser sends the cookie automatically |
| Agent, script, CLI | Authorization: Bearer <token> | /api/ only; non-browser callers cannot complete the interactive SSO redirect |
| Missing or wrong credential | none | nginx 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.
| Method | Path | Purpose | Auth | Notable response |
|---|---|---|---|---|
GET | /api/decks | List imported decks | Cookie or bearer | DeckSummary[]; unexpected failure is 500 |
POST | /api/decks/import | Import md_path or md_url; optional id, copy_from, base_url | Cookie 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/:id | Read one FrameDeck | Cookie or bearer | 400 malformed id, 404 absent deck, 500 corrupt deck |
GET | /api/decks/:id/assets/* | Serve one imported asset | Cookie or bearer | Raw bytes with derived content type and max-age=300; escape is 400, non-file is 404 |
POST | /api/sessions | Create a tandem session from {deckId} | Cookie or bearer | SessionMeta; bad body/id is 400, absent deck is 404 |
GET | /api/sessions | List sessions; optional ?deckId= filter | Cookie or bearer | SessionRow[] with lastSeq, lastEventTs, doneSections |
GET | /api/sessions/:id | Attach by reading the session and deck together | Cookie or bearer | {session, deck}; unknown id is 404 |
GET | /api/sessions/:id/events | Poll the bus; optional exclusive ?after= cursor | Cookie or bearer | BusEvent[]; bad cursor is 400, unknown session is 404 |
POST | /api/sessions/:id/events | Append one event input | Cookie or bearer | {ok, seq}; invalid event is 400, unknown session is 404 |
GET | /api/sessions/:id/stream | Replay after a cursor, then follow live events over SSE | Cookie or bearer | text/event-stream; bad cursor is 400, unknown session is JSON 404 |
POST | /api/sessions/:id/scribble | Decode {frame, dataUrl}, save PNG, append scribble event | Cookie or bearer | {ok, seq, file}; invalid frame/data URL is 400 |
GET | /api/sessions/:id/export.md | Render the session decision log | Cookie or bearer | text/markdown: YAML stats header plus one bullet per exported decision |
POST | /api/sessions/:id/collab | Validate and attach {link} to the session | Cookie or bearer | {ok, link, webUrl}; unrecognizable link is opaque 400 |
GET | /api/sessions/:id/collab | Read the attached collab room | Cookie or bearer | {link, webUrl} or {link: null} |
DELETE | /api/sessions/:id/collab | Clear the attached collab room | Cookie 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 bulletsEvent schema
Server ownership: clients send neither
seqnorts. Inside the per-session lock, the server assigns the next monotonicseqand a commit-time ISOts, appends one JSON object toevents.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.
| Kind | Required fields and normal payload | Who emits it | Meaning |
|---|---|---|---|
note | None beyond shared fields; UI sends frame, section, text, tags | Browser as user; agents may POST | Review note; becomes an export bullet |
task | None beyond shared fields; UI sends frame, section, text, tags | Browser as user; agents may POST | Follow-up task; becomes an export bullet |
verdict | verdict is required; UI also sends frame, section | Browser as user or attached agent | Frame ruling: keep, recapture, or kill; becomes an export bullet |
speech | None enforced; voice helper sends text | Browser voice helper as user | Human transcript; becomes an export bullet |
say | None enforced; agents conventionally send text | Attached agent | Dialog-rail message; live agent speech may be voiced; no export bullet |
scribble | Scribble route requires frame, dataUrl; stored event has frame, file | Server for browser scribble, as user | Saved PNG reference; becomes an export bullet |
nav | None enforced; browser sends section and optional frame | Browser as user | Scroll-position telemetry; no export bullet |
route_done | None enforced; browser sends section | Browser as user; agents may POST | Marks a section reviewed; counted by sections_done, no bullet |
status | None enforced | External agent/client; no built-in UI emitter | Hidden transport status; no export bullet |
| Shared field rule | Contract |
|---|---|
frame, section, text, file | Optional strings, each at most 16,384 characters |
tags | Optional string array; values are trimmed, empties removed, first 32 retained |
verdict | One of keep, recapture, kill; mandatory only for kind: verdict |
| Export histogram | by_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 phase | Contract |
|---|---|
| Source selection | Supply exactly one of md_path or md_url |
md_path | Must resolve to a regular file inside /home/loca/dev |
md_url | Must 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-) |
id | Slugified, then checked against the deck-id pattern |
copy_from | Existing 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_url | Used only when copy_from is absent; prefixes root-relative sources, leaves absolute URLs unchanged, copies nothing |
| Neither image option | Image src values remain exactly as written |
| Asset path | Drops . and .. segments during copy; the serving route rechecks containment against the deck asset directory |
| Re-import | Rewrites deck.json and recopies assets without pruning; existing sessions and their event logs remain untouched |
Shared errors, cursors, storage, and export rules
| Area | Contract |
|---|---|
| Error JSON | Every 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 bodies | Must be objects; arrays and non-objects are rejected |
after | Missing or empty means 0; otherwise a non-negative integer; reads are exclusive (seq > after) |
| SSE | Starts with : connected, replays past the cursor, stays live, and sends : ping every 25 seconds |
| Durable bus | One complete JSON object per line; publish occurs after append; garbage/torn lines are skipped on read rather than poisoning the log |
| Export counts | events counts decision bullets, not all bus events; frames_touched counts distinct frames on bullets; sections_done counts distinct route_done targets |
| Export text | Bullet 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 storage | Link 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 rule | Result |
|---|---|
| Bare dotted form, legacy fragment form, or encoded-fragment variant | Normalized to the canonical stored form |
Schemeless host route, ws:, wss:, http:, or https: route | Normalized host/room data plus a derived browser webUrl |
| Wrapper URL with a valid room link in its fragment | Recursively parsed, capped at four nesting levels |
| Cleartext transport | http: and ws: accepted only for localhost origins |
| Identifiers | Room id is 8–128 base64url characters; secret is base64url with exactly 43 or 64 characters |
| Read degradation | No 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
| Contract | Source or observation |
|---|---|
| Shared HTTP parsing/errors | src/lib/server/http.ts:8-28,46-54; src/lib/server/errors.ts:2-9 |
| Event types, validation, append order | src/lib/types.ts:4-36; src/lib/server/store.ts:260-357 |
| Scribble persistence | src/lib/server/store.ts:381-417 |
| Import and asset containment | src/lib/server/importer.ts:25-29,52-59,78-105,109-278; src/lib/server/store.ts:51-81 |
| Export selection and counters | src/lib/server/export.ts:5-35,49-70,89-124 |
| Collab parsing and storage | src/routes/api/sessions/[id]/collab/+server.ts:17-200; src/lib/server/store.ts:360-379 |
| 2026-08-21 evidence pass | Bearer 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.