Files
brain/API.md
T
Andrew S Erwin 5eab58f99c
release gate / check (push) Failing after 6m36s
Add trust features: history, trash, export, and collection sharing
Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
2026-10-19 23:07:38 -05:00

20 KiB

API reference

Go API: http://localhost:8080. The web UI uses the same paths through its server-side proxy at http://localhost:3000 and its NextAuth session.

Successful responses are JSON unless marked Markdown. Errors are {"error":"message"}. Data endpoints require authentication: 401 without a valid session, 404 for another user's private record, 403 for non-admin administration, 400 for invalid input, 409 for duplicates.

Authentication

Method Path Returns
GET /healthz {"status":"ok"} (process liveness)
GET /readyz {"status":"ready"} after database ping; 503 when unavailable
POST /api/signup 201 {ok:true} — the same uniform body for fresh, duplicate and operator-reserved addresses (review CR-03: no existence oracle); body {email,password,name?}
POST /api/auth/login {ok:true,user:{id,name,email,image,role}} and signed HttpOnly cookie
GET /api/auth/session {user} or null for no valid cookie
POST /api/auth/signout {ok:true} and expired cookie
GET /api/me {user,effectiveUser,isAdmin,viewingAs}; real and effective identities
GET /api/auth/oidc/status {enabled,issuerSet,clientIdSet,clientSecretSet,issuer}; never includes secrets
GET /api/auth/oidc/login 501, client-owned Authelia integration stub
GET, POST /api/auth/callback/authelia 501, client-owned callback stub

The browser's NextAuth /api/auth/* endpoints remain on the Next server. Direct Go API consumers use the computer_session cookie from login. Go login accepts JSON and rejects foreign browser origins. The custom Next.js login endpoint also requires the configured same-origin Origin header. Login and signup are throttled per client IP (429 with Retry-After), plus a consecutive-failure lockout per IP+email on login; loopback clients are exempt. Browser traffic arrives through the trusted Next proxy, which forwards the observed client IP in X-Computer-Client-Ip signed with HMAC-SHA256 under the shared secret (X-Computer-Client-Sig); Go buckets on that identity only when the signature verifies and falls back to the direct peer otherwise, so proxied browsers neither share one bucket nor forge another identity. ADMIN_EMAILS reserves administrator addresses for operator provisioning and disables public first-admin bootstrap while set. Automatic promotion of an existing allowlisted user requires emailVerified; DB administrator roles remain authoritative. First-signup administrator promotion is one-shot: once bootstrap completion is recorded (AppSetting adminBootstrap), the absence of a qualifying administrator — e.g. after demoting the last one — never re-opens promotion through signup. Recovery is operator-driven (ADMIN_EMAILS + make seed, or promotion by an existing admin).

Bookmarks

Bookmark responses are permission-specific. Owners receive camelCase scalar fields plus tags:[{tag}], contact, sharedWith:[{userId,user}] and owner (with email). Authenticated shared readers receive a narrowed allowlist — URL, title/description, notes, preview metadata, dueDate, isPublic, kanbanStatus, timestamps, tags and owner:{id,name} — never the owner's contact record, the recipient list, alerts, mailbox watches or board placement. Detail responses also include pageLinks:[{pageId,page}], limited to the effective user's pages.

Method Path Returns / behavior
GET /api/bookmarks Array, newest-updated then ID descending; query search,tagId,contactId,kanban,date,shared,limit,cursor; default/max page size 200
POST /api/bookmarks 201 bookmark; body {url,title?,description?,notes?,dueDate?,alertAt?,kanbanStatus?,contactId?,tagIds?,isPublic?,imapWatchEnabled?,imapQuery?}
GET /api/bookmarks/{id} Owner-full or shared-reader projection if visible; public-only access uses the redacted public endpoint
PUT /api/bookmarks/{id} Updated bookmark, owner only; fields above plus kanbanColumnId; omitted fields stay unchanged; nullable fields can be cleared. Requires version (the revision the client edited from): 400 when missing, 409 when stale — success bumps version (review CR-04)
DELETE /api/bookmarks/{id} {ok:true}, owner only
POST /api/bookmarks/{id}/share {ok:true}; body {userIds?,isPublic?}; adds shares
DELETE /api/bookmarks/{id}/share/{userId} {ok:true}; revokes that direct share
GET /api/bookmarks/{id}/markdown Markdown attachment, owned/shared only
GET /api/preview?url=... {title,description,ogTitle,ogDescription,ogImage,favicon}; nullable on fetch failure
GET /api/public/bookmarks/{id} Public bookmark projection without login; private records return 404

