# 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 `_count.bookmarks`; title/content substring search treats `%` and `_` literally and uses trigram indexes | | POST | `/api/pages` | 201 page; body `{title?,content?}` | | PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` | | GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]` | | PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches stay version-free and do not bump the revision | | DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks | | GET | `/api/pages/{id}/markdown` | Markdown attachment | ## 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.