Skip to content

feat(shares): shared tree, the navigation of a share link (#1252, 4/n) - #1797

Open
lboehm wants to merge 3 commits into
perber:mainfrom
lboehm:feat/1252-shared-tree
Open

lboehm wants to merge 3 commits into
perber:mainfrom
lboehm:feat/1252-shared-tree

Conversation

@lboehm

@lboehm lboehm commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Related issue

Part of #1252 (PR 4 of the agreed series; does not close it).

What changed

The navigation for a share link, as you outlined PR 4: root-only vs subtree, no ancestors above the root, moved and new descendants.

Commit 1: feat(tree), TreeService.SubtreeRoutes(id)

  • Returns a node followed by its descendants, depth-first in navigation order, as RouteEntry values from one walk under the read lock, without file reads. Building the subtree from several ChildRoutes calls would race with a move between the calls.
  • PageRoutes and SubtreeRoutes share the walk.
  • RouteEntry gets ParentID ("root" for the top level), so a caller can nest the flat list without walking PageNode.Parent outside the lock. RouteEntry is never serialised as it is; both existing consumers map it explicitly.
  • The tree root is not a subtree here (ErrPageNotFound); PageRoutes covers the whole tree. Like IsInSubtree in PR 1, it is a question about structure, not visibility.

Commit 2: feat(scopedread), TreeReader.Tree(ctx, scope) (the shape from your proposal, ADR-0015)

  • The scope root is on top; nothing above or beside it.
  • Subtree scope: every page below the root as the tree is now. Pages created or moved in appear; pages moved out disappear.
  • Single-page scope: the root alone with empty children, so the client has one code path and the subpages are not revealed.
  • TreeNodeView is an allow-list like PageView: id, title, kind, children (never null). No slug or path (they would reveal the tree above the scope), no position, pin state, authors or timestamps. TestTreeNodeView_FieldsAreAllowListed guards it.
  • An unknown or deleted root is ErrNotFound; operational errors pass through, with the same "never send the text to a scoped caller" rule.

Commit 3: feat(shares), GET /api/shared/{token}/tree

  • Same capability group (ADR-0019) as the page routes, so the same identity exemption, hardening headers and log redaction.
  • Same answers as the page routes: one 404 body for every failure (including a link whose page was deleted), 503 during a restore, a generic 500 otherwise.
  • One limiter for the slice: failed lookups on /tree and on the page routes count towards the same per-client limit, so a second route doesn't double the budget.
  • From the PR 3 review notes: with a second use case, the grant → scope mapping and the scoped-read error mapping move into resolveScope and scopedReadError, and the handlers share serveShared.
  • Swag annotations, docs/api regenerated (additions only), scopedread.TreeNodeView in documentedModels.

Size: 268 lines of production code. ADR-0015 is updated: TreeNodeView's allow-list in decision 5, SubtreeRoutes next to IsInSubtree in decision 8.

Decisions worth a look:

  • No slug in the tree. The public view can navigate by ID (/pages/{id} exists). If the UI in PR 7 wants readable relative paths, a relative slug can be added without breaking the shape.
  • No size limit, like /api/tree. A link on a very large section returns the whole section. A valid token is needed, nothing is read from disk, and the read lock is held only for the in-memory walk; nesting and encoding happen outside it. If this ever matters, a short per-link cache or a concurrency cap on /shared would be the place.
  • A single-page link still walks its subtree in memory and keeps only the root. That is simpler than a second core read; a limit on SubtreeRoutes would avoid it if needed.

Noted in review, not changed here (both already apply to the page routes from PR 3):

  • A success resets a client's failed-lookup count, so someone holding one valid link can interleave it with guesses. With 256-bit tokens the limit is about signalling a misbehaving client, not about guessing odds; I'd rather keep valid links unthrottled than change that here.
  • A request cancelled by the client is logged as a failed read and answered 500.

Not in this PR: scoped assets and link safety (PR 5), the remaining items of PR 6, the UI (PR 7).

How I tested it

  • internal/core/tree:
    • SubtreeRoutes order (after SortPages), parent IDs, errors (not loaded, unknown, empty, tree root);
    • concurrent moves, where every read sees a page either inside or outside, and parents always come before children.
  • internal/scopedread:
    • nesting for single-page and subtree scopes, the exact JSON, and the field allow-list;
    • a 1000-level chain and a 200-wide level, from a fake source with no files;
    • invalid scopes, which never touch the tree, and the error mapping;
    • against a real tree:
      • siblings and ancestors are absent;
      • pages created later and moved in appear, and a page moved out, including one moved above the root, disappears;
      • the order follows SortPages;
      • the shared root moved to the top level, renamed or converted stays on top with nothing of its old ancestors;
      • a deleted root is ErrNotFound.
  • internal/wiki/shares: the grant → scope mapping for both link kinds, the error mapping, the exact response body, and a 500 that never carries the cause.
  • internal/http (real router):
    • a subtree link, a single-page link, and a nested shared page with nothing above it;
    • hardening headers;
    • create, move in and move out through the API;
    • the 404 for an unknown token and for a deleted page is byte-identical to the page routes' 404;
    • failed lookups across /page and /tree share one limit;
    • the access-log test from PR 3 now also covers /tree shapes (trailing slash, token in the query, HEAD, POST).
  • An independent test pass tried to break the route (concurrent moves, deep trees, odd request shapes) and found no bug. Its unique cases are folded into the commits above; duplicates were dropped.
  • API docs and route coverage tests pass with the regenerated spec, and the architecture test passes.
  • golangci-lint: no new findings (only the existing SA4023 in internal/wiki/restore/routes.go).
  • Not run locally: go test -race is unsupported on Windows/arm64. The concurrency tests are written for -race in CI.
  • Manual testing: none, because there is no UI.

Checklist

  • I discussed the approach on the linked issue before starting (or this is a trivial fix: typo, docs, obvious one-liner)
  • This PR is focused on a single change
  • New behaviour plugs into an extension point; no new if feature branches in shared code (rule 1)
  • I updated documentation if needed
  • For UI changes: behaviour is covered by automated tests where possible (rule 11)
  • For UI changes: I checked by hand what tests can't cover (look in light/dark mode, mobile width where relevant)
  • AI tools were used for this PR (name them: Claude Code). I have reviewed and understand every line

TreeService.SubtreeRoutes returns a node followed by its descendants,
depth-first in navigation order, as RouteEntry values computed under the
read lock without file reads. Assembling a subtree from several ChildRoutes
calls would race with moves between the calls. PageRoutes and SubtreeRoutes
share one walk.

RouteEntry gets ParentID ("root" for the top level), so a caller can nest
the flat list without walking PageNode.Parent outside the lock. The tree
root is not a subtree here: PageRoutes covers the whole tree.

Refs perber#1252
TreeReader.Tree(ctx, scope) returns the scope as a tree: the scope root on
top, nothing above or beside it, children in navigation order. A subtree
scope holds every page below the root as the tree is now, so pages created
or moved in appear and pages moved out disappear. A single-page scope is
the root alone with no children, so its subpages are not revealed.

It reads TreeService.SubtreeRoutes, one locked walk, and nests the flat
list by ParentID. TreeNodeView is an allow-list like PageView (id, title,
kind, children): no slug or path, which would reveal the tree above the
scope, and no position, pin state, authors or timestamps. An unknown or
deleted root is ErrNotFound; operational errors pass through.

ADR-0015 records TreeNodeView's allow-list and SubtreeRoutes as a second
structural query on the tree.

Refs perber#1252
GET /api/shared/{token}/tree returns the shared page as the top of a
scopedread.TreeNodeView tree. A link that includes subpages holds every
page below it as the tree is now, so pages created or moved in appear and
pages moved out disappear; a single-page link returns the page alone with
no children. Nothing above or beside the shared page is included.

The route sits in the same capability group as the page routes and answers
the same way: one 404 body for every failure, 503 during a restore, a
generic 500 otherwise. Its failed lookups count towards the same
per-client limit as the page routes'.

With a second use case, resolving the token to a scope and mapping
scoped-read errors move into resolveScope and scopedReadError, and the
handlers share serveShared.

Refs perber#1252
@lboehm
lboehm requested a review from perber as a code owner November 10, 2026 14:35
@lboehm lboehm closed this Nov 10, 2026
@lboehm lboehm reopened this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant