# 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. Request bodies over 1 MiB answer **413**. Rate-limited endpoints answer **429** with `Retry-After`; public bookmark lookups are limited per effective client and per bookmark id, and previews per client. Share recipient lists are capped (100 per bookmark/tag/collection share, 200 per shared date). ## 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; same-origin `Origin` required when present | | GET | `/api/me` | `{user,effectiveUser,isAdmin,viewingAs}`; real and effective identities | | GET | `/api/auth/oidc/status` | `{enabled,issuerSet,clientIdSet,clientSecretSet}`; never includes secrets or the issuer URL | | 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. Login and signup are throttled per client IP (429 with `Retry-After`), plus a consecutive-failure lockout per IP+email on login. Login applies to every peer, loopback included; signup exempts only a direct loopback peer. 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`, timestamps, `tags` and `owner:{id,name}` — never the owner's contact record, the recipient list, alerts, mailbox watches or board placement (`kanbanColumnId`/`kanbanPosition`). The legacy `kanbanStatus` badge IS included and is status, not placement; it is documented here so the projection and UI agree. 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/)` links to another page. `/pages?id=` and `page/` 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:}}` embeds the collection's live default view; `{{collection:|view=}}` 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:[...]}`. Sharing a date also grants read-only access to the creator's bookmarks due that day (including notes); the UI discloses this. Re-sharing the same date updates its note. | | 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.