Public HTML pages remain at /share/bookmark/{id} on the Next.js server. For pagination, read the X-Next-Cursor response header and pass that opaque value as cursor with the same filters. An absent header marks the final page. limit must be between 1 and 200. The web proxy forwards the cursor header. Public responses allowlist URL/preview/notes/date/tag fields only; owner identity, contacts, share recipients, mailbox queries, and alert metadata are excluded. Updating alertAt resets alertSent; replacing tagIds is transactional. Tags, columns and contacts are checked for accessibility/ownership. The version contract covers the owner-driven PUT (fields and tag replacement) and kanban card moves (PUT /api/kanban/cards/{id}): kanban column deletion moves bookmarks server-side and bumps their version too, so a stale board save 409s instead of moving a card back into a deleted column. Sharing (POST /share) and the server-driven alertSent flag are deliberately outside the contract (review CR-04).

Input limits are enforced per field, not just by the 1 MiB request-body cap: bookmark url 2048, title 500, description 2000, notes 100000 and imapQuery 500 characters; tag names 80; page content 524288 characters and icon 64; collection names/properties/views and item values have their own documented bounds. Caps on these fields reject over-limit input with 400; a few older metadata fields (page titles, kanban labels, collection names/options) still trim to their existing limits.

Outbound preview fetches are restricted to HTTP/HTTPS ports 80/443 and mailbox connections to IMAP ports 143/993 (the fixture/LAN escape hatches are test-only). CardDAV keeps the configured HTTPS port.

Tags, kanban and pages

Method Path Returns / behavior
GET /api/tags Owned/shared tags with _count.bookmarks and sharedWith
POST /api/tags 201 tag; body {name,color?}
PUT /api/tags/{id} Updated tag; body {name?,color?,shareUserIds?}, owner only
DELETE /api/tags/{id} {ok:true}
DELETE /api/tags/{id}/share/{userId} {ok:true}, revokes tag share
GET /api/kanban/columns Ordered columns; creates four defaults if none exist
POST /api/kanban/columns 201 column; body {label,color?}
PUT /api/kanban/columns Ordered columns; body {order:[id,...]}, complete permutation required
PATCH /api/kanban/columns/{id} Updated column; body {label?,color?}
DELETE /api/kanban/columns/{id} {ok:true,movedTo,moved}; moves cards, never deletes bookmarks; refuses last column
PUT /api/kanban/cards/{id} Moved bookmark; body {columnId,position,version}. Reorders the target column (0-based position, clamped at the end) and normalizes every card's kanbanPosition. Owner only. version is REQUIRED: 400 when missing/invalid, 409 when stale; success bumps the moved card's version
GET /api/pages?search=... Pages, pinned first, with parentId and _count.{bookmarks,children}; title/content substring search treats % and _ literally and uses trigram indexes
POST /api/pages 201 page; body {title?,content?,parentId?}. parentId must be an owned page; a missing/foreign parent is 400
PUT /api/pages {ok:true}; complete {order:[id,...]}
GET /api/pages/{id} Page plus ordered bookmarks:[{bookmark}], owner-scoped backlinks:[{id,title,updatedAt}], resolved outgoing links:[{id,title}], and embeds:[{collectionId,viewId,position,collection,view}]
PATCH /api/pages/{id} Updated page; {title?,content?,pinned?,icon?,parentId?,version?,addBookmarkId?,removeBookmarkId?}. version (the base revision from GET) is REQUIRED with content: 400 when missing, 409 when stale. Metadata-only patches (including moving a page) stay version-free and do not bump the revision. Moving under itself or a descendant is rejected 400 (no cycles)
GET /api/pages/{id}/backlinks Owner-scoped [{id,title,updatedAt}] from PageLink — the pages whose Markdown links here
GET /api/pages/{id}/revisions Version history metadata [{id,version,title,icon,createdAt,length}], newest first; bodies fetched separately
GET /api/pages/{id}/revisions/{revisionId} Full immutable revision snapshot {...,content}
POST /api/pages/{id}/revisions/{revisionId}/restore Restores a snapshot as a new version; body {version} (current base) — 400 missing, 409 stale. The restore itself is recorded in history
DELETE /api/pages/{id} Moves the page to Trash; keeps bookmarks and promotes child pages. Restoring also restores its revisions and creates a new revision
GET /api/pages/{id}/markdown Markdown attachment

Connected pages (phase 3)

