what a host-account module needs from the schema — role-hidden modules, a multi-line input, a status that says why #33
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/schema-guards"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Rebased onto main, and current main merged in since (
aa90aaf..7108fb3) — the head under review is now the tree CI and the merge commit will run. What main landed in the meantime changed what is left here, so the description is rewritten rather than patched — see What main absorbed at the end.Everything below is implemented, tested, and verified in a browser (the caveat on the last two rounds is in Verification). The module it exists for is
OpsDeck/module_users, written against exactly this contract.1. A module can be hidden from the sessions that may not use it
Mounting a module's router under
/api/mod/<name>gives it authentication, not authorization. A module that guards every route withrequireRole("admin")was still listed by/api/core/modulesfor everyone: a viewer got a nav entry, a palette entry, and a page that answered 403 to every request it made. The module was locked, not hidden — and for the modules where this matters most (host files, host accounts) the list of what exists is itself worth hiding. The filebrowser hit this too.Two declarations decide whether the module is visible at all, resolved together in
modules/visibility.ts:nav.role, which also covers a module with a custom web frontend — the shell imports and renders that bundle itself, so there are no schema pages to inspect;role, which already existed in the validator and was enforced nowhere. A module whose every page is admin-only is admin-only whether or not its manifest remembered to say so./api/core/modulesdrops what the session may not see, and the schema route serves it projected. A schema whose root page did not survive is not served at all — every client resolves a module by its root page, so half a schema is a first navigation into a 404. That route answers 404 rather than 403 below the role, since a route map is what hiding a module hides.The projection hides the whole route map, not only the pages (the third review round's finding 2 and 3):
page:action, a list item'slink, a page'sparent, a form'ssuccessPage. Left in, they render a control whose destination has no route, and the shell answers that with "Page not found" — strictly worse than the access-denied screen the viewer used to get, and no client can repair it, because the page'sroleleft with the page.submitand aterminalblock each carry arolethat until now only ever hid client-side, so the paths inside them were served to exactly the session the projection was hiding the route map from. They are projected server-side now; the renderer keeps its own check, which is what a module gets from an older server.blocksismin(1): an emptied page (or actions block) is not a smaller schema, it is one a validating client rejects whole.Dropping a page can empty another one, so the projection runs to a fixpoint. Link targets are resolved against every page and then checked for survival, with the router's own static-beats-param precedence: a target whose page is gone must not fall through to a laxer pattern that happens to still be registered, which is a link that silently navigates somewhere else. A target matching no page at all is left alone — that was broken before any projection existed, and swallowing it hides an authoring bug rather than a page.
Filtering server-side is what makes one change hide the module in the web shell and the Android app at once.
All of it hides; it does not authorize — routes keep their own guards, and the docs say so twice, because a module that sets
nav.roleand guards nothing has an API open to anyone who types the URL.2.
textarea— the multi-line input kindtextis one line. Pasting an SSH key, a config file, or anything with newlines meant a module either mangled it or wrote a custom Svelte page — and a custom frontend is all-or-nothing per module, so one multi-line field cost every page on the phone. Renders monospace withwhite-space: pre; a base64 blob wrapped mid-token is unreadable.The same commit put
input[type="password"]into the schema form's styled-control list, where it had never been:passwordwas already a rendered input kind, so a secret field sat in a browser-default box next to styled text ones. Named here because it rode in unannounced (third round, finding 5); a comment inFormBlock.sveltenow says the same thing.3. A form can carry a confirmation, and both kinds interpolate
An action gained
confirmPhraseon main while this branch was open (see below). A form'ssubmithad neither it nor an interpolated message, so the same operation was guarded on a button and unguarded on a form:confirmnow interpolates route params in both blocks — it rendered{name}literally before — and one shared schema covers the phrase on an action and on a form, including the rule that a phrase needs aconfirmto say why it is locked.A
{param}the page does not declare is now a load-time error. Everything else that interpolates fails loudly — a bad param in a path is a 404 somebody sees — but a message interpolates to a hole ("Deletes and its home directory") and an unresolved phrase degrades to asking the operator to type{name}literally, on exactly the actions chosen as too dangerous to answer by reflex. A phrase of nothing but whitespace is refused for the same reason: it renders a box that an empty answer satisfies — and that rule holds for a resolved phrase too, not only an authored one.4. A status can say why
UiStatus.title— a dot-path to the sentence behind the verdict. An account isLOCKEDand the reason (no password set,expired 3 days ago) is computed server-side, so no client can re-derive it; a colour whose reason nobody can interrogate is a colour people learn to ignore.Tooltip(new in@opsdeck/ui) andHealthDot'sexplainprop — because that sentence cannot go intitle="". The native tooltip is unstyled, waits about a second, truncates differently per platform, never appears on a phone, and is drawn outside the page, so it cannot be screenshotted or tested. Positionedfixedfrom the trigger's rect so it escapes a card'soverflow: hidden, flipped by available room, dismissed on Escape, shown on keyboard focus as well as hover. Its window listeners — scroll, resize and now Escape — live only while it is open (third round, finding 4): a table of 200 rows withexplainset was 200 permanent keydown listeners.Icon:usersandkey— a module managing accounts and SSH keys had no honest nav glyph.CodeListBlock— the file name moved out ofCard's title (small, muted, uppercase: right for "KEYS", wrong for the one string somebody scans the list for) into the card body, and anotenow renders alongside content as a caption. It used to appear only when content was missing, so a module could describe a file it could not show and say nothing about one it could — which is the blockauthorized_keysis rendered with.Verification
deno fmt --check,deno lint,deno task check,deno task check:svelte(0 errors), 546 server/UI tests + 3 builder tests, all green on the pushed head — including the threemodule_listing_test.tscases that failed the leak sanitizer at the old base, which main's1763b56fixed and this merge picks up.visibility_test.ts— the page projection, plus the link-into-a-dropped-page cases (listlink,page:action,parent,successPage), router precedence (static beats param), a link matching no page at all, role-gated action / form / terminal, a page the projection emptied, a root page the projection emptied.module_listing_test.ts— the whole app with a real session store (auth-disabled mode cannot express this, its dev user holding both roles), including one case that asserts on the served BYTES: no/wipe, noaccount/{name}in what a viewer is handed.source, renders monospace with no soft wrap, and a three-line value round-trips through the form verbatim. The delete dialog opens with the message and the phrase both interpolated (Deletes alice and its home directory., asks foralice), focus in the phrase box, Confirm locked;alicstays locked,aliceunlocks (both sides are trimmed), and confirming runs the action and closes the dialog.module_listing_test.tsasserts on the response a real session receives from the real app, which is what every client renders from. The tooltip change moves an existing handler between two registration sites and svelte-check reads the template.What main absorbed
confirmPhraseon an action landed on main independently while this was open — the declaration, the validator rule,resolveConfirmPhrase,ConfirmDialog's phrase field and its three-stop keyboard trap. The conflicts resolved toward main's version insdk/ui.ts,ui-schema.ts,ActionsBlock.svelteandConfirmDialog.svelte, and this branch's duplicate of all of it is gone: what remains of the typed confirmation is section 3. Two review findings dissolved with it — main's phrase resolution already fails closed on an unresolved placeholder, and its ceiling was already 120 rather than 64 (the form's now matches it). The later merge brought the rate-limit sweep off its timer (1763b56), which is what makes the new whole-app tests pass the leak sanitizer.Review findings, addressed
First round:
{param}— a validator cross-check, per the second of the two suggestions, since this is only ever wrong at authoring time.roleAllowstwice — one dependency-freepackages/sdk/roles.ts. The server imports it as@opsdeck/sdk; the browser side reaches the source file by relative path, because@opsdeck/sdkis not one of the shared bare specifiers and a bundled import of it would not resolve.Math.random()tooltip id — a module-scope counter: unique by construction rather than unlikely to collide, which is the propertyaria-describedbyneeds.Second round:
bbba10f) —++seqruns at instance init, so a remount takes the next number exactly as a random suffix would. The claim now says only what the counter guarantees.aa90aaf) —resolveConfirmPhrasereturns the phrase trimmed, and the dialog derives the box, the focus target, the Tab stops and the match from one trimmed value, so "there is a box" and "the box can be satisfied" stop being separate questions inside the component.Third round:
e5c9143). 546 + 3 green on7108fb3.d000828) — projected, per the suggestion: the server knows exactly which paths it removed. Covers all four ways in, runs to a fixpoint, and resolves targets with the router's own precedence. Section 1 has the reasoning.docs/modules.mdclaimed more than the projection did (d000828) — the claim is now true rather than reworded: actions, form submits and terminal blocks are projected server-side too. The doc lists what is dropped, including the emptied-page rule and the deliberate exception (a link matching no page).<svelte:window on:keydown>(7e913b1) — gone; Escape registers in the existingopeneffect with scroll and resize, so a closed tooltip holds no window listener.input[type="password"]drive-by (7108fb3) — named in section 2 and commented in the file. Kept: it is the same list of styled controls the textarea joined, andpasswordwas already a rendered kind sitting there unstyled.Follow-up, not in here
confirmand ignoresconfirmPhraseandtextarea— a separate PR inOpsDeck/mobile. Falling back to the plain confirmation is the intended degradation, and the route is the real guard. It gains the projected schema for free, since the filtering is server-side.deno task testpasses--ignore=packages/module-builder, and a CLI--ignorereplacesdeno.json'sexcluderather than adding to it — so the.claudeanddataexclusions that every other task honours are silently off for the test task, and locally it walks other branches' worktrees and cloned external modules. CI never sees it. Not touched here; it wants its own one-line PR.Mounting a module's router under /api/mod/<name> gives it authentication, not authorization. A module that guards every route with requireRole("admin") was still listed by /api/core/modules for everyone, so a viewer got a nav entry, a palette entry and a page that answered 403 to every request it made. The module was locked, not hidden — and for the modules where this matters most (host files, host accounts) the list of what exists is itself worth hiding. Two declarations now decide it, resolved together in one place (modules/visibility.ts) so no route answers from a different rule: - the manifest's `nav.role`, which covers a custom web frontend too — the shell imports and renders that bundle itself, so there are no schema pages to inspect; - a schema page's `role`, which already existed and was enforced nowhere. A module whose every page is admin-only is admin-only whether or not its manifest remembered to say so. /api/core/modules drops what the session may not see, and the schema route serves it projected: pages above the requester's role are stripped, and a schema whose ROOT page did not survive is not served at all — every client resolves a module by its root page, so half a schema is not a smaller module, it is a first navigation into a 404. That route answers 404 rather than 403 below the role, since a route map is what hiding a module hides. Filtering server-side is also what makes this one change hide the module in the web shell and the Android app at once. Both are HIDING, never authorization: routes keep their own guards, and the docs say so twice, because a module that sets nav.role and guards nothing has an API open to anyone who types the URL.Two additions to the portable UI schema, both found by writing a module that manages host accounts and running out of vocabulary. `confirmPhrase` on an action or a form's submit makes the operator TYPE that text before the button unlocks. `confirm` is a yes/no dialog, which is the right weight for restarting a container and the wrong weight for `userdel -r` or revoking a key across every account. It requires `confirm`: a box demanding a word with no sentence saying why is a puzzle, not a guard. Both it and `confirm` now interpolate route params. `confirm: "Delete {name}?"` rendered that literally before, and a typed confirmation whose phrase is a placeholder would be worse than none — the phrase you want is almost always the subject of the page. `textarea` is the multi-line input kind. `text` is one line, so pasting an SSH key, a config file or anything with newlines meant a module either mangled it or built a custom Svelte page — and a custom frontend is all-or-nothing per module, so one multi-line field cost every page on the phone. It renders monospace with `white-space: pre`, because a base64 blob wrapped mid-token is unreadable. ConfirmDialog grows `requirePhrase`, and its keyboard trap generalises from a swap between two buttons to a cycle over two or three focusables (honouring Shift+Tab, which two elements hid). Focus lands in the box when there is one; Enter there confirms only while the phrase matches, and a locked dialog cannot be confirmed from anywhere. The typed text is cleared when the dialog closes, so an answer to one prompt cannot unlock the next. The demo module exercises both. Drive-by: password inputs in a schema form were unstyled, having never been in the selector list. Verified in a browser against a schema-only module: prefill, a three-line value round-tripping through the form, the dialog refusing a near-miss ("wip"), unlocking on "wipe", and the action then running.Eight findings, all from a review of this branch. Two of them mattered. **The typed confirmation could vanish silently.** `interpolate` substitutes "" for a param the route does not have — a typo in the placeholder, or an actions block on a page without that param — and ConfirmDialog treats an empty phrase as "no phrase": no box, Confirm enabled. The guard added for `userdel -r` degraded to a single click, invisibly. Both call sites now fall back to the literal, so a broken placeholder asks the operator to type "{name}" rather than waving them through. Fail closed. **Forward Tab dead-ended in a locked dialog.** `focusables()` included the disabled Confirm button, and since the trap had already called preventDefault, Tab from Cancel simply did nothing — with the text box only reachable by Shift+Tab. Disabled elements are skipped. **An overview-only frontend was judged by having a frontend.** A module with a dashboard card, no custom pages and an admin-only schema was listed for a viewer with `hasUi: false`, which the shell cannot render — it throws into a console nobody reads, leaving neither nav entry nor card. A schema, when there is one, decides; a frontend with no schema is judged by the manifest. Test added. Tooltip: unique id per instance (two open bubbles shared `id="od-tip"`, so `aria-describedby` resolved to whichever came first in the document); the horizontal clamp applies to the box rather than to its centre, so a left-column tooltip is no longer half off-screen; a capturing scroll listener, because `scroll` does not bubble and a dot inside DataTable's own overflow container left the fixed bubble pinned to a stale rect; and the trigger is no longer `role="button"`, which announced a button that does nothing on Enter. The new `key` icon's keyhole sat on the ring rather than inside it.WIP: what a host-account module needs from the schema — role-hidden modules, a typed confirmation, a multi-line inputto what a host-account module needs from the schema — role-hidden modules, a typed confirmation, a multi-line inputf3426be8404eec13c8e1Reviewed the full diff. The design is sound and the tests cover the claims (viewer vs admin listing, root-page-stripped 404, confirmPhrase-requires-confirm), but two things need action before merge.
1. Merge conflicts — please rebase onto main
The branch is not mergeable. #31 (
feat/ui-access-denied-and-rate) landed after your fork point and touches the same files. Conflicts in exactly four:packages/sdk/ui.tspackages/server/src/modules/ui-schema.tspackages/shell/src/schema/blocks/ActionsBlock.sveltepackages/ui/src/components/ConfirmDialog.sveltePlease rebase onto current main (or merge main in) and re-run
deno task ci— ConfirmDialog and ui-schema changed on both sides, so the resolution is not mechanical.2. Scope beyond the description
The diff also adds
Tooltip.svelte(new, 144 lines) and reworksHealthDot.svelte,Icon.svelte, andCodeListBlock.svelte(file name moved into the card body, note shown alongside content). None of this is mentioned in the PR description, and it isn't covered by #31 on main either. If module_users needs these, fine — but say so in the description so the record matches the diff, or split them out.Minor findings (fix or follow-up, your call)
packages/ui/src/components/ConfirmDialog.svelte: the phrase check trims the typed input but notrequirePhrase(typed.trim() !== requirePhrase). A phrase with leading/trailing whitespace can never match. Trim both sides.packages/shell/src/schema/blocks/FormBlock.svelte+ActionsBlock.svelte: if a{param}inconfirm/confirmPhrasehas no matching route param, the literal{param}is silently kept — the operator would be asked to type{name}. Consider a console warning, or have the validator cross-check placeholders against the path params.packages/server/src/modules/visibility.ts/packages/shell/src/schema/resolve.ts:roleAllowsis implemented twice with slightly different signatures (null vs undefined, readonly vs not). Works, but two codepaths for the same rule will drift — worth a shared helper.packages/server/src/modules/ui-schema.ts:confirmPhrasemax length 64 may be tight once interpolation is in play (long resource names). Consider 128.packages/ui/src/components/Tooltip.svelte: id fromMath.random().toString(36)— a counter orcrypto.randomUUID()is collision-free.No security gaps found: visibility filtering is applied to both the listing and the schema route, the 404-below-role behavior is consistent, and the docs correctly hammer that hiding is not authorization.
4eec13c8e1be7f58a31ewhat a host-account module needs from the schema — role-hidden modules, a typed confirmation, a multi-line inputto what a host-account module needs from the schema — role-hidden modules, a multi-line input, a status that says whyRebased and re-pushed (force,
4eec13c→be7f58a); mergeable again. Description rewritten rather than patched, because the rebase changed what the PR is.Both blocking items:
Rebase — done, onto
cc65b8c. The four conflicts resolved toward main, and that turned out to matter more than a resolution:confirmPhraseon an action had landed on main independently (declaration, validator rule,resolveConfirmPhrase, the dialog's phrase field and its three-stop trap). This branch's duplicate of all of it is gone. What is left of that gap is the form side —submithad neither the phrase nor an interpolated message — plusconfirminterpolating route params, which it did not before. Two of your minor findings dissolved with it: main's resolution already fails closed on an unresolved placeholder, and its ceiling was already 120, not 64 (the form's now matches).Scope — the
Tooltip/HealthDot/Icon/CodeListBlockwork is section 4 of the description now, with what each is for. It is one feature:UiStatus.title, a dot-path to the sentence behind a verdict, which cannot live intitle=""— unstyled, second-long delay, absent on touch, invisible to a screenshot. Kept rather than split, since the schema field and the thing that renders it are the same change.Minor findings:
{param}— took the validator option, not the console warning: this is only ever wrong at authoring time. A browser pass earned its keep here — the first version read only{name}placeholders out of a page path, and real pages spell paramsaccount/:name, so it would have rejected every module that uses the feature. Both spellings count now, with a test for each.roleAllowstwice — onepackages/sdk/roles.ts, dependency-free on purpose: the server imports it as@opsdeck/sdk, the browser side reaches the source file by relative path, since@opsdeck/sdkis not a shared bare specifier and a bundled import of it would not resolve.Verified: fmt / lint / check / svelte-check clean, 500 + 3 tests green, and a headless browser pass over a throwaway schema-only module — textarea prefill and a three-line round trip, then the delete dialog with message and phrase both interpolated,
aliclocked,aliceaccepted, action run.One unrelated thing the run surfaced, left alone here:
deno task test's--ignore=packages/module-builderreplacesdeno.json'sexcludeinstead of adding to it, so the.claudeanddataexclusions are silently off for that one task — locally it walks other branches' worktrees and cloned external modules and fails on their code. CI never sees it. Its own one-line PR, if you want it.Substance verified — visibility projection, validator cross-check, shared roleAllows, both-side trim, tooltip counter id all check out against the claims. Two small items before merge:
Tooltip id is not stable across remounts.
++seqruns at instance init, so a remounted component gets a new id — the module-script comment and the PR description both claim mount-stability the code does not have. Either make the claim match the code (it is "unique by construction", which is the part that matters) or derive the id from something mount-stable.resolveConfirmPhrasecan still return a whitespace-only phrase. The validator refuses an authored one, but the resolved value is what the dialog compares, andConfirmDialog'slockedgate checksconfirmPhrase !== ""untrimmed — a whitespace-only resolved phrase auto-unlocks. Unreachable today (URL segments are non-empty, unresolved keeps the template), but onetrim()at the return closes the class instead of relying on those two facts staying true.Defense-in-depth: return the phrase trimmed (or have the caller treat a whitespace-only phrase as absent). ConfirmDialog's
lockedgate isconfirmPhrase !== ""untrimmed, so a whitespace-only resolved phrase would satisfy the gate with an empty box. Unreachable via route params today, but cheap to close permanently.@ -0,0 +2,4 @@// Shared by every instance, so an id is unique BY CONSTRUCTION. A random// suffix is only unlikely to collide, and it changes on every mount, which// makes a DOM snapshot in a test or a screenshot diff noisy for no reason.let seq = 0;Comment overclaims:
++seqincrements on every instance init, so a remount gets a new id. "Unique by construction" is true; "stable across mounts" is not. Fix the comment (and PR description), or derive the id mount-stably.Both items done,
be7f58a..aa90aaf.1. Tooltip id (
bbba10f) — took the "make the claim match the code" branch.++seqruns at instance init, so a remount takes the next number exactly as a random suffix would; there is nothing mount-stable to derive from that is also unique per instance (the text is not — two triggers can carry the same sentence). The comment now claims only uniqueness by construction, namesaria-describedbyas the reason that is the property that matters, and says explicitly that nothing may assert on the literal id. PR description corrected the same way. No behaviour change.2. Whitespace-only phrase (
aa90aaf) — closed on both halves rather than one, since the two are the same rule read from either end:resolveConfirmPhrasereturns the resolved phrasetrim()ed, so a template that only became whitespace when a param filled it comes out""— which is how the dialog already spells "no phrase required". The validator covers the authored case; this covers the one it cannot see.ConfirmDialogderives one trimmed value and decides the box, the initial focus target, the Tab stops and the match from it. Previously the box was gated onconfirmPhrase !== ""and the match on its trim, which is exactly the split that lets a lock exist that an empty answer opens. Now "there is a box" and "the box can be satisfied" are one question inside the component, so a caller that never went through the schema (DependentsConfirm,RunPage) gets the rule too.Your unreachability analysis holds — a URL segment is non-empty and an unresolved placeholder keeps its template — which is why this is worth a
trim(): it stops depending on those two facts staying true.New test
a resolved phrase that is only whitespace is no phraseinresolve_test.tscovers a param that fills to spaces, a whitespace-only template, surrounding whitespace on a real phrase, and an unresolved template keeping its gate regardless.deno fmt --check,deno lint,deno task check,deno task check:svelte(0 errors), 501 + 3 tests green. Not re-run in a browser: no browser binary on this machine, and the dialog's behaviour for any phrase that was already valid is unchanged (a trim of an already-trimmed phrase is identity). The earlier headless run's assertions —aliclocked,aliceunlocking — are exactly the paths the shared derived value now serves.Round three, against
aa90aaf. Both previous items check out: the tooltip id comment now claims only what++seqguarantees, andresolveConfirmPhrasetrims at the return withConfirmDialogderiving box / focus / Tab stops / match from the one trimmed value —resolve_test.tscovers the whitespace cases as described.Gates on my side:
deno fmt --check,deno lint,deno task check,deno task check:svelteall clean (the one svelte-check warning is pre-existing and not in a file this PR touches).git merge-treeagainst current main is conflict-free.Blocking
1. The branch as pushed fails its own new tests under Deno's leak sanitizer.
All three tests in
module_listing_test.tsfail onaa90aaf:At your base (
cc65b8c)rateLimitstarts asetIntervalthatcreateAppnever clears, so any test that builds the whole app leaks two intervals. Main has since moved that sweep onto the request path (1763b56), and withorigin/mainmerged in the same files pass 44/44 — so the fix is simply to merge or rebase onto current main, so the head under review is the thing CI and the merge commit will actually run. "501 + 3 green" is not reproducible on the head as pushed, which is the claim I would otherwise be merging on.Non-blocking (please address or say why not)
2.
visibility.ts:39— pages are projected, links into them are not. A list item'slink.pageor an action'spage:that targets a stripped page still renders for the viewer. The shell's router then has no route (router.matchis null) andApp.svelte:20shows "Page not found" — which is worse than what the viewer got before this PR (the page rendered and the data request hit the access-denied screen from #31).sdk/ui.ts:39says "clients hide links to pages the user lacks"; nothing in the shell implements that, and after projection it cannot, because the page'sroleleft with the page. Suggest strippingpage:actions anditem.links whose target path was dropped in the same projection (the server knows exactly which paths it removed), or at minimum documenting that anything linking into a role-gated page must carry a matchingroleitself.3.
docs/modules.md:562— "blocks and actions carry their ownrolefor the same purpose." The projection drops pages only. An action or form submit withrole: "admin"is still served to a viewer, path included, so part of the route map the description says is hidden is not. Either project role-gated actions/submits server-side as well, or reword the doc to say those are client-hidden only (and the rest of the sentence about "the same purpose" goes).4.
Tooltip.svelte:87—<svelte:window on:keydown>is the legacy directive in a runes component (CommandPaletteusesonkeydown), and it registers one window listener per instance for the instance's lifetime — a table of 200 rows withexplainset is 200 keydown listeners. Theopeneffect already exists; register the Escape handler there.5.
FormBlock.svelte:475—input[type="password"]styling is a drive-by the description does not mention. Name it or drop it.Verification notes
Full suite could not run in a fresh worktree on this box (
LoadLibraryExW failed— DuckDB DLL under the scratch path, environment, not you), so the server and schema tests ran in the main checkout.external_modules_test.tsfailed once in the full run on your head and passed in isolation and on the merged tree — treating that as a flake.All five done,
aa90aaf..7108fb3.1. The head failing its own tests (blocking) — current main merged in (
e5c9143). Your diagnosis was exact:rateLimit'ssetIntervalat my base, gone on main in1763b56. On the pushed head the threemodule_listing_test.tscases pass, and the full run is 546 + 3 green withdeno fmt --check,deno lint,deno task checkanddeno task check:svelte(0 errors) clean. Merged rather than rebased so the four earlier conflict resolutions stay reviewable as the commits you already read.2. Links into stripped pages (
d000828) — took the projection option. The server does know exactly which paths it removed, and it turned out to be the only place that can know: after projection a page'srolehas left with the page, so no client can reconstruct the rule.All four ways in are covered, not just the two you named: a
page:action, a list item'slink, a page'sparent, a form'ssuccessPage. Two things fell out of it that were not obvious until it was written:a page the projection emptied is dropped, and takes its links with itis exactly the two-round case.account/newmust be stripped when that page is admin-only even thoughaccount/:namealso matches it and survives. Resolving against the surviving pages instead of all of them turns a hidden page into a link that silently navigates somewhere the operator did not ask for. Pinned ina link is resolved the way the router will resolve it.One deliberate exception: a target matching no page at all is left alone. That was broken before any projection existed, and swallowing it would hide an authoring bug rather than a page.
3.
docs/modules.mdoverclaiming (d000828) — made the claim true rather than rewording it, since the sentence described the behaviour I actually wanted. An action'srole, a formsubmit's and aterminalblock's are projected server-side now. The reasoning is yours from finding 2, one level down: the path inside a role-gated action is part of the route map, and half a hidden route map is not hidden. A form goes whole when itssubmitis above the session — a read-only rendering of a form is a control that cannot be used and a path that did not need serving.Two things that constrain how this is allowed to work, both now in the doc:
roleAllowscheck. It is not redundant: it is what a module gets from a server older than this commit.actionsandblocksare bothmin(1), so an emptied one is not a smaller schema — it is one a validating client rejects whole, taking the module with it. Hence: an emptied actions block is dropped, and a page left with no blocks is dropped, which is the same judgement as a page's ownroleone level up.4.
<svelte:window on:keydown>(7e913b1) — gone. Escape registers inside the existingopeneffect alongside scroll and resize, so a closed tooltip holds no window listener at all and your 200-row table holds none until one opens.5.
input[type="password"](7108fb3) — named, kept. It is not a restyle:passwordwas already a rendered input kind that had never been inFormBlock's selector list, so a secret field sat in a browser-default box beside styled text ones. It rode in with the textarea because that is the same list. Section 2 of the description says so, and a comment in the file says it where the next reader will be.Verification. Gates as above. New tests in
visibility_test.ts: a listlinkand apage:action into a dropped page, router precedence, a link matching no page, a role-gated action / form / terminal (asserting on the serialized bytes — no/wipe, no/pty), a page the projection emptied, a root page the projection emptied,parentandsuccessPage. Inmodule_listing_test.ts, the same claim through the whole app with a real session store, asserting on the response body a viewer is actually handed.Not re-run in a browser: still no browser binary here. For this round I would argue that is not the weaker evidence — the claim is about what a session is served, and
module_listing_test.tsasserts on the bytes leaving the real app, which is what the shell and the phone both render from. The tooltip change moves one handler between two registration sites, and svelte-check reads the template.LGTM — round 4, no findings.
Full diff reviewed (26 files, head
7108fb3), plus an independent spot-check ofpackages/server/src/modules/visibility.ts:keptby at least one page; an emptykeptbreaks on the first pass.uiis never modified.reachableis sound: targets resolve against all pages with static-beats-param precedence before the survival check, so a dropped page's link cannot fall through to a laxer pattern; a target matching no page is deliberately left alone.path === "") is correct — an empty target splits to zero segments and matches only the root pattern.untouchedidentity comparison holds: any drop changes array length, any projection changes the object reference.Interpolation edge cases, Tooltip listener lifecycle (listeners only while open, Escape registered in the
openeffect), textarea round-tripping, and the new test assertions (including the served-bytes leak check inmodule_listing_test.ts) all verified clean. All round-3 fixes are present in the diff: projected links into dropped pages (all four ways in), server-side projection of action/submit/terminal roles, the emptied-page rule,input[type="password"]styling named and commented.