Commit Graph
1121 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 6ba24b0f4a feat(federation): inbound peer requests live on the map — blinking nodes + accept/reject
- Inbound pending requests render as blinking yellow point-globes on an
  orbit outside the peers, dotted-linked to self, with their own dashed
  guide ring and a Request legend chip.
- Tapping one opens a black-glass popover (name, request message,
  Accept/Reject) centred over the scene; tap-away or ✕ dismisses.
- Reject: the node swells and pops out of existence, taking its link.
- Accept: green burst ring, the point cloud/link morph to the trusted
  colour and the globe glides inward onto the peer orbit, then the data
  refresh replaces it with the real peer node.
- Wired to the same approve/reject RPCs as the pending panel; new
  motionTokens.color.pending (#facc15) token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:37:39 -04:00
archipelagoandClaude Fable 5 e0aebf50f8 feat(federation): 2D/3D projection toggle + interaction fixes + mobile layout polish
- Small glass 2D/3D toggle top-right of the map: tweens tilt/perspective/fit
  (0.9s inOut) so the scene folds flat into the original radial 2D layout and
  back. Default: portrait/mobile opens 2D, desktop 3D; last choice saved to
  localStorage.
- Fix: setPointerCapture retargeted pointerup to the container, suppressing
  click synthesis on children — node taps and the toggle never fired. Drag
  now tracks via window listeners, no capture.
- Fix: stale post-drag distance made the click-suppressor swallow toggle taps
  indefinitely ('stuck' toggle). Distance resets on every pointerdown and the
  suppressor is one-shot.
- Map no longer slides under the floating mobile back pill: the
  mobile-scroll-pad-back panel keeps its full 64px clearance when filled.
- Mobile DID copy/rotate card moved out of the header to below the view tabs
  (new DidCardMobile.vue) and hidden on the Network Map tab.
- Node labels: dark stroke halo removed; 10px on mobile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:31:07 -04:00
archipelagoandClaude Fable 5 aea90984d6 feat(federation): flat 2D-style top-down view on portrait/mobile
Portrait containers now render near top-down and near-orthographic (tilt
-1.35, persp 16) so the map reads like the previous 2D radial layout —
rings become circles with no perspective squash. Desktop keeps the 3D
depth camera. Globe point-spheres unchanged on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:22:31 -04:00
archipelagoandClaude Fable 5 abb4c95337 fix(federation): keep view tabs pill auto-width on desktop in map view
The map view turns the root into a flex column; default stretch alignment
was pulling the mode-switcher full-width on desktop. md:self-start restores
the content-fit pill (mobile stays full-width by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:21:35 -04:00
archipelagoandClaude Fable 5 8354db8e14 feat(federation): point-cloud globe nodes, auto-fit centering, calmer motion
- Nodes are now spheres made of points: fibonacci point-cloud globes with
  depth-shaded dots, limb outline, and a slow local spin (drag adds parallax).
  Self node is black — dark dots over a soft light backing disc with the
  brand-orange sonar pulse marking 'you'. Spheres and dots sized down.
- Scene auto-fits and centres to the container on every device: the outermost
  orbit is sampled through the real camera projection to get true bounds,
  then scaled/centred between the overlays. Portrait screens tilt the camera
  towards top-down so the orbit uses the full height (mobile readability).
- No idle orbiting: nodes hold position with a gentle side-to-side sway;
  drag inertia now settles to a stop. Intro dolly pushes in (0.82→1) instead
  of pulling back, so nothing clips during the intro.
- Labels get a dark paint-order halo and bump to 12.5px on mobile.
- Point clouds update at half frame rate to keep mobile/companion smooth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:20:24 -04:00
archipelagoandClaude Fable 5 fa14f6ebaa feat(federation): GSAP-powered 3D orbital node map, fills viewport to bottom
- Add gsap 3.15 + design-system-aware motion module (src/utils/motion.ts):
  shared colour/duration/ease tokens mirrored from style.css, gsap.defaults,
  live prefers-reduced-motion check.
- Replace the d3 force NetworkMap with NetworkMap3D.vue: peers on projected
  3D orbital rings around the self node, cinematic intro (camera dolly +
  staggered fly-in + ring draw), idle rotation with drag-to-orbit inertia,
  depth-sorted painter's order, trust-colour palette, online/offline states,
  sonar pulse on self, tap-a-node opens the detail modal.
- Map view now fills the dashboard panel to the bottom edge on desktop,
  mobile and companion: .dashboard-scroll-panel:has(.node-map-stage) turns
  the panel into a column (tab-bar/safe-area/audio-player aware padding)
  instead of leaving the old dead bottom margin.
- Reduced motion: intro/idle skipped, scene renders static.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:09:09 -04:00
archipelagoandClaude Fable 5 7fbdabf136 chore(release): carry Cargo.lock version bump + regenerated UI catalog for v1.7.127-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 16:46:12 -04:00
archipelago 41fd231a36 chore: release v1.7.127-alpha
Demo images / Build & push demo images (push) Successful in 3m47s
2026-08-09 16:25:53 -04:00
archipelagoandClaude Fable 5 76e6109aae chore(whats-new): sync v1.7.127-alpha block from CHANGELOG (generated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:45:46 -04:00
archipelagoandClaude Fable 5 44fbdd381b fix(wallet): transactions modal tab strip is glass, not a black slab
Demo images / Build & push demo images (push) Successful in 4m14s
The sticky tab header painted bg-black/70 across the modal — reported as
a black container where the design is transparent glass. The backdrop
blur alone keeps pinned tabs legible over rows scrolling beneath; the
tint drops to the standard bg-white/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 12:36:26 -04:00
archipelago 6f1efcbf7e Merge remote-tracking branch 'gitea-ai/gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf'
Demo images / Build & push demo images (push) Successful in 3m14s
2026-08-09 08:17:22 -04:00
archipelagoandClaude Fable 5 4c430fddff fix(types): tor-running is optional — older daemons don't send it
Demo images / Build & push demo images (push) Successful in 3m41s
The full project build (vue-tsc -b, which also checks test files) caught
two default-ServerInfo constructors the scoped typecheck missed. Rather
than adding the field to both, the type now tells the truth for a
mixed-version fleet: daemons older than 2026-08-09 omit the field, so
consumers must treat absent as "unknown" and only === true as connected —
which is exactly how Home.vue already reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 07:59:33 -04:00
archipelagoandClaude Fable 5 e34937f497 fix(ui): Tor status from the live probe, not from a leftover onion address
Demo images / Build & push demo images (push) Successful in 3m14s
Home's Network card computed "Connected" from server-info's tor-address —
a string read from the hidden-service hostname file, which persists on
disk however dead the daemon is. Three fleet nodes ran with Tor down for
days while their dashboards said Connected; that indicator is why nobody
noticed. Server.vue's Tor label had the same flaw one step removed,
inferring "running" from services having onion_address values while the
correct signal (torDaemonRunning, backed by a real socket probe) sat
computed and unused twelve lines above.

Both now read liveness signals only: Home reads the new tor-running field
(server-side connect to 127.0.0.1:9050), Server.vue uses
torDaemonRunning. The address remains displayed as what it is — an
address — never as proof of life.

Verified: vue-tsc clean; rpc-client suite 79/79.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 07:51:13 -04:00
archipelagoandClaude Fable 5 7ec4bdb22d fix(settings): say plainly which AI categories are context-only
Demo images / Build & push demo images (push) Successful in 4m9s
All ten AI data-access categories offer a context toggle, but only five
(Apps, System, Network, Media, Bitcoin) have any tool behind them — Wallet,
Files, Search, Local AI and Notes grant the assistant visibility with
nothing to act on. The Settings copy implied actions that do not exist.

Operator decision (2026-08-09): ship honest copy now, build the tools as
ordinary backlog. Each of the five carries a contextOnly flag, a "context
only" badge, and an appended description line saying the assistant can see
this but can't take actions with it yet. Drop the flag per category as
real tools land.

Verified: vue-tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 07:11:24 -04:00
archipelagoandClaude Fable 5 6193a009de fix(federation-ui): show trust-change failures inside the modal, not behind it
Demo images / Build & push demo images (push) Successful in 3m35s
changeTrust() wrote failures into `error`, which renders inside <NodeList>
— page content that sits BEHIND the open NodeDetailModal. So when
promoting a peer to Trusted failed, the operator saw the dropdown snap
back and nothing else: the error banner was painted on a surface the
modal was covering. Reported as "the error doesn't show in the modal",
2026-08-09.

NodeDetailModal gains an actionError prop rendered directly under the
Trust Level control, and Federation.vue routes failures there whenever
that node's modal is open (clearing on close and on the next attempt).
The page banner still serves failures with no modal in the way, and the
password-retry path keeps its own in-modal error as before.

Verified: vue-tsc clean; federation component tests 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 06:50:05 -04:00
archipelagoandClaude Opus 5 51a4b59ce8 fix(settings): card the Lightning section, and stop it crying "not installed" mid-rotation
Two operator reports on the same screen.

The section had no card. Every other Settings section wraps itself in
`glass-card px-6 py-6 mb-6` — AccountSection, AIDataAccessSection,
NodeCertificateSection, BackupSection, the lot — and this one rendered as
bare text on the page. Reported twice, because the wrapper lives in the new
component and nothing about adding `<LightningCredentialsSection />` to
SystemSection.vue's list tells you it is missing. Heading moved to h2/text-xl
to match its siblings. A test now asserts the card, so a third report is not
needed.

And rotating told the operator Lightning did not exist. Rotation restarts
LND, so `status.installed` reads false for a moment — and the template read
that literally: "Lightning is not set up on this node yet, so there are no
credentials to rotate. Install the Lightning app first." Seconds after
rotating. On a node with a working wallet. It also replaced the progress they
had every reason to be watching, on the one action that invalidates every
credential their wallet holds.

A container briefly absent is what rotating LOOKS like, not evidence
Lightning was never there. The not-installed message is now gated on
`!rotationInFlight`, which covers both `running: true` and the awaitUntil
window between asking for a rotation and the node reporting one — `installed`
can already be false in that gap, so gating on `running` alone would have
left the same hole. Mid-rotation with no status yet says "Rotating
credentials — Lightning is restarting" instead of falling through to a
details block with empty fields.

awaitUntil became a ref so the computed re-evaluates rather than holding a
stale value until some other reactive dependency happens to change.

Three tests: the card exists; a running rotation does not claim Lightning is
missing; and — the half that matters just as much — a node with genuinely no
Lightning still gets told there is nothing to rotate, so the fix has not
simply hidden a true statement. 16/16, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:11:18 -04:00
archipelago 0ce88de87f Merge remote-tracking branch 'gitea-ai/main' into gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf 2026-08-08 10:10:33 -04:00
archipelagoandClaude Opus 5 cfa6c6cb0d fix(lnd): hold LND's lifecycle lock across a rotation; mock the rotation RPCs
Demo images / Build & push demo images (push) Successful in 3m55s
Reviewing the rotation against what this dev node actually did to LND today —
25 restarts, most of them automatic — surfaced a race the code did not defend
against. Between "stop LND" and "start LND" the rotation owns a stopped
container whose credential material is being deleted, and two background actors
step in there unasked: the health monitor restarts any container it finds
stopped, and the reconciler starts one whose unit is enabled.

Either brings LND back up mid-deletion. LND re-mints macaroons.db on unlock, so
the deletion loop would race a live process writing that file, or "succeed"
against material that had already been regenerated — and the operator would be
told they had rotated while the old root key was still in service. That is the
one outcome this feature exists to make impossible.

It now holds `app_ops::op_lock("lnd")` for the whole rotation. That is the lock
both actors already consult (`lifecycle_op_in_flight`; the health monitor
reaches it through `lifecycle_op_covers_container`), and it additionally
serialises against the package.start/stop/restart workers, so "Restart" on
Lightning mid-rotation queues instead of interleaving. A rotation requested
while one of those is in flight fails fast with a short explanation rather than
waiting silently behind an operation that may itself take minutes.

Deliberately NOT the `user-stopped` marker `recreate_wallet_destructively` uses
for its own window. That marker is a file on disk: a rotation that died between
marking and clearing would leave Lightning suppressed permanently, fixable only
by finding and editing JSON on the node. A lock guard releases when it drops, on
every path including a panic.

Also mocks the three RPCs in mock-backend.js, so the Settings section can be
driven end-to-end without a node — the dev preview otherwise shows only a load
error. The mock advances one step per poll rather than on a timer, which is
deterministic and makes every intermediate state observable.

Verified: cargo check + fmt clean, 6/6 rotation tests, 12/12 component tests,
mock-rpc-parity unchanged (its 2 failures are the in-flight Reticulum panel, not
this), and the three RPCs driven against the live mock through the full arc —
idle → started → 7 steps → ok with the channel count preserved, plus both
password-rejection paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:33:10 -04:00
archipelagoandClaude Opus 5 73813e4738 fix(aiui): a context gather that hangs must not strand the request
Reported: "`files` context request times out". sanitizeFiles makes three
sequential calls into the File Browser app — login, getUsage, listDirectory
— wrapped in a try/catch. A catch only sees a REJECTION. A socket that
connects and then says nothing leaves the promise pending forever, so
handleContextRequest never posts a `context:response` and the AIUI side sits
until its own bridge timeout instead. The File Browser is a plausible source
of exactly that: on this node `/app/filebrowser/api/resources/` does not even
route (404), and its session-cookie path is the subject of a separate open
bug.

The guard goes at handleContextRequest rather than inside sanitizeFiles, so
no category — present or future — can strand the bridge. `files` is merely
the one with three network hops today; sanitizeSystem is also async.

withTimeout resolves rather than rejects, because the caller's one job is to
always answer, and a rejection would just relocate the problem into a catch.
A late null is safe by the protocol's existing shape: the AIUI reader
already treats a response with no usable data as "nothing to show", the same
as an empty category.

Two tests: a never-settling File Browser still produces a
`context:response`, and a healthy category still returns real data rather
than being flattened to null. 27/27 contextBroker, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:18:44 -04:00
archipelagoandClaude Opus 5 b1523d3e42 merge: bring the open-source readiness work onto the phase-13 branch
Merges gitea-ai/main (65 commits) into the phase-13 branch (419) so one
build carries both lines — the AIUI/assistant/container work and the
open-source readiness work (licensing, the marketplace DID signature layer,
the registry domain migration, the secrets and infrastructure scrub).

Every Rust file auto-merged. The container fixes from this branch and main's
registry-domain migration and node-name genericisation coexist without
manual intervention.

Conflict resolution — all of them were modify/delete, and all were resolved
in main's favour deliberately:

`.planning/**`, `scripts/deploy-to-target.sh` and `scripts/setup-aiui-server.sh`
were deleted by main's `6ba05996` ("security: remove all infrastructure and
internal process material from the repo") and added to .gitignore there.
Keeping this branch's copies would have re-committed internal process and
infrastructure material into a repo being prepared for publication, silently
undoing that cleanup. Resolved with `git rm --cached`, so every file remains
on disk locally and in this branch's history — it is untracked, not lost.
The remaining .planning files this branch added after the merge base were
untracked the same way, so the result is consistent rather than half-tracked.

Container suite 221/221 on the merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:02:32 -04:00
archipelagoandClaude Opus 5 1a98b2d0e7 fix(lnd-ui): don't cancel rotation polling before the node reports it running
Demo images / Build & push demo images (push) Successful in 3m24s
Writing the first tests for this section found the bug they were written to look
for. `rotate()` started the poll, then the `load()` immediately behind it took a
status snapshot that did not yet carry `running: true` and cancelled the interval
— so the screen froze on the one action that most needs to show progress. The
operator has just invalidated every credential their wallet holds, the rotation
is genuinely running on the node, and the page tells them nothing is happening
until they reload it by hand.

It survived manual review because the backend flips `running` inside the same
critical section that accepts the request, so the happy path usually wins the
race. "Usually wins a race" is not a property to ship on a credential rotation.

Polling now continues for a bounded window after a request the node accepted,
and stops early as soon as `running` is observed. Bounded, so a request that was
accepted but never acted on stops polling rather than hammering the node.

12 component tests cover the states that carry consequences: the channel census
shown before the button is offered, the stale-BTCPay warning, the difference
between "BTCPay has no internal node" (silence — an absence, not a fault) and
"BTCPay's credential is dead" (a warning), the block on rotating while LND is
unreachable, both poll races above, and that an idle tab does not wake the node.

Verified: 12/12 new, 880/880 frontend tests, vue-tsc clean, and the rebuilt
bundle contains the new strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:32:04 -04:00
archipelagoandClaude Opus 5 7ed28c3c02 chore(open-source): Phase 2 — drop generated, stale and inert tracked artifacts
Open-source readiness plan, Phase 2. Removed (~17 MB, 12.8k lines):

- `.githooks/pre-push` — the hook that re-committed the 27 MB companion APK on
  every push, which the plan names as the root cause of the 5.5 GB history.
  Verified inert first: `core.hooksPath` is unset, so it only ever ran for a dev
  who opted in by hand.
- `neode-ui/dev-dist/` — generated vite-plugin-pwa output (a Workbox bundle),
  tracked and not ignored. Added to .gitignore so it cannot come back.
- `Android/archipelago-0.3.0-debug.apk.zip` — 16 MB, stale, zero references.
- `RELEASE-NOTES-v1.0.0.md` — superseded by CHANGELOG.md.
- `docs/container-architecture.html` (311 KB) and the two generated archive
  HTML artefacts, whose rows are removed from the archive index in the same
  commit so the table doesn't point at deleted files.

THREE items the plan lists were verified and deliberately NOT deleted — the
plan is wrong about each, and following it literally would have lost content or
broken a build:

- `neode-ui/docs/GAMEPAD-NAV-MAP.md` is called "a duplicate of
  docs/GAMEPAD-NAV.md". It is 660 lines against that file's 159 — four times the
  content, not a copy. Needs a human read to decide what to keep.
- `Android/app/debug.keystore` is called "standard practice" to remove. This
  repo deliberately commits it: `build.gradle.kts` sets
  `storeFile = file("debug.keystore")` and `Android/.gitignore` carries an
  explicit `!/app/debug.keystore`, with a comment explaining it exists so every
  machine produces the same debug signing identity. Deleting it breaks Android
  debug builds.
- The three "move to release assets" binaries are not a pure git operation —
  two have live consumers. Detailed in the next message rather than guessed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:57:17 -04:00
archipelagoandClaude Opus 5 d15cd58d7f feat(lnd): rotate Lightning macaroons from the dashboard, and stop stranding BTCPay
Demo images / Build & push demo images (push) Successful in 3m34s
Rotating LND's macaroons was an SSH-only script, which in practice meant it did
not happen — while a macaroon is a bearer token with no revocation and no expiry,
so anything that ever read one keeps the ability to spend until they are
replaced. Settings → Lightning credentials now does it behind the node password,
shows a step checklist, and refuses to report success unless it has confirmed the
node identity and channel census are unchanged.

Three findings from performing a real rotation on a dev node, each fixed here:

1. BTCPay was left holding a dead credential, silently. Its connection string
   carries the macaroon INLINE (LND's datadir is owned by its container subuid,
   so btcpay cannot bind-mount the file), and the daemon only regenerates that
   secret when LND's TLS cert thumbprint changes — which macaroon rotation does
   not touch. Result: btcpay up, LND up, both healthy, every Lightning payment
   failing, nothing anywhere saying why.

2. Rewriting the secret is not enough to fix it. `secret_env_hash` makes the
   change visible as env drift, but the reconcile loop runs `ExistingOnly` at
   boot AND periodically, and there it deliberately leaves running
   restart-sensitive apps untouched — observed once per tick for half an hour on
   the dev node. So this reuses FED-07's `credential_rotated` carve-out via a new
   default-no-op `ContainerOrchestrator::mark_credential_rotated`, on the same
   reasoning: restart sensitivity protects apps that are working, and this one is
   working only in appearance. The shell script cannot reach an in-process flag,
   so it removes the container and lets desired-state recovery rebuild it.

3. LND stayed locked forever on a loaded node. The unlocker is only served after
   channel.db/graph.db/wallet.db open, measured at 2m38s on a box running 30
   containers; the unlock helper gave up at ~60s. That is not a harmless retry —
   reconcile records the post-start hook as failed, restarts LND, and the slow
   open begins again, so the wallet never opens and every LND-dependent app stays
   broken. The not-ready budget is now ~10 minutes; a genuinely wrong password
   still exits on the first pass via `all_rejected`.

Safety properties worth not regressing:
- No macaroon content in any response, error, log line or the polled progress
  feed — digests and byte counts only.
- Rotation unlocks via a new `unlock_existing_wallet_no_wipe`, so there is no
  code path from "rotate my credentials" to `recreate_wallet_destructively`. A
  wallet whose password this node lacks fails the rotation with the wallet intact.
- Channels are compared as active+inactive totals, not `num_active_channels`,
  which legitimately dips after any restart while peers reconnect.
- Backup verified by file count before anything is deleted.

Verified: cargo check + fmt clean, 6 new unit tests and the 6 existing
container::lnd tests pass, vue-tsc clean, and the built bundle contains the three
new RPC method names (the frontend build can silently no-op).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:45:51 -04:00
archipelagoandClaude Opus 5 b7e57ca9cf fix(update): stop presenting the same server as two mirrors
Demo images / Build & push demo images (push) Successful in 3m29s
Both default update mirrors resolve to the SAME host — the primary by name over
HTTPS, the fallback by IP over plain HTTP — while SystemUpdate.vue told the
operator "Servers this node checks for updates. The primary is tried first; if
it's slow or unreachable, the next one in the list is tried automatically."

That promises availability redundancy the pair cannot provide: if the origin is
down, both entries are down. Reported by the operator, who read the list and
correctly concluded the fallback made no sense.

The mechanism is fine and deliberate — it recovers a node whose DNS is broken
or whose clock is wrong, both of which fail TLS while plain HTTP still works,
and it is safe because the manifest carries an Ed25519 signature verified
against the pinned release-root anchor, so transport integrity is not what
protects the update. (That last part only became true once Workstream B pinned
the anchor; before then this fallback would have been a real hole.)

So the bug was the labelling, not the design:

- Backend label "Direct (fallback)" -> "Same server, no DNS/TLS", and the
  comment now states plainly that it is the same host, what it recovers, and
  that real redundancy needs a different one.
- UI copy now scopes the redundancy sentence to genuine mirrors and adds a
  paragraph saying the two built-in entries are one server, what the second
  actually recovers, that it does not help if the server is down, why an
  unencrypted fetch is acceptable, and how to get real redundancy.

The relabel reaches existing nodes: force_ovh_update_primary rewrites labels for
the two default URLs on every load, while the merge matches on URL and never on
label — without that rewrite path a renamed default would have sat in the code
and never propagated to a single deployed node. Noted inline so it is not
re-broken.

Verified: 40/40 update tests pass (including the mirror load/merge/strip ones),
vue-tsc clean, build green, and the new copy is present in the freshly built
SystemUpdate chunk. Nothing in the tree pinned the old label string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:08:38 -04:00
archipelagoandClaude Opus 5 6542f7f736 chore(open-source): sanitize real infra identifiers; tighten .gitignore
Open-source readiness plan, Phase 1 items 3 and 5.

Item 3 turned out to be far narrower than the plan's "93 files" once each hit
was classified rather than bulk-replaced. Sanitized only genuine operator
identifiers:

- FIPS test fixtures and a pine_ha comment carried real node LAN addresses ->
  RFC 5737 TEST-NET-1, the convention already used elsewhere in this repo.
- Real tailnet addresses in fips/endpoints.rs, mock-backend.js and the mesh
  test runner -> the base of the CGNAT range, obviously synthetic.
- Incident comments in appgate/mod.rs and apps/fedimint/manifest.yml named a
  specific node; the role is what carries the meaning, so the address is gone.
- CHANGELOG.md held five real addresses in published release notes — the most
  exposed of the lot.

Deliberately NOT touched, because the plan's item-3 list is over-broad and
following it literally would break working code:

- 192.168.1.1 / .254, 192.168.0.0/16 and 100.64.0.0/10 are generic router
  defaults, RFC1918 classification in backup_rpc, and CGNAT range logic in
  pine_ha / CompanionIntroOverlay. Not leaked infra.
- `tx1138` is listed as a hostname to scrub but is two live things: the
  user-facing default block explorer (`DEFAULT_TX_EXPLORER`) and
  `RETIRED_TX1138_HOST`, the migration constant whose entire job is stripping
  that retired registry from existing nodes' saved mirror lists. Scrubbing
  either breaks a feature. The plan needs this correction.
- Android's `192.168.1.100` strings are UI placeholder text.

Item 5: added *.key, *.pem, id_rsa*, *.sqlite, *.db to .gitignore, with a
negation for core/archipelago/src/appgate/testdata/*.key. Checked those first —
they are documented throwaway TLS fixtures compiled in via include_bytes!, not
node identity — and the negation stops the new rule silently dropping them if
they are ever regenerated. Verified both directions: fixtures not ignored, a
stray key elsewhere caught.

Verified: residual grep for real infra addresses is clean; audit-secrets.sh
still 5/5; app-catalog drift 0 (the fedimint edit is a YAML comment, which does
not survive parsing into the signed catalog); 44/44 fips tests pass with the
rewritten assertion fixtures.

Note: these test runs shared the working tree with another agent's in-flight
LND work, which was present but unstaged and is not part of this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:08:21 -04:00
archipelagoandClaude Opus 5 cd5d7daeae feat(marketplace-ui): show whether an app's authorship was actually proven
Demo images / Build & push demo images (push) Successful in 3m27s
The backend verifies DID signatures as of f0c289a4, but the card only rendered
`trust_tier` / `trust_score`, so the verdict reached the frontend and died
there. Adds a badge next to the existing trust pill.

Deliberately a *separate* badge rather than folding into the trust tier: the
score blends relay count, provenance and policy compliance, while this answers
one narrow question — did the author prove control of the key their `author.did`
names. Merging them would hide the distinction that the signature layer exists
to draw.

- `valid`   → green "signed" with a lock glyph
- `missing` → neutral grey "unsigned" (an unsigned publisher is unproven, not
              hostile, so it reads as absence rather than alarm)
- `invalid` → red "bad signature". Discovery drops these before they reach the
              cache, so it should be unreachable; rendered anyway so the UI
              fails visibly rather than silently if that ever changes.

Two fail-safe details:
- The mapping defaults a missing field to `{status:'missing'}` rather than
  leaving it undefined. A node on an older backend returns no field at all, and
  "we couldn't check" must never render as "signed".
- The `invalid` arm is typed in the RPC client for the same reason: an
  unhandled status falls through to "unsigned", not to the green badge.

The tooltip carries the meaning the two-word badge can't. "Signed" is easy to
misread as "safe", so it says what was actually proven — who published it — and
explicitly that this is not a statement about the app being safe.

Verified: vue-tsc clean, build green, and the new strings are present in the
freshly built Marketplace chunk (the build can silently no-op).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 06:00:19 -04:00
archipelagoandClaude Opus 5 6d33fea157 chore(license): actually delete the proprietary fonts and unused packages
Demo images / Build & push demo images (push) Successful in 3m38s
The audit has claimed since 2026-07-23 that these were git-rm'd. They weren't —
only the web/dist copies went, and all of them were still tracked at HEAD nearly
three weeks later, in a repo about to be published under MIT.

Deleted (~40.7 MB):
  neode-ui/public/assets/fonts/Courier_New/{CourierNew-Bold,CourierNew-Regular}.ttf
  neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf
  neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf
  neode-ui/public/packages/wireguard.apk   (17 MB)
  neode-ui/public/packages/atob.s9pk       (23 MB)

Courier New is Monotype proprietary and Benton Sans is a commercial Font Bureau
typeface — neither is redistributable. wireguard.apk carries GPL-2.0 libwg
components, so shipping it triggers a source offer. atob.s9pk is a Start9
package of unknown license. Redacted's upstream is OFL-1.1 but no license text
was shipped; deleting was cheaper than sourcing it, since it was unused.

Verified unreferenced before deleting, not after:
- Every @font-face rule in the tree (2 in src/style.css, 2 in
  public/entropy/index.html) loads Montserrat. None of these files was ever
  loaded by CSS.
- The three `Courier New` hits (tailwind.config.js `mono`, two public HTML
  font-family lists) name the *system* font as a fallback — they are not
  @font-face sources, so rendering is unchanged.
- wireguard.apk and atob.s9pk have zero references in any tracked file.
- These live under neode-ui/public/, which Vite copies verbatim rather than
  resolving, so their absence cannot break a build.

Deliberately kept: neode-ui/public/packages/archipelago-companion.apk, which IS
live (staged by .githooks/pre-push, the Android release flow, and the in-app
pairing QR); Montserrat (OFL.txt) and Open Sans (LICENSE.txt), both properly
licensed; and neode-ui/test-install.sh, which the same audit line listed but
which is not a licensing concern.

Audit updated: §1 and §3's font/package items marked closed, the false DONE
entry rewritten as a history note rather than deleted — a DONE line here is a
claim and should be re-verified with git ls-tree, which is exactly the lesson.
§2 (zbase32, LGPL-3.0+) is now the last hard blocker.

Side effect: ~40 MB off the frontend OTA tarball.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:24:37 -04:00
archipelagoandClaude eaf0f07346 fix(ui): #ai-data-access anchor exists, so the banner's Settings button lands
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:01:12 -04:00
archipelagoandClaude 2787a9bbfc fix(ui): unknown app ids fall back to the A mark, not a 404 png guess
resolveAppIcon's final arm guessed /assets/img/app-icons/<id>.png — strfry
404'd live. DEFAULT_APP_ICON already existed; the chain now ends on it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:48:55 -04:00
archipelagoandClaude Opus 5 1cd068e4f7 docs(release): curate the v1.7.126-alpha changelog and What's New entry
create-release.sh builds the frontend at step 4 and validates the curated
changelog at step 5, then requires the freshly built bundle to contain the new
version. The version reaches the bundle only through the hand-written What's
New list, so on a fresh release that check can only pass if the changelog and
What's New entries are written BEFORE the script runs. Writing them after is
what aborted the first attempt.

Leads with the downgrade bug, since that is the one users saw: an Update button
offering the release withdrawn for an actively exploited 2FA bypass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:15:01 -04:00
archipelagoandClaude Opus 5 cbfda30579 fix(update): never advertise a downgrade as an update; clear every stale BTCPay pin
Demo images / Build & push demo images (push) Failing after 2m22s
The app store offered "update to 2.3.9" on a node already running 2.4.2 — the
release that fixes an actively exploited 2FA bypass. Taking it would have
rolled the node back onto the vulnerable version.

Root cause: available_update_for_images compared tags for inequality only.
Same repo + different tag meant "update available", with no ordering. Every
version claim upstream of it can go stale — the signed catalog, a legacy
catalog entry, the image-versions.sh baseline pin — and any one of them
lagging turned into a backwards Update button.

Guard added: when both tags parse as dotted-numeric versions, a lower pinned
version is never offered. Tags that cannot be ordered (RELEASE.2024-11-07…,
14-vectorchord0.4.3) keep the previous behaviour rather than silently losing
updates. This makes stale data fail safe, which matters more than any single
pin being correct.

Four sources still named 2.3.9, three of them able to act on it:
- releases/app-catalog.json — a LEGACY `btcpay` entry, distinct from
  `btcpay-server`, carrying a concrete 2.3.9 image. catalog_primary_image
  treats that as authoritative, so this is what drove the button. Fixed, but
  held back from this commit: it needs re-signing.
- scripts/image-versions.sh — the baseline pin used when the catalog does not
  cover an app.
- stacks.rs — the legacy BTCPay installer, twice. The fallback install path
  would have deployed 2.3.9 outright.
- neode-ui curatedApps/marketplaceData and public/catalog.json — the store's
  displayed version, hardcoded rather than read from the catalog, which is why
  it still showed 2.3.9 after the update landed.

Audited every other installer for the same shape. The remaining literals are
the immich stack, which currently agrees with its manifests; hits in
set_config.rs and app_catalog.rs are test fixtures. To keep it that way,
scripts/check-installer-image-pins.py asserts that any installer literal
naming the same repository as an app manifest carries the same tag, and runs
blocking in CI. Verified it catches a simulated revert to 2.3.9.

Tests: 13/13 in image_versions including the exact BTCPay case, a genuine
upgrade still offered, equal versions silent, prerelease suffixes ordered on
their numbers, and opaque tags unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:42:11 -04:00
archipelagoandClaude Opus 5 8e814ca06a feat(registry): move image and OTA references to the public domain
Demo images / Build & push demo images (push) Failing after 2m22s
Replaces the registry host across 86 files: 309 references, covering all 40
app manifests, the orchestrator and container crates, the release and catalog
scripts, both demo-images workflows, the ISO builder, demo-deploy, and the
frontend marketplace data.

Verified the domain actually serves the registry before rewriting anything,
rather than assuming the web host implies the registry:
- TLS verifies clean, HTTP/2 on the web root
- an anonymous token grants a manifest fetch (HTTP 200) with no credentials
- skopeo inspect --no-creds resolves an image and lists its tags

That last check is the one that matters: an outside developer with no account
can now pull, which was the functional blocker for publishing at all.

Plain-HTTP references become HTTPS in the same pass, so OTA downloads stop
crossing the network in the clear.

Deliberately NOT rewritten:
- The public FIPS anchor on port 8444. It is a functional network endpoint
  every node dials to bootstrap the mesh — closer to Bitcoin Core's hardcoded
  seeds than to leaked infrastructure. The domain does resolve to the same
  host, so it could become a hostname, but that adds a DNS dependency to the
  path used precisely when things are broken. Worth a deliberate decision,
  not a side effect of this change.
- The companion APK on port 2100. The domain returns 404 for that path, so
  rewriting it would swap a working URL for a broken one. The Releases page
  does serve (200), which is where the plan already wants those binaries.
- releases/app-catalog.json, releases/manifest.json and release-manifest.json.
  These carry `signature` and `signed_by`; editing their contents invalidates
  the signature and the fleet refuses artifacts that fail verification. They
  were rewritten in a first pass and reverted — they must be regenerated and
  re-signed through the signing ceremony instead, which needs the mnemonic.

So the catalog still advertises the old host until that ceremony runs. Nodes
resolve images through the signed catalog, not the on-disk manifests, so this
commit alone does not change what a node pulls.

Verified: archipelago-container 75/75; every manifest still parses with a
top-level app block; no signed artifact modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 11:31:20 -04:00
archipelagoandClaude Opus 5 b2c7592840 security: parameterize node addresses; drop dead APP_URLS config
Demo images / Build & push demo images (push) Failing after 2m16s
Keeps the dev and test tooling an outside contributor would want, and takes
our node addresses out of it.

Scripts that silently defaulted to one of our nodes now require an explicit
host and exit 2 without one: smoke-test.sh, trust-archipelago-cert.sh,
dev-container-test.sh (which also derives its RPC and health URLs from the
SSH target instead of a second hardcoded copy), and image-recipe/dev-branding.sh.
A default that points at a machine the user does not own is worse than no
default: it fails confusingly, or reaches a stranger's device.

Usage examples, mock data and test fixtures move to the RFC 5737
documentation range (192.0.2.0/24). CGNAT test values stay inside
100.64.0.0/10 so the range-check semantics they exercise still hold, and
192.168.1.0/.1/.254 are left alone — those are gateway logic and UI
placeholders, not our addresses.

Playwright and the perf spec defaulted their baseURL to one of our nodes;
they now default to localhost:8100, the local dev server.

Removed neode-ui APP_URLS entirely. It is dead code — exported, never
imported — and it pinned fedimint's *prod* launch URL to 192.168.1.228:8175.
Had anything consumed it, every user's node would have tried to reach an
address that on their LAN is either nothing or someone else's machine.
Deleting beats sanitizing dead config.

Verified: frontend 868/868 vitest across 108 files; archipelago-container
75/75; mesh tests 9/9; audit-secrets 5/5. Zero node addresses and zero node
names remain in tracked files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:53:25 -04:00
archipelagoandClaude Opus 5 6ba0599639 security: remove all infrastructure and internal process material from the repo
Demo images / Build & push demo images (push) Failing after 2m13s
The repo is source code and guidelines only. Nothing about how Archipelago's
own fleet is run, or how the team works, stays in it.

Untracked (kept on disk, gitignored) — 250 files:
- .planning/ (199) and loop/ — internal development process
- fleet operations tooling that targets specific nodes: deploy-to-target,
  deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server,
  setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair,
  image-recipe/sync-from-live.sh
- image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of
  which are live-server workflow and fleet node inventories
- the Phase 10 on-node verification and evidence records, which cite .planning/
  as their evidence base

KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10
docs and then put back: it is cited as normative rationale from ten places in
the codebase, including core/clippy.toml, which bans rand::thread_rng and
points at it for the reason. That makes it a guideline, not an internal record.

Node names removed from source (48 occurrences across comments, manifests and
test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt,
zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the
date, which is what carried the meaning; the machine name did not.

Three of those were live test values rather than comments and were replaced
with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name.
An earlier pass substituted "a test node" into a hostname assertion, producing
an invalid hostname; caught and fixed as test-node.local.

Wipe mechanism: .local-only/manifest.txt inventories every local-only path and
.local-only/wipe.sh deletes them on one confirmation, refusing to touch
anything git still tracks. Both are themselves untracked, so the public repo
does not carry a map of internal filenames.

Verified: cargo check -p archipelago --all-features clean; archipelago-container
75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link
in tracked markdown resolves (0 broken).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:37:20 -04:00
archipelagoandClaude Opus 5 19082a44f0 security: remove node credentials from tracked files (open-source Phase 1)
Demo images / Build & push demo images (push) Failing after 2m28s
Scrubs the fleet SSH/UI password from every tracked file (22 occurrences)
and removes inline credentials from the code paths that used them.

Docs and trackers keep the surrounding context — these are published under
docs/history/ per the open-source plan — with the literals replaced by
<FLEET_PW> / <FLEET_PW_ALT> so the "two variants exist" detail survives
without the values.

Three of the eight files were in .planning/ and were NOT in the plan's
enumerated list; the reworked audit-secrets.sh found them.

Code changes:
- neode-ui/test-openwrt.mjs: node URL and password come from ARCHY_NODE_URL /
  ARCHY_NODE_PW; the SSH target derives from the URL instead of a hardcoded
  tailnet IP; exits 2 when unset.
- scripts/run-post-install-tests.sh: drops the built-in "testpass123!"
  default and adds --password-stdin; refuses to run unauthenticated instead
  of silently trying a known password. --phase1-only still needs no password.
- .gitea/workflows/post-install-tests.yml: sshpass with an inline literal
  replaced by key auth (NODE_SSH_KEY secret); password comes from the
  NODE_UI_PASSWORD secret and is piped over stdin rather than argv, so it
  stays out of the node's process list and the job log. Default target IP
  removed.

scripts/audit-secrets.sh now reports 5/5 pass, 0 fail.

Note: rotation of the exposed credentials is deliberately deferred to the
pre-publish gate and is NOT done by this commit — these values are still
live. See Phase 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:59:10 -04:00
archipelagoandClaude 3f22e4375d fix(ui): strict-TS cast in peers per-onion grouping
vue-tsc rejected the double cast; vitest strips types so it slipped
through. Narrow once into a local instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 08:45:46 -04:00
archipelagoandClaude c25fd8b650 fix(content): owner never pays for their own files; purchased serves from cache
- serve_content takes owner_session: a validated operator session skips the
  availability/paid gates (Availability::Nobody stays delisted); the cookie
  is re-validated in the content handler, same discipline as the model proxy
- the Tor proxy serves already-purchased items from the local content_owned
  cache with Range slicing (206) instead of re-hitting the seller's 402 —
  the buyer-side store exists so an owned item is never bought twice, and
  its cards were rendering as permanent placeholders
- adapter: 'own'-scope items never render locked (a locked card suppresses
  the playable URL — the placeholder-only grid the operator reported)
- broker: normalize 'purchased' OwnedRpcItems per item with the seller's
  onion, and group 'peers' items per seller onion, so buildMediaUrl gets a
  peerOnion and card URLs stop coming out empty

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 08:40:49 -04:00
archipelagoandClaude Opus 5 c810b514ed fix(search): AIUI web search was 403ing on every query
SearXNG defaults to `formats: [html]`. Its JSON API answers 403 —
and JSON is the only thing AIUI's web search speaks, since
`/aiui/api/web-search` proxies straight through to `/search`. Both
places that seed settings.yml (the first-boot script and the installer)
omitted `search.formats`, so web search has never worked on a node
whose SearXNG was installed, running and healthy. It reads as the
assistant being unable to search rather than as one missing config key.

Verified on archi-dev-box: `format=json` went 403 -> 200, returning 28
results for "bitcoin halving" from Brave and DuckDuckGo. Google and
Startpage self-suspend on a self-hosted instance (access denied /
CAPTCHA), which is expected and costs little given Brave's independent
index.

Existing nodes need the same two lines added to
/var/lib/archipelago/searxng/settings.yml and a restart; this commit
only fixes what new installs get.

Also fixes a build break: `fetchLibraryContent` built a bundle literal
that predates the images bucket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 05:07:10 -04:00
archipelagoandClaude Opus 5 9abc162394 fix(aiui): the content surface renders what the assistant found
Four defects, one visible symptom: a correct prose answer beside an
empty grid.

1. The assistant's curated RPC bridge had an arm only for
   `content.list-mine`. `tools.rs` mapped the `peers`, `purchased` and
   `films` scopes onto three real, dispatcher-registered handlers that
   `assistant_dispatch_tool` had never heard of, so every non-"own"
   scope died on its catch-all. Downstream that read as "the peers have
   no content" — it was a missing match arm, and the tool never ran.
   Regression test added: every scope the schema advertises must reach a
   real handler.

2. `content.browse-all-peers` wrapped its whole fan-out in one
   `timeout(..).unwrap_or_default()`, which DISCARDED every completed
   batch the moment the budget expired. One slow peer turned a
   partly-successful browse into "0 reached, 16 unreachable". Observed
   live on archi-dev-box: back-to-back calls returned real peer items,
   then nothing. Now accumulates per batch and checks a deadline between
   them, so partial results always survive. Budget 20s -> 45s: two
   batches of eight at a 10s per-peer timeout had no headroom at all.

3. `assistant.chat` returned only `{ text }`. The structured results of
   any content tool the turn ran were dropped inside the loop, so the
   surface had nothing to render. The turn now carries them through
   (captured raw, before the untrusted wrap, since they go to a renderer
   that treats every field as inert data, never back into the prompt).

4. The adapter classified images as 'excluded' and dropped them. A node
   sharing mostly photos rendered as an empty grid while AIUI's image
   grid sat unused. Images now have a bucket, with the paid-lock and
   extension-fallback handling audio and video already had.

Also: the panel says "Loading…" while a turn is in flight and "Nothing
found" when it comes back empty, instead of leaving the previous
query's heading standing as though it answered this one; the system
prompt tells the model to call the content tool and summarise rather
than re-list what the cards already show; and a refused tool now names
its permission category so the trusted chrome can offer the settings
screen instead of leaving "I don't have a tool for that" as the only
clue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 05:00:54 -04:00
archipelagoandClaude Opus 5 75919a2071 fix: cap peer browse, containerise the cert section, serve LAN HTTPS
content.browse-all-peers had a per-peer timeout but no OVERALL budget. On this
node that meant >45s with no answer, which the assistant reported to the
operator as "having trouble accessing the peer content list". Measured cause:
16 federated peers, 1 reachable. Now bounded to 20s total, returning partial
results with peers_reached / peers_total / peers_unreachable / partial, so the
assistant can say "1 of 16 peers answered" instead of implying the rest have
nothing. Verified on the node: 20.015s, was >45s.

NodeCertificateSection had no container — I copied a section that sits INSIDE a
card rather than one that provides its own. Now uses the same
`glass-card px-6 py-6 mb-6` shell and heading level as every other settings
section, so it matches on desktop and mobile.

setup-node-ca.sh now also ensures the nginx HTTPS listener, because a CA is
useless if nothing serves TLS. It binds LAN addresses ONLY: tailscaled already
owns :443 on the tailnet addresses with its own Let's Encrypt cert, so a plain
`listen 443 default_server` binds 0.0.0.0 and fails EADDRINUSE — and nginx then
keeps running the OLD config while the reload reports success. Hit exactly that
on archi-dev-box. Port 80 keeps serving: nodes are reached by IP on LANs where
forcing a redirect would strand anyone who has not installed the CA.

Live now: https://192.168.63.240/ and https://<host>.local/ both 200 with
verify=0 against the node CA, http still 200, tailscaled's 443 untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 03:52:57 -04:00
archipelagoandClaude Opus 5 0a23c99463 fix(mesh): view crashed with a temporal-dead-zone error on load
"[Vue Error] ReferenceError: Cannot access 'b' before initialization" from
Ye.immediate, taking the whole Mesh view down.

A watcher with `immediate: true` runs DURING setup. This one calls
handleFetchContent, whose body touches consts declared further down the setup
block — so on any session where history already contained an inline
content_ref, it dereferenced a binding that did not exist yet. handleFetchContent
itself is a hoisted `function`, which is why the call site looked innocent.

The initial pass moves to onMounted, which runs after setup completes: every
binding is initialized, and already-loaded history still gets the same
treatment as new messages, which is what `immediate` was there for.

Also adds .planning/todos/pending/2026-08-07-open-task-list.md — one flat list
of everything open, including the app-lifecycle reports (fedimint guardian
installs but does not work, BTCPay wipe not wiping, Bitcoin Knots vanishing,
fedimint gateway dying at 88%), the missing app_install tool behind
"!ai install bitcoin knots", and the LND UI 401s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 03:33:43 -04:00
archipelagoandClaude Opus 5 762c72b4d0 feat(ai): AI Data Access grants live on the node, not in localStorage
Operator: "the AI Data Access settings are not persistent through sessions,
often turns them all off."

They were stored in localStorage, which is scoped to an ORIGIN — and a node
answers on several: LAN address, Tailscale address, <host>.local, hostname.
Granting Media over the LAN and returning over Tailscale showed every switch
off again. Not reset: never set *there*. It also made a working content path
look broken, because every scope silently returns nothing without a grant, so
an ungranted permission is indistinguishable from an empty library — that is
exactly what an empty films search turned out to be.

The grant answers "what may the assistant read about THIS NODE", which is a
property of the node, not of one browser at one address. New
settings/ai_permissions.rs (same shape as session_policy: atomic temp+rename,
sanitised on read and write, fails closed on a corrupt file — an unreadable
grant file must never read as "everything allowed"). New ai.permissions.get /
.set, absent from the unauthenticated allowlist so they require a session.

Migration, not replacement: if this browser holds grants and the node holds
none, the local set is pushed UP rather than wiped. Without that, upgrading
would silently revoke the grants of everyone who set them before this change.
The node still wins in every other direction, so a revocation made on one
device takes effect everywhere — otherwise revoking would be impossible from a
second device.

Unknown category ids are stored verbatim rather than validated against a
hardcoded list: a third copy of that list would silently drop a new category on
upgrade. Storing a category grants nothing by itself — the broker checks before
fetching and the node re-checks before answering (T-13-33).

Hydration happens ONCE at broker start, not inside each permission gate: the
gates are hot-path, and awaiting there adds an RPC to every content and context
request. The first attempt did it per-gate and the existing broker tests caught
it by failing on consumed mocks.

Rust 7/7, store 18/18, broker 23/23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:47:40 -04:00
archipelago f7bde19860 Merge branch 'main' into gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf
# Conflicts:
#	.planning/config.json
2026-08-06 16:02:47 -04:00
archipelagoandClaude Opus 5 aac81503c3 fix(13-11): content grid dropped everything except music
The AIUI-03 stale-response guard used ONE counter for every content:request,
so requests for different kinds cancelled each other.

useArchy.ts init fires content('all','own') and library('own') back to back.
Both sequence numbers are assigned synchronously, before either awaits, so the
first request always resolved with a stale number and was silently discarded.
Films, podcasts and this node's own files never reached the grid no matter what
the user did — only music ever arrived. Nothing logged, because discarding is
the guard working as written.

The guard is now keyed by kind+scope. Different kinds populate different grids
and cannot stale each other by definition; only a newer request for the same
grid can, which is what the guard was actually for. The existing out-of-order
test (same kind twice) is untouched and still passes.

This is the same shape as the defect 13-11 already fixed once: machinery built
and unit-tested end to end, while nothing real ever reached the UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:45:27 -04:00
archipelagoandClaude Opus 5 f09ff102ee fix(ui): app frames follow the dashboard's scheme instead of forcing http
Demo images / Build & push demo images (push) Failing after 2m10s
Both schemes now work, and each one works properly:

- HTTP dashboard  -> http app origin  (unchanged; no certificate needed)
- HTTPS dashboard -> https app origin (needs the node CA + TLS on the port)

The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed
content — blocked outright, before the SameSite cookie question the symptom
was filed under. It is also what made the two origins schemefully cross-site,
so following the page's scheme fixes both causes at once.

Backend-reported runtime URLs get the same treatment: the daemon reports
http:// because that is how the app binds locally, which is right for the node
and wrong for a browser on an HTTPS page.

pageScheme() defaults to http when location.protocol is absent (non-browser
contexts) — the safe direction, since inventing an https URL for a port that
serves no TLS would break a working setup. That default is also why the three
existing resolveAppUrl tests, whose fixture stubs location without a protocol,
keep passing unmodified rather than being edited to fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:43:08 -04:00
archipelagoandClaude Opus 5 aab74127f5 feat(tls): per-node certificate authority + Settings install flow
Demo images / Build & push demo images (push) Failing after 2m19s
The node served a bare self-signed leaf, so a browser exception had to be
granted per ORIGIN — scheme + host + port. The dashboard on :443 and an app
on :8334 are different origins, and a certificate interstitial CANNOT be
accepted inside an iframe, so a gated app embedded over HTTPS could never
render no matter how many warnings the user clicked through. (Mixed content
blocks the plain-HTTP variant first, before the SameSite cookie question the
symptom was originally filed under.)

A CA fixes it structurally: ports are not part of a certificate's identity, so
one leaf with the right SANs covers every port on the host, and one installed
CA trusts them all.

- scripts/setup-node-ca.sh generates the CA (4096-bit, pathlen:0, keyCertSign
  only) and issues a 397-day leaf covering archipelago.local, the hostname, the
  Tailscale MagicDNS name and every global address the host holds. Idempotent —
  re-running reuses the CA and only reissues the leaf, so gaining an address
  does not invalidate copies users already installed. --force-ca is the
  deliberate escape hatch and says what it costs.
- nginx serves the public CA at /ca.crt on both schemes, unauthenticated by
  design: a device fetches it before it can validate the node, so gating it
  behind HTTPS or a login would be a chicken-and-egg.
- Settings → System shows the fingerprint and per-platform install steps.
  crypto.subtle does not exist outside a secure context — precisely the case
  this feature exists to fix — so an HTTP dashboard gets the openssl command
  to verify by hand instead of a blank field.

Verified locally: chain validates, key pairs with the leaf, CA:TRUE/CA:FALSE
are correct, keys are 0600. Two TLS servers on different ports both verify
(ssl_verify_result=0) against the CA alone and are rejected without it — the
one-CA-covers-every-port claim, tested rather than assumed.

Not yet wired: app ports still serve plain HTTP. Putting TLS on them is the
next step and is what actually closes the iframe-login bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:38:30 -04:00
archipelagoandClaude Opus 5 c65ee03a5c fix(ui): a warming-up app reads as "starting", not "App not reachable"
Demo images / Build & push demo images (push) Failing after 3m45s
A container that is up but hasn't answered its probe yet rendered the hard
failure overlay — padlock icon, "App not reachable", "the container is
stopped". Both bitcoind (RPC -28 for its whole warm-up) and lnd (unreachable
until the wallet unlocks) sit in that window on every boot, so the node
looked broken while it was working normally.

The retry machinery was already correct: 6 × 10s of automatic re-checks, and
the app appears on its own when it answers. Only the headline was wrong. While
those retries are in flight AND the package reports running/starting/restarting
(or health "starting"), the overlay now shows the app's own pulsing icon,
"<App> is starting…", and says the container is running. Once retries are
exhausted the failure is real again and the original copy returns.

Follows the ElectrumX sync-screen precedent already in this file, which
suppresses the same overlay for the same reason. The explicit blocked-reason
and must-open-new-tab paths are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:28:17 -04:00
archipelagoandClaude Fable 5 24a34a373f fix(ui): tx links stop leaking to a third-party explorer on a load race
Recurring regression (reported again on .228, 2026-08-06): clicking a tx
opened the tx1138.com consent modal even though the node runs Mempool.

Root cause was never the preference — getAppState() reports
'not-installed' for an app whose container list simply has not been
fetched yet, so a click that landed before the list arrived took the
external path. Timing-dependent, hence 'fixed a thousand times'.

- container store:  flag +  (shared in-flight
  promise) so 'not yet known' is distinguishable from 'not installed'.
- openTx: awaits real data, and the local app wins whenever installed —
  including stopped/restarting, where the app session's own controls are
  the right landing place. Only a genuinely app-less node goes external.
- 5 regression tests incl. the race itself; vue-tsc -b clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:11:57 -04:00
archipelagoandClaude Fable 5 ee9dde9936 fix(mesh-ui): rnodePlan computed for the setup modal (strict TS)
Demo images / Build & push demo images (push) Failing after 4m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:35:55 -04:00
archipelagoandClaude Fable 5 4773b32a76 feat(mesh-ui): region-recommended RNode plan applies from the setup modal too
Demo images / Build & push demo images (push) Successful in 4m4s
The device-detected modal's region selector now drives real RNode
settings instead of a "managed by the daemon config" shrug: choosing a
region shows its concrete plan (frequency/bw/SF/CR/power) and Apply &
Connect writes it through mesh.rnode-config-apply — the same
radio-confirmed round-trip as the Device panel, best-effort so a plan
failure never aborts the connect. RNODE_REGION_PLANS moves to
utils/loraRegions (single source shared by panel + modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:31:01 -04:00