Add trust features: history, trash, export, and collection sharing
release gate / check (push) Failing after 6m36s

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.
This commit is contained in:
Andrew S Erwin
2026-10-19 23:07:38 -05:00
parent 9db69efe4b
commit 5eab58f99c
22 changed files with 1496 additions and 76 deletions
+40 -11
View File
@@ -116,8 +116,11 @@ test-only). CardDAV keeps the configured HTTPS port.
| 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}/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)
@@ -139,15 +142,39 @@ in the same transaction as every content save:
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; 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
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
@@ -156,11 +183,13 @@ 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 |
| 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}` | 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 |
| 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 |