diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 99edec19..69f6c0a7 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -386,4 +386,8 @@ Plans:
- [ ] 13-15-PLAN.md — On-device sign-off: archi-dev-box, embedded iframe, desktop + mobile (AIUI-06)
**Track note (D-13):** the music-library track (13-04 → 13-07 → 13-11) is independent — no plan
-on the control or content track depends on any music plan.
+on the control or content track depends on any music plan, **and neither does the phase-closing
+gate**. 13-15 depends on 13-06, 13-09 and 13-14 only, so there is no path from it to 13-04,
+13-07 or 13-11: if the music track slips or is deferred, 13-15 records that at its step 7b and
+the control and content work still closes and ships. 13-11 is therefore a terminal plan of the
+phase rather than a gate on it.
diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md
index 8ae61a40..e953b103 100644
--- a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md
+++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-PLAN.md
@@ -112,8 +112,15 @@ Goes red if a future phase reintroduces the mesh-only assumption.
-None in this plan. (The four `unclassified` edge-probe rows are surfaced in 13-05, 13-09 and
-13-15, where their requirements live.)
+None in this plan.
+
+**Edge-probe accounting for AIUI-01.** Its probe resolved `covered` and produced two findings,
+both here: the pending-confirmation lifecycle truth tagged `(edge: AIUI-01 concurrency)` above,
+and the two-tab nonce finding carried as a `verification: backstop` scalar rather than a plain
+truth. The four probes that returned `unclassified` belong to other requirements and are
+surfaced where those requirements live — AIUI-02 in 13-05, AIUI-04 and AIUI-05 in 13-09, AIUI-06
+in 13-15. Six requirements probed, two `covered`, four `unclassified`, nothing dropped; the full
+reconciliation with its counts is in `13-VALIDATION.md` § Edge-Probe Reconciliation.
diff --git a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md
index b22c9827..e665ccaa 100644
--- a/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md
+++ b/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-PLAN.md
@@ -7,6 +7,8 @@ depends_on: ["13-01"]
files_modified:
- neode-ui/src/composables/archyContentAdapter.ts
- neode-ui/src/composables/__tests__/archyContentAdapter.test.ts
+ - neode-ui/src/api/filebrowser-client.ts
+ - neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts
- neode-ui/src/services/contextBroker.ts
- neode-ui/src/types/aiui-protocol.ts
- /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts
@@ -23,7 +25,8 @@ must_haves:
- "An empty content list renders the grid's empty state, not a spinner and not an error; a single item renders a one-card grid; an item with a null or absent description maps to an empty string, never the literal 'null' or 'undefined' (edge: AIUI-03 empty)"
- "Content ordering is added_at descending with id ascending as the deterministic tiebreak, so items with equal timestamps come back in the same order on every call (edge: AIUI-03 ordering)"
- "A content refresh arriving while an earlier one is still in flight is discarded by a request-id guard, so the grids never flip back to older data (edge: AIUI-03 concurrency)"
- - "No new streaming URL in this phase carries a credential in its query string — the filebrowser-client.ts streamUrl JWT-in-URL leak is not propagated"
+ - "No new streaming URL in this phase carries a credential in its query string — the leak is not propagated into the adapter"
+ - "The pre-existing leak is actually closed, not merely avoided: filebrowser-client.ts's streamUrl returns a bare same-origin raw-file URL with no query component, and playback still works because the same-origin filebrowser cookie already travels on media subresource requests"
artifacts:
- path: "neode-ui/src/composables/archyContentAdapter.ts"
provides: "ContentItem -> Film/Song/Podcast mapping; there is no shape overlap, so this is hand-written mapping logic"
@@ -31,6 +34,9 @@ must_haves:
- path: "neode-ui/src/composables/__tests__/archyContentAdapter.test.ts"
provides: "Fixture-pinned mapping including the adjacency, empty, ordering and concurrency edges"
min_lines: 80
+ - path: "neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts"
+ provides: "Regression pin that streamUrl emits no query component, so the JWT-in-URL leak cannot come back"
+ contains: "streamUrl"
key_links:
- from: "neode-ui/src/services/contextBroker.ts"
to: "neode-ui/src/composables/archyContentAdapter.ts"
@@ -65,12 +71,27 @@ a node serves, so TMDB posters, web search and RSS stay 404 on a node. Only the
replaces gets a production answer; the rest stays explicitly deferred, and the plan says so
rather than implying otherwise.
-Output: `archyContentAdapter.ts`, a `content:push` channel on the existing broker, and
-`setArchyContent` in AIUI.
+This plan also closes the one credential-in-URL leak CONTEXT.md names by hand:
+`filebrowser-client.ts`'s `streamUrl` puts the filebrowser JWT in the query string, where it
+reaches browser history, `Referer` headers and access logs. CONTEXT.md calls it "the known leak
+to **fix** rather than propagate", so not reproducing it in new code is only half the
+instruction. The fix is small because the credential there is redundant: `login()` already sets
+that JWT as a `path=/` cookie on the page's own origin, and the browser attaches it to the
+same-origin media request without being asked.
+
+Output: `archyContentAdapter.ts`, a `content:push` channel on the existing broker,
+`setArchyContent` in AIUI, and a query-free `streamUrl`.
-None in this plan. (AIUI-03's four edge probes are all resolved as `covered` truths above.)
+None in this plan.
+
+**Edge-probe accounting for AIUI-03.** The probe surfaced **four** edges — adjacency, empty,
+ordering, concurrency — and all four are discharged here as covered truths tagged
+`(edge: AIUI-03 …)`. 13-07 carries three further truths with an AIUI-03 edge tag; those are
+**planner-authored** re-applications of the same edge kinds to the persisted music index, marked
+`— authored, not probe-surfaced` so the phase does not count one four-finding probe as seven.
+The reconciliation is in `13-VALIDATION.md` § Edge-Probe Reconciliation.
@@ -88,6 +109,10 @@ Symbols created by **this plan**:
- `composables/useArchy.ts`: `requestArchyContent`
- `composables/useContentPanel.ts`: `setArchyContent`, `archyContentActive` (ref)
+Changed, not created: `neode-ui/src/api/filebrowser-client.ts` — `streamUrl`'s body only. No new
+export, no signature change; it still returns `Promise`, so every existing call site is
+untouched.
+
Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid.vue`,
`NewsGrid.vue`, `ContentGridView.vue`, and every `Film`/`Song`/`Podcast` type in
`packages/core/src/types/content.ts`.
@@ -111,7 +136,7 @@ Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid
Task 1: The adapter — hand-written mapping, fixture-pinned, edges decided
- neode-ui/src/composables/archyContentAdapter.ts, neode-ui/src/composables/__tests__/archyContentAdapter.test.ts
+ neode-ui/src/composables/archyContentAdapter.ts, neode-ui/src/composables/__tests__/archyContentAdapter.test.ts, neode-ui/src/api/filebrowser-client.ts, neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts
- A video-mime `ContentItem` becomes a `Film` with `id` carried through, `title` derived from `filename` minus its extension, and exactly one entry in `sources[]` describing where it came from.
- An audio-mime `ContentItem` becomes a `Song`; an image or document mime becomes neither and is excluded from all three buckets rather than mis-typed.
@@ -121,12 +146,16 @@ Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid
- An empty input array produces empty `films`/`songs`/`podcasts` arrays — not `undefined`, not a thrown error.
- A `null`/absent `description` maps to `''`; a `null` `added_at` sorts last rather than crashing the comparator.
- Sorting is `added_at` descending, `id` ascending on ties — calling the adapter twice on the same input in a different array order yields identical output order.
+ - `fileBrowserClient.streamUrl('/Music/x.m4a')` resolves to a same-origin raw-file URL carrying no query component and no credential anywhere in the string — the returned value contains no `?`, and does not contain the cookie's value.
+ - `streamUrl` still awaits authentication before returning, so the cookie the media request depends on is guaranteed to be set by the time the caller assigns the URL to a media element.
+ - `sanitizePath` traversal handling is unchanged by the fix — a path containing `..` is still resolved and never escapes root.
- `/home/archipelago/Projects/AIUI/packages/core/src/types/content.ts` lines 7-70 — the exact target shapes: `Film` (line 7), `FilmSource` (23), `SongSource` (37), `Song` (44), `Podcast` (63). **This file is read, never modified** — D-12 keeps AIUI's design exactly.
- `core/archipelago/src/content_server.rs` — `ContentItem` and `AccessControl` (`Free | PeersOnly | Paid`), the source shape being mapped from.
- `core/archipelago/src/api/rpc/content.rs` — `content.list-mine`, `content.browse-peer`, `content.owned-list`, `content.preview-peer`, and the MIME auto-filing logic around line 668 (the classification precedent to stay consistent with).
-- `neode-ui/src/api/filebrowser-client.ts` lines 172-176 — `streamUrl`'s `?auth=${token}` JWT-in-query-string. **This is the known leak to fix rather than propagate**; read it so you recognise the pattern and do not reproduce it.
+- `neode-ui/src/api/filebrowser-client.ts` in full — CONTEXT.md names this "the known leak to fix rather than propagate", and **this task fixes it**, so read the whole client, not just the leaking function. The four facts that make the fix small and safe: `login()` (lines 55-83) sets the filebrowser JWT as a **cookie** with `path=/` and `SameSite=Lax` on the page's own origin; `baseUrl` (line 43) is `window.location.origin + '/app/filebrowser'`, so a media element's request for it is **same-origin**; a same-origin subresource request carries that cookie automatically and `SameSite=Lax` does not restrict same-site subresources; and filebrowser's own auth reads the `auth` cookie, which is why its own web UI works without a query parameter. The credential in the query string is therefore redundant, not load-bearing.
+- `neode-ui/src/stores/cloud.ts` lines 117-119 and `neode-ui/src/components/cloud/MediaLightbox.vue` lines 138 and 202-203 — the call sites. They consume a URL string and are unaffected by dropping its query component; confirm that before changing anything.
- `neode-ui/src/composables/__tests__/useFileType.test.ts` — the in-repo convention for a fixture-driven pure-function Vitest suite.
- `13-RESEARCH.md` Pitfall 4 and Pitfall 5.
@@ -137,7 +166,13 @@ Create `neode-ui/src/composables/archyContentAdapter.ts` exporting `adaptContent
`adaptToFilm`/`adaptToSong`/`adaptToPodcast` carry `id` through unchanged as the card key (this is what makes the adjacency case correct: two peers sharing a byte-identical file are two rows, because they are two things the operator can act on separately). Derive `title` from `filename` with the extension stripped. Map `description ?? ''`. Build exactly one `sources[]` entry per item, with a `type` value that distinguishes this node's own file from a peer's file from IndeeHub — pin those three literal values in the test so a later refactor cannot quietly change what a grid badge means.
-For playback URLs: **do not build any URL containing a credential in its query string.** Own-node media resolves through the existing content endpoints (`/content/`), peer media through the existing Rust Range-streaming proxy (`/api/peer-content//`) — both of which already carry the page's session. Where a bare `
@@ -257,7 +297,8 @@ Commit and push on `development`, staging explicitly by path.
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
-| T-13-32 | Information Disclosure | A credential in a media URL query string | high | mitigate | The adapter builds no `?auth=`/`?token=` URL; own media goes through session-carrying content endpoints and peer media through the existing Rust Range proxy. Asserted by a grep gate and a test assertion. This is the `filebrowser-client.ts` leak, fixed rather than propagated |
+| T-13-32 | Information Disclosure | A credential in a media URL query string built by **new** code (the adapter) | high | mitigate | The adapter builds no credential-bearing URL; own media goes through session-carrying content endpoints and peer media through the existing Rust Range proxy. Asserted by a comment-filtered grep gate and a test assertion. Scope of this row is the new code only — the pre-existing leak is T-13-39 |
+| T-13-39 | Information Disclosure | The **pre-existing** leak: `filebrowser-client.ts`'s `streamUrl` puts the filebrowser JWT in the query string, so it reaches browser history, `Referer` headers and any access log on the path | high | mitigate | `streamUrl` is changed in this plan's Task 1 to return a query-free same-origin URL and rely on the `path=/` cookie `login()` already sets — `filebrowser-client.ts` is in `files_modified` and `filebrowserStreamUrl.test.ts` pins it. **Residual, stated rather than implied:** the JWT is still a 24-hour token, now confined to the cookie jar; making it short-lived or per-resource is a separate change this phase does not make |
| T-13-33 | Information Disclosure | Content pushed to the iframe without a grant | high | mitigate | `handleContentRequest` checks `media`/`files` through the existing permissions store before any RPC call |
| T-13-34 | Tampering | Iframe names its own RPC method or params | high | mitigate | The iframe supplies only a `kind`/`scope` enum; the broker chooses the method. Asserted by the "iframe never names an RPC method" grep |
| T-13-35 | Elevation of Privilege | Paid content playable without unlock | high | mitigate | `access: 'Paid'` maps to a locked card with no playable source; unlock stays on the existing invoice / `X-Payment-Token` path (D-14). No new payment rail |
@@ -271,13 +312,15 @@ Commit and push on `development`, staging explicitly by path.
- `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` all green
- `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run && npx vue-tsc --noEmit` green
- `git -C /home/archipelago/Projects/AIUI diff --exit-code HEAD~1 -- packages/app/src/components/content/ packages/core/src/types/content.ts` exits 0
-- No adapter-produced URL matches `[?&](auth|token)=`
+- No adapter-produced URL carries a credential query parameter, and `filebrowserStreamUrl.test.ts` is green
AIUI's existing grids show the node's real content, with no grid component or content type
changed; the mapping is pinned by fixtures at its adjacency, empty, ordering and concurrency
-edges; and the phase gains no new credential-in-URL leak and no new payment rail.
+edges; the phase gains no new credential-in-URL leak and no new payment rail; and the one
+credential-in-URL leak that already existed is closed at its source, with its remaining
+long-lived-token residual named rather than glossed.
@@ -223,6 +254,7 @@ address, the build marker, and screenshots for steps 5, 9, 11 and 12.
| T-13-105 | Spoofing | The confirm dialog clipped or trapped by an ancestor transform at phone width, so the backdrop is not full-screen | medium | mitigate | Step 12 checks it on real hardware. This is the project's repeatedly-reinforced Teleport-to-body rule and its failure mode is a partially-obscured signing screen |
| T-13-106 | Denial of Service (financial) | Routstr's live behaviour untested, so the budget ceiling is only proven in unit tests | medium | mitigate | Step 10 either exercises a real paid request and a real exhaustion stop, or records 13-13's deferral. Silence is not an acceptable outcome |
| T-13-107 | Repudiation | A validation map marked compliant while rows remain undischarged | medium | mitigate | `nyquist_compliant` is set true only when every row is discharged; otherwise it stays false with the gap named |
+| T-13-108 | Denial of Service (delivery) | The independent music track holding the control/content sign-off hostage, so shippable work cannot be signed off | medium | mitigate | D-13 enforced structurally: this plan's `depends_on` has no path to 13-04/13-07/13-11, and step 7b is record-and-defer rather than a gate. The wave graph, not a comment, is what makes the two tracks separable |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added; this plan changes no source in either repository. No install task, so no legitimacy checkpoint required |
@@ -230,15 +262,21 @@ address, the build marker, and screenshots for steps 5, 9, 11 and 12.
- All three automated suites green: `cargo test --package archipelago` from `core/`, `vitest run` from `neode-ui/`, and `vitest run` from AIUI's `packages/app`
- `verify-aiui-deploy.sh` and `aiui-proxy-closed.sh` both exit 0 against archi-dev-box
- `grep -c TBD 13-VALIDATION.md` returns 0 and no row is `pending`
-- `13-UAT.md` records all fifteen steps with observations and the four screenshots
+- `13-UAT.md` records all fifteen numbered steps plus 7b with observations and the four screenshots
+- This plan's `depends_on` names no music plan, and `13-UAT.md` states which track the sign-off covers
The phase is done in the sense the phase itself demands: a typed request in the real embedded
AIUI on real hardware reaches a real node action and returns a real result; writes stop at a
-dialog the iframe cannot touch; the ceiling holds; the grids and the library show real data;
-the unauthenticated doors are shut; and all of it is recorded against a node, on desktop and on
-a phone, rather than asserted from a test run.
+dialog the iframe cannot touch; the ceiling holds; the content grids show real data; the
+unauthenticated doors are shut; and all of it is recorded against a node, on desktop and on a
+phone, rather than asserted from a test run.
+
+The music library's state is recorded here, not required here. Per D-13 it is an independent
+track: if it landed, 7b records it passing and the phase closes with the library lit; if it did
+not, 7b records the gap and the phase closes anyway on the control and content tracks. Either
+outcome is a valid close — an unrecorded one is not.