Page hierarchy, internal links and live collection embeds are ordinary Markdown contained in the page body; the server rebuilds the resolved graph in the same transaction as every content save:

  • [label](computer:page/<pageId>) links to another page. /pages?id=<id> and page/<id> are accepted as hand-written aliases. Only owned targets are stored, so a backlink can never reveal another account's page title; self-links are ignored.
  • {{collection:<collectionId>}} embeds the collection's live default view; {{collection:<collectionId>|view=<viewId>}} pins a saved view's filters/sorts. Only owned collections are stored, and item reads still go through the normal collection endpoint, so an embed displays data without publishing it or widening bookmark access.
  • Deleting a page removes its links/embeds, promotes its children, and keeps its bookmarks. Deleting a collection removes its embeds; deleting a view falls the embed back to the collection's default view.

Trust (phase 4)

Deleting a bookmark, page or collection now moves it to the owner's Trash. The snapshot is taken in the same transaction as the hard delete and includes intrinsic content plus owner-scoped links (tags, page links, collection memberships, page revisions). It deliberately excludes access grants: bookmark public flags, bookmark/tag shares and collection shares are never restored, so revoking access while an item is in the Trash cannot be undone by restoring it. Deleted bookmarks keep their existing delete endpoints; the UI and docs now call this “move to trash”.

Method Path Returns / behavior
GET /api/trash Owner's deleted items [{id,type,originalId,title,deletedAt}], newest first
POST /api/trash/{id}/restore Restores the item; 404 for another owner. Page restores recreate their history and append a new revision. Neither bookmark isPublic nor any share is restored
DELETE /api/trash/{id} Permanently removes one Trash entry (explicit purge)
GET /api/export Owner-scoped structured JSON archive: user, bookmarks, tags, pages (content + revisions), collections (properties, views, items, shares) and contacts. Credentials are never exported

Collections

Contextual workflows over existing bookmarks (phase 2 of the Notion-like upgrades). Collections are owner-scoped unless explicitly shared; membership references bookmarks without owning them — removing an item or a whole collection never touches the bookmark. An explicit CollectionShare gives a recipient read-only access to the collection and the narrowed bookmark projection for member bookmarks owned by the collection owner. A bookmark that the collection owner can only see through someone else's share is omitted, so collection sharing never passes a third-party grant onwards. Revoking the share or removing the item removes that access immediately, and mutations remain owner-only. Items whose bookmark the caller cannot see are omitted from lists/counts and behave as not-found for mutations; re-sharing restores the membership. Every mutating PATCH requires the expected version (400 missing, 409 stale). Indirect mutations advance revisions too (review CR-09): item reorder bumps every affected item's version, and property deletion bumps the versions of items whose value it dropped and views whose filter/sort definition it changed. Deletions are idempotent removals and take no revision — they never write state computed from a client snapshot.

Method Path Returns / behavior
GET /api/collections Owned and explicitly shared collections with shared, owner, _count.{items,properties,views}; item counts include only bookmarks the caller can see
POST /api/collections 201 detail {...collection,properties:[],views:[default Table view]}; body {name,description?,icon?}
GET /api/collections/{id} Readable collection detail (owner or share recipient) plus ordered properties, views, authorized _count, shared; owner also gets sharedWith
PATCH /api/collections/{id} Updated collection; {version,name?,description?,icon?}, bumps version; owner only
DELETE /api/collections/{id} Moves the collection to Trash; memberships/properties/views cascade, bookmarks kept; owner only
POST /api/collections/{id}/share Owner only; {userIds:[...]} grants read-only access, idempotent
DELETE /api/collections/{id}/share/{userId} Owner only; revokes read-only access immediately
POST /api/collections/{id}/properties 201 property; {name,type,config?}. Types: TEXT,NUMBER,SELECT,MULTI_SELECT,DATE,CHECKBOX,URL (RELATION reserved, rejected). SELECT config {options:[{label,color?}]} — the server assigns stable option ids; DATE config {mode:"date"|"datetime"}. Duplicate name → 409
PATCH /api/collections/{id}/properties/{propertyId} Updated property; {version,name?,position?,config?}. Type and date-mode changes are rejected (400) — conversion must never destroy values. Option updates send the full list: existing ids rename/recolor (identity preserved), omitted ids are removed but refused (400 with the using-item count) while any item still carries them
DELETE /api/collections/{id}/properties/{propertyId} {ok:true}; strips the property's values from all items and its clauses/sorts/grouping from saved views
GET /api/collections/{id}/items?viewId=&cursor=&limit= Items {...item,bookmark} (bookmark uses the permission-specific projection), visible bookmarks only, ≤limit (default/max 200) with X-Next-Cursor. With viewId: the view's filters (AND/OR over typed operator allowlists) and its FIRST sort key run server-side; ordering always tie-breaks on item id. Without viewId: canonical manual order (position,id)
POST /api/collections/{id}/items 201 item; {bookmarkId,propertyValues?}. 400 if the bookmark is not currently accessible; duplicate membership → 409. Values are validated per property type; DATE values store canonically (YYYY-MM-DD or fixed-width UTC RFC3339) so comparisons never shift across timezones
PUT /api/collections/{id}/items {ok:true}; complete {order:[itemId,...]} permutation
PATCH /api/collections/{id}/items/{itemId} Updated item; {version,propertyValues?,position?}. propertyValues merges: omitted keys untouched, null removes a value, unknown property ids 400
DELETE /api/collections/{id}/items/{itemId} {ok:true}; membership only, bookmark untouched
POST /api/collections/{id}/views 201 view; {name,type?,filters?,sorts?,groupByPropertyId?,displaySettings?}, types TABLE,BOARD,CALENDAR,LIST (default TABLE)
PATCH /api/collections/{id}/views/{viewId} Updated view; {version,name?,type?,position?,filters?,sorts?,groupByPropertyId?,displaySettings?}
DELETE /api/collections/{id}/views/{viewId} {ok:true}

