Journal API
The collaborative wiki-journal at /journal. Mounted at /api/journal. Invite-only: every endpoint below, reads included, requires journal membership. Verified against production 2026-09-10.
Semantics you must know first#
- The journal is private. Every
/api/journalroute except/access/*andGET /images/:idreturns 403Journal access requiredunless the caller is a member. See Access below for how to get in. - One entry per calendar date, site-wide. The first poster becomes the permanent author.
- Entry bodies are append-only. There is NO edit endpoint for anyone, author included;
PATCH /entries/:datealways returns 405. Content changes only via appends, approved suggestions, or revert. - Authors append; non-authors suggest. An author gets 403 trying to suggest on their own entry, a non-author gets 403 trying to append.
- Trashing an entry (
DELETE /entries/:date, author only, soft) has no undo and the date still 409s on re-create. Do not create test entries on dates you care about. - Versions are immutable forever. Appends are not (changed 2026-09-11): the author can
PATCHorDELETEtheir own appends, so a duplicate post is fixable. The delete is soft — the append stops rendering, stops matching?q=search, and stops counting towardappendCount, but the row survives for audit. Comments are the only hard delete. - Every mutation is logged to a member-visible audit trail. See Activity feed below: if you write through this API under a PAT, your token name shows up next to the action.
Quickstart#
B=https://shanebackend-production.up.railway.app
# needs scope entries:write
curl -X POST $B/api/journal/entries \
-H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"date":"2026-09-01","content":"# Title\n\nGFM markdown body..."}'
# 201 {entry, currentVersionNum: 1} | 409 date taken
Endpoints#
Reads (members only, like everything else here):
| Method | Path | Notes |
|---|---|---|
| GET | /entries | ?limit=1..100&cursor=YYYY-MM-DD&from=&to=&q=; q is case-insensitive substring over body and appends; returns {entries, nextCursor} |
| GET | /entries/:date | {entry, author, content, currentVersionNum, appends}; the If-Match seed |
| GET | /entries/:date/versions | metadata only, NO content; ?limit=1..100&cursor=<versionNum> returns {versions, nextCursor} descending by versionNum; the cursor and :num are bounded to 1..2147483647, the int4 range of the column |
| GET | /entries/:date/versions/:num | one version, with content: this is where you read a body |
| GET | /entries/:date/appends | append timeline; soft-deleted appends are omitted |
| GET | /activity | site-wide audit trail, newest first; ?limit=1..100&cursor=<opaque, from nextCursor> returns {activity, nextCursor} |
| GET | /entries/:date/activity | same shape, scoped to one entry |
| GET | /entries/:date/neighbors | {prev, next} published dates |
| GET | /entries/:date/suggestions | ?status=pending|approved|rejected|withdrawn |
| GET | /suggestions/:id | one suggestion; includes baseVersionNum (the list endpoint does not) so you can fetch its base body from /versions/:num |
| GET | /entries/:date/comments | with author objects |
| GET | /entries/:date/reactions | {summary, mine} |
| GET | /comments/:id/reactions | {summary, mine} |
Writes:
| Method | Path | Scope | Body | Key errors |
|---|---|---|---|---|
| POST | /entries | entries:write | {date, content} (content trimmed 1..100k) | 409 date taken |
| POST | /entries/:date/appends | entries:write | {content} 1..100k | 403 not author |
| PATCH | /entries/:date/appends/:id | entries:write | {content} 1..100k | 404 unless you authored that append on that entry |
| DELETE | /entries/:date/appends/:id | entries:write | none | soft delete; 204. Repeat delete 404s |
| POST | /entries/:date/revert | entries:write + If-Match | {target_version_num} | 428/400/409 If-Match; 403 not author; 404 when target_version_num is not a version this entry has (SHAN-530) |
| DELETE | /entries/:date | entries:write | none | 404 not author; irreversible |
| POST | /entries/:date/suggestions | suggestions:write | {base_version_num, proposed_content} (full replacement, no diff format) | 403 if you are the author |
| PATCH | /suggestions/:id/approve | suggestions:write + If-Match | none | 403 not entry author; 409 {currentStatus} if it was decided since you read it |
| PATCH | /suggestions/:id/reject | suggestions:write | {reason?} max 2000 | 403 not entry author; 409 {currentStatus} if it was decided since you read it |
| PATCH | /suggestions/:id/withdraw | suggestions:write | none | 404 no such suggestion; 403 not the proposer; 409 {currentStatus} if the author decided it since you read it (SHAN-536) |
| GET | /inbox | auth only | none | pending suggestions on entries you author |
| POST | /entries/:date/comments | comments:write | {content, parent_comment_id?, anchor?} 1..10k | one reply level; the parent must be a top-level comment on this entry, else 400. anchor makes it an inline note (below) |
| PATCH | /comments/:id | comments:write | {content} | author only; an anchor cannot be changed |
| DELETE | /comments/:id | comments:write | none | comment author or entry author; 204. Its replies are kept and become top-level comments, except under an inline note: there the earliest reply becomes the note, inheriting its anchor, and the rest reply to it |
| POST | /entries/:date/reactions | reactions:write | {emoji} | toggle; shortcodes only |
| POST | /comments/:id/reactions | reactions:write | {emoji} | toggle |
The two If-Match writes (revert, suggestion approve) take the version number as a plain integer, seeded from currentVersionNum on GET /entries/:date. From a browser, send it as X-If-Match (SHAN-489): shanejli.com requests are same-origin and ride a rewrite through Vercel's edge, which evaluates a real If-Match against the response ETag. Ours are weak validators, a weak validator can never satisfy If-Match's strong comparison, and the edge therefore turns the origin's 200 into a 412 after the write has committed: a success reported as a failure, and a retry that writes it twice. Measured on 2026-09-13, the edge does this to PATCH (so the approve call) and lets POST through, but send X-If-Match on both rather than tracking which methods it intercepts. If-Match stays correct for direct callers hitting Railway with a PAT, and wins if both are sent. Full writeup in Conventions.
Reaction shortcode allowlist: +1 -1 laugh heart hooray rocket eyes confused (raw unicode is 400 Invalid emoji).
Inline notes#
An inline note is a top-level comment pinned to words on the page: anchor is {target, quote, prefix?, suffix?}, strict.
targetis"entry"for the entry body or the id of a live append on the same entry; anything else is 400.quote(trimmed, 1..1000) is the text as rendered, not the markdown source:**ring buffer**is quoted asring buffer.prefixandsuffix(each 0..64, default"") are the rendered text just before and after it, and are what pick the right occurrence when the quote appears more than once.- Compare with whitespace collapsed to single spaces; the site does, so line breaks inside a quote never matter.
- An anchor on a reply (
parent_comment_idset) is 400. Reply to a note withparent_comment_idand no anchor, like any comment. meta({kind: "ai", provider, model}) marks a note as an Explain answer; it is only accepted alongsideanchor. See AI and Reader Notes API.- A new note is also filed into Knowledge as a card unless its author turned that off in AI settings; replies are not.
- Anchors are text, not offsets, so a note survives edits that leave its words alone. When the words are edited away the note is kept: the site lists it under the entry as a note on text that changed.
GET /entries/:date/commentsreturns every comment,anchorincluded (null on ordinary comments and replies).
Reading positions#
Where the signed-in reader left off in each entry, synced across their devices. Browser session only: any PAT gets 403, because an agent has no place in an entry to save. Membership applies as everywhere else.
| Method | Path | Notes |
|---|---|---|
| GET | /reading | ?limit=1..100 (default 20). {positions}, most recently read first; trashed entries left out |
| GET | /reading/:date | {position}, or {position: null} when you have not read that entry |
| PUT | /reading/:date | {progress: 0..1, anchor: {block, offset, text}}, strict. 200 {position}; 404 when the date has no published entry |
- A position is
{date, progress, anchor, updatedAt}. One per reader per entry, and every PUT replaces it, so a lowerprogressthan before is saved like any other: it is the last place read, not the furthest. anchoris measured on the page, not in pixels.block(int 0..100000) indexes the text blocks of the entry body and each append body in document order (paragraphs, headings, list items, code blocks, tables, images, diagrams; the outermost wins; headers, dates and buttons around them are not counted),offset(0..100) is how far down that block the reading line sits as a fraction of its height (past 1 in a gap below it), andtext(up to 200 chars) is the block's opening words, used to find it again when an edit moved it.progressis the fallback when it cannot be found.progressis 1 once the end of the entry is on screen. The site counts 0.98 and up as read.- Last write wins, by server time.
Access (invite-only membership)#
The journal is invite-only. Membership is checked on every entry, append, version, suggestion, comment, reaction and inbox route: a non-member gets 403 {"error":"Journal access required","code":"journal_access_required"}, whether they are signed out, signed in without an invite, or holding a perfectly valid PAT.
How a PAT gets access: it does not, on its own. A PAT resolves to the user who minted it, so it inherits exactly that user's membership and nothing more. Invite the human through the browser and their agents work; revoke the human and their agents stop.
Two deliberate exemptions from the gate:
/access/*— that is the door, so it cannot be behind itself.GET /images/:id— an<img>tag sends noAuthorizationheader, so gating inline images would break them for members too. Image ids are unguessable UUIDs; treat an image URL as a capability and don't paste one somewhere public.
Every route under /access is additionally browser-session only. A PAT gets 403 Journal access management requires a browser session, not a PAT — an agent token cannot grant itself access, and there is no scope that unlocks this.
| Method | Path | Who | Notes |
|---|---|---|---|
| GET | /access/me | anyone | {role, requestStatus, requestMessage}; role is owner|member|null. Signed out returns all nulls, not 401 |
| POST | /access/requests | signed-in JWT | {message?} max 500; 201 first time, 200 if you already have a pending request, 409 if you already have access |
| GET | /access/requests | owner | ?status=pending|approved|rejected |
| POST | /access/requests/:id/approve | owner | 404 if the request is missing or already decided |
| POST | /access/requests/:id/reject | owner | 404 if the request is missing or already decided |
| GET | /access/members | owner | owner row first |
| POST | /access/members | owner | {email}; 404 if nobody has signed in with that address (identity comes from Google OAuth, so invitees must log in once first) |
| DELETE | /access/members/:userId | owner | 204; also clears their access request, so they read as "no access, no request" and can ask again. 404 for a non-member, and the owner row can never be revoked |
Re-requesting after a rejection flips the same row back to pending rather than creating a second request — there is exactly one request row per user.
Activity feed#
Every journal mutation writes one row to a member-visible audit trail. Read it at GET /activity (site-wide) or GET /entries/:date/activity (one entry). Same membership gate as everything else here, so it is transparent within the journal but not world-public.
Rows carry metadata only, never content bodies — read the body from /versions/:num or /entries/:date if you need it.
{
"activity": [
{
"id": "...", "entryId": "...", "entryDate": "2026-09-11",
"action": "append.delete", "targetType": "append", "targetId": "...",
"actorId": "...", "detail": null, "createdAt": "2026-09-11T10:00:00.000Z",
"actor": {
"id": "...", "name": "Shane", "avatarUrl": null,
"agent": { "tokenId": "...", "name": "jira-worker" }
}
}
],
"nextCursor": "2026-09-11T10:00:00.000Z_9c4f1e77-0b3a-4e52-9a61-7d2f8c5b0e14"
}
actor.agent is the part that matters for agents. A PAT resolves to the user who minted it, so actor is always the human — but when the write came from a PAT, agent.name is that token's name. Browser writes have agent: null. Name your tokens after the agent that holds them, because that name is what shows up in the feed. Revoking and deleting a token sets agent.name to null but leaves tokenId, so history still shows an agent did it.
action is one of: entry.create, entry.delete, entry.revert, append.create, append.update, append.delete, comment.create, comment.update, comment.delete, suggestion.create, suggestion.approve, suggestion.reject, suggestion.withdraw. Reactions are not logged. detail is a small action-specific object (version numbers, content length, rejection reason) or null — treat it as advisory, not a stable contract.
Pagination keys on createdAt plus the row id: send the previous response's nextCursor back verbatim as ?cursor=. It is the compound form <iso-timestamp>_<row-id>, not a YYYY-MM-DD date like the /entries cursor; a bare date is a 400. Do not rebuild it from a row's createdAt, because the id half is what keeps a tie or a sub-millisecond gap from silently dropping rows. See Pagination.
Content rules#
- Markdown is CommonMark + GFM (tables, task lists, strikethrough, autolinks, footnotes, fenced code with language tag but no highlighting).
- Raw HTML is silently stripped. No math rendering.
- Fenced ```mermaid blocks render as diagrams in entry bodies and appends (client-side; SSR, feeds, and comments show the raw code; invalid mermaid falls back to the code block with an error note). For other diagram tools see Images API.
- Bodies containing an in-flight editor upload placeholder (
uploading-...image token) are 400.
Freshness#
The API reflects writes instantly, and so do the site pages: because the content is private, /journal and /journal/:date no longer server-render entries at all — they load from this API in the browser with your session. There are no journal RSS/JSON feeds any more (/journal/feed.xml and /journal/feed.json return 404 — a feed pull carries no viewer to check membership against, so the only correct answer is that the URL no longer exists; the public Blog has feeds instead), the per-date OG image is a generic card with no entry text, and journal URLs are excluded from sitemap.xml and disallowed in robots.txt.