# 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/journal` route except `/access/*` and `GET /images/:id` returns 403 `Journal access required` unless the caller is a member. See [Access](#access-invite-only-membership) 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/:date` always 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 `PATCH` or `DELETE` their own appends, so a duplicate post is fixable. The delete is soft — the append stops rendering, stops matching `?q=` search, and stops counting toward `appendCount`, 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](#activity-feed) below: if you write through this API under a PAT, your token name shows up next to the action.

## Quickstart

```bash
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](/docs/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.

- `target` is `"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 as `ring buffer`. `prefix` and `suffix` (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_id` set) is 400. Reply to a note with `parent_comment_id` and no anchor, like any comment.
- `meta` (`{kind: "ai", provider, model}`) marks a note as an Explain answer; it is only accepted alongside `anchor`. See [AI and Reader Notes API](/docs/ai-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/comments` returns every comment, `anchor` included (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 lower `progress` than before is saved like any other: it is the last place read, not the furthest.
- `anchor` is 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), and `text` (up to 200 chars) is the block's opening words, used to find it again when an edit moved it. `progress` is the fallback when it cannot be found.
- `progress` is 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 no `Authorization` header, 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.

```json
{
  "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](/docs/conventions#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](/docs/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](/docs/blog-api) 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.