Filter shape: {operator:"AND"|"OR",clauses:[{propertyId,op,value?}]} (max 20 clauses; no nested groups). Operator allowlists per type: TEXT/URL eq,ne,contains,empty,notEmpty; NUMBER/DATE eq,ne,gt,gte,lt,lte,empty, notEmpty; SELECT/CHECKBOX eq,ne,empty,notEmpty; MULTI_SELECT contains, empty,notEmpty. Sorts: max 3 {field,dir} where field is position, addedAt, title or a sortable property id (not multi-select); only the first drives server-side ordering/pagination in this phase. ne matches missing values (IS DISTINCT FROM). Property ids and values are bind parameters end to end — filter JSON never reaches SQL text. Collection sharing does not exist yet; views/counts can only expose what the owner can already see.

Signals and integrations

Method Path Returns / behavior
GET /api/bookmarks/{id}/signals {sources,signals,types}, newest 50 events
POST /api/bookmarks/{id}/signals 201 watcher for {type:"email",config:{from?,subject?}}; {checked,created} for {action:"check"}
PATCH /api/signal-sources/{id} Updated watcher for {enabled:boolean}
DELETE /api/signal-sources/{id} {ok:true}
GET /api/alerts/check Undismissed bookmarks due within 15 minutes, soonest first
DELETE /api/alerts/{bookmarkId} {ok:true}; sets alertSent
GET /api/contacts Synced contacts with _count.bookmarks
POST /api/contacts/sync {synced,removed}; {nextcloudUrl,username,password,cardDavPath?}. Mirrors the configured address book: contacts previously imported from the SAME source URL that the server no longer returns are removed (removed), but only on a complete, non-empty snapshot — partial or empty responses never delete. Manually created contacts and other address books' imports are untouched; bookmarks keep working (contactId is SET NULL)
GET /api/settings/imap Configuration with masked password, or null
POST /api/settings/imap {ok:true}; {host,port?,tls?,username,password,folder?}; empty/masked password preserves an existing password. port must be 143 or 993, and TLS is required outside the test/LAN flags
POST /api/imap/test {ok:true} after real connection/login; same mailbox fields and port rules
GET /api/imap/check {matches:[{bookmarkId,title,from,externalId,occurredAt}]} for legacy watchers; if a search fails after finding matches, the partial matches are returned with an error field
GET /api/shared-dates Created/received dates with creator and recipient
POST /api/shared-dates 201 array of created shares for {date,note?,recipientIds:[...]}
DELETE /api/shared-dates/{id} {ok:true}, creator only
GET /api/users/search?q=... Safe user projections; minimum two characters, maximum ten results

Email signals are deduplicated by source and Message-ID. Failed mailbox checks are recorded on lastStatus rather than reported as successful email matches; messages fetched before a partial failure are still persisted (the checkpoint does not advance, so the failed batch is retried and deduplicated). Sharing a tag or date exposes associated bookmarks read-only; direct-share revocation does not remove access independently granted by a tag/date share.

Administration

Always uses the real user, even while viewing another account.

Method Path Returns / behavior
GET /api/admin/users Safe user projections with bookmark/page/tag counts; excludes @example.com test users
PATCH /api/admin/users {ok:true} for {userId,role:"USER"|"ADMIN"}; refuses self-demotion
POST /api/admin/view-as {ok:true}; {userId} sets an eight-hour HttpOnly cookie, {userId:null} clears it

Route registration: internal/http/server.go. Only the client-owned OIDC login/callback remain stubs; application data routes use PostgreSQL.