Skip to content

feat(lists): list folders (/api/folders) with a client-side cycle guard - #180

Merged
Adron merged 2 commits into
mainfrom
issue-63-list-folders
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-63-list-folders

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #63

An entirely missing domain. The app implements document folders (/api/documents/folders) but not list folders — a separate resource with different endpoints and ids, despite the similar shape.

All four operations verified live against throwaway folders (since deleted, account confirmed restored):

GET    /api/folders      -> 200 {"folders":[{id,name,parentId}, …]}   // FLAT
POST   /api/folders      -> 201 {"message":"Folder created successfully","folder":{id,name,parentId}}
PUT    /api/folders/{id} -> 200 {"message":"Folder updated successfully","folder":{…}}
DELETE /api/folders/{id} -> 200

The payload is exactly {id, name, parentId} — no timestamps, no userId, no server-side nesting. The tree is built client-side.

⚠️ The important finding: the server does not guard against cycles

Moving a folder into its own child returns 200 and creates a genuine loop. I verified it by doing exactly that:

PUT /api/folders/{parent}  {"parentId": "{its own child}"}
  -> 200 {"message":"Folder updated successfully",
          "folder":{"id":"ae78480c…","parentId":"aceec8c9…"}}   ← cycle created

(Then repaired, and both folders deleted.) Nothing server-side prevents this, so a tree renderer walking parentId would recurse forever.

Two defences, because one isn't enough:

  1. WouldCreateCycle walks the ancestor chain before any move and refuses it client-side. Not defensive politeness — it's the only thing standing between a move and an unrenderable tree.
  2. The tree build is itself cycle-tolerant. Nodes are placed at most once; anything left over sets HasOrphanedFolders and the panel says "Some folders form a loop and aren't shown. Move one of them to the top level to fix it." Because the server permits cycles, the data can genuinely already contain one — hanging or silently dropping folders would both be wrong.

The guard is bounded by the folder count, so it terminates even on already-looped input.

Harness results

move   under  blocked   expected  case
a      b      True      True      ok  into its own direct child — THE case the server allows (200)
a      d      True      True      ok  into a deep descendant
a      a      True      True      ok  into itself
d      a      False     False     ok  up the tree — legitimate
b      x      False     False     ok  to an unrelated branch
a      null   False     False     ok  to root
a             False     False     ok  to root (empty string)
d      zzz    False     False     ok  under an unknown id — no loop possible

pre-existing cycle in data: blocked=True in 0ms (must terminate)
ALL CHECKS PASSED

Also verified — relevant to #179

folderId is not among the eleven fields POST /api/lists accepts, so a list cannot be created straight into a folder. PUT /api/lists/{id} with {"folderId": …} does work (200, reflected on re-read) and {"folderId": null} clears it — exposed as SetListFolderAsync.

I found this by assigning the account's real New list to a probe folder, which I then had to undo. It's restored (folderId: None), confirmed by a closing GET.

Smaller decisions

  • Create is subscriber-gated — 402/403 renders as "Creating list folders is a Subscriber feature" rather than a bare status code.
  • Delete cascades to subfolders, which the endpoint name doesn't convey, so it's two-step and the armed label reads "Delete this and its subfolders?".
  • parentId is omitted rather than sent as null on create, since an explicit null is a 500 on some writers on this API (Settings: "Save profile" has never worked — it sends theme: null and gets HTTP 500 #171).

Additive only

New model, new InterlinedApiClient.ListFolders.cs partial, self-contained ListFolderPanel. ListsView is being reworked across #161/#163/#164, so hosting is a one-line add afterward:

<local:ListFolderPanel x:Name="ListFolderPanelHost"/>
  • dotnet build -c Debug — green. dotnet build -c Release — green.

🤖 Generated with Claude Code

An entirely missing domain. The app implements DOCUMENT folders
(/api/documents/folders) but not LIST folders — a separate resource with
different endpoints and ids, despite the similar shape.

All four operations verified live 2026-09-16 against throwaway folders, since
deleted and the account confirmed restored:

  GET    /api/folders      -> 200 {"folders":[{id,name,parentId}, …]}   // FLAT
  POST   /api/folders      -> 201 {"message":"Folder created successfully",
                                   "folder":{id,name,parentId}}
  PUT    /api/folders/{id} -> 200 {"message":"Folder updated successfully","folder":{…}}
  DELETE /api/folders/{id} -> 200

The payload is exactly {id, name, parentId} — no timestamps, no userId, and no
server-side nesting. The tree is built client-side from parentId.

*** The important finding: the server does NOT guard against cycles. ***

Moving a folder into its own child returns 200 and creates a genuine loop —
verified by doing it (and then repairing it). Nothing server-side prevents this,
so a tree renderer walking parentId would recurse forever.

Two defences, because one isn't enough:

1. InterlinedApiClient.WouldCreateCycle walks the ancestor chain before any
   move and refuses it client-side. This is not defensive politeness; it is the
   only thing standing between a move and an unrenderable tree.
2. The tree BUILD is itself cycle-tolerant — nodes are placed at most once and
   anything left over sets HasOrphanedFolders, so data that already contains a
   loop (possible, since the server allows it) shows a message explaining how
   to repair it instead of hanging or silently dropping folders.

The guard is bounded by the folder count, so it terminates even when the input
already contains a cycle — verified (0ms on a 2-node loop).

Also verified, and relevant to #179: folderId is NOT among the eleven fields
POST /api/lists accepts, so a list cannot be created straight into a folder.
PUT /api/lists/{id} with {"folderId": …} does work (200, reflected on re-read)
and {"folderId": null} clears it — exposed as SetListFolderAsync.

Create is subscriber-gated; 402/403 renders as "Creating list folders is a
Subscriber feature" rather than a bare status code. Delete cascades to
subfolders, which the endpoint name doesn't convey, so it is two-step and the
armed label says "Delete this and its subfolders?".

Additive only: new model, new InterlinedApiClient.ListFolders.cs partial, and a
self-contained ListFolderPanel with its own ViewModel. ListsView is being
reworked across #161/#163/#164, so hosting is a one-line add afterward.

Verified by harness: the live payload parses; the cycle guard blocks
into-own-child / into-deep-descendant / into-itself and permits
up-the-tree / unrelated-branch / to-root / unknown-parent; and it terminates on
pre-looped data. Build green in Debug and Release.

Closes #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Lists: list folders (/api/folders) — a separate domain from document folders

1 participant