Files
brain/API.md
T
Andrew S Erwin 9db69efe4b
release gate / check (push) Failing after 7m7s
Add connected pages: hierarchy, backlinks, and live embeds
Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
2026-10-19 22:37:21 -05:00

18 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)
DELETE /api/pages/{id} {ok:true}; keeps bookmarks and promotes child pages to the top level
GET /api/pages/{id}/backlinks Owner-scoped [{id,title,updatedAt}] from PageLink — the pages whose Markdown links here
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.

Collections

Contextual workflows over existing bookmarks (phase 2 of the Notion-like upgrades). Collections are owner-scoped; membership references bookmarks without owning them — removing an item or a whole collection never touches the bookmark. Membership never broadens access: 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 collections with _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} Collection plus ordered properties, views and authorized _count.{items,properties,views}
PATCH /api/collections/{id} Updated collection; {version,name?,description?,icon?}, bumps version
DELETE /api/collections/{id} {ok:true}; memberships/properties/views cascade, bookmarks kept
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.