docs(01): gap plans 01-11..01-18 for FED-07 + UIFIX-01..06
Phase 1 success criteria 7-13 were added 2026-07-30, after the phase's original 10 plans were written. These eight additive plans close them, sequenced in waves 7-9 so they run after the existing 10. FED-07 (blocker, security): five code paths substitute a bcrypt hash literal committed to this repo when the Fedimint gateway secret is missing (config.rs, dependencies.rs, first-boot-containers.sh, deploy-to-target.sh, deploy-tailscale.sh), and one deploy path substitutes a plaintext password literal. 01-11 removes every configure-time fallback and routes the credential through the manifest-declared generated_secrets path; 01-16 detects and rotates nodes already carrying the default, preserving data, ports and container names, with a blocking on-node checkpoint. UIFIX-01..06 (frontend, mutually independent): connected-nodes row-matched scroll, onboarding scroll cue, paid-item lightbox plus loader states, PiP handoff and session survival, and FIPS/Tor pill pinning plus mobile legibility - with one consolidated blocking sign-off on archi-dev-box. ROADMAP: phase 1 plan count 10 -> 18, new plans appended with waves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf7a603ee5
commit
0eff666a6c
+18
-1
@@ -53,7 +53,7 @@ signed/decentralized registry and a user installs it on their node.
|
||||
13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory)
|
||||
NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes)
|
||||
|
||||
**Plans**: 10 plans
|
||||
**Plans**: 18 plans
|
||||
|
||||
Plans:
|
||||
|
||||
@@ -68,6 +68,23 @@ Plans:
|
||||
- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03)
|
||||
- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06)
|
||||
|
||||
**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)*
|
||||
|
||||
- [ ] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07)
|
||||
- [ ] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02)
|
||||
- [ ] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03)
|
||||
- [ ] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06)
|
||||
- [ ] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05)
|
||||
|
||||
**Wave 8** *(blocked on Wave 7 completion)*
|
||||
|
||||
- [ ] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07)
|
||||
- [ ] 01-17-PLAN.md — FIPS/Tor pills pinned against removal and readable at phone widths (UIFIX-01)
|
||||
|
||||
**Wave 9** *(blocked on Wave 8 completion)*
|
||||
|
||||
- [ ] 01-18-PLAN.md — Six-fix sign-off on archi-dev-box (UIFIX-01/02/03/04/05/06)
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 2: UI Performance
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 11
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/api/rpc/package/config.rs
|
||||
- core/archipelago/src/api/rpc/package/dependencies.rs
|
||||
- scripts/first-boot-containers.sh
|
||||
- scripts/deploy-to-target.sh
|
||||
- scripts/deploy-tailscale.sh
|
||||
- scripts/reconcile-containers.sh
|
||||
- scripts/container-specs.sh
|
||||
autonomous: true
|
||||
requirements: [FED-07]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A fresh Fedimint gateway install derives its admin credential from the per-install secret the manifest declares, so two nodes installed from the same image never share a gateway password (FED-07)"
|
||||
- "No code path configures a gateway container with a credential literal carried in this repository — a missing or unreadable gateway secret makes the install fail loudly instead of quietly starting with a shipped default (FED-07 failure-surfacing)"
|
||||
- "The compromised default hash exists in exactly one place in the tree, as a detection denylist that is never used to configure a container"
|
||||
- "The gateway credential lives under one canonical secret name across the Rust orchestrator, first-boot, reconcile, and both deploy scripts — a node can no longer end up with the daemon reading one file while the scripts wrote another"
|
||||
- "A first boot on a host without htpasswd still produces a unique per-install credential rather than falling back to a shipped one (FED-07 empty edge — the ISO path)"
|
||||
- "Generating the gateway credential twice on the same node is idempotent: the second call leaves the existing value untouched, so a reconcile pass never rotates a working gateway out from under itself (FED-07 adjacency edge)"
|
||||
prohibitions:
|
||||
- statement: "No credential value that grants access to a running service may be committed, printed to a log line, embedded in a container image, or written into an ISO/release artifact — the denylist entry retained for detection is a bcrypt hash of an already-public value and is never passed to a container"
|
||||
category: safety
|
||||
- statement: "Removing the default MUST NOT silently disable the gateway — an install that cannot obtain a per-install credential reports an error naming the missing secret; it never starts an unauthenticated or partially configured gateway instead"
|
||||
category: transparency
|
||||
artifacts:
|
||||
- path: core/archipelago/src/container/secrets.rs
|
||||
provides: "Canonical per-install gateway credential accessor plus the known-default denylist"
|
||||
contains: "KNOWN_DEFAULT_GATEWAY_HASHES"
|
||||
key_links:
|
||||
- from: core/archipelago/src/api/rpc/package/config.rs
|
||||
to: core/archipelago/src/container/secrets.rs
|
||||
via: "the fedimint-gateway spec builder asks container::secrets for the per-install hash and propagates the error instead of substituting a literal"
|
||||
pattern: "gateway_bcrypt_hash"
|
||||
- from: scripts/container-specs.sh
|
||||
to: core/archipelago/src/container/secrets.rs
|
||||
via: "both read the same canonical secret filename, so the shell reconcile path and the daemon agree on one credential"
|
||||
pattern: "fedimint-gateway-hash"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Remove every shipped Fedimint gateway credential from the tree and make each install derive its own,
|
||||
so two nodes flashed from the same ISO never answer to the same gateway password.
|
||||
|
||||
Purpose: FED-07 is a BLOCKER. `apps/fedimint-gateway/manifest.yml` already declares the right thing
|
||||
(`generated_secrets: fedimint-gateway-hash, kind: bcrypt`), and `container::secrets` already
|
||||
materialises it per install at 0600 — but five code paths bypass that and substitute a hash literal
|
||||
committed to this repository when the secret is missing, and one deploy path substitutes a plaintext
|
||||
password literal. Anyone with a copy of this repo holds the admin credential for every gateway that
|
||||
ever took one of those fallbacks. The repo's own standing invariant already forbids this: "Secrets are
|
||||
manifest-declared (`generated_secrets`, materialised by `container::secrets`, 0600/rootless) — never
|
||||
hardcoded, per-app, or logged."
|
||||
Output: one canonical per-install accessor, five fallback sites removed, a detection-only denylist,
|
||||
and tests that fail if a credential literal is ever reintroduced.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
|
||||
@apps/fedimint-gateway/manifest.yml
|
||||
@core/archipelago/src/container/secrets.rs
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `KNOWN_DEFAULT_GATEWAY_HASHES` | detection-only denylist constant | `core/archipelago/src/container/secrets.rs` |
|
||||
| `gateway_bcrypt_hash(secrets_dir) -> Result<String>` | canonical per-install accessor | same |
|
||||
| `ensure_gateway_credential(secrets_dir) -> Result<()>` | idempotent generator (bcrypt hash + `.pw` sibling) | same |
|
||||
| fallback-free `fedimint-gateway` spec arm | changed match arm | `core/archipelago/src/api/rpc/package/config.rs` |
|
||||
| fallback-free `configure_fedimint_lnd` | changed function | `core/archipelago/src/api/rpc/package/dependencies.rs` |
|
||||
| credential generation without a shipped fallback | changed shell blocks | `scripts/first-boot-containers.sh`, `scripts/reconcile-containers.sh`, `scripts/deploy-to-target.sh`, `scripts/deploy-tailscale.sh` |
|
||||
| canonical secret-name read with an empty guard | changed shell block | `scripts/container-specs.sh` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a gateway spec that cannot be built without a per-install credential</name>
|
||||
<files>core/archipelago/src/container/secrets.rs, core/archipelago/src/api/rpc/package/config.rs, core/archipelago/src/api/rpc/package/dependencies.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/container/secrets.rs` — the whole file (about 225 lines). Note
|
||||
`ensure_one`'s `SecretGenKind::Bcrypt` arm: it already generates a 24-byte random hex password,
|
||||
bcrypt-hashes it, writes the hash to `<name>` and the plaintext to `<name>.pw`, both 0600 via
|
||||
the atomic `write_secret` helper. Note the idempotent fast path and the self-heal branch. This
|
||||
is the behaviour the new accessor must reuse, not reimplement.
|
||||
- `core/archipelago/src/api/rpc/package/config.rs` lines 596-620 (`read_secret`, which takes a
|
||||
`default: &str` — the mechanism that makes a fallback literal possible) and lines 1051-1084
|
||||
(the `"fedimint-gateway"` match arm inside the app-config table, where the hash is read with a
|
||||
literal default and then passed to `--bcrypt-password-hash`).
|
||||
- `core/archipelago/src/api/rpc/package/dependencies.rs` lines 718-769 (`configure_fedimint_lnd`)
|
||||
— the second site, reading the same secret path directly with `unwrap_or_else` onto the same
|
||||
literal, then rebuilding the whole argv in LND mode.
|
||||
- `core/archipelago/src/api/rpc/package/install.rs` lines 583-606 — how `get_app_config` and
|
||||
`configure_fedimint_lnd` are called during install, so you can see what an error from either
|
||||
has to propagate through.
|
||||
- `apps/fedimint-gateway/manifest.yml` — the `generated_secrets` block already declaring
|
||||
`fedimint-gateway-hash` with `kind: bcrypt`, and the `secret_env` mapping `FEDI_HASH` to it.
|
||||
The manifest is already correct; this task makes the non-manifest paths agree with it.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `ensure_gateway_credential` on an empty secrets dir writes both the hash file and its `.pw`
|
||||
sibling, each 0600, and the plaintext verifies against the hash.
|
||||
- Called a second time on the same dir it changes nothing — the hash read back is byte-identical.
|
||||
- `gateway_bcrypt_hash` on a dir with no gateway secret returns `Err`, and the error message names
|
||||
the missing secret file so an operator can act on it.
|
||||
- `gateway_bcrypt_hash` on a dir whose stored hash is a known-default denylist entry returns `Err`
|
||||
rather than handing the compromised value back to a caller.
|
||||
- Two successive fresh generations in two different temp dirs produce two different hashes — the
|
||||
value is per install, not per build.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests in `secrets.rs`'s existing `mod tests` first and confirm they fail.
|
||||
|
||||
In `core/archipelago/src/container/secrets.rs` add three items.
|
||||
|
||||
First, a private denylist constant `KNOWN_DEFAULT_GATEWAY_HASHES: &[&str]` holding the single
|
||||
bcrypt hash currently used as a fallback at `config.rs:1054` (copy it from there verbatim). Give
|
||||
it a doc comment saying it exists only so an install carrying it can be detected and rotated, that
|
||||
it must never be handed to a container, and that plan 01-16 consumes it for the migration. This is
|
||||
the one and only place that value may appear in the tree after this plan.
|
||||
|
||||
Second, `pub fn ensure_gateway_credential(secrets_dir: &Path) -> Result<()>` — a thin wrapper that
|
||||
reuses the existing bcrypt generation path for the `fedimint-gateway-hash` name rather than
|
||||
duplicating it. Factor the `SecretGenKind::Bcrypt` arm of `ensure_one` into a small helper both
|
||||
call so there is exactly one bcrypt-generation implementation; keep `ensure_one`'s existing
|
||||
idempotent fast path and self-heal semantics intact so callers on a reconcile tick never rotate a
|
||||
working credential.
|
||||
|
||||
Third, `pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result<String>` — reads the canonical
|
||||
hash file, trims it, and returns `Err` with a message naming the file path when it is missing,
|
||||
empty, or unreadable. Before returning Ok, compare the trimmed value against the denylist and
|
||||
return `Err` if it matches, with a message saying the install is carrying a publicly known default
|
||||
and pointing at the rotation path.
|
||||
|
||||
In `config.rs`: change the `"fedimint-gateway"` arm to obtain its hash from
|
||||
`container::secrets::gateway_bcrypt_hash`, calling `ensure_gateway_credential` first so a fresh
|
||||
node self-provisions. Because `get_app_config` returns a tuple rather than a `Result`, do not
|
||||
silently swallow the error — surface it the way the surrounding code surfaces other hard install
|
||||
failures (an `Err` return threaded to the caller if the signature already allows it, otherwise a
|
||||
logged error plus an argv the install path rejects; whichever you choose, an install with no
|
||||
credential must not reach `podman run`). Record the choice and its reason in the SUMMARY. Delete
|
||||
the `default` parameter from `read_secret` if no other caller needs it; if other callers do, leave
|
||||
the helper alone and simply stop routing the gateway through it.
|
||||
|
||||
In `dependencies.rs`: `configure_fedimint_lnd` must take the already-resolved hash as a parameter
|
||||
from its caller rather than re-reading the file with its own fallback, so there is one read site
|
||||
and one failure point. Update the `install.rs` call accordingly.
|
||||
|
||||
Do not change the gateway's ports, volumes, data directory, network, capabilities, health check,
|
||||
or any non-credential argv element. This task changes where the credential comes from, nothing
|
||||
else about how the gateway runs.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago secrets 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && cargo test -p archipelago secrets` exits 0 and its output names at least five test cases covering: fresh generation, idempotence, missing-secret error, denylisted-value error, and two dirs producing two different values.
|
||||
- `grep -rl 't9YjjxkiktrlYvjajB' --include='*.rs' core/ | wc -l` equals 1, and that one file is `core/archipelago/src/container/secrets.rs`.
|
||||
- `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/config.rs` equals 0.
|
||||
- `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/dependencies.rs` equals 0.
|
||||
- `grep -v '^\s*//' core/archipelago/src/api/rpc/package/config.rs | grep -c 'gateway_bcrypt_hash'` is at least 1.
|
||||
- `grep -v '^\s*//' core/archipelago/src/container/secrets.rs | grep -c 'KNOWN_DEFAULT_GATEWAY_HASHES'` is at least 2 (the definition and its use in the accessor).
|
||||
- `cd core && cargo build -p archipelago` exits 0.
|
||||
- `cd core && cargo test -p archipelago` exits 0 — no existing suite regressed.
|
||||
- The SUMMARY records how a credential-less install is made to fail and why that mechanism was chosen.
|
||||
</acceptance_criteria>
|
||||
<done>The Rust orchestrator can only configure a gateway with a per-install credential; the compromised literal survives in exactly one detection-only location.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The shell install paths generate their own credential instead of shipping one</name>
|
||||
<files>scripts/first-boot-containers.sh, scripts/reconcile-containers.sh, scripts/deploy-to-target.sh, scripts/deploy-tailscale.sh, scripts/container-specs.sh</files>
|
||||
<precondition>`openssl` is on PATH on this machine (the scripts already rely on it for the other per-install database passwords, so the replacement generator introduces no new host dependency)</precondition>
|
||||
<read_first>
|
||||
- `scripts/first-boot-containers.sh` lines 390-426 — the per-install password loop for
|
||||
mempool/btcpay/mysql-root (the correct pattern: `openssl rand`, write, chmod 600), then the
|
||||
gateway block immediately below it that writes `fedimint-gateway-password`, tries `htpasswd` for
|
||||
the hash, and on a host without `htpasswd` logs a warning and assigns the shipped literal. This
|
||||
is the ISO first-boot path, so this is the site that put the default on real nodes.
|
||||
- `scripts/reconcile-containers.sh` lines 690-710 — the same generate-or-skip block, with the same
|
||||
`htpasswd` dependency and the same two-file naming.
|
||||
- `scripts/deploy-to-target.sh` lines 1224-1262 — the remote generation block, the
|
||||
`FEDI_HASH=` export read back over SSH, and the literal fallback when the read comes back empty.
|
||||
- `scripts/deploy-tailscale.sh` lines 494-513 (generation plus the same literal fallback) and lines
|
||||
770-793 (the container-creation block, where a plaintext password fallback is substituted when
|
||||
the password file cannot be read, and where the argv uses a plaintext password flag rather than
|
||||
the hash flag every other path uses).
|
||||
- `scripts/container-specs.sh` lines 60-72 — the shared spec loader, which reads
|
||||
`fedimint-gateway-hash` (correct name) and escapes `$` so the bcrypt hash survives the
|
||||
`eval` in `reconcile-containers.sh`'s `build_run_cmd`. Preserve that escaping.
|
||||
</read_first>
|
||||
<action>
|
||||
Replace the htpasswd-or-fallback pattern everywhere with generation that has no fallback.
|
||||
|
||||
In `first-boot-containers.sh` and `reconcile-containers.sh`: keep generating the plaintext with
|
||||
`openssl rand`, but when `htpasswd` is unavailable do NOT assign a shipped value. Either compute
|
||||
the bcrypt hash without `htpasswd` (openssl's `passwd` applet does not emit bcrypt, so if you go
|
||||
this route use a hasher the host actually has — verify what is present on a node before choosing)
|
||||
or, if no local hasher exists, leave the hash file absent and let the daemon's
|
||||
`ensure_gateway_credential` from Task 1 materialise it on the next reconcile tick. The second
|
||||
option is preferred: it removes the host dependency entirely and puts generation on the one
|
||||
canonical path. In that case the script must log that the gateway credential will be generated by
|
||||
the daemon, and must not create a half-provisioned pair of files.
|
||||
|
||||
Unify the naming. The manifest and the daemon use `fedimint-gateway-hash` for the hash and
|
||||
`fedimint-gateway-hash.pw` for the plaintext; the scripts use `fedimint-gateway-password` for the
|
||||
plaintext. Converge on the manifest's names. Where a script currently writes
|
||||
`fedimint-gateway-password`, have it write the `.pw` sibling name instead, and — because
|
||||
migrations never destroy data — if the legacy file exists and the new one does not, copy the value
|
||||
across (preserving 0600) rather than regenerating, so a node that already has a working unique
|
||||
credential keeps it. Never delete the legacy file in this plan; plan 01-16 owns retirement.
|
||||
|
||||
In `deploy-to-target.sh` and `deploy-tailscale.sh`: when the hash read back from the target comes
|
||||
back empty, abort that step with a clear message instead of substituting the literal. A deploy that
|
||||
cannot read the target's credential must not create a gateway container. In
|
||||
`deploy-tailscale.sh`'s container-creation block, remove the plaintext-password fallback on line
|
||||
777 entirely and switch that argv to the same hash flag every other path uses, sourced from the
|
||||
same secret; if the hash is unavailable, skip creating the gateway container and print why.
|
||||
|
||||
In `container-specs.sh`: leave the secret name as-is (it is already canonical) and leave the `$`
|
||||
escaping intact; only add the empty-value guard so a missing hash produces a skipped spec with a
|
||||
message rather than an empty hash argument.
|
||||
|
||||
Every changed script must stay `sh`-compatible where it already is and must pass `bash -n`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>for f in scripts/first-boot-containers.sh scripts/reconcile-containers.sh scripts/deploy-to-target.sh scripts/deploy-tailscale.sh scripts/container-specs.sh; do bash -n "$f" || exit 1; done; test "$(grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l)" -eq 0</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n` exits 0 for all five scripts.
|
||||
- `grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l` equals 0.
|
||||
- `grep -c "|| echo 'archipelago'" scripts/deploy-tailscale.sh` equals 0.
|
||||
- `grep -c -- '--password ' scripts/deploy-tailscale.sh` equals 0 — the gateway argv uses the hash flag, like every other path.
|
||||
- `grep -rl 't9YjjxkiktrlYvjajB' . --include='*.rs' --include='*.sh' --include='*.yml' --include='*.json' --include='*.md' | wc -l` equals 1 (only the Task 1 denylist).
|
||||
- `grep -v '^\s*#' scripts/first-boot-containers.sh | grep -c 'htpasswd'` is 0, or the SUMMARY records which hasher replaced it and that it is present on a node.
|
||||
- `cd core && cargo test -p archipelago` exits 0.
|
||||
- The SUMMARY records, for each of the five scripts, what the no-credential path now does, and confirms the legacy plaintext filename is copied forward rather than regenerated when present.
|
||||
</acceptance_criteria>
|
||||
<done>No script in the tree can configure a gateway with a credential that shipped with the repo; a node with no credential gets one generated for it or is told why the gateway was skipped.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **Whether the compromised hash's plaintext is publicly recoverable:** the planner did not run
|
||||
`bcrypt::verify` against candidate plaintexts. The severity of FED-07 does not depend on it (a
|
||||
shipped hash is a shipped credential regardless), but the migration in plan 01-16 phrases its
|
||||
operator message differently if the plaintext is a guessable word. Task 1's tests are the natural
|
||||
place to settle it; record the finding in the SUMMARY either way.
|
||||
- **Whether `get_app_config`'s signature can return `Result` without a wide refactor:** the planner
|
||||
read the call site but not every arm of the table. Task 1 explicitly allows either mechanism and
|
||||
requires the choice to be recorded, so this is a bounded implementation decision, not a scope gap.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| repository → running node | Anything committed here reaches every node and every reader of the mirror |
|
||||
| gateway admin API (`0.0.0.0:8176`) → network | The credential this plan governs is the only thing gating Lightning gateway administration |
|
||||
| deploy host → target node over SSH | Credentials are read back across this boundary by two deploy scripts |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-50 | Elevation of Privilege | shipped default credential granting gateway admin on any node that took a fallback | critical | mitigate | Both tasks delete every configure-time fallback; the repo-wide grep acceptance criterion fails the task if any credential literal survives outside the detection denylist |
|
||||
| T-01-51 | Spoofing | an attacker authenticating to a node's gateway with the publicly known default | critical | mitigate | `gateway_bcrypt_hash` refuses to return a denylisted value, so a node carrying it cannot be reconfigured with it even by this codebase |
|
||||
| T-01-52 | Information Disclosure | the generated plaintext leaking through a log line or a deploy transcript | high | mitigate | Generation reuses `write_secret` (0600, atomic, never logged); the scripts are changed to log only that generation happened, never the value; the acceptance criteria forbid printing it |
|
||||
| T-01-53 | Denial of Service | removing the fallback bricking installs on hosts without a bcrypt hasher | medium | mitigate | Task 2's preferred branch removes the host-tool dependency entirely by deferring to the daemon's own generator, and requires the skip path to print a reason rather than fail silently |
|
||||
| T-01-54 | Tampering | a half-written credential pair leaving a gateway configured against a hash whose plaintext nobody holds | medium | mitigate | Generation reuses the existing atomic temp-file-plus-rename `write_secret` and its self-heal branch; Task 2 forbids creating a half-provisioned pair |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs no packages — it edits existing Rust and shell only. If an implementation choice would add a crate, stop and raise it: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd core && cargo test -p archipelago` — green.
|
||||
- `cd core && cargo build -p archipelago` — green.
|
||||
- `bash -n` clean on all five changed scripts.
|
||||
- Repo-wide: exactly one occurrence of the compromised hash, in the detection denylist.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The gateway credential comes from `container::secrets` on every path — daemon, first boot, reconcile, and both deploys.
|
||||
- No credential literal in the tree configures anything; the one retained copy exists solely to detect and reject.
|
||||
- A node with no credential gets one generated, or is told clearly why the gateway was not created.
|
||||
- One canonical secret filename, with the legacy plaintext value carried forward rather than regenerated.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-11-SUMMARY.md` when done, recording the
|
||||
credential-less failure mechanism chosen, the per-script no-credential behaviour, and whether the
|
||||
compromised hash's plaintext turned out to be recoverable.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 12
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
- neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-02]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On a wide viewport the connected-nodes card's height is set by its row sibling, not by how many nodes are in the list — adding nodes makes the inner list scroll instead of making the row taller (UIFIX-02)"
|
||||
- "The inner list scrolls within the matched height: with more rows than fit, a scrollbar appears inside the card and the card stays put"
|
||||
- "With a short sibling the card still has a usable list height rather than collapsing to its header and tabs (UIFIX-02 empty edge, sibling half)"
|
||||
- "With zero connected nodes the card renders its existing empty/loading row and does not collapse (UIFIX-02 empty edge, list half)"
|
||||
- "All three tabs — trusted, observers, requests — share the same scroll behaviour, so switching tabs never changes the card's height (UIFIX-02 adjacency edge)"
|
||||
- "The stacked single-column layout below the row breakpoint is unchanged: the list keeps its existing capped height and its existing scroll"
|
||||
prohibitions:
|
||||
- statement: "Nothing outside the connected-nodes card's own height and overflow behaviour may change — the card's glass styling, padding, header, tab strip, row markup, counts, and every animation stay byte-identical, and no sibling card in any Web5 row is restyled to make the fix work"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
|
||||
provides: "Structural pin on the scroll contract for all three tab panes"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
to: neode-ui/src/views/web5/Web5.vue
|
||||
via: "the card is a min-height-zero flex column whose scroll pane contributes no intrinsic height at the row breakpoint, so the grid row is sized by the sibling and stretch gives the card that height"
|
||||
pattern: "overflow-y-auto"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the connected-nodes list obey the row: its height tracks the taller sibling beside it and the
|
||||
list scrolls inside that height, instead of growing until every node fits.
|
||||
|
||||
Purpose: UIFIX-02 is a BLOCKER, and it is a regression of an earlier request ("was still meant to
|
||||
scroll"). Quick task 260729-je5 made the list fill the card's height; what is missing is the other
|
||||
half — the list must not *drive* the card's height. Today all three tab panes carry
|
||||
`max-h-72 xl:max-h-none`, so at the `xl` breakpoint where the row becomes two columns the cap is
|
||||
lifted and nothing bounds the list: it grows to fit every row, stretches the grid row, and the
|
||||
scrollbar the user expects never appears.
|
||||
Output: a bounded, sibling-matched card with an internal scroll at the row breakpoint, an unchanged
|
||||
stacked layout below it, and a test that pins the contract so a future cleanup cannot undo it again.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
||||
@neode-ui/src/views/web5/Web5.vue
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| scroll-contract classes on the three tab panes | changed template classes | `neode-ui/src/views/web5/Web5ConnectedNodes.vue` |
|
||||
| row-breakpoint height floor on the card root | changed template classes | same |
|
||||
| `neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` | new vitest suite | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height</name>
|
||||
<files>neode-ui/src/views/web5/Web5ConnectedNodes.vue, neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/web5/Web5ConnectedNodes.vue` lines 1-135: the card root
|
||||
(`glass-card p-6 scroll-mt-24 flex flex-col`), the desktop and mobile header blocks, the four-tab
|
||||
strip, and the three `v-show` tab panes at lines 57, 90 and 120 — all three currently carrying
|
||||
`space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`. Also read the loading and
|
||||
empty rows inside the trusted pane so you know what renders when the list is empty.
|
||||
- `neode-ui/src/views/web5/Web5.vue` lines 57-74: the three `grid grid-cols-1 xl:grid-cols-2 gap-6`
|
||||
rows. The connected-nodes card is the left item of the first row and `Web5NodeVisibility` is its
|
||||
right sibling. Confirm no `items-start`/`self-start` is applied anywhere on that row — grid's
|
||||
default `align-items: stretch` is what makes the sibling-matched height work, and this plan must
|
||||
not add or remove alignment utilities on the row.
|
||||
- `neode-ui/src/views/web5/Web5NodeVisibility.vue` — read only far enough to see roughly how tall
|
||||
it renders (it is the sibling whose height the card must adopt). Do not modify it.
|
||||
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — the house convention for a
|
||||
structural DOM/class pin test in this repo (this is the file the standing rule names as
|
||||
must-stay-green; read it for its mounting and assertion style, do not change it).
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Mounting the component and reading the trusted pane's class list: it has `overflow-y-auto`, has
|
||||
`min-h-0`, and has no class that removes its height bound at the row breakpoint.
|
||||
- The same three assertions hold for the observers pane and the requests pane.
|
||||
- The pane keeps a capped height below the row breakpoint (the stacked layout is unchanged).
|
||||
- The card root is a flex column with a height floor at the row breakpoint, so a short sibling
|
||||
cannot collapse the list area.
|
||||
- With an empty node list the pane still renders (the existing empty/loading row is present) and
|
||||
the pane element is still in the tree.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails.
|
||||
|
||||
In `Web5ConnectedNodes.vue`, change only the height/overflow contract:
|
||||
|
||||
On each of the three tab panes, replace the current sizing classes so that below the row
|
||||
breakpoint nothing changes (keep the existing capped height and `overflow-y-auto`, keep basis
|
||||
`auto` so the auto-height stacked column still sizes to content), and at the row breakpoint the
|
||||
pane becomes a zero-basis growing flex child with no height cap — `xl:flex-1 xl:basis-0
|
||||
xl:max-h-none` alongside the existing `min-h-0 overflow-y-auto`. Zero basis is the whole trick:
|
||||
it makes the pane contribute nothing to the card's intrinsic height, so the grid row is sized by
|
||||
the sibling alone, `align-items: stretch` gives the card that row height, and `flex-1` then hands
|
||||
the leftover height to the pane, which scrolls inside it.
|
||||
|
||||
On the card root, keep `glass-card p-6 scroll-mt-24 flex flex-col` exactly as it is and add
|
||||
`min-h-0` plus a row-breakpoint height floor (`xl:min-h-[20rem]`) so a sibling shorter than the
|
||||
header-plus-tabs block still leaves a usable, scrolling list area rather than a collapsed strip.
|
||||
Choose the floor to sit close to today's stacked cap so the visual weight of the card is familiar.
|
||||
|
||||
Change nothing else. Do not touch the header blocks, the tab strip, the per-row markup, the count
|
||||
badges, the pulse dot on the requests tab, any `v-show`/`v-if` condition, any script logic, or any
|
||||
class on `Web5.vue`'s grid rows. Do not add a scrollbar style — the list already scrolls with the
|
||||
house default below the breakpoint and must look identical above it.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would pass vacuously).
|
||||
- `grep -c 'xl:max-h-none' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 and each of those three lines also matches `xl:basis-0`.
|
||||
- `grep -c 'flex-auto' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 0.
|
||||
- `grep -c 'max-h-72' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 — the stacked cap is untouched.
|
||||
- `grep -c 'xl:min-h-' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 1.
|
||||
- `git diff --stat -- neode-ui/src/views/web5/Web5.vue` reports no change.
|
||||
- `git diff -- neode-ui/src/views/web5/Web5ConnectedNodes.vue | grep -c '^[-+].*<script'` equals 0 — no script-block change.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite, including `src/views/dashboard/__tests__/keepAliveTabs.test.ts`, stays green.
|
||||
</acceptance_criteria>
|
||||
<done>All three panes carry the bounded scroll contract, the stacked layout is untouched, and a test pins it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Prove it against the real preview and settle the second "connected nodes" surface</name>
|
||||
<files>neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) — jsdom cannot compute layout, so the height claim has to be observed in a real browser engine</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/DEV-SCRIPTS.md` lines 1-40 — how the dev preview and mock backend are started and on
|
||||
which ports, and how to stop them cleanly.
|
||||
- `neode-ui/src/views/settings/AccountInfoSection.vue` (grep it for "connected" / "nodes" first) —
|
||||
the todo flags a second "connected nodes" block living in settings. Determine whether it is the
|
||||
same list in a different place or unrelated copy, and record the verdict.
|
||||
</read_first>
|
||||
<action>
|
||||
Start the dev preview, open the Web5 tab at a wide viewport (at or above the row breakpoint), and
|
||||
observe the first row directly. Confirm three things and record each in the SUMMARY with the
|
||||
viewport width you used:
|
||||
|
||||
1. The connected-nodes card and its right-hand sibling are the same height.
|
||||
2. With more connected nodes than fit, the list scrolls inside the card and the card does not grow
|
||||
— if the mock backend does not supply enough nodes to overflow, temporarily add rows in the
|
||||
browser's element inspector to force the condition rather than editing the mock backend, and say
|
||||
so in the SUMMARY.
|
||||
3. Narrowing below the row breakpoint restores exactly the previous stacked appearance.
|
||||
|
||||
Then settle the second surface: grep the settings section named above for a connected-nodes list.
|
||||
If it is a genuinely separate list with the same grow-to-fit behaviour, fix it the same way in this
|
||||
plan and add its file to the plan's `files_modified` in the SUMMARY. If it is unrelated (for
|
||||
example a count or a link rather than a scrolling list), record that finding and leave it alone.
|
||||
Do not silently skip this step — the todo explicitly flagged the ambiguity.
|
||||
|
||||
Extend the test file with a case for whichever surface the investigation confirmed, so the pin
|
||||
covers what actually shipped.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0.
|
||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'xl:basis-0' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op, so grep the built bundle for a string this plan introduced).
|
||||
- The SUMMARY records all three dev-preview observations with the viewport width used for each.
|
||||
- The SUMMARY records an explicit verdict on the settings "connected nodes" block: same defect and fixed here, or unrelated and why.
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The behaviour is confirmed in a real browser at both sides of the breakpoint, and the second candidate surface has a recorded verdict rather than an assumption.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The 20rem floor is a judgement call, not a measured value.** The planner did not render
|
||||
`Web5NodeVisibility.vue` to learn its height. If the sibling is reliably taller than the floor the
|
||||
floor never binds and the exact value is invisible; if it is shorter, the floor is what the user
|
||||
sees. Task 2's dev-preview observation is where that gets confirmed — if the floor looks wrong on
|
||||
screen, adjust it there and record the final value.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| federated peer data → rendered node row | The list renders peer-supplied names and identifiers |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-55 | Information Disclosure | a bounded, scrolling list hiding a connected node the operator needs to notice | medium | mitigate | The tab strip's existing count badges stay untouched and remain visible above the scroll area, so the total is always readable without scrolling; the acceptance criteria forbid changing them |
|
||||
| T-01-56 | Spoofing | a long peer-supplied node name overflowing the newly bounded pane and overlapping adjacent chrome | low | accept | Row markup is unchanged by this plan; the panes already truncate as they do today, and this plan alters only the container's height and overflow |
|
||||
| T-01-57 | Denial of Service | a very large peer list making the card expensive to render | low | accept | The list is already fully rendered today; bounding the container reduces painted area rather than increasing it, and virtualisation is out of scope for a layout fix |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — template class changes and one vitest file only. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the new class.
|
||||
- Dev-preview observation recorded at both sides of the row breakpoint.
|
||||
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The card's height comes from its row sibling; the list scrolls inside it and never grows to fit.
|
||||
- A short sibling still leaves a usable list height.
|
||||
- The stacked layout below the breakpoint is byte-identical to before.
|
||||
- The settings "connected nodes" block has a recorded verdict.
|
||||
- A test pins the contract so the behaviour cannot silently regress a third time.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md` when done, recording the
|
||||
dev-preview observations, the final floor value, and the settings-surface verdict.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 13
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
- neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-03]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On a viewport too short to show the whole seed step, an on-brand cue at the bottom of the scrolling area tells the user there is more below — the confirmation tickbox is no longer silently out of sight (UIFIX-03)"
|
||||
- "Activating the cue brings the confirmation tickbox into view, so discovering it takes one action rather than a guess"
|
||||
- "The cue disappears once the tickbox is visible, and never reappears while it stays visible (UIFIX-03 adjacency edge)"
|
||||
- "On a viewport tall enough to show everything the cue never renders at all — no element, no reserved space, no layout shift, so tall screens look exactly as they did (UIFIX-03 empty edge)"
|
||||
- "The cue is absent while the seed is still generating and while an error is showing, because there is no tickbox to point at yet"
|
||||
- "The cue's motion is disabled under prefers-reduced-motion, matching the site-wide convention"
|
||||
prohibitions:
|
||||
- statement: "Nothing about the existing onboarding step may change other than the addition of this cue — the header, the seed word grid, the words/QR tabs, the warning box, the tickbox itself, the fixed footer and its Continue button, and every existing animation stay exactly as they are, and the shared onboarding container styles in style.css are not touched"
|
||||
category: safety
|
||||
- statement: "The cue MUST NOT let a user proceed without ticking the box — it is a wayfinding affordance only; it never sets the confirmation state, never enables the Continue button, and never auto-ticks on scroll"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts
|
||||
provides: "Overflow-driven show/hide behaviour of the cue, including the no-overflow no-render case"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
to: neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
via: "the cue's visibility is derived from the scroll container's own overflow measurement and the tickbox's position within it, so it is impossible for the cue to show when there is nothing below"
|
||||
pattern: "scrollHeight"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the seed-confirmation tickbox obviously findable on short screens, in a way that looks like it
|
||||
was always part of the design.
|
||||
|
||||
Purpose: UIFIX-03 is a BLOCKER — on a short viewport the tickbox sits below the fold inside the
|
||||
step's scrolling area while the Continue button stays pinned and disabled in the fixed footer, so
|
||||
onboarding reads as broken rather than incomplete. The user asked for this to be solved "in a
|
||||
beautiful way": the fix has to feel intentional and native to the house glass/dark style, not a
|
||||
bolted-on arrow, and it must be invisible on screens tall enough not to need it.
|
||||
Output: a bottom scroll cue on the seed step that appears only when it is needed, scrolls the tickbox
|
||||
into view when activated, and vanishes once the tickbox is on screen.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| bottom scroll-cue overlay | new template block (conditional) | `neode-ui/src/views/OnboardingSeedGenerate.vue` |
|
||||
| `showScrollCue` + `updateScrollCue()` + `revealConfirm()` | new script state and handlers | same |
|
||||
| `.onb-cue-*` scoped styles incl. reduced-motion guard | new scoped CSS | same |
|
||||
| `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` | new vitest suite | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a short viewport shows a cue that reveals the tickbox</name>
|
||||
<files>neode-ui/src/views/OnboardingSeedGenerate.vue, neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/OnboardingSeedGenerate.vue` — the whole file (262 lines). The structure that
|
||||
matters: a `h-[100dvh]` outer centring wrapper; a `path-glass-container onb-scroll-container
|
||||
flex flex-col` card; a `flex-shrink-0` header; the scrolling middle region
|
||||
(`flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0`) that contains the loading
|
||||
state, the error state, the words/QR tabs, the word grid, the orange warning box and — last —
|
||||
the confirmation `<label>` with the checkbox bound to `confirmed`; and the `flex-shrink-0`
|
||||
fixed footer holding the Continue button gated on `confirmed`. Note the existing
|
||||
`watch(confirmed, …)` that focuses the Continue button, and the `onb-lock-spin` scoped keyframes
|
||||
block at the bottom (the house pattern for a small scoped animation in this file).
|
||||
- `neode-ui/src/style.css` — find the `.onb-scroll-container` rules (around line 1146 and a
|
||||
breakpoint block around line 1162) to see what the shared onboarding container already does.
|
||||
Read only; this plan must not modify the shared stylesheet, because these classes are used by
|
||||
every other onboarding step.
|
||||
- `neode-ui/src/components/RefreshIndicator.vue` — the house convention for a small, purely
|
||||
presentational overlay component with a scoped keyframes animation, for style reference.
|
||||
- `neode-ui/src/components/SendBitcoinModal.vue` — grep it for `prefers-reduced-motion` and copy
|
||||
that media-query syntax verbatim for the cue's guard, so all reduced-motion guards in this repo
|
||||
read identically.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- With the scroll region reporting more content than fits and the tickbox below the visible area,
|
||||
the cue element is in the DOM.
|
||||
- With the scroll region reporting no overflow, the cue element is absent from the DOM entirely —
|
||||
not merely hidden, so it can occupy no space and cause no shift.
|
||||
- Scrolling to the bottom (tickbox now inside the visible area) removes the cue.
|
||||
- Activating the cue calls the scroll-into-view path for the tickbox and does not change
|
||||
`confirmed`.
|
||||
- While `loading` is true, or while `errorMessage` is set and no words have arrived, the cue is
|
||||
absent regardless of overflow.
|
||||
- Ticking the box removes the cue.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails. In jsdom there is no layout engine, so drive the
|
||||
measurements by defining `scrollHeight`, `clientHeight` and `scrollTop` on the scroll element with
|
||||
`Object.defineProperty` and dispatching a `scroll` event — assert on what the component renders in
|
||||
response, not on computed geometry.
|
||||
|
||||
In `OnboardingSeedGenerate.vue`:
|
||||
|
||||
Add a template ref to the existing scrolling middle region and one to the confirmation label. Add
|
||||
a `showScrollCue` ref and an `updateScrollCue()` function that sets it true only when all of these
|
||||
hold: words are present, not loading, the scroll element reports more scrollable content below the
|
||||
current position, the confirmation label's bottom lies below the scroll element's visible bottom,
|
||||
and `confirmed` is still false. Call it from a `scroll` listener on the scroll element, from a
|
||||
`resize` listener on the window, from a `ResizeObserver` on the inner content wrapper (the word
|
||||
grid changes height when the user switches between the words and QR tabs), from a watcher on
|
||||
`words`, and from a watcher on `confirmed`. Remove every listener and disconnect the observer in
|
||||
`onUnmounted` alongside the existing `stopTimers()` call.
|
||||
|
||||
Render the cue as a `v-if="showScrollCue"` overlay positioned against the scrolling region's
|
||||
bottom edge, inside a `Transition` so it fades rather than pops. Compose it from two layers, both
|
||||
`pointer-events-none` except the button itself:
|
||||
a soft gradient fade from transparent to the card's own dark backdrop across roughly 64px, so the
|
||||
content appears to slide under the edge rather than being cut off; and, centred on it, a small
|
||||
glass pill — the house `bg-black/60` + `backdrop-blur` treatment, `rounded-full`, `text-white/75`
|
||||
at `text-xs`, with the orange accent (`#fb923c` / `text-orange-400`) used only for a downward
|
||||
chevron drawn as inline 24×24 `stroke-width="2"` SVG per the icon convention in
|
||||
`01-UI-SPEC.md`. Copy for the pill: **"One more step below"**. Give the chevron a gentle 2s
|
||||
ease-in-out vertical bob of no more than 3px, defined in the file's existing scoped style block
|
||||
next to `onb-lock-spin`, and guard it with the `prefers-reduced-motion` media query copied from
|
||||
`SendBitcoinModal.vue`.
|
||||
|
||||
Make the pill a real `<button type="button">` whose click smooth-scrolls the confirmation label
|
||||
into view (`scrollIntoView({ behavior: 'smooth', block: 'center' })`) and nothing else — it must
|
||||
never touch `confirmed`, never focus or enable the Continue button, and never call `proceed()`.
|
||||
Give it an `aria-label` naming what it reveals so it is reachable and understandable without
|
||||
sight, and make sure it is keyboard-focusable in the natural order.
|
||||
|
||||
Do not alter the header, the words/QR tab strip, the word grid, the QR block, the warning box, the
|
||||
tickbox markup, the footer, the Continue button, or any existing class on the card or the scroll
|
||||
region. Do not edit `style.css`. Add nothing that renders when `showScrollCue` is false.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/views/__tests__/OnboardingScrollCue.test.ts && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite contains a case asserting the cue element is absent when the scroll element reports no overflow, and a case asserting it is present when it reports overflow with the tickbox below the fold.
|
||||
- The suite contains a case asserting activating the cue leaves `confirmed` false.
|
||||
- `grep -c 'prefers-reduced-motion' neode-ui/src/views/OnboardingSeedGenerate.vue` equals 1.
|
||||
- `grep -c 'scrollIntoView' neode-ui/src/views/OnboardingSeedGenerate.vue` equals 1.
|
||||
- `git diff --stat -- neode-ui/src/style.css` reports no change.
|
||||
- `git diff -- neode-ui/src/views/OnboardingSeedGenerate.vue | grep -c '^-.*type="checkbox"'` equals 0 — the tickbox markup is untouched.
|
||||
- `git diff -- neode-ui/src/views/OnboardingSeedGenerate.vue | grep -c '^-.*path-action-button'` equals 0 — the footer button markup is untouched.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite stays green.
|
||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'One more step below' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op).
|
||||
</acceptance_criteria>
|
||||
<done>The cue appears only when the tickbox is out of reach, reveals it on activation, and leaves everything else about the step untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Confirm it on a real short viewport and settle whether any other step needs it</name>
|
||||
<files>neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) and the onboarding route is reachable there — jsdom proves the logic but only a browser proves it looks right</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/ONBOARDING_FLOW.md` — the step order and which routes make up the flow, so you know
|
||||
which steps to check in the next paragraph.
|
||||
- `neode-ui/DEV-SCRIPTS.md` lines 1-40 — starting and stopping the preview.
|
||||
</read_first>
|
||||
<action>
|
||||
Start the dev preview and open the seed-generate step. Check it at three heights and record each
|
||||
observation in the SUMMARY with the exact viewport used:
|
||||
|
||||
1. A short viewport (for example 1280×620, a small laptop or mobile landscape). Expected: the cue
|
||||
is visible, reads as part of the card rather than an overlay bolted on top of it, and clicking
|
||||
it brings the tickbox into view; the cue then disappears.
|
||||
2. A tall viewport (for example 1440×1000). Expected: no cue at all, and the step is
|
||||
pixel-identical to before this change — compare against the current build if you are unsure.
|
||||
3. A narrow phone viewport (for example 390×740). Expected: the cue reads correctly at that width
|
||||
and does not overlap the word grid or the warning box.
|
||||
|
||||
If the cue does not look like it belongs at any of the three, adjust the gradient depth, the pill
|
||||
size, or the copy until it does, then re-run the test suite. This is the "beautiful way" the user
|
||||
asked for — treat a cue that looks bolted on as a failure of this task, not a matter of taste.
|
||||
|
||||
Then settle the scope question the requirement leaves open. Run
|
||||
`grep -l 'type="checkbox"' neode-ui/src/views/Onboarding*.vue` and, for every step that has a
|
||||
confirmation tickbox inside a scrolling region, check it at the short viewport. If another step has
|
||||
the same defect, apply the same cue there in this plan, add the file to the plan's
|
||||
`files_modified` in the SUMMARY, and extend the test. If no other step does, record the grep
|
||||
output and the verdict. Do not assume the seed step is the only one.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts` exits 0.
|
||||
- `cd neode-ui && npm run build` exits 0.
|
||||
- The SUMMARY records all three viewport observations with exact dimensions, and states explicitly that the tall-viewport rendering was unchanged.
|
||||
- The SUMMARY includes the `grep -l 'type="checkbox"' neode-ui/src/views/Onboarding*.vue` output and a per-file verdict.
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The cue is confirmed to look right at three real viewports, and every onboarding step with a confirmation tickbox has a recorded verdict.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The seed-generate step is assumed to be the one the user hit.** The todo says "verify it's this
|
||||
step". The planner confirmed this step has a confirmation tickbox at the bottom of a scrolling
|
||||
region with a pinned, disabled Continue button below it — the exact reported symptom — but did not
|
||||
enumerate every onboarding view. Task 2 closes this with a grep and a per-file verdict rather than
|
||||
leaving it as an assumption.
|
||||
- **The chosen affordance is the scroll cue, not the sticky-footer alternative.** The todo listed
|
||||
three candidate approaches. The cue was chosen because the other two change the tall-screen
|
||||
appearance (a sticky footer alters the card at every height; an auto-scroll moves content the user
|
||||
did not ask to move), and the standing rule forbids changing existing visuals. If the cue proves
|
||||
unsatisfying at Task 2, raise it rather than silently switching approach.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| displayed recovery seed → screen | This step renders 24 words that grant full control of the node, identities and wallet |
|
||||
| user consent → onboarding progression | The tickbox is the recorded acknowledgement that the seed was written down |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-58 | Repudiation | an affordance that advances onboarding without a genuine acknowledgement | high | mitigate | The prohibition and an explicit test case forbid the cue from touching `confirmed`; the cue only scrolls |
|
||||
| T-01-59 | Information Disclosure | a new overlay covering seed words so a user transcribes them wrongly and loses recovery | high | mitigate | The cue renders only at the bottom edge of the scroll region and only while content remains below; Task 2 requires checking at a narrow width that it does not overlap the word grid |
|
||||
| T-01-60 | Denial of Service | scroll and resize handlers firing continuously on a low-power onboarding device | low | mitigate | The handler is a few property reads and one boolean assignment with no allocation or RPC; listeners and the observer are removed in `onUnmounted` |
|
||||
| T-01-61 | Tampering | the shared onboarding stylesheet being edited and silently restyling every other step | medium | mitigate | All new CSS is scoped to this component, and an acceptance criterion fails the task if `style.css` shows any diff |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — one component and one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the cue copy.
|
||||
- Short, tall and narrow viewport observations recorded, with the tall case confirmed unchanged.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A short viewport shows an on-brand cue that leads to the tickbox in one action.
|
||||
- A tall viewport renders no cue and is unchanged.
|
||||
- The cue never affects consent state or the Continue button.
|
||||
- Motion is reduced-motion guarded and the shared stylesheet is untouched.
|
||||
- Every onboarding step with a confirmation tickbox has a recorded verdict.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md` when done, recording the three
|
||||
viewport observations, any design adjustments made to reach "belongs here", and the per-step grep
|
||||
verdict.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 14
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/composables/usePaidItemViewer.ts
|
||||
- neode-ui/src/views/Cloud.vue
|
||||
- neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-04, UIFIX-06]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Clicking a purchased picture in Paid Files opens it in the app's own lightbox — no browser tab, matching how My Files already behaves (UIFIX-04)"
|
||||
- "A purchased video opens in the same lightbox with its player controls, consistent with every other video in the app"
|
||||
- "A purchased audio track still goes to the global bottom-bar player and never to the lightbox, exactly as today (UIFIX-04 adjacency edge)"
|
||||
- "A purchased file with no in-app viewer (a document) still opens the way it does today rather than failing silently — the change adds a viewer path, it does not remove one"
|
||||
- "The row shows a house loading state for the whole time the purchased file is being fetched, so a slow open never looks like a dead click (UIFIX-06)"
|
||||
- "A fetch that fails or times out surfaces the existing error treatment instead of being swallowed, and the row's loading state clears (UIFIX-06 failure-surfacing)"
|
||||
- "Clicking the same purchased item twice in quick succession produces one fetch, not two (UIFIX-04 concurrency edge)"
|
||||
- "Every surface named by phase 2's findings as slow-opening has a recorded verdict — an existing loader confirmed, or a missing one added (UIFIX-06)"
|
||||
- "A cached revisit still shows no spinner: loaders are driven by a first load, never by a background refresh, preserving PERF-02"
|
||||
prohibitions:
|
||||
- statement: "The viewer path MUST NOT re-charge, re-purchase, or re-request payment for content the buyer already owns — opening a purchased item reads the local owned cache and nothing else"
|
||||
category: safety
|
||||
- statement: "Purchased bytes MUST NOT outlive the viewing session as a reachable object URL — every URL this path creates is revoked by whichever component owns it, with exactly one owner per URL"
|
||||
category: privacy
|
||||
- statement: "No loading affordance may be added to a path that is already instant or already cached — a spinner on a cached revisit is a PERF-02 regression, not a UIFIX-06 fix"
|
||||
category: transparency
|
||||
artifacts:
|
||||
- path: neode-ui/src/composables/usePaidItemViewer.ts
|
||||
provides: "Fetch, decode, route-to-viewer and loading/error state for a purchased item"
|
||||
contains: "opening"
|
||||
- path: neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
|
||||
provides: "Per-mime routing, loading state, error surfacing and double-click dedup"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: neode-ui/src/views/Cloud.vue
|
||||
to: neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
via: "a paid-items lightbox instance fed synthetic FileBrowserItem entries and a resolver that returns the already-fetched object URL"
|
||||
pattern: "MediaLightbox"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make a purchased picture open where every other picture in the app opens — the lightbox — and make
|
||||
the wait visible while it loads.
|
||||
|
||||
Purpose: two user-reported issues that phase 2 classified as pre-existing and captured rather than
|
||||
fixed. `Cloud.vue`'s `viewPaidItem()` calls `window.open(url, '_blank', 'noopener')` (introduced
|
||||
f3393581, 2026-07-22), so Paid Files is the one media surface that leaves the app. The same function
|
||||
issues `content.owned-get` with a 60-second timeout and renders no loading affordance at all, and its
|
||||
`catch` swallows every failure — so a slow or failed open is indistinguishable from a click that did
|
||||
nothing. Both live in the same twelve lines, so they are fixed together.
|
||||
Output: a small viewer composable with tested per-mime routing, a paid-items lightbox in Cloud, an
|
||||
inline house loading state on the row, real error surfacing, and a recorded verdict for every other
|
||||
surface phase 2 flagged as slow.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
||||
@neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `usePaidItemViewer()` | new composable — fetch, decode, route, state | `neode-ui/src/composables/usePaidItemViewer.ts` |
|
||||
| paid-items `MediaLightbox` instance + row loading state | changed template | `neode-ui/src/views/Cloud.vue` |
|
||||
| `viewPaidItem` delegating to the composable | changed script | same |
|
||||
| `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts` | new vitest suite | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible</name>
|
||||
<files>neode-ui/src/composables/usePaidItemViewer.ts, neode-ui/src/views/Cloud.vue, neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/Cloud.vue` lines 150-178 (the Paid Files tab rows: each row is a
|
||||
`glass-card p-3 flex items-center gap-3 cursor-pointer` div with `@click="viewPaidItem(it)"`, an
|
||||
emoji type glyph, filename, size/sats/date line, and a "Paid" pill), lines 456-495 (the
|
||||
`PaidItem` interface, the `paidResource` cached resource with `persist: false`, and
|
||||
`viewPaidItem` itself — the `content.owned-get` call with `timeout: 60000`, the base64→Uint8Array
|
||||
→Blob→`URL.createObjectURL` chain, the audio branch that hands off to `useAudioPlayer`, the
|
||||
`window.open` call, the 60-second revoke timer and the empty `catch`), lines 390-400 (the
|
||||
existing `MediaLightbox` instance for own files, showing exactly which props it takes:
|
||||
`items`, `start-index`, `show`, `fetch-blob-url`, `stream-url`, and `@close`), and lines 696-731
|
||||
(`lightboxIndex`/`lightboxItems` refs and `handlePreview`, the working example of driving that
|
||||
component). Also note `loadError` and the `alert-error` block at line 369 — the error surface
|
||||
this plan reuses rather than inventing one.
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` — the whole file. What matters: it takes
|
||||
`items: FileBrowserItem[]` and filters them by extension through `getFileCategory`, so a
|
||||
synthetic item's `name` must carry a real extension; it calls `props.fetchBlobUrl(item.path)`
|
||||
for images and `props.streamUrl(item.path)` for video/audio when supplied; it caches returned
|
||||
URLs in its own `urlCache` and revokes every one of them in `onUnmounted`. That last point
|
||||
decides URL ownership: whatever this plan hands to the lightbox must not also be revoked by
|
||||
Cloud.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 172-190 — the existing house treatment for exactly this
|
||||
interaction: a per-item `playing === item.id` guard rendering a 3×3 border spinner with an
|
||||
"Opening..." label inside the button. Reuse this treatment; do not invent a new one.
|
||||
- `neode-ui/src/api/filebrowser-client.ts` — the `FileBrowserItem` shape, so the synthetic item is
|
||||
structurally valid rather than cast.
|
||||
- `neode-ui/src/composables/useAudioPlayer.ts` — the `play(url, name)` contract the audio branch
|
||||
already uses.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Given a purchased item with an `image/*` mime, `open()` fetches it once, then exposes it as a
|
||||
lightbox item with a resolvable object URL; it does not call `window.open`.
|
||||
- Given a `video/*` mime, same — routed to the lightbox.
|
||||
- Given an `audio/*` mime, `open()` routes to the audio player and never to the lightbox.
|
||||
- Given a mime with no in-app viewer, `open()` falls back to the existing browser-tab behaviour.
|
||||
- `opening` is set to the item's key for the whole duration of the fetch and cleared in every exit
|
||||
path, including the failure path.
|
||||
- A rejected or timed-out fetch sets an error message and clears `opening`; it does not throw past
|
||||
the caller.
|
||||
- Calling `open()` twice for the same item while the first call is in flight issues one RPC.
|
||||
- The synthetic lightbox item's `name` ends in the real file extension so the lightbox's own
|
||||
category filter accepts it.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails. Stub the RPC client and `URL.createObjectURL`/
|
||||
`atob` at the module boundary; jsdom has no real blob decoding, so assert on what was requested and
|
||||
what was routed where, not on byte content.
|
||||
|
||||
Create `neode-ui/src/composables/usePaidItemViewer.ts` exporting `usePaidItemViewer()` returning at
|
||||
least: `opening` (a ref holding the key of the item currently being fetched, or null), `error` (a
|
||||
ref holding a user-facing message or null), `lightboxItems`, `lightboxIndex`, `resolveBlobUrl(path)`
|
||||
and `open(item)`. Move the existing fetch-and-decode chain out of `Cloud.vue` verbatim — same RPC
|
||||
method, same params, same 60-second timeout, same base64 decode, same blob construction. Then
|
||||
branch on the resolved mime: audio hands off to `useAudioPlayer` exactly as today; image and video
|
||||
build a synthetic `FileBrowserItem` (a stable synthetic `path` key, a `name` that is the item's
|
||||
basename so its extension survives, `isDir: false`, and the size from the item), register the
|
||||
created object URL against that path in an internal map that `resolveBlobUrl` reads, and set
|
||||
`lightboxItems`/`lightboxIndex` to show it; anything else keeps today's browser-tab behaviour
|
||||
including its existing revoke timer.
|
||||
|
||||
URL ownership, stated once so there is exactly one owner: URLs handed to the lightbox are revoked
|
||||
by the lightbox on unmount — the composable must not schedule a revoke for those. URLs handed to
|
||||
the audio player keep today's behaviour. URLs opened in a browser tab keep today's revoke timer.
|
||||
|
||||
Guard concurrency by keying on the item and returning early when that key is already in `opening`.
|
||||
Replace the empty `catch` with one that sets `error` to a short user-facing message (reuse the tone
|
||||
of the existing copy in this view) and clears `opening` in a `finally`.
|
||||
|
||||
In `Cloud.vue`: import the composable, delete the old `viewPaidItem` body and delegate to
|
||||
`open(it)`, and wire two things into the template. First, the Paid Files row gets the PeerFiles
|
||||
loading treatment — while `opening` matches that row's key, render the 3×3 border spinner and an
|
||||
"Opening…" label in place of the "Paid" pill, and make the row non-interactive for the duration so
|
||||
a second click cannot queue. Second, add a second `MediaLightbox` instance below the existing one,
|
||||
bound to the composable's `lightboxItems`/`lightboxIndex`, with `fetch-blob-url` and `stream-url`
|
||||
both pointing at `resolveBlobUrl`, and `@close` clearing the composable's index. Surface `error`
|
||||
through the view's existing `loadError` alert rather than adding a new error element.
|
||||
|
||||
Change nothing else in `Cloud.vue` — not the tab strip, not the category pills, not the Folders,
|
||||
My Files or Peer Files sections, not the peer cards, not the existing own-files lightbox instance,
|
||||
not any cached-resource key, TTL or `persist` flag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/composables/__tests__/usePaidItemViewer.test.ts && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite contains a case per mime family — image, video, audio, and no-in-app-viewer — plus a loading-state case, an error case and a double-click dedup case.
|
||||
- `grep -c 'window.open' neode-ui/src/views/Cloud.vue` equals 0.
|
||||
- `grep -c 'MediaLightbox' neode-ui/src/views/Cloud.vue` is at least 3 (import plus two instances).
|
||||
- `grep -c 'usePaidItemViewer' neode-ui/src/views/Cloud.vue` is at least 2.
|
||||
- `git diff -- neode-ui/src/views/Cloud.vue | grep -c "^-.*key: 'cloud\."` equals 0 — no cached-resource key was moved or renamed.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite stays green.
|
||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'usePaidItemViewer\|Opening…' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op).
|
||||
</acceptance_criteria>
|
||||
<done>Purchased pictures and videos open in the app lightbox with a visible wait and a real error path; audio and documents behave exactly as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing</name>
|
||||
<files>neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts</files>
|
||||
<read_first>
|
||||
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the `## Outstanding` section and the
|
||||
`### Per-surface verdict` block under `## Re-measurement (gap closure)`. These name the surfaces
|
||||
to audit: Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails and
|
||||
Wallet-send. Read them for what each surface's cost actually is — several are *revisit*
|
||||
regressions on already-cached views, which is the one case where a loader would be a PERF-02
|
||||
regression rather than a fix.
|
||||
- `neode-ui/src/components/RefreshIndicator.vue` — the whole file, including its doc comment: it
|
||||
renders only in the `refreshing` state and deliberately renders nothing for `loading`, because a
|
||||
first load is the view's own skeleton's job. This is the rule that decides which affordance a
|
||||
surface needs.
|
||||
- `neode-ui/src/components/SkeletonCard.vue` — the house first-load skeleton, and
|
||||
`neode-ui/src/components/cloud/FileGrid.vue`'s skeleton block for the grid variant.
|
||||
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — the D-05 rules on what a background-refresh
|
||||
indicator may and may not show, so anything added here matches decisions already locked.
|
||||
</read_first>
|
||||
<action>
|
||||
Audit each surface named above. For each one, determine two things from the code: does a first
|
||||
open (no cache) render a loading affordance today, and is the open genuinely slow and uncached
|
||||
rather than a cached revisit. Record a one-line verdict per surface in the SUMMARY as a table with
|
||||
columns: surface, file, existing affordance, genuinely slow first open, action taken.
|
||||
|
||||
Add a house loading affordance only where the audit proves both a genuinely slow uncached first
|
||||
open and no existing affordance — a `SkeletonCard`/`FileGrid`-style skeleton for a list or grid, a
|
||||
`RefreshIndicator` only for background revalidation. Never gate a new affordance on a
|
||||
`refreshing` state for a first load, and never add one to a cached revisit path; PERF-02's
|
||||
no-spinner-on-revisit guarantee outranks this requirement wherever they meet, and phase 2's
|
||||
verdict is that the named revisit regressions are client-side render cost, not a missing loader.
|
||||
|
||||
If a surface needs a fix, implement it in this plan and add its file to `files_modified` in the
|
||||
SUMMARY. If every surface already has one — which the planner's own read of these files suggests
|
||||
is likely, with `Cloud.vue`'s paid-open being the single genuine gap — say so explicitly with the
|
||||
evidence, and do not add a loader for its own sake. A verdict of "already covered" is a valid
|
||||
outcome; an unrecorded surface is not.
|
||||
|
||||
Extend the test file with a case pinning that the paid-open loading state is driven by the fetch
|
||||
being in flight and not by any cached-resource `refreshing` state, so a later refactor cannot turn
|
||||
it into a revisit spinner.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts && npx vitest run && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
|
||||
- The SUMMARY contains the per-surface verdict table with a row for every surface named in `02-FINDINGS.md`'s outstanding list, each with a file path and an explicit action.
|
||||
- Every surface where the action is "loader added" names the file, and that file appears in the SUMMARY's `files_modified` addendum.
|
||||
- The SUMMARY states explicitly that no affordance was added to a cached-revisit path, naming PERF-02.
|
||||
- The test suite contains the case pinning that the paid-open loading state is not derived from a `refreshing` state.
|
||||
</acceptance_criteria>
|
||||
<done>Every flagged surface has an evidence-backed verdict, and the only loaders added are on genuinely slow uncached opens.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **Documents keep the browser-tab path.** UIFIX-04's text names pictures, and the app has no in-app
|
||||
document viewer; routing a PDF into a media lightbox would be a downgrade, not a fix. This is a
|
||||
deliberate scope boundary, not an omission — recorded here so it is visible rather than silent. If
|
||||
the user wants documents in-app too, that is a new requirement, not a gap in this one.
|
||||
- **The planner's read suggests every other named surface already has a loading affordance** (grep
|
||||
showed loading/skeleton markup in Server, Web5, Fleet, AppDetails, MarketplaceAppDetails,
|
||||
Marketplace, Apps, OpenWrtGateway and the Discover app grid). Task 2 re-verifies rather than
|
||||
assuming, because a grep hit is not proof that the affordance covers the *first uncached open*.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| peer-supplied purchased bytes → app-origin viewer | Content bought from another node is now rendered inside the app origin instead of a separate tab |
|
||||
| purchase records → rendered row | Paid amounts and purchase history are financial data already marked `persist: false` |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-62 | Elevation of Privilege | peer-supplied bytes rendered in-origin instead of an isolated tab | high | mitigate | The content is delivered as a blob object URL with the mime the backend reports and rendered only through `<img>`/`<video>` elements the lightbox already uses for local files; no `srcdoc`, no `innerHTML`, no iframe, and the no-in-app-viewer branch keeps today's separate-tab behaviour for anything that is not an image or a video |
|
||||
| T-01-63 | Information Disclosure | a purchased-content object URL outliving the view and remaining fetchable | medium | mitigate | The prohibition fixes exactly one owner per URL; the lightbox revokes what it is given on unmount, and the composable is forbidden from scheduling a competing revoke for those |
|
||||
| T-01-64 | Repudiation | a failed open being indistinguishable from a click that did nothing | medium | mitigate | The empty `catch` is replaced with one that sets a user-facing error through the view's existing alert, and a test case asserts the failure path both surfaces and clears state |
|
||||
| T-01-65 | Denial of Service | repeated clicks queuing multiple 60-second fetches of large purchased files | medium | mitigate | The `opening` key guard returns early for an in-flight item, the row is made non-interactive while loading, and a test case pins single-fetch behaviour |
|
||||
| T-01-66 | Spoofing | a purchased item's declared mime steering it to the wrong viewer | low | accept | Mime comes from the same backend response the current code already trusts for its blob type; this plan changes routing, not provenance, and tightening mime provenance belongs to the content pipeline, not a viewer fix |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — one new composable, one view edit, one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the new strings.
|
||||
- Per-surface slow-open verdict table recorded in the SUMMARY.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Paid Files pictures and videos open in the app lightbox; audio and documents are unchanged.
|
||||
- The fetch is visibly in progress while it runs and its failures are surfaced, not swallowed.
|
||||
- One fetch per click, one owner per object URL.
|
||||
- Every phase-2-flagged slow surface has a recorded verdict, and no cached revisit gained a spinner.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md` when done, recording the
|
||||
per-surface verdict table, any files added to scope by Task 2, and the URL-ownership decision.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 15
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/composables/usePipSession.ts
|
||||
- neode-ui/src/utils/pip.ts
|
||||
- neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
- neode-ui/src/composables/__tests__/usePipSession.test.ts
|
||||
- neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-05]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Entering picture-in-picture closes the lightbox, and it closes with a deliberate handoff animation rather than blinking out (UIFIX-05)"
|
||||
- "The video keeps playing in the picture-in-picture window after the lightbox has closed — closing the lightbox no longer takes the session with it"
|
||||
- "An active picture-in-picture session survives a main-tab change: the playing element is no longer a descendant of any view that a tab switch can detach"
|
||||
- "Buffering does not end the session — a waiting or stalled event pauses nothing, tears nothing down, and leaves the session active (UIFIX-05 adjacency edge)"
|
||||
- "Only an explicit stop ends the session: leaving picture-in-picture is the single path that releases the element and cleans up"
|
||||
- "A normal close, with no picture-in-picture involved, looks and animates exactly as it does today (UIFIX-05 empty edge — the no-session case)"
|
||||
- "The lightbox's props and emitted events are unchanged, so every existing call site keeps working without edits"
|
||||
- "The handoff animation is disabled under prefers-reduced-motion, matching the site-wide convention"
|
||||
prohibitions:
|
||||
- statement: "A picture-in-picture session MUST NOT keep media playing after the user has ended it, and MUST NOT leave an orphaned video element or a live object URL in the document once released — release always tears down what it adopted"
|
||||
category: privacy
|
||||
- statement: "The persistent host MUST NOT be visible, focusable, interactive, or able to affect layout in any state — it is an off-screen custodial element, never a second player UI"
|
||||
category: safety
|
||||
- statement: "This plan MUST NOT change MediaLightbox's prop names, prop types, or emitted events — plan 01-14 adds a second instance of this component in parallel, and a contract change would break it"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/composables/usePipSession.ts
|
||||
provides: "Singleton picture-in-picture session with a body-level custodial host for the playing element"
|
||||
contains: "adopt"
|
||||
- path: neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
|
||||
provides: "Handoff-closes-lightbox, buffering-survives, release-on-leave assertions"
|
||||
min_lines: 50
|
||||
key_links:
|
||||
- from: neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
to: neode-ui/src/composables/usePipSession.ts
|
||||
via: "on enterpictureinpicture the lightbox hands its video to the session host before emitting close, so the element outlives its own unmount"
|
||||
pattern: "usePipSession"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make picture-in-picture behave like a handoff: the lightbox gets out of the way with a fluid
|
||||
animation, and the session then survives everything that used to kill it.
|
||||
|
||||
Purpose: two user reports, both classified by phase 2 as pre-existing. `src/utils/pip.ts`'s
|
||||
`togglePip()` (f72d4b92, 2026-07-23) only toggles the browser API and never touches
|
||||
`MediaLightbox.vue`'s visibility, so entering PiP leaves a full-screen backdrop sitting over the app.
|
||||
And the session dies on a tab change because the `<video>` lives inside a view that used to unmount
|
||||
outright — phase 2's KeepAlive work removed the unmount, which is what makes survival achievable now,
|
||||
but the element is still a descendant of the view tree and of a `Teleport`, both of which a
|
||||
deactivation can move. The fix is to stop relying on where the element happens to live: hand it to a
|
||||
body-level custodial host at the moment PiP begins.
|
||||
Output: a session composable owning the custodial host, a handoff animation on the lightbox, explicit
|
||||
buffering tolerance, and tests that pin all three.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
||||
@neode-ui/src/utils/pip.ts
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `usePipSession()` — `active`, `adopt`, `release`, `element` | new singleton composable | `neode-ui/src/composables/usePipSession.ts` |
|
||||
| body-level custodial host element | new runtime DOM node (off-screen) | same |
|
||||
| `isPipSupported()` | new lazy support probe | `neode-ui/src/utils/pip.ts` |
|
||||
| PiP handoff close + buffering tolerance | changed component behaviour | `neode-ui/src/components/cloud/MediaLightbox.vue` |
|
||||
| `.lightbox-pip-handoff` + reduced-motion guard | new scoped CSS | same |
|
||||
| `usePipSession.test.ts`, `MediaLightboxPip.test.ts` | new vitest suites | new files |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a video handed to the session outlives its owner's unmount</name>
|
||||
<files>neode-ui/src/composables/usePipSession.ts, neode-ui/src/utils/pip.ts, neode-ui/src/composables/__tests__/usePipSession.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/utils/pip.ts` — the whole file (19 lines). Note that `pipSupported` is a
|
||||
module-level `const` evaluated at import time: that is why a test cannot stub support after
|
||||
import, and why this task adds a lazy probe alongside it rather than replacing it outright.
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` lines 20-31 (the PiP button, `v-if` gated on
|
||||
`pipSupported`, calling `togglePip(videoEl)`) and lines 76-87 (the `<video>` element: `ref`,
|
||||
`:src="currentUrl"`, `:key="currentUrl"`, `controls`, `autoplay`). The `:key` binding matters —
|
||||
any change to `currentUrl` destroys and recreates the element, which is one of the ways a
|
||||
session can die.
|
||||
- `neode-ui/src/App.vue` lines 1-60 — how app-level persistent UI is mounted (`GlobalAudioPlayer`
|
||||
is the precedent for something that must outlive route changes). Read for the precedent only;
|
||||
this plan does not modify `App.vue`, because a composable-owned body-level node needs no
|
||||
template anchor and therefore cannot disturb the dashboard DOM shape that
|
||||
`src/views/dashboard/__tests__/keepAliveTabs.test.ts` pins.
|
||||
- `neode-ui/src/composables/useAudioPlayer.ts` — the house convention for a module-singleton
|
||||
composable holding cross-view media state.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `adopt(video)` moves the element into the session host, and the host is a child of
|
||||
`document.body`.
|
||||
- After `adopt`, unmounting the component that originally rendered the video leaves the element
|
||||
still connected to the document.
|
||||
- `release()` removes the element from the host and leaves nothing behind under `document.body`.
|
||||
- The host is created at most once no matter how many times the composable is called, and its
|
||||
computed presentation is non-interactive and off-screen.
|
||||
- `active` is true between adopt and release and false outside that window.
|
||||
- `isPipSupported()` reads the document at call time, so a test can stub support before or after
|
||||
the module is imported.
|
||||
- `togglePip`'s existing behaviour and signature are unchanged.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails. jsdom has no picture-in-picture API, so stub
|
||||
`document.pictureInPictureEnabled`, `document.pictureInPictureElement`,
|
||||
`HTMLVideoElement.prototype.requestPictureInPicture` and `document.exitPictureInPicture` in the
|
||||
test setup, and drive state by dispatching `enterpictureinpicture` / `leavepictureinpicture`
|
||||
events on the element.
|
||||
|
||||
In `neode-ui/src/utils/pip.ts`: add `export function isPipSupported(): boolean` that performs the
|
||||
same three checks at call time instead of at import time. Leave the existing `pipSupported` const
|
||||
and `togglePip` exactly as they are so nothing that imports them today changes behaviour.
|
||||
|
||||
Create `neode-ui/src/composables/usePipSession.ts` as a module singleton exporting
|
||||
`usePipSession()` returning at least `active` (readonly ref), `element` (readonly ref) and the
|
||||
functions `adopt(video: HTMLVideoElement)` and `release()`.
|
||||
|
||||
The host: create it lazily on first `adopt`, once per module, as a plain `div` appended to
|
||||
`document.body` with an identifying `data-` attribute. Style it so it can never be seen or
|
||||
interacted with and can never affect layout — fixed position, off-screen, one pixel, zero opacity,
|
||||
no pointer events, `aria-hidden`, and not focusable. Do not give it a visible size or a z-index
|
||||
that could ever place it over the app.
|
||||
|
||||
`adopt(video)`: append the element into the host (this both keeps it in the document and detaches
|
||||
it from whatever view owned it), record it as `element`, set `active`, and attach a
|
||||
`leavepictureinpicture` listener that calls `release()`. Adopting while a session is already
|
||||
active must release the previous one first rather than leaking it.
|
||||
|
||||
`release()`: remove the adopted element from the host, pause it, clear its `src` and call `load()`
|
||||
so no media keeps buffering, drop the listener, clear `element`, and clear `active`. Leave the
|
||||
host itself in place for reuse — an empty off-screen div costs nothing and re-creating it on every
|
||||
session is churn.
|
||||
|
||||
Do not import this composable anywhere yet; Task 2 wires it. Keep it free of Vue lifecycle hooks —
|
||||
it is a module singleton, and a lifecycle hook in a bare composable is exactly the silent-no-op
|
||||
class phase 2 hit twice.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/composables/__tests__/usePipSession.test.ts && npx vitest run src/composables/__tests__/usePipSession.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/composables/__tests__/usePipSession.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite contains a case asserting the adopted element is still `document.body.contains(...)` after the owning component unmounts.
|
||||
- The suite contains a case asserting `release()` leaves no adopted element under the host.
|
||||
- The suite contains a case asserting repeated `usePipSession()` calls create exactly one host node.
|
||||
- `grep -v '^\s*//' neode-ui/src/utils/pip.ts | grep -c 'isPipSupported'` equals 1.
|
||||
- `git diff -- neode-ui/src/utils/pip.ts | grep -c '^-'` is at most 1 (only the trailing-context line changes; `togglePip` and `pipSupported` are additions-only edits).
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A video handed to the session stays in the document no matter what happens to the component that rendered it, and release tears it down completely.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: The lightbox hands off — enter PiP, animate closed, keep playing</name>
|
||||
<files>neode-ui/src/components/cloud/MediaLightbox.vue, neode-ui/src/components/__tests__/MediaLightboxPip.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` — the whole file (449 lines). Specifically:
|
||||
the `Teleport`/`Transition name="lightbox-fade"` shell and the `v-if="show"` backdrop; the PiP
|
||||
button; the `<video>` with its `ref` and `:key`; `close()`, which emits and nothing else;
|
||||
`onUnmounted`, which revokes every URL in `urlCache`; and the `.lightbox-backdrop` /
|
||||
`lightbox-fade` CSS at the bottom, which is what a normal close animates with today and must
|
||||
keep animating with.
|
||||
- `neode-ui/src/composables/usePipSession.ts` as left by Task 1.
|
||||
- `neode-ui/src/components/SendBitcoinModal.vue` — grep it for `prefers-reduced-motion` and copy
|
||||
that media-query syntax verbatim for the handoff guard.
|
||||
- `neode-ui/src/components/__tests__/` — any existing suite in this directory, for the house
|
||||
mounting and assertion conventions.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Dispatching `enterpictureinpicture` on the lightbox's video causes the component to emit `close`
|
||||
exactly once.
|
||||
- Before that emit, the video has been adopted by the session, so it is no longer a descendant of
|
||||
the lightbox's own subtree.
|
||||
- The handoff class is applied to the backdrop for the duration of the animation and only on the
|
||||
PiP path — closing with the close button or Escape applies no handoff class.
|
||||
- After the component unmounts following a handoff, the video is still connected to the document.
|
||||
- Dispatching `leavepictureinpicture` releases the session.
|
||||
- The component's declared props and emits are unchanged.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test cases first and confirm they fail.
|
||||
|
||||
In `MediaLightbox.vue`, wire the session. On the video element, add `enterpictureinpicture` and
|
||||
`leavepictureinpicture` handlers — listen for the events rather than inferring from the button
|
||||
click, so a PiP entered by any route (the browser's own control, a keyboard shortcut) behaves the
|
||||
same.
|
||||
|
||||
On enter: adopt the video into the session, add a `lightbox-pip-handoff` class to the backdrop, and
|
||||
emit `close` when the handoff animation finishes — drive that off `transitionend` with a bounded
|
||||
fallback timer so a browser that skips the transition still closes. Order matters and must be
|
||||
exactly this: adopt first, animate second, emit last. Adopting first is what makes the element
|
||||
survive the unmount that the emit triggers.
|
||||
|
||||
Design the handoff so it reads as the video moving into the picture-in-picture window rather than a
|
||||
dismissal: the backdrop's blur and opacity fall away while the content scales down slightly and
|
||||
drifts toward the corner the PiP window occupies, over roughly 300ms on the house easing. Keep it
|
||||
scoped, keep it on the existing `.lightbox-backdrop`/content elements rather than restructuring the
|
||||
markup, and guard the motion with the `prefers-reduced-motion` media query copied from
|
||||
`SendBitcoinModal.vue` — under reduced motion the handoff becomes an immediate close, never a
|
||||
lingering one.
|
||||
|
||||
On leave: call the session's release. Because the lightbox has already unmounted by then, the
|
||||
session's own listener from Task 1 is the primary path; the component-level handler exists for the
|
||||
case where PiP is exited while the lightbox is somehow still mounted, and must be idempotent with
|
||||
it.
|
||||
|
||||
Switch the PiP button's `v-if` from the import-time `pipSupported` const to `isPipSupported()` so
|
||||
the button's presence is testable.
|
||||
|
||||
Do not change `props`, `defineEmits`, `close()`'s emitted event, the normal-close transition, the
|
||||
navigation arrows, the keyboard handler, the media-loading logic, `urlCache`, or the `onUnmounted`
|
||||
revoke. Plan 01-14 adds a second instance of this component with the same prop set; a contract
|
||||
change would break it.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/components/__tests__/MediaLightboxPip.test.ts && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts` exits 0 (the `test -f` guard is required).
|
||||
- The suite contains a case asserting exactly one `close` emit on `enterpictureinpicture`, and a case asserting no handoff class is applied on a button-driven close.
|
||||
- The suite contains a case asserting the video is still document-connected after the post-handoff unmount.
|
||||
- `grep -c 'usePipSession' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 2.
|
||||
- `grep -c 'enterpictureinpicture' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1.
|
||||
- `grep -c 'prefers-reduced-motion' neode-ui/src/components/cloud/MediaLightbox.vue` equals 1.
|
||||
- `git diff -- neode-ui/src/components/cloud/MediaLightbox.vue | grep -cE '^-.*(defineProps|defineEmits|fetchBlobUrl|streamUrl|startIndex)'` equals 0 — the public contract is untouched.
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>Entering picture-in-picture animates the lightbox away and leaves the video playing; a normal close is unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Buffering and navigation cannot end a session — then prove it in a browser</name>
|
||||
<files>neode-ui/src/components/cloud/MediaLightbox.vue, neode-ui/src/components/__tests__/MediaLightboxPip.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) and it serves at least one video — jsdom has no picture-in-picture implementation, so only a Chromium-based browser can prove the session actually survives</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` as left by Task 2 — specifically `prev()`,
|
||||
`next()`, the `watch(currentItem, …)` that calls `loadMedia` and sets `currentUrl` to null, and
|
||||
the `:key="currentUrl"` binding on the video. Each of these can destroy the playing element.
|
||||
- `neode-ui/src/views/dashboard/keepAlive.ts` (or wherever `KEEP_ALIVE_PATHS` is defined — grep for
|
||||
it) — enough to understand that a main-tab switch now deactivates rather than unmounts the view,
|
||||
which is the change that makes tab survival reachable at all.
|
||||
</read_first>
|
||||
<action>
|
||||
Close the remaining ways a session can die.
|
||||
|
||||
Add explicit `waiting` and `stalled` handlers on the video that do nothing but record that
|
||||
buffering is happening — no pause, no reload, no src change, no release. Their existence is the
|
||||
point: they document that buffering is a tolerated state and give a test something to assert
|
||||
against, so a later change cannot quietly add teardown there. Do not add a `pause` handler that
|
||||
releases the session; a pause during buffering and a pause by the user are indistinguishable from
|
||||
the element, and only an explicit exit from picture-in-picture may end a session.
|
||||
|
||||
Guard the destroy-the-element paths: while the session is active, `prev()` and `next()` return
|
||||
early, and the `currentItem` watcher does not reset `currentUrl`. In the normal flow the lightbox
|
||||
has already closed by then and these are unreachable, but they are cheap insurance against the
|
||||
exact class of bug this requirement is about.
|
||||
|
||||
Add test cases: a `waiting` event leaves the session active; a `stalled` event leaves the session
|
||||
active; `next()` during an active session does not change the rendered item.
|
||||
|
||||
Then prove it in a browser, because jsdom cannot. Start the dev preview in Chromium, open a video
|
||||
in the lightbox, and record each of these in the SUMMARY:
|
||||
|
||||
1. Click the picture-in-picture button. Expected: the lightbox animates away as a handoff — it
|
||||
should read as the video moving, not as a dismissal — and the video keeps playing in the PiP
|
||||
window.
|
||||
2. With PiP playing, switch between main tabs several times. Expected: playback continues
|
||||
uninterrupted.
|
||||
3. With PiP playing, force a buffering pause (throttle the network in devtools, or seek far ahead).
|
||||
Expected: it resumes and the PiP window stays.
|
||||
4. Close the PiP window explicitly. Expected: playback stops and nothing is left behind — check the
|
||||
element inspector for a stray video under `document.body`.
|
||||
5. Open the lightbox again and close it with the close button and with Escape. Expected: exactly
|
||||
the animation it had before this plan.
|
||||
|
||||
If the handoff does not read as a handoff, adjust the animation and re-record; the requirement
|
||||
asks for a fluid on-brand transition, so a jarring one is a failed task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts && npx vitest run && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
|
||||
- `grep -c 'waiting' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1 and `grep -c 'stalled' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1.
|
||||
- The suite contains buffering-tolerance cases for both `waiting` and `stalled`, and a navigation-guard case.
|
||||
- `grep -rq 'lightbox-pip-handoff' ../web/dist/neode-ui/assets/` succeeds from `neode-ui` after the build (per CLAUDE.md the frontend build can silently no-op).
|
||||
- The SUMMARY records all five browser observations, naming the browser and version, and states whether the handoff needed adjustment to read correctly.
|
||||
- The SUMMARY explicitly confirms observation 4 found no orphaned element left under the document.
|
||||
</acceptance_criteria>
|
||||
<done>Buffering and navigation cannot end a session, and all five behaviours are confirmed in a real browser.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The custodial-host approach was chosen over "keep the lightbox mounted but invisible".** The todo
|
||||
offered both. Keeping the component mounted would leave the video inside a `Teleport` inside a
|
||||
`KeepAlive`d view, and both of those move their subtrees on deactivation — a moved element is a
|
||||
removed element as far as the picture-in-picture spec is concerned. The planner did not verify Vue
|
||||
3.5's exact teleport-under-deactivation behaviour, and deliberately chose the design that does not
|
||||
depend on the answer. If the executor establishes that the simpler approach is safe, raise it rather
|
||||
than switching silently.
|
||||
- **The PiP window's corner is browser- and user-controlled**, so the handoff's drift direction is a
|
||||
best-effort convention, not a guaranteed match. Task 3's browser observation is where it is judged.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| in-app media playback → an OS-level window outside the app's own chrome | Picture-in-picture puts content in a surface the app no longer draws |
|
||||
| adopted element → document lifetime | An element deliberately kept alive past its owner's unmount is state that outlives its normal cleanup |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-67 | Information Disclosure | private media continuing to play in a floating window after the user believes they closed it | high | mitigate | The lightbox closing is now an explicit consequence of the user starting PiP, not a side effect; release pauses, clears `src` and calls `load()`, and Task 3's fourth browser observation requires confirming nothing is left behind |
|
||||
| T-01-68 | Denial of Service | an orphaned adopted element buffering a large stream forever after its owner is gone | high | mitigate | Release is bound to `leavepictureinpicture` inside the session itself, so it fires even when the component that adopted the element no longer exists; a test asserts the host is empty after release |
|
||||
| T-01-69 | Tampering | the custodial host being reachable or clickable and intercepting input | medium | mitigate | The host is off-screen, one pixel, zero opacity, pointer-events none, `aria-hidden` and non-focusable, and the prohibition forbids any state in which it can affect layout |
|
||||
| T-01-70 | Elevation of Privilege | a second component adopting into an already-active session and leaking the first element | medium | mitigate | `adopt` releases any existing session first; a test covers the repeated-adopt path |
|
||||
| T-01-71 | Repudiation | a contract change to the lightbox silently breaking the parallel plan 01-14 | medium | mitigate | An explicit prohibition plus a diff-based acceptance criterion fail the task if props or emits change |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — two new source files, two edits, two vitest files. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the handoff class.
|
||||
- Five browser observations recorded, including the no-orphan check.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Entering picture-in-picture closes the lightbox with a handoff animation and the video keeps playing.
|
||||
- The session survives main-tab changes and buffering; only an explicit stop ends it.
|
||||
- Release leaves nothing playing and nothing orphaned.
|
||||
- A normal close is visually unchanged, and the component's public contract is untouched.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md` when done, recording the five
|
||||
browser observations, the browser and version used, and any animation adjustment made.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 16
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["01-11"]
|
||||
files_modified:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/container/prod_orchestrator.rs
|
||||
autonomous: false
|
||||
requirements: [FED-07]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A node already running a gateway on the shipped default credential rotates itself onto a unique one without an operator having to know it was affected (FED-07 migration)"
|
||||
- "Rotation preserves the gateway's data: /var/lib/archipelago/fedimint-gateway survives, and so do the container name, its ports, its volumes and its adoption identity (CLAUDE.md — migrations never destroy data)"
|
||||
- "A node already carrying a unique credential is left completely alone — detection matches the known defaults only, never 'anything I did not generate this run' (FED-07 adjacency edge)"
|
||||
- "Rotation runs at most once per affected node: after it completes, later reconcile ticks detect nothing and change nothing (FED-07 idempotence)"
|
||||
- "A rotation the operator can see: it is announced in the node's logs naming the app and that credentials changed, and it never prints the credential itself"
|
||||
- "After rotation the operator has a supported way to obtain the new gateway credential, so rotating does not lock them out of their own gateway"
|
||||
- "A rotation that cannot complete leaves the previous working state intact and reports an error rather than leaving a gateway configured against a credential nobody holds (FED-07 failure-surfacing)"
|
||||
prohibitions:
|
||||
- statement: "Rotation MUST NOT delete, move, reinitialise or chown the gateway's data directory, its Lightning backend credentials, or any other app's secrets — it replaces one credential file and lets the existing recreate path rebuild the container around unchanged data"
|
||||
category: safety
|
||||
- statement: "The rotated credential MUST NOT be written to a log line, a status RPC response, a deploy transcript, or any file outside the 0600 rootless secrets directory"
|
||||
category: privacy
|
||||
- statement: "Detection MUST NOT rotate a credential merely because it is unrecognised — only an exact match against the known-default denylist triggers rotation, so an operator who set their own credential deliberately keeps it"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: core/archipelago/src/container/secrets.rs
|
||||
provides: "Denylist-driven detection and rotation of a compromised gateway credential"
|
||||
contains: "rotate_compromised_gateway_credential"
|
||||
key_links:
|
||||
- from: core/archipelago/src/container/prod_orchestrator.rs
|
||||
to: core/archipelago/src/container/secrets.rs
|
||||
via: "the reconcile path that already materialises generated secrets also asks for compromised-credential rotation, so an existing node heals on its next tick"
|
||||
pattern: "rotate_compromised_gateway_credential"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Get the nodes that are already running on the shipped gateway credential off it, without touching
|
||||
their data.
|
||||
|
||||
Purpose: plan 01-11 stops new installs from ever taking a shipped credential, but it does nothing for
|
||||
the nodes that already did. Those gateways answer to a credential published in this repository, so
|
||||
until they rotate, FED-07 is only half closed — and the requirement is explicit that existing installs
|
||||
carrying the default get a migration path. The repo's standing rule bounds how: migrations never
|
||||
destroy data — preserve `/var/lib/archipelago/<app>`, secrets, credentials, ports and adoption
|
||||
container names, and keep a rollback path.
|
||||
Output: detection against the denylist plan 01-11 established, rotation through the recreate machinery
|
||||
that already preserves data, an operator-visible announcement, and a sign-off on a real node.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-11-SUMMARY.md
|
||||
@apps/fedimint-gateway/manifest.yml
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `rotate_compromised_gateway_credential(secrets_dir) -> Result<bool>` | new detection + rotation entry point | `core/archipelago/src/container/secrets.rs` |
|
||||
| rotation call on the reconcile path | changed reconcile step | `core/archipelago/src/container/prod_orchestrator.rs` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a node carrying the default rotates itself and keeps its data</name>
|
||||
<files>core/archipelago/src/container/secrets.rs, core/archipelago/src/container/prod_orchestrator.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/container/secrets.rs` as left by plan 01-11 — the
|
||||
`KNOWN_DEFAULT_GATEWAY_HASHES` denylist, `ensure_gateway_credential`, `gateway_bcrypt_hash`, the
|
||||
shared bcrypt generation helper, and the atomic 0600 `write_secret`. Rotation reuses all of it;
|
||||
write no new generation or file-writing code.
|
||||
- `core/archipelago/src/container/prod_orchestrator.rs` around line 3227 and line 3245 — the
|
||||
comment naming the per-app generated secrets (`fmcd-password`, `fedimint-gateway-hash`, …) and
|
||||
the `crate::container::secrets::ensure_generated_secrets(&self.secrets_dir, manifest)?` call.
|
||||
This is the tick that runs on every reconcile and the natural place to hang detection.
|
||||
- `core/container/src/manifest.rs` — grep for `secret_env_hash` and read its definition and every
|
||||
use. This is the existing mechanism by which a changed secret drives a container recreate, and
|
||||
it is what makes rotation preserve data: the recreate path it feeds already keeps the data
|
||||
directory, ports, volumes and container name. Reuse it rather than stopping and removing the
|
||||
container by hand.
|
||||
- `apps/bitcoin-ui/manifest.yml` lines 8-45 — the in-repo precedent for "the password rotated, so
|
||||
the rendered bytes changed, so the container is recreated". Read it for how a rotation is
|
||||
expected to propagate on this platform.
|
||||
- `core/archipelago/src/container/boot_reconciler.rs` — enough to determine whether boot has its
|
||||
own separate path that also needs the call, or whether it funnels through the same reconcile
|
||||
step. Record the finding; if it needs the call too, add that file to `files_modified` in the
|
||||
SUMMARY.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Given a secrets dir whose gateway hash file contains a denylist entry, rotation replaces it with
|
||||
a freshly generated pair and reports that it rotated.
|
||||
- Given a secrets dir whose gateway hash is not on the denylist, rotation changes nothing and
|
||||
reports that it did not rotate — including when the value is one nobody recognises.
|
||||
- Given a secrets dir with no gateway hash at all, rotation changes nothing and reports that it did
|
||||
not rotate; generation is `ensure_gateway_credential`'s job, not rotation's.
|
||||
- Running rotation twice on the same affected dir rotates once; the second run is a no-op.
|
||||
- After rotation the new hash is not on the denylist and its `.pw` sibling verifies against it.
|
||||
- Rotation touches no file other than the gateway credential pair — every other file in the
|
||||
secrets dir is byte-identical afterwards.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests in `secrets.rs`'s `mod tests` first and confirm they fail. Use `tempfile::tempdir`
|
||||
the way the existing tests in that module do, and seed the affected case by writing a denylist
|
||||
entry into the hash file. Include a case that seeds several unrelated secret files alongside it and
|
||||
asserts they are untouched.
|
||||
|
||||
Add `pub fn rotate_compromised_gateway_credential(secrets_dir: &Path) -> Result<bool>` to
|
||||
`secrets.rs`. It reads the gateway hash file; if it is absent or unreadable it returns `Ok(false)`
|
||||
without writing; if its trimmed value is not an exact match for a denylist entry it returns
|
||||
`Ok(false)`; only on an exact match does it generate a replacement pair through the same helper
|
||||
`ensure_gateway_credential` uses and return `Ok(true)`. Because the underlying write is the
|
||||
existing atomic temp-file-plus-rename, a failure mid-rotation leaves the previous file in place —
|
||||
that is the rollback path, and it should be stated in the function's doc comment so nobody later
|
||||
"improves" it into a truncate-in-place.
|
||||
|
||||
Wire it into `prod_orchestrator.rs` immediately alongside the existing `ensure_generated_secrets`
|
||||
call. When it returns `true`, log at info level that the Fedimint gateway credential was rotated
|
||||
because the node was carrying a publicly known default, that the gateway will be recreated, and
|
||||
where the operator can obtain the new one — and never log the value. Then make the recreate happen
|
||||
through the existing `secret_env_hash` change-detection path rather than by stopping or removing
|
||||
the container directly: the hash file changed, so the resolved secret env changes, so the platform's
|
||||
own recreate machinery fires with the data directory, ports, volumes and container name all
|
||||
preserved. If that path does not fire for this app for some reason you discover, do not hand-roll a
|
||||
remove-and-run; stop and record what you found, because a hand-rolled recreate is the exact
|
||||
anti-pattern CLAUDE.md names.
|
||||
|
||||
Settle the operator-recovery question and record the answer. The plaintext already lands at
|
||||
`fedimint-gateway-hash.pw`, 0600, rootless. Determine whether the app-credentials surface in the UI
|
||||
(`neode-ui/src/views/Credentials.vue` and whatever RPC feeds it) already exposes per-app generated
|
||||
credentials. If it does, confirm the rotated value appears there and say so. If it does not, the
|
||||
log line must name the exact path an operator reads, and the SUMMARY must record that a UI surface
|
||||
is a gap with the file that would own it. Do not leave "how does the operator get the new password"
|
||||
unanswered — rotating a credential the user cannot retrieve is a lockout, not a fix.
|
||||
|
||||
Do not change the gateway's ports, volumes, data directory, network, capabilities, health check or
|
||||
any other manifest-driven property.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago secrets 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && cargo test -p archipelago secrets` exits 0 and its output names cases for: rotates-on-denylisted, no-op-on-unique, no-op-on-absent, idempotent-second-run, and other-secrets-untouched.
|
||||
- `grep -v '^\s*//' core/archipelago/src/container/secrets.rs | grep -c 'rotate_compromised_gateway_credential'` is at least 2 (definition plus test use).
|
||||
- `grep -v '^\s*//' core/archipelago/src/container/prod_orchestrator.rs | grep -c 'rotate_compromised_gateway_credential'` equals 1.
|
||||
- `git diff -- core/archipelago/src/container/prod_orchestrator.rs | grep -ciE '^\+.*(rm -f|remove_dir_all|podman rm|chown)'` equals 0 — no hand-rolled teardown was introduced.
|
||||
- `cd core && cargo build -p archipelago` exits 0 and `cd core && cargo test -p archipelago` exits 0.
|
||||
- The SUMMARY records the boot-reconciler finding, whether the recreate fired through `secret_env_hash`, and the operator-recovery answer with its evidence.
|
||||
</acceptance_criteria>
|
||||
<done>An affected node heals itself on its next reconcile tick, once, without losing data, and the operator can still get into their gateway.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Confirm the rotation on a real node</name>
|
||||
<what-built>
|
||||
FED-07 in full, ready to run on a node:
|
||||
- No code path in the tree can configure a Fedimint gateway with a credential that shipped with
|
||||
the repository. The five fallback sites (two in the Rust orchestrator, three in the install and
|
||||
deploy scripts) are gone, along with the plaintext password fallback in the Tailscale deploy
|
||||
path. The one surviving copy of the old hash is a denylist used only to detect it.
|
||||
- Every install now takes its credential from the per-install secret the manifest already
|
||||
declared, generated at 0600 by `container::secrets`.
|
||||
- A node that is already carrying the old default rotates itself on its next reconcile tick and
|
||||
is recreated around its existing data directory, ports and container name.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run this on archi-dev-box. Note that archy-x250-dev has been offline since phase 2 — do not wait
|
||||
for it; single-node verification with the second-node gap recorded honestly is the expected
|
||||
pattern here.
|
||||
|
||||
1. **Before you change anything, record the current state.** On the node, check whether the
|
||||
gateway credential file currently holds the old shipped value, and whether a gateway container
|
||||
is running. Note both. This is what tells you whether you are testing the rotation path or the
|
||||
already-clean path — say which one you got.
|
||||
|
||||
2. **Deploy this phase's build to archi-dev-box only.** Use the dev-pair deploy path, not a
|
||||
release, not an OTA, and not the Tailscale alpha-tester path. Record the exact command.
|
||||
|
||||
3. **Watch the rotation.** Follow the node's logs across a reconcile tick. Expected if the node was
|
||||
affected: one info line saying the gateway credential was rotated because a publicly known
|
||||
default was in use, naming where to get the new one — and no credential value anywhere in the
|
||||
log. Expected if the node was already clean: no rotation line at all.
|
||||
|
||||
4. **Confirm the credential is now unique.** Read the gateway hash file on the node and confirm it
|
||||
is not the old shipped value, and that its file mode is 0600 and it is owned by the rootless
|
||||
service user, not root.
|
||||
|
||||
5. **Confirm the data survived.** List `/var/lib/archipelago/fedimint-gateway` and confirm its
|
||||
contents are the same ones that were there in step 1 — the gateway's own state must not have been
|
||||
reinitialised. Confirm the container came back with the same name and the same published ports.
|
||||
|
||||
6. **Confirm the gateway actually works.** Check the container is running and healthy, and that its
|
||||
admin endpoint answers. Then authenticate to it with the new credential from the path the log
|
||||
line named. Expected: the new credential works. Then try the old shipped one. Expected: rejected.
|
||||
|
||||
7. **Confirm a fresh install is unique too.** If practical, uninstall and reinstall the gateway on
|
||||
the node and confirm the credential it comes up with differs from the one from step 4 — that is
|
||||
the per-install property, and it is the whole point of the requirement.
|
||||
|
||||
8. **Confirm nothing else moved.** Run `tests/lifecycle/run-gate.sh` on the node (the gate runs
|
||||
on-node, never over RPC) and confirm it is still green. This plan changed orchestrator reconcile
|
||||
behaviour, which is exactly the case CLAUDE.md says to re-run the gate for. A single clean pass
|
||||
is enough here; the 5× run is Phase 3's criterion.
|
||||
|
||||
If any step fails, say which numbered step and what you saw — that becomes the gap list rather than
|
||||
a re-run of the whole plan.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to sign off FED-07, or describe the issues by step number.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **Whether archi-dev-box is actually affected is unknown to the planner.** Its gateway may have been
|
||||
provisioned by a path that generated a unique credential. Step 1 makes the executor establish which
|
||||
case they are in and say so, rather than reporting a green run that never exercised the rotation. If
|
||||
the node is clean, the rotation path still needs proving — seed the old value into the credential
|
||||
file on the node deliberately, then re-run steps 3 to 6, and record that you did.
|
||||
- **Whether the UI already exposes per-app generated credentials** was not verified by the planner.
|
||||
Task 1 makes it an explicit finding with a named owning file if it turns out to be a gap.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| gateway admin API → network | The credential being rotated is the only gate on Lightning gateway administration |
|
||||
| reconcile tick → running container | An automated rotation recreates a live, funded service without asking |
|
||||
| node logs → operator and anyone who can read them | The rotation announcement crosses this boundary |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-72 | Elevation of Privilege | a node continuing to answer to the published default after the code fix ships | critical | mitigate | Detection and rotation run on the same reconcile tick that already materialises secrets, so an affected node heals without operator action; step 6 proves the old credential is rejected afterwards |
|
||||
| T-01-73 | Denial of Service | rotation recreating the gateway repeatedly, or in a loop, on every tick | high | mitigate | Rotation is denylist-exact and therefore self-terminating — the rotated value is not on the denylist, so the next tick is a no-op; an idempotence test and step 3's log observation both cover it |
|
||||
| T-01-74 | Information Disclosure | the new credential appearing in a log line, status output or deploy transcript | high | mitigate | An explicit prohibition, the log line is specified to name a path rather than a value, and step 3 requires confirming no value appears in the log |
|
||||
| T-01-75 | Tampering | a hand-rolled remove-and-recreate losing the gateway's data directory | critical | mitigate | The action forbids hand-rolled teardown, routes the recreate through the existing `secret_env_hash` path, and an acceptance criterion greps the diff for teardown primitives; step 5 verifies the data on the node |
|
||||
| T-01-76 | Repudiation | signing off without ever exercising the rotation because the node happened to be clean | high | mitigate | Step 1 forces the executor to declare which case they are in, and the planner assumption requires deliberately seeding the affected state if the node is clean |
|
||||
| T-01-77 | Denial of Service | an operator locked out of their own gateway by a rotation they cannot recover from | high | mitigate | Task 1 requires the recovery path to be settled and named in the log line before this plan is done; step 6 proves the new credential actually authenticates |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — two Rust edits. If an implementation choice would add a crate, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd core && cargo test -p archipelago` — green.
|
||||
- The blocking checkpoint's eight steps, run on archi-dev-box, with the affected-or-clean case declared.
|
||||
- `tests/lifecycle/run-gate.sh` green on-node after the change.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- An affected node rotates itself once, keeps its data, ports and container name, and comes back healthy.
|
||||
- The old shipped credential no longer authenticates; the new one does.
|
||||
- A fresh install produces a different credential again.
|
||||
- The rotation is announced without ever printing the value, and the operator has a named way to retrieve it.
|
||||
- The second dev-pair node's absence is recorded as a gap rather than glossed over.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md` when done, recording the
|
||||
affected-or-clean verdict for archi-dev-box, the deploy command used, the gate result, the operator
|
||||
recovery path, and any issue text verbatim.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 17
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["01-14"]
|
||||
files_modified:
|
||||
- neode-ui/src/views/Cloud.vue
|
||||
- neode-ui/src/views/PeerFiles.vue
|
||||
- neode-ui/src/views/__tests__/TransportPills.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-01]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every place the cloud surfaces show a file's transport state shows it at mobile widths too — a phone user can see whether a file came over FIPS or over Tor (UIFIX-01)"
|
||||
- "The pills are pinned by a test, so a future cleanup or refactor that removes one fails the suite instead of shipping (UIFIX-01 — 'kept, never removed')"
|
||||
- "A peer whose transport is not yet known renders the existing not-known treatment rather than a fabricated pill (UIFIX-01 empty edge)"
|
||||
- "A pill never truncates into meaninglessness or overlaps its neighbour at the narrowest supported width — it wraps or compacts instead"
|
||||
- "Desktop rendering of every pill is unchanged: same text, same colours, same position, same spacing"
|
||||
- "Every render site of the transport pill in the cloud surfaces has a recorded mobile verdict — no site is left unchecked"
|
||||
prohibitions:
|
||||
- statement: "The transport pill MUST NOT claim a transport the app has not actually observed — it renders from the recorded result of the last real browse, and a missing or stale reading shows the not-known treatment rather than defaulting to the more reassuring value"
|
||||
category: transparency
|
||||
- statement: "Nothing on these views may change except the transport pills' responsive rendering — file rows, peer cards, buttons, badges, counts, tabs and every animation stay exactly as they are, and desktop is untouched"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/views/__tests__/TransportPills.test.ts
|
||||
provides: "A render-site pin for every FIPS/Tor pill, so removal breaks the build"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: neode-ui/src/views/PeerFiles.vue
|
||||
to: neode-ui/src/views/Cloud.vue
|
||||
via: "both read the same recorded browse transport for a peer, so the pill means the same thing wherever it renders"
|
||||
pattern: "transport"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Keep the FIPS/Tor pills forever, and make sure a phone shows them.
|
||||
|
||||
Purpose: UIFIX-01 is a BLOCKER with two halves. The user explicitly values these pills ("really
|
||||
helpful") and asked that no future cleanup remove them — that half is solved by pinning them with a
|
||||
test, which nothing in the repo does today. The other half is that at mobile widths they are hidden or
|
||||
cramped, so exactly the users least able to judge their connection cannot see whether a file arrived
|
||||
over the fast encrypted mesh or over Tor. The planner could not determine which specific render site
|
||||
fails on a phone, so this plan audits every site rather than guessing at one.
|
||||
Output: a complete, recorded per-site mobile verdict; a fix at every failing site; and a test that
|
||||
makes their removal a build failure.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| responsive transport-pill rendering | changed template classes at the failing sites | `neode-ui/src/views/Cloud.vue`, `neode-ui/src/views/PeerFiles.vue` |
|
||||
| `neode-ui/src/views/__tests__/TransportPills.test.ts` | new vitest suite — the "never remove these" pin | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Audit every transport-pill site and fix the ones a phone cannot read</name>
|
||||
<files>neode-ui/src/views/Cloud.vue, neode-ui/src/views/PeerFiles.vue, neode-ui/src/views/__tests__/TransportPills.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) with peer data present — jsdom cannot tell you whether a pill is cramped, only whether it exists</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/Cloud.vue` lines 280-324 — the peer cards in the Folders tab. The badge row
|
||||
is `flex items-center gap-2 text-xs` holding the trust pill and, when
|
||||
`peerTransport(peer.onion)` is known, the transport pill rendering
|
||||
`FIPS`/`TOR` plus a latency figure, with a `Peer Node` text fallback when it is not known. Note
|
||||
the row has no wrapping and no responsive treatment at all.
|
||||
- `neode-ui/src/views/Cloud.vue` lines 199-218 — the Peer Files aggregated list rows. Each row
|
||||
shows a category icon, filename, size and price, and a peer-name pill — and no transport pill,
|
||||
even though these rows are files from peers. Decide, and record, whether this is a site that
|
||||
should carry one: the requirement is about a user seeing a file's transport state.
|
||||
- `neode-ui/src/views/Cloud.vue` lines 150-178 — the Paid Files rows, for the same decision.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 8-38 — the header. There is a desktop title block
|
||||
(`hidden md:block`) carrying the pill, and a separate `md:hidden` copy of the pill added
|
||||
specifically so mobile still sees it. Read the comment above it: someone already fixed one half
|
||||
of this. Confirm whether that copy actually renders and is legible on a phone today.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 640-676 — `transportPill`, the single source of the
|
||||
label, colour classes and tooltip for `fips` / `mesh` / `lan` / `tor` / unknown. This is the
|
||||
canonical mapping; anything this plan adds must use it rather than re-deriving colours.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 152-232 — the per-file card body, which shows an access
|
||||
badge and action buttons, for the same site decision.
|
||||
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — the house convention for a
|
||||
structural pin test, and the file the standing rule requires stay green.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Mounting each surface with a known transport renders the transport pill with its label text.
|
||||
- Mounting with an unknown transport renders the existing not-known treatment and no pill.
|
||||
- Removing the pill from any audited site makes the suite fail — that is the whole point of the
|
||||
test, so write each assertion so it is specific to a site, not satisfied by any pill anywhere.
|
||||
- The pill's label and colour come from the canonical mapping, not from a duplicated table.
|
||||
</behavior>
|
||||
<action>
|
||||
Start with the audit, because the fix depends on it. Start the dev preview and open each of the
|
||||
sites listed above at a phone viewport (390×740 is the reference; also check 320×640, the narrowest
|
||||
the app supports). For each site record: does the pill render at all, is its text fully readable,
|
||||
does it overlap or push anything, and does it survive a long peer name or a long filename. Put the
|
||||
result in the SUMMARY as a table with columns: site, file and line, renders on mobile, legible,
|
||||
action.
|
||||
|
||||
Then fix every site the audit marked as failing, and only those. The likely shapes, depending on
|
||||
what you find: let the badge row wrap (`flex-wrap`) so a pill drops to a second line instead of
|
||||
overflowing; drop the latency figure from the pill at small widths while keeping the transport word,
|
||||
since the word is the security-relevant part and the milliseconds are not; or render a compact pill
|
||||
variant on mobile the way `PeerFiles.vue`'s header already renders a mobile-specific copy. Choose
|
||||
per site based on what you actually saw, and record why. Do not apply a responsive change to a site
|
||||
the audit passed — an unnecessary change to a working desktop layout is exactly what the standing
|
||||
rule forbids.
|
||||
|
||||
Settle the two open site questions rather than leaving them: whether the Peer Files aggregated
|
||||
rows and the Paid Files rows should carry a transport pill. Both list files that came from peers,
|
||||
and the requirement is about a user seeing a file's transport state — but the aggregated rows show
|
||||
files from many peers at once, and a per-row pill may be the honest answer or may be noise. Make a
|
||||
decision, state the reasoning, and if the answer is yes, implement it using the canonical mapping
|
||||
and add it to the pin test. If the answer is no, record why the existing peer-level pill is
|
||||
sufficient for those rows.
|
||||
|
||||
Write the pin test as you go: one assertion per confirmed render site, each keyed to something that
|
||||
identifies that site specifically, plus a comment at the top of the file saying in plain words that
|
||||
these pills are a user-requested permanent feature and that a failure here means someone removed
|
||||
one, not that the test is stale.
|
||||
|
||||
Change nothing else on either view. Desktop rendering must be untouched at every site, including
|
||||
the ones you fix.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/views/__tests__/TransportPills.test.ts && npx vitest run src/views/__tests__/TransportPills.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/views/__tests__/TransportPills.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite has at least one site-specific assertion per render site the audit confirmed, and at least one unknown-transport case asserting no pill is fabricated.
|
||||
- The SUMMARY contains the per-site audit table, with a row for each of the five sites named in `read_first` and an explicit action for each.
|
||||
- The SUMMARY records the decision and reasoning for the Peer Files aggregated rows and the Paid Files rows.
|
||||
- `grep -c 'transportPill' neode-ui/src/views/PeerFiles.vue` is unchanged or higher — the canonical mapping was reused, never replaced.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite, including `keepAliveTabs.test.ts`, stays green.
|
||||
</acceptance_criteria>
|
||||
<done>Every transport-pill site has a recorded mobile verdict, the failing ones are fixed, and a test makes their removal a build failure.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Re-check the fixed sites on a phone viewport and confirm desktop is untouched</name>
|
||||
<files>neode-ui/src/views/__tests__/TransportPills.test.ts</files>
|
||||
<precondition>Task 1's changes are in the working tree and the dev preview can be restarted against them</precondition>
|
||||
<read_first>
|
||||
- The audit table Task 1 wrote into the SUMMARY — it is the checklist for this task.
|
||||
- `neode-ui/DEV-SCRIPTS.md` lines 1-40 — starting and stopping the preview.
|
||||
</read_first>
|
||||
<action>
|
||||
Re-open every site the audit marked as fixed at both 390×740 and 320×640 and confirm the pill now
|
||||
renders fully and legibly, with a long peer name and a long filename present so the overflow case
|
||||
is actually exercised — if the mock data has no long names, edit the rendered text in the element
|
||||
inspector to force it rather than changing the mock backend, and say so.
|
||||
|
||||
Then confirm desktop is untouched. Open each changed site at 1440×900 and compare against the
|
||||
pre-change build. State in the SUMMARY that each changed site renders identically on desktop, or
|
||||
name what moved and fix it — the standing rule is that the only visual change this phase ships is
|
||||
the one the user asked for.
|
||||
|
||||
Finally, confirm the pin does its job: temporarily delete one pill from one site, run the suite,
|
||||
and confirm it fails. Restore the pill and confirm the suite passes again. Record both results.
|
||||
A pin that does not fail when the thing it pins is removed is not a pin.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
|
||||
- The SUMMARY records the 390×740 and 320×640 re-check for every fixed site, including the long-name case and how it was forced.
|
||||
- The SUMMARY states explicitly, per changed site, that desktop rendering at 1440×900 is unchanged.
|
||||
- The SUMMARY records the deliberate-removal check: which pill was removed, that the suite failed, and that it passed again after restoring.
|
||||
- `git status --short -- neode-ui/src/views/Cloud.vue neode-ui/src/views/PeerFiles.vue` shows no leftover deliberate-removal edit.
|
||||
</acceptance_criteria>
|
||||
<done>The pills are readable on the narrowest supported phone, desktop is unchanged, and the pin is proven to actually fail on removal.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The planner could not identify which specific site fails on mobile.** Reading the source showed
|
||||
`PeerFiles.vue`'s header already carries a mobile-specific pill copy added for exactly this reason,
|
||||
and `Cloud.vue`'s peer-card badge row has no responsive treatment at all — the latter is the most
|
||||
likely culprit, but "most likely" is not evidence. Task 1 is therefore an audit that fixes what it
|
||||
finds, rather than a fix aimed at a guessed target. If the audit finds every site already renders
|
||||
correctly, that is a legitimate outcome for the mobile half — record it with the evidence, and the
|
||||
"kept, never removed" half of the requirement is still fully delivered by the pin test.
|
||||
- **Whether the aggregated Peer Files rows and the Paid Files rows should carry their own pill is a
|
||||
genuine product question**, not something the planner should decide from a file read. Task 1
|
||||
requires a stated decision with reasoning either way, so the answer is recorded rather than assumed.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| observed browse transport → security claim shown to the user | The pill is a security signal: it tells the user whether their file moved over the encrypted mesh or over Tor |
|
||||
| peer-supplied names → rendered alongside the pill | Long or hostile peer names share the row the pill lives in |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-78 | Spoofing | a pill claiming a transport that was not actually used, so a user trusts a channel they should not | high | mitigate | The prohibition requires the pill to render only from the recorded browse result; an unknown-transport test case asserts no pill is fabricated, and the canonical mapping is reused rather than duplicated |
|
||||
| T-01-79 | Information Disclosure | a mobile user unable to see that a file arrived over Tor and acting as though it were the trusted mesh path | high | mitigate | This is the requirement itself; Task 1's audit covers every render site and Task 2 re-checks each fix at the two narrowest supported widths |
|
||||
| T-01-80 | Tampering | a later cleanup silently deleting the pills again | high | mitigate | The pin test asserts per site, and Task 2 proves the pin actually fails when a pill is removed |
|
||||
| T-01-81 | Spoofing | a long peer-supplied name pushing the pill off screen so it is effectively absent on mobile | medium | mitigate | Task 2 requires the long-name case to be exercised deliberately at both narrow widths, not just whatever the mock data happens to contain |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — template class changes and one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
|
||||
- `cd neode-ui && npm run build` — green.
|
||||
- Per-site audit table plus the 390×740 / 320×640 re-check and the 1440×900 desktop comparison, all recorded.
|
||||
- The deliberate-removal check confirming the pin fails on removal.
|
||||
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every transport-pill render site has an evidence-backed mobile verdict.
|
||||
- Every failing site is fixed, and no passing site was touched.
|
||||
- Desktop rendering is unchanged everywhere.
|
||||
- A test pins the pills so removing one breaks the build, and that pin is proven to work.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md` when done, recording the audit
|
||||
table, the two site decisions with reasoning, the re-check observations, and the deliberate-removal
|
||||
result.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 18
|
||||
type: execute
|
||||
wave: 9
|
||||
depends_on: ["01-12", "01-13", "01-14", "01-15", "01-17"]
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements: [UIFIX-01, UIFIX-02, UIFIX-03, UIFIX-04, UIFIX-05, UIFIX-06]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All six UI fixes are exercised on real node hardware, not only on the local preview, because two of them are about how the app behaves on a device rather than in a viewport"
|
||||
- "The connected-nodes list scrolls at a sibling-matched height on the node's own screen and at a phone width"
|
||||
- "The onboarding tickbox is discoverable on a genuinely short viewport on the node"
|
||||
- "A purchased picture opens in the app's lightbox on the node, with the wait visible"
|
||||
- "Picture-in-picture closes the lightbox with a handoff and survives a real tab change and a real buffering pause on the node"
|
||||
- "The FIPS/Tor pills are readable at phone width on the node"
|
||||
- "Every surface that was not supposed to change is confirmed unchanged on the node — the standing visual-invisibility rule is verified, not assumed"
|
||||
prohibitions:
|
||||
- statement: "This phase's frontend MUST NOT be deployed beyond the dev pair — no OTA, no release, no fleet node, no alpha-tester path; a verification step is never a reason to widen a deploy"
|
||||
category: safety
|
||||
- statement: "Sign-off MUST NOT be given on local-preview evidence alone for any check that names the node — the local preview and a real device disagree exactly where these fixes matter, which is why phase 2's on-device pass found four issues the preview did not"
|
||||
category: transparency
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Put all six UI fixes in front of a human, on the node, once.
|
||||
|
||||
Purpose: each of plans 01-12 through 01-17 verifies itself with tests and a local-preview observation,
|
||||
which is the right granularity for an autonomous plan but is not sufficient evidence for a
|
||||
user-reported blocker. Two of these fixes — picture-in-picture surviving a tab change, and the pills
|
||||
at phone width — are about device behaviour that a desktop preview cannot reproduce. Rather than
|
||||
interrupting five plans with five checkpoints, they are gathered here so the operator is asked once,
|
||||
after the code is on archi-dev-box. This mirrors how plan 01-10 consolidates the federation and
|
||||
Lightning sign-offs.
|
||||
Output: a recorded sign-off, or a numbered issue list that becomes the input to a gap-closure pass.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Put the six fixes on archi-dev-box, frontend only, dev pair only</name>
|
||||
<files>none — this task builds, deploys and verifies delivery; it modifies no file in the repository</files>
|
||||
<precondition>archi-dev-box resolves and answers over HTTP from this machine, and `scripts/deploy-config.sh` exists (it is gitignored; `scripts/deploy-config.example` documents it) so the deploy script can authenticate</precondition>
|
||||
<read_first>
|
||||
- `scripts/deploy-to-target.sh` lines 1-30 — the usage block. `--frontend-only` skips the Rust
|
||||
build and container rebuilds; `--live` targets the default host; `--both` fans out to additional
|
||||
hosts; `--tailscale` reaches the alpha-tester nodes and must not be used here.
|
||||
- `.planning/phases/02-ui-performance/02-08-SUMMARY.md` — the exact command phase 2 used for the
|
||||
same kind of dev-pair frontend deploy, and its record of archy-x250-dev being offline. Reuse the
|
||||
command shape rather than inventing one.
|
||||
- The five plan SUMMARYs listed in `<context>` — specifically each one's recorded local-preview
|
||||
observations, so you know what the node is expected to reproduce.
|
||||
</read_first>
|
||||
<action>
|
||||
Build the frontend and deploy it to archi-dev-box with the dev-pair frontend-only path. Record the
|
||||
exact command in the SUMMARY.
|
||||
|
||||
Do not use the Tailscale or alpha-tester paths, do not cut a release, do not touch the OTA manifest,
|
||||
and do not deploy to any fleet node. Note that this plan set's FED-07 work is backend and is
|
||||
verified separately by plan 01-16 — this deploy is frontend only.
|
||||
|
||||
Check whether archy-x250-dev is reachable. It has been offline since phase 2. If it is still
|
||||
offline, record that plainly as a gap rather than waiting for it or pretending the pair was
|
||||
covered; single-node verification on archi-dev-box with the second-node gap recorded honestly is
|
||||
the expected pattern for this phase.
|
||||
|
||||
Then confirm the node is actually serving this build before handing over to the checkpoint — fetch
|
||||
the served bundle from archi-dev-box and grep it for strings this plan set introduced (the
|
||||
onboarding cue copy, the picture-in-picture handoff class, and the paid-item viewer). Grep the
|
||||
served asset, not the local `web/dist` copy: per CLAUDE.md the frontend build can silently no-op,
|
||||
and a checkpoint run against a stale bundle is worse than no checkpoint.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>curl -fsS --max-time 20 "${ARCHY_DEV_URL:?set ARCHY_DEV_URL to archi-dev-box's UI base URL}/" -o /dev/null && echo served</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the exact deploy command and the host list it targeted, and that list contains no fleet, alpha-tester or Tailscale host.
|
||||
- The SUMMARY records the served-bundle grep result for all three introduced strings, naming the URL fetched.
|
||||
- The SUMMARY records archy-x250-dev's reachability, and if unreachable records it as an explicit gap.
|
||||
- No release artifact, OTA manifest or catalog was modified — `git status --short -- release-manifest.json releases/ app-catalog/` is empty.
|
||||
</acceptance_criteria>
|
||||
<done>archi-dev-box is serving a bundle that provably contains all six fixes, and the second-node gap is recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Six-fix sign-off on archi-dev-box</name>
|
||||
<what-built>
|
||||
Six user-reported UI issues, now on archi-dev-box:
|
||||
- **Connected nodes (UIFIX-02).** On the Web5 tab, the connected-nodes card no longer grows to fit
|
||||
every node. Its height now comes from the card beside it in the row and the list scrolls inside
|
||||
that height, with a floor so a short neighbour cannot squash it. Below the two-column breakpoint
|
||||
nothing changed.
|
||||
- **Onboarding tickbox (UIFIX-03).** On a screen too short to show the whole seed step, a soft
|
||||
gradient and a small glass pill reading "One more step below" now appear at the bottom edge of
|
||||
the scrolling area. Clicking it scrolls the confirmation tickbox into view, and it disappears
|
||||
once the tickbox is visible. On a tall screen it never appears at all.
|
||||
- **Paid Files pictures (UIFIX-04).** Purchased pictures and videos now open in the app's own
|
||||
lightbox instead of a browser tab. Purchased music still goes to the bottom-bar player, and
|
||||
purchased documents still open the way they did.
|
||||
- **Loader states (UIFIX-06).** Opening a purchased file now shows a spinner and an "Opening…"
|
||||
label on its row for as long as the fetch takes, and a failure now shows an error instead of
|
||||
appearing to do nothing. Every other surface that was flagged as slow was audited and its verdict
|
||||
recorded.
|
||||
- **Picture-in-picture (UIFIX-05).** Entering picture-in-picture now closes the lightbox with a
|
||||
handoff animation, and the video keeps playing. The session survives switching main tabs and
|
||||
survives buffering pauses; only explicitly stopping it ends it.
|
||||
- **FIPS/Tor pills (UIFIX-01).** The pills are now pinned by a test so no future cleanup can remove
|
||||
them, and every site that could not be read at phone width was fixed.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run all of this against archi-dev-box's own UI, not the local preview. Use a real phone or the
|
||||
browser's device emulation for the narrow checks, and say which you used.
|
||||
|
||||
1. **Connected nodes scroll (UIFIX-02).** Open the Web5 tab on a wide window. Expected: the
|
||||
connected-nodes card and the card to its right are the same height, and if there are more nodes
|
||||
than fit, the list scrolls inside the card — the row does not get taller. Switch between the
|
||||
trusted, observers and requests tabs: expected the card's height does not change. Then narrow the
|
||||
window to a single column: expected exactly the layout you had before this change.
|
||||
|
||||
2. **Onboarding cue (UIFIX-03).** Open the onboarding seed step at a short viewport (a small laptop
|
||||
height, or device emulation at roughly 1280×620). Expected: a soft fade and a small pill reading
|
||||
"One more step below" at the bottom of the scrolling area; clicking it brings the tickbox into
|
||||
view and the cue disappears. Confirm the cue does not tick the box for you and the Continue
|
||||
button stays disabled until you tick it yourself. Then open the same step at full height:
|
||||
expected no cue at all and a step that looks exactly as it did before.
|
||||
|
||||
3. **Paid Files in the lightbox (UIFIX-04) and the loader (UIFIX-06).** Go to Cloud → Paid Files
|
||||
and click a purchased picture. Expected: the row shows a spinner and "Opening…" while it loads,
|
||||
then the picture opens in the app's lightbox — no new browser tab. Click a purchased video:
|
||||
expected the same, in the lightbox with player controls. Click a purchased music track: expected
|
||||
the bottom-bar player, not the lightbox. If you can, click one twice quickly: expected one load,
|
||||
not two.
|
||||
|
||||
4. **Picture-in-picture (UIFIX-05).** Open a video in the lightbox and click the
|
||||
picture-in-picture button. Expected: the lightbox animates away in a way that reads as the video
|
||||
moving into the small window rather than the lightbox being dismissed, and the video keeps
|
||||
playing. Now switch between main tabs a few times: expected playback continues. Now cause a
|
||||
buffering pause — throttle the network in devtools, or seek far ahead: expected it recovers and
|
||||
the small window stays. Now close the small window explicitly: expected playback stops and
|
||||
nothing is left behind. Finally open the lightbox again and close it with the close button and
|
||||
with Escape: expected exactly the close animation it had before.
|
||||
|
||||
5. **FIPS/Tor pills at phone width (UIFIX-01).** At a phone width, go to Cloud and look at the peer
|
||||
cards, then open a peer's files. Expected: wherever a FIPS or Tor pill appears on desktop it
|
||||
appears here too, fully readable, not clipped and not overlapping anything, including when a peer
|
||||
name or filename is long. Compare the same screens at desktop width: expected unchanged.
|
||||
|
||||
6. **Nothing else moved.** Move through the main tabs and the Cloud sub-tabs. Expected: the page
|
||||
margins, the slide transitions between tabs, and every existing animation look exactly as they
|
||||
did before this plan set. Phase 2 broke margins and slide transitions this way once, so this is a
|
||||
real check, not a formality.
|
||||
|
||||
If anything fails, say which numbered step and what you saw — that becomes the gap list rather than
|
||||
a re-run of the whole plan set.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to sign off UIFIX-01 through UIFIX-06, or describe the issues by step number.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **archy-x250-dev is assumed to still be offline.** Phase 2 checked three times and found it gone.
|
||||
Task 1 re-checks rather than assuming, and records the gap either way; nothing in this plan blocks on
|
||||
it.
|
||||
- **Whether archi-dev-box has purchased content to test step 3 with** is unknown to the planner. If it
|
||||
has none, say so in the sign-off rather than marking step 3 passed on the demo — a demo-only pass for
|
||||
a paid-content path is exactly the divergence class this phase exists to remove.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| operator judgement → sign-off | A human verdict gates whether these six blockers are considered closed |
|
||||
| deploy host → node | A frontend bundle crosses this boundary onto a live node |
|
||||
| live node → operator observation | Verification runs against a real node holding real federation trust, real purchases and real funds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-82 | Elevation of Privilege | a verification deploy reaching fleet or alpha-tester nodes | high | mitigate | Task 1 requires the frontend-only dev-pair path, forbids the Tailscale and alpha-tester flags and any release or OTA path, and requires the exact command and host list to be recorded for audit |
|
||||
| T-01-83 | Repudiation | signing off against a stale bundle the node never actually received | high | mitigate | Task 1 requires grepping the bundle served by the node — not the local build output — for three strings this plan set introduced, before the checkpoint runs |
|
||||
| T-01-84 | Information Disclosure | a screenshot or recording of the verification exposing a recovery seed from step 2 | high | mitigate | Step 2 exercises the onboarding step's layout only; the operator is not asked to capture or transcribe the words, and nothing in this plan asks for an image of that screen |
|
||||
| T-01-85 | Repudiation | a demo-only pass on the paid-content path being recorded as a node pass | medium | mitigate | The prohibition forbids local-preview evidence for node-named checks, and the planner assumption requires saying so explicitly if the node has no purchased content |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing and modifies no source file — it builds, deploys and asks. If a fix arising from the checkpoint needs a dependency, it belongs in a gap-closure plan whose research covers the Package Legitimacy Gate first |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
The operator's response is the verification. An "approved" response closes UIFIX-01 through UIFIX-06;
|
||||
any described issue is captured verbatim in the SUMMARY as a gap for `/gsd-plan-phase 1 --gaps`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All six numbered checks were exercised on archi-dev-box, at the widths each one names.
|
||||
- The operator either approved or produced a numbered issue list.
|
||||
- The outcome is recorded in the SUMMARY, including which device or emulation was used for the narrow checks.
|
||||
- The archy-x250-dev gap is recorded rather than glossed over.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-18-SUMMARY.md` when done, recording the
|
||||
verdict, the deploy command, the served-bundle grep evidence, the device used for narrow checks, and
|
||||
any issue text verbatim.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user