High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
21 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.
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/<pageId>)links to another page./pages?id=<id>andpage/<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:[...]}. 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.