Compare commits

...
Author SHA1 Message Date
archipelagoandClaude Opus 5 6c6e237646 fix(container): remember that an app is installed, so a vanished one comes back
An app whose container disappeared could be lost permanently. Desired-state
recovery decided whether to recreate it from running-containers.json — "what
was running at the last snapshot" — which is a different question and a
perishable answer: it records only what is running NOW, so an app that stays
down long enough simply ages out. Once out, boot's ExistingOnly mode will not
recreate it, because it cannot tell "installed and lost" from "merely
available in the catalog". Manifest still on disk, nothing to bring it back.

This is the second occurrence of one root cause. indeedhub-minio/-postgres
went permanently absent on one node (2026-08-06); the fix then was
`absent_stack_member_with_live_sibling`, which only rescues a stack member
that still has a living sibling. bitcoin-knots is standalone, so on
archi-dev-box (2026-08-08) it vanished, aged out, and stayed gone — LND
crash-looping on `lookup bitcoin-knots: no such host` for hours, electrumx
unable to reach its daemon, and an orphaned fedimint container waiting 30
hours for a host that no longer resolved. Recovering it took a manual
reinstall. This is the general fix the narrow one implied.

Installation is a DECISION, not a runtime observation, so it gets a record
that no amount of downtime erodes: installed-apps.json, written when an
install succeeds and cleared on uninstall, in the same breath as
mark_user_uninstalled — leaving a stale claim would let recovery recreate the
app that was just removed. It is the durable counterpart to the
user-uninstalled marker that already existed.

Safety, in order of how badly each could go wrong:
- Cannot resurrect a deliberate uninstall: user_uninstalled is checked
  earlier in ensure_running_with_mode and returns before anything is created,
  and uninstall clears this record too.
- Cannot install an app nobody asked for: only names in the record qualify,
  and ExistingOnly's other guards are untouched.
- Cannot mislead a node upgrading into the feature: backfill seeds from
  ADOPTED containers only — evidence that something is really there — skips
  anything user-uninstalled, is additive so a momentarily-down app is never
  dropped, and no-ops on an empty adoption list (podman unreachable must not
  read as "nothing is installed").

Four tests, including the one that states the point: the record must outlive
a running-snapshot that has gone empty.

Container suite 221/221, crash_recovery 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:59:23 -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
archipelagoandClaude Opus 5 b6010af10d fix(install): Bitcoin starting up is not a reason to refuse an install
Reproduced live: the operator pressed install on ElectrumX shortly after
Bitcoin Knots started and got

  Bitcoin RPC returned 500 Internal Server Error while checking pruning
  status: {"error":{"code":-28,"message":"Verifying blocks…"}}

-28 is RPC_IN_WARMUP — bitcoind's normal path on every start, lasting
minutes on a large chainstate. check_bitcoin_pruning_compatibility retried
`for _ in 0..3` with a 2s sleep, so it gave the node about six seconds and
then reported warm-up as a hard failure. Any app requiring unpruned Bitcoin
was therefore uninstallable in the ordinary window after Bitcoin starts.
This is the likely mechanism behind the operator's "fedimint gateway
disappeared at 88% install": both fedimint apps declare a bitcoin-core
dependency and sit in exactly that window.

The same install path already knew better. wait_for_bitcoin_rpc_gate waits
180s precisely because getblockchaininfo answers during sync. This check
runs earlier and disagreed — one concern, two contradictory answers, in one
install. It now shares the budget, and a test asserts the two stay equal.

Only NOT-READY is waited out: -28 by code, plus the "loading block index" /
"verifying blocks" / "rewinding blocks" message shapes for a proxy that
rewrites the envelope. A genuine fault — bad auth, method not found,
unparseable body — still ends the loop on the first response, so a broken
RPC fails fast instead of burning 180s. Both directions are pinned by tests,
because being too loose here is as bad as being too strict.

Warm-up is announced ONCE to the install log, so a slow install reads as
"waiting for Bitcoin" rather than a stall. And the failure message now says
what to do — "Bitcoin is still starting up… wait until it reports it's
synced, then try again" — instead of pasting the raw JSON-RPC envelope,
which was accurate and useless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:07:17 -04:00
archipelagoandClaude Opus 5 ec0cfd1b7d fix(container): unwire the companion reaper — absence is not uninstallation
Caught on archi-dev-box within minutes of deploying 3ac59a73: the reaper
removed archy-bitcoin-ui and archy-lnd-ui, whose backends ARE installed.
archy-bitcoin-ui was gone for 36 minutes, until the operator reinstalled
bitcoin-knots and `reconcile` put the companion back.

Not a logic error — the arithmetic did what it was told. The inputs were
false. Both backends' containers were missing because of the clean-exit
vanishing bug (8908fb4f), and both had already aged out of
running-containers.json, which only ever records what is CURRENTLY RUNNING.
So the two signals `installed_app_ids` combines are not independent: one root
cause falsifies both simultaneously. ORPHAN_GRACE could not help either — the
condition was persistent, not transient, which is exactly the case the grace
period cannot distinguish.

The asymmetry decides it. An un-reaped orphan costs a stale UI tile. A
wrongly-reaped companion costs a working screen and turns one lost app into
two — the reaper amplifies the very failure it was meant to tidy up after.

`reap_orphans` and its tests stay, documented as NOT TO BE WIRED until a
durable record of "this app is installed" exists to drive it. Inferring
installation from runtime state cannot answer that question, however many
runtime signals are combined.

The provisioning half is untouched and is the actual fix for "fedimint
installs but does not work": driving `reconcile` from installed_app_ids means
a companion is never stood up for an app nobody installed, so no NEW orphans
appear. The one genuine orphan on this node (archy-fedimint-ui, for an app
never installed) was correctly removed before this change landed.

Unit tests passed the reaper because they verify the set arithmetic, not
whether the "installed" signal is truthful. Only the device could show that.

Container suite 221/221.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:08:48 -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 18f09d49d4 fix(indeehub): NIP-98 auth must prove an identity, never mint one
`sign_nip98` read the node's pubkey through `get_nostr_pubkey`, which goes
through `load_or_create_nostr_keys`. On a node with no Nostr identity that
does not fail — it GENERATES a keypair, writes the secret to disk, and signs
with it. So an IndeeHub auth header could quietly create a new node identity
as a side effect, then authenticate as a stranger holding a key nobody has
ever seen. The `.context("node has no Nostr identity")` guarding the call
could never fire, because the call could never fail that way.

`nostr_identity_exists` is the missing distinction: bootstrap may create,
but anything AUTHENTICATING as this node must prove the identity it already
has. sign_nip98 now gates on it and bails loudly.

Caught by `a_nip98_event_names_the_exact_url_and_method`, which asserts
exactly this ("must fail loudly rather than sign something empty") and had
been failing since the file landed in 58c759c1 — invisible because the
earlier runs on this branch filtered to `container::`.

Full bin suite 1381/1381.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:51:32 -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 78f0370afc test(aiui): the three "pre-existing failures" were stale tests, not defects
AIUI has carried 353/356 as a known-open item (W1.7) for long enough that
the three reds were treated as background noise. All three were the tests
being wrong. 357/357 now.

`injects web results into system prompt when enabled` asserted
`body.webSearch === true`. The code deliberately sends false there:
`proxyWebSearch = webSearchEnabled && !clientSearchSucceeded`, so when the
client-side search has already run and injected its results into the system
prompt, asking the proxy to search again would be a second redundant search
on every turn. The assertion predates that change. Fixed, and the other half
of the contract added as its own case — zero client results must still ask
the proxy to search.

The two seed-songs failures ("extracts 10 songs", and the conversation
regression built on the same fixture) were one wrong number:
`expected: { songs: 10 }` against an assistantResponse containing exactly
six `song_ext` entries and ending coherently on Treefingers. Not truncated —
just miscounted. The extractor was returning the right answer the whole
time. Counted from the fixture rather than from intent, so the number now
describes the input instead of contradicting it.

Full branch state after the main merge: AIUI 357/357, neode-ui 972/972
across 114 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:31:45 -04:00
archipelagoandClaude Opus 5 71164137ef perf(aiui): drop the 1 MB backdrop JPG the webp replaced
e36f36ee re-encoded the chat backdrop to webp (1052K -> 478K) to fix "the
background takes ages to load", but left the JPG in place. ChatPage.vue
references only `bg-intro-3.webp`, so the 1053K JPG has been shipping in
every AIUI bundle, every deploy and every ISO since, referenced by nothing.

Checked before removing, because the same filename IS live elsewhere:
`appgate/mod.rs` serves `bg-intro-3.jpg` as one of four LOGIN_BACKGROUNDS,
but from `/opt/archipelago/web-ui/assets/img` — the neode-ui copy, which is
untouched here. Only AIUI's duplicate goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:21:59 -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 278232d7cb docs(13): the UAT record is stale — the node reverted to a main build
13-15's gate requires the DEPLOYED surface to be checked, not only the
source. That half is currently void: archi-dev-box runs a binary dated
2026-08-08 03:20 built from main, not this branch — no `app_uninstall` in
`strings`, and the ownership hooks chown unconditionally with no drift-gate
`stat` calls, so b9e64eb6/db8937f9/ca106c5a/b8869307 are all absent. Every
row of the acceptance table was verified against a binary the node no longer
runs, 417 commits back.

Also corrects row 2. The record captured scope `own` only, which cannot
discharge check 2's "real peer/owned files"; and the 2026-08-06 note saying
peers/owned "exist only in type signatures" is obsolete — 05b459a6 and
9abc1623 made requestArchyAllContent fetch all three scopes from init(),
deduped through one sink, which the operator confirmed on 2026-08-08 is the
intended auto-load-at-init behaviour. Check 2 is code-complete and
verification-pending, not unbuilt.

Records the ordered steps to actually close the phase, and the pre-deploy
orphan-companion evidence snapshot the reaper will consume.

The phase stays OPEN. Nothing here closes 13-15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:39:58 -04:00
archipelagoandClaude Opus 5 b57f363745 fix(container): a no-op ownership repair must not fail the whole reconcile
archi-dev-box logged `reconcile failed app_id=btcpay-server error=chown
/var/lib/archipelago/postgres-btcpay failed with status exit status: 1`
while BTCPay was running and healthy and there was nothing to repair:
`find /var/lib/archipelago/postgres-btcpay ! -uid 100998` returns zero
files, and the identical command run by hand exits 0. The chown through
`sudo systemd-run` had simply failed once, and that transient failure
propagated out of the pre-start hook and took the app's entire reconcile
with it.

These hooks exist to repair OLD installs. On a healthy node the repair is
already a no-op, so its failure is not evidence of anything being wrong.

repair_dir_ownership folds the gate, the chown and the verdict into one
place: skip when ownership is already right, chown when it is not, and on a
failed chown RE-PROBE before deciding it matters. If the ownership is
correct anyway — a concurrent repair, or a transient sudo/systemd-run
failure on an already-correct tree — warn and continue. Only a chown that
fails AND leaves the ownership wrong is an error, which is the case the
loud failure was written for: a mis-owned volume the app genuinely cannot
open.

Replaces the three hand-rolled gate+chown+bail blocks in
ensure_btcpay_stack_dirs and the one in ensure_fedimint_dirs.

Container suite 215/215.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:37:44 -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 a9cefb8326 fix(lnd): give the rotation's verify step its own deadline
The mint wait and the post-rotation verify shared one 15-minute budget. A
rotation that legitimately spent 14 of those minutes waiting for LND to mint a
fresh macaroon — normal on a loaded node, where opening channel.db/graph.db/
wallet.db alone has been measured at 2m38s — then had 60 seconds to confirm the
node identity and channel census came back, and would report FAILURE on a wallet
that was completely healthy.

That is the most alarming possible way to be wrong about someone's Lightning
node: it names a backup directory and tells them to investigate before retrying,
at the exact moment nothing is actually broken. Each wait now gets its own
budget. Waiting longer costs nothing here — the failure this step exists to catch
(changed identity, missing channels) is not time-sensitive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:14:48 -04:00
archipelagoandClaude Opus 5 45c2925bdd chore(open-source): drop the indeedhub submodule — it breaks --recursive clones
Demo images / Build & push demo images (push) Successful in 3m30s
Open-source readiness plan, Phase 2.

The `indeedhub` submodule points at a Gitea repo that is not public and carries
no known licence (the licence audit defers it: "partnership in place; license
the submodule before/at public release"). An outside developer running
`git clone --recursive` today either fails on auth or pulls unlicensed code —
a bad first five minutes with the project either way. It was never checked out
in this tree.

Removing it costs nothing, because nothing builds from it:

- `indeedhub-demo/Dockerfile` states in its own header "No submodule or local
  source needed" and clones the public GitHub mirror instead.
- Every other `indeedhub/` reference in the tree is `apps/indeedhub/` — the app
  package — which is a different path and untouched. The app itself ships as a
  container image from the registry and is unaffected.

Kept `indeedhub-demo/` rather than dropping it as the plan suggested: it is a
working, self-contained demo build with no submodule dependency, which is
exactly the shape the rest of Phase 2 is moving toward.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:57:18 -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 8908fb4ff9 fix(container): apps stopped cleanly must come back — Restart=always
"Bitcoin Knots disappeared again, plus other apps." Root cause is a pairing,
not a single bug: quadlet renders `podman run ... --replace --rm`, so the
container is deleted the moment it stops, and from_manifest set
Restart=on-failure, which declines to restart after a CLEAN exit. bitcoind
exits 0 on SIGTERM. So any clean stop deleted the container AND left it
deleted — the app vanished from podman and from My Apps until a later
archipelago reconcile tick noticed and recreated it. That is the
"previously-running app has no container after boot — recreating
(desired-state recovery)" line, which fired for bitcoin-knots at 18:53,
19:57 and 20:39 and for electrumx at 19:57 and 20:42 on 2026-08-07.

A crash always self-healed: on-failure restarted the unit and podman run
recreated the container. Only a clean exit stranded it, which is why this
survived so long.

The justification for on-failure was wrong on systemd's own semantics. It
read "clean exits — e.g. operator-issued systemctl stop — stay stopped", but
Restart= is never consulted for a unit stopped via systemctl stop
(systemd.service(5)), and that is exactly how archipelago stops these apps
(prod_orchestrator -> stop_service_with_timeout). Always keeps the
stopped-stays-stopped behaviour and drops the failure mode.

Always also restores the premise of the Quadlet migration — systemd owns
supervision, so an app returns without archipelago alive to notice it left.

Checked before flipping: no manifest declares a one-shot container and there
is no manifest-level restart field, so nothing gets restart-looped.
Propagation to existing nodes is via sync_quadlet_unit's drift re-render,
which rewrites the unit and daemon-reloads WITHOUT restarting the service —
running containers are undisturbed and the new policy governs the next start.

OnFailure is kept as a deliberate opt-in with a note not to wire it back to
backends. Two tests now pin the new default and assert on-failure is absent
from a rendered backend unit.

Container suite 215/215.

NOTE FOR THE OPERATOR: this changes supervision semantics for every app on
the Quadlet canary path. Wants sign-off and a lifecycle-gate run before OTA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:56:08 -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 adc3c444cd fix(container): an absent container is not proof of uninstallation
installed_app_ids judged installation on live containers alone. Watched on
archi-dev-box within the hour: lnd read as ABSENT, then as EXISTS again.
Containers on this node come and go — the boot reconciler logs
"previously-running app has no container after boot — recreating" for
bitcoin-knots and electrumx repeatedly — so a momentary gap looked exactly
like a removal, and the reaper would have taken a healthy companion's unit
with it. ORPHAN_GRACE narrows that window but cannot close it: nothing
bounds how long a gap lasts.

An app now counts as installed if its container exists in any state OR its
container name is in the durable last-running snapshot. That snapshot is
what crash_recovery itself calls "installation evidence" and what
reconcile_all_with_mode already trusts to recreate a previously-running app
whose container vanished — the same signal, for the same reason, now shared
rather than reinvented.

Only fedimint is a true orphan on this box: it appears in no adoption list
and has no quadlet unit of its own. lnd is installed and merely flapping,
which is a separate bug.

Container suite 215/215.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:44:05 -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 7567c25c00 fix(aiui): stop fighting the virtualizer over scrollTop
"Failed to scroll to index N after 10 attempts" appeared in the console on
every send. It was not a real failure — it was two scroll controllers
arguing.

scrollToBottom() called virtualizer.scrollToIndex(last) AND then assigned
el.scrollTop on the next tick. scrollToIndex runs a retry loop that nudges
scrollTop toward the target row's measured offset and re-checks, up to ten
times, because dynamically-measured rows move the target as they settle.
The manual assignment overwrote each nudge, so the loop never observed
itself converge and always exhausted its attempts.

For "go to the end" the index-settling machinery buys nothing: scrollHeight
already is the bottom, the virtualizer renders whatever window that offset
implies, and it keeps working while a response streams and the last row
grows — the case the manual fallback was added for in the first place.
scrollToMessageIndex still uses scrollToIndex, which is the right tool for
jumping to an arbitrary row.

Console-only change; needs a device check that the chat still pins to the
bottom while streaming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:08:28 -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 3ac59a73b3 fix(container): companions follow installed apps, not available manifests
archi-dev-box was running archy-fedimint-ui and archy-lnd-ui with no
fedimint and no lnd container anywhere on the box. The Fedimint Guardian
UI sat on :8175 serving its "waiting for Bitcoin" page forever with
nothing behind it, which is what the operator reported as "fedimint
guardian installs but does not work" — there was nothing to install, the
UI was already up.

The boot reconciler drove companion provisioning from manifest_ids(),
which is every manifest the node can SEE: the whole apps/ directory plus
the signed-catalog overlay, 56 of them. The app reconciler has drawn this
line since phase 3 (ReconcileMode::ExistingOnly, "merely listing a
catalog manifest never installs an unqualified app"); the companion stage
never got the equivalent guard, so it stood up a UI for every app that
merely had a manifest and then self-healed it forever.

The other half is that reconcile() could only ever ADD. remove_for fires
only on the explicit uninstall RPC, so nothing ever subtracted: an
install that failed after its companion landed, or a container removed
by any other route, left a Restart=always unit alive permanently.

- installed_app_ids() replaces manifest_ids(): app ids whose container
  actually exists. Returns Option, because a caller that removes things
  on absence must not read "I could not look" as "nothing is installed".
  Container presence in ANY state is the whole test — it deliberately
  does not inherit the user_stopped/disabled filters, since a stopped app
  is still an installed app and treating it otherwise would tear its
  companion down and rebuild it on the next start.
- manifest_ids() is deleted rather than left unused. Its contract reads
  as "installed" to anyone skimming, which is the whole bug.
- reap_orphans() removes companions whose backend is not installed, after
  ORPHAN_GRACE (300s). The grace period is required, not defensive: this
  node runs ARCHIPELAGO_USE_QUADLET_BACKENDS=true and a Quadlet app is
  briefly containerless while restarting, so reaping on the first absent
  tick would cost a healthy companion a teardown plus a possible 900s
  image rebuild. A backend that reappears clears its clock.
- Reap failures are logged but kept out of the backoff input. Repair
  keeps a companion available; reaping only tidies one away, and a wedged
  reap must not back the repair path off to its 1h ceiling.

Every uncertain signal resolves toward not removing: no unit file and a
hung is-active reads as leave-it-alone.

Container suite 215/215.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 07:08:17 -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 f0c289a415 feat(marketplace): implement the DID signature layer that was only specified
docs/marketplace-protocol.md described a full authorship-verification chain and
was marked "shipped end-to-end". It wasn't: `signatures.manifest_hash` and
`signatures.did_signature` existed only as two struct fields that nothing read.
The authenticity actually delivered was the Nostr event's NIP-01 Schnorr
signature — which proves who *relayed* an event, not who *authored* the manifest
inside it. Anyone could republish someone else's manifest under their own DID.

Implemented:

- `canonical_signing_bytes` / `manifest_digest` — the signed preimage is the
  manifest as canonical JSON (recursively sorted keys, no whitespace) with
  `signatures` omitted, SHA-256'd. Canonicalisation is load-bearing, not
  cosmetic: `container.env` is a HashMap with per-process random iteration
  order, and `serde_json::Map` is only sorted while the `preserve_order` feature
  stays off — a feature any crate in the graph can enable for everyone via
  feature unification. Either would make the digest vary between runs, so
  signatures would fail *intermittently*, which is far worse to diagnose than
  failing cleanly.
- `sign_manifest` / `verify_manifest_signature` — Ed25519 over the 32 raw digest
  bytes, verified against the key `author.did` encodes (reusing the existing
  `identity::pubkey_bytes_from_did_key`).
- `publish` signs before broadcasting, fills `author.did` when empty, and
  **refuses** to publish under a DID this node cannot sign as — otherwise we'd
  spray manifests across every relay that every verifier then rejects.
- `discover` verifies before caching. A `missing` signature is a normal
  unsigned publisher: listed, but earning no identity trust. An `invalid` one is
  tampered or forged, so it is **dropped entirely** and logged — it fails closed
  rather than appearing behind a warning badge a user can click through.

Trust scoring now requires proof for both identity-derived factors:

- The 30-point identity factor was `did.starts_with("did:")`. An unsigned
  manifest with a plausible DID string and a pinned image scored 65 —
  "Community" — on no cryptography at all. It now scores 35, "Unverified".
- **The 20-point federation factor is gated too**, which the original spec did
  not say. An unverified `author.did` is just a string the publisher chose, so
  without this an attacker could copy the DID of a peer the user federates with
  and be rewarded for impersonating the party they trust most.

`marketplace.verify` now returns the signature verdict separately from the
advisory policy issues — `valid` has always meant "passes the advisory security
checks", so conflating it with authenticity would have been its own trap.

Tests (22 pass), weighted to the adversarial cases: tampering; tampering that
also rewrites `manifest_hash` while reusing the stolen signature; signing with
key A while claiming B's DID; undecodable did:keys including the old
`z6MkTest123` fixture that used to score 30/30; malformed base64 and
wrong-length signatures; digest stability across map insertion order; the digest
ignoring the `signatures` block; the federation-impersonation case; and a legacy
cache without the new field loading as `missing` rather than defaulting trusted.

Protocol doc rewritten so the preimage rules are normative — a third-party
implementation that canonicalises differently produces signatures we reject, so
"sorted keys, no whitespace, signatures omitted, sign the raw digest" now has to
be stated exactly rather than sketched.

Not included: surfacing the verdict in Marketplace.vue, which reads only
trust_score/trust_tier today. The field reaches the frontend; where the badge
goes is a UI call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 05:39:55 -04:00
archipelagoandClaude Opus 5 fe46c898d1 fix(license): replace the LGPL zbase32 crate with an in-tree implementation
`zbase32 0.1.2` is LGPL-3.0+ — the only hard copyleft dependency in the whole
Rust graph and the last remaining blocker for the MIT release
(docs/LICENSE-COMPLIANCE-AUDIT.md §2). Statically linking LGPL code into a Rust
binary obliges us to ship relinkable objects, which is impractical for a node
image.

The audit offered two routes: the MIT `z32` crate, or an original
implementation. Took the latter — z-base-32 is an alphabet substitution over a
bit stream, so ~60 lines removes the blocker while adding *zero* new
dependencies rather than trading one supply-chain entry for another.

**Byte-compatibility was the requirement, not a nice-to-have.** A `did:dht`
identifier IS this encoding of an Ed25519 public key, so any drift would
silently rotate every node's DID and orphan its already-published DHT records.
So the semantics were not guessed: I read the vendored zbase32-0.1.2 source to
extract exactly what `encode_full_bytes` and `decode_full_bytes_str` do —
including that decode truncates to the next lower byte boundary, which is why a
52-character string round-trips to 32 bytes while discarding 4 padding bits.

A model implementation was then validated against three independent sources
before any Rust was written, all five vectors agreeing:

    encode(b"testdata", 64)       -> qt1zg7drcf4gn   (crate doctest)
    encode_full_bytes("Just an…") -> jj4zg7bycfzn…   (crate doctest)
    decode_full_bytes("qb1ze3m1") -> b"peter"        (crate doctest)
    encode([f0,bf,c7])            -> 6n9hq           (Zimmermann spec)
    encode([d4,7a,04])            -> 4t7ye           (Zimmermann spec)

The module pins all of those plus four known 32-byte keys, a 0..40-byte
round-trip sweep, a 52-char/round-trip check over 64 keys, rejection of the
characters z-base-32 deliberately omits (`l`, `v`, `2`, `0`) and of non-ASCII,
and an alphabet/decode-table consistency check so the compile-time reverse table
can't drift from the alphabet.

`did_dht.rs` gains `did_for_a_known_key_is_stable`, which pins the full
identifier string for a known key — the regression that would actually hurt,
asserted at the call site that gives the string its meaning.

Dropped from Cargo.toml and Cargo.lock (7 lines); no other user in the tree.
Verified: 28/28 network tests pass, zero copyleft crates remain in the lockfile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:55:51 -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 Opus 5 c3341fc680 docs(signing-runbook): Workstream B is complete — the anchor is pinned
The runbook still opened with "the catalog is accepted unsigned (migration
window) and the anchor is unpinned (RELEASE_ROOT_PUBKEY_HEX = None)". Both have
been true-for-a-while false: `trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is a
`Some(...)` with a verification note in its doc comment, and
`releases/app-catalog.json` carries both a `signature` and a `signed_by`
did:key.

This one matters more than a normal stale status: a reader taking the header at
face value would think the fleet still accepts unsigned catalogs and that the
one-way anchor-pinning door is still open. It isn't — pinning already happened,
so any future ceremony is a *rotation*, which is the case the doc's own warning
about mismatched-signature hard-rejection applies to most sharply.

Marked complete and kept the procedure verbatim below, since it's exactly what a
key rotation or publisher change needs. Also dropped a stale `:21` line number
from the anchor.rs citation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:14:58 -04:00
archipelagoandClaude Opus 5 a7992233ab docs: two design docs say "no code" for subsystems that exist
The inverse of the usual drift — these understate rather than overstate, which
is just as misleading for someone deciding what is safe to change.

**dht-distribution-design.md** was headed "Status: Design (no code yet)".
`core/archipelago/src/swarm/` has five modules plus `content_hash.rs`.

**phase4-streaming-ecash-plan.md** was headed "not implemented". `swarm/paid.rs`
states in its own header that it implements "DHT distribution plan, Phase 4 step
F", and there is a `streaming::` module behind five `streaming.*` RPCs
(list-services, configure-service, toggle-service, pay, prepare-payment).

Neither is reachable in a stock build, which is presumably why the headers were
never updated — and that is the part worth documenting rather than eliding. Both
now state the gates: the `iroh-swarm` cargo feature is off by default (iroh and
iroh-blobs are optional deps pulled in only by it), `config.swarm_enabled` is off
by default, and paid serving stays free for everyone until the operator enables
the `content-download` streaming service.

Checked the other plan-only docs for the same error; these two were the only
ones. `nostr-identity-import-plan.md`, `nostr-signer-login-research.md` and
`hardware-signer-design.md` correctly say no code exists — verified: no identity
import or NIP-07 login RPC, and no TROPIC01 reference anywhere in core.
`dual-ecash-design.md`'s "in progress" is right too — the `wallet.fedimint-*`
RPCs exist, no Cashu ones do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:13:31 -04:00
archipelagoandClaude Opus 5 1481b873f8 docs(license-audit): the "deleted" proprietary fonts and APKs were never deleted
The 2026-07-23 status block lists as DONE: "Deleted: Courier_New/, Benton_Sans/,
Redacted/ fonts; wireguard.apk; atob.s9pk; obsolete test-install.sh (all
git-rm'd)". All seven are still tracked at HEAD and present on disk. Only the
web/dist copies went; the sources never did.

    git ls-tree -r HEAD --name-only | grep -iE 'Courier_New|Benton_Sans|Redacted/|wireguard.apk|atob.s9pk'

That means a repo about to be published under MIT still carries a commercial
Font Bureau typeface and two proprietary Monotype fonts — precisely what §3 of
this audit says must not ship. An audit that reports a blocker as closed is
worse than one that never checked, so the entry is now struck through with the
file list and the verification command inline.

Deleting them is safe and I checked before saying so: nothing references the
font *files* (the three `Courier New` hits are CSS font-family fallbacks naming
the system font, not @font-face sources), and wireguard.apk / atob.s9pk have
zero references anywhere in the tree. Left the deletion itself to the operator —
it is 40 MB of tracked binaries and outside a docs pass. Removing them also
takes 40 MB off the frontend OTA tarball, which is a separate open item.

Also re-verified the rest of the remaining list:
- `zbase32` (LGPL-3.0+) is still a direct dep (Cargo.toml:113, did_dht.rs:40,49).
  Still the only hard copyleft blocker.
- LICENSE (MIT), NOTICE and both THIRD-PARTY-LICENSES inventories are present —
  so the headline "no license of its own" is closed; softened the verdict to say
  which blockers remain rather than leaving a stale "not releasable as-is".
- The four StartOS-derived crates still exist; flagged that KEY-05 cites
  core/models, so that one needs review rather than a blind delete.
- Item 6 (git filter-repo history purge) is superseded — the launch plan is a
  fresh-history publish, so there is no history to rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:10:09 -04:00
archipelagoandClaude Opus 5 b33138a13d docs(adr): record what ADR-009 actually enforces and amend ADR-004
**ADR-009** lists six "non-negotiable" mandatory security defaults. Checked each
against `core/container/src/manifest.rs` and `core/security/src/`:

- `seccomp_profile: Default` — the string `seccomp` appears **nowhere in
  `core/`**. Not as code, not as a TODO. This constraint is entirely fictional.
- AppArmor — `container_policies.rs` generates and `apparmor_parser -r`s a
  profile, but its own comment reads `TODO: Configure Podman to use the
  profile`. `security.apparmor_profile` parses into a manifest field that
  nothing ever reads.
- `user` UID > 1000 — no UID validation exists in the runtime parser at all.
- `image_tag` pinned — preflight script only; the parser accepts `:latest`.
- `readonly_root` / `no_new_privileges` — safe defaults when omitted, but
  `validate_security()` never rejects an explicit `false`, so the ADR's
  "Reject manifests that violate mandatory defaults" step does not exist.

Genuinely enforced: the capability allow-list and bind-mount confinement (the
latter stronger than the ADR describes). Added an Implementation status section
saying so per-row. The decision stands; the claim of enforcement did not, and on
a security ADR that gap is the whole point of writing it down.

**ADR-004** said Tor carries *all* inter-node communication and runs as the
`archy-tor` container. Neither holds: transport priority is mesh → LAN → FIPS →
Tor (`TransportKind` 1-4, Tor as last fallback, largely because of the latency
this ADR itself lists), and Tor is the host Debian service driven by
`archipelago-tor-helper` — `container-doctor.sh` actively removes an `archy-tor`
container if it finds one, and no `apps/tor` manifest exists. Added an amendment
rather than rewriting the record. Worth flagging that both changes landed
without their own ADR.

All 10 ADRs are Status: Accepted; 001-003, 005-008 and 011 verified consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:06:55 -04:00
archipelagoandClaude Opus 5 7c214e6497 docs(bulletproof-containers): say which parts of the plan actually shipped
Header claimed the whole 2026-04-22 plan "has been implemented". The
architecture was adopted, but checking each item against the tree:

- The `core/archipelago/src/reconcile/` module the doc lays out in detail —
  desired.rs / current.rs / diff.rs / apply.rs / derived.rs / backoff.rs — was
  never created. The reconciler shipped as container/boot_reconciler.rs +
  container/prod_orchestrator.rs instead.
- FM2's named fix `reconcile::derived::render_bitcoin_conf` does not exist. The
  drift was eliminated a different way: bitcoind runs with an explicit `-conf`
  derived from secrets each start, and stale datadir configs are removed.
- FM1/FM3 are partial — companion UIs are Quadlet units, main app containers are
  not, since `use_quadlet_backends` still defaults false. The "v1.7.48+ full
  reconcile module / main containers become Quadlet units" step has not happened.
- **FM6 was never implemented.** There is no podman corrupt-state probe and no
  `system renumber` recovery anywhere in the tree. The 2026-04 failure that made
  a registry node unreachable would still require manual SSH today — which is
  precisely the "zero-manual-intervention" target this doc opens with.

FM4 and FM5 did ship as described.

Replaced the blanket claim with a per-item table so the doc stays useful as
incident history without reading as a description of the code, and noted that
the unit path throughout says /etc/containers/systemd/ while units are actually
written to ~/.config/containers/systemd/ (the path is rootless).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:03:45 -04:00
archipelagoandClaude Opus 5 0b7fabdfa1 test(seed): pin known answers for the six unpinned derivations
`test_node_key_known_answer_vs_python_verifier` pinned the node Ed25519 and node
Nostr keys, and `test_release_root_known_answer` covers the release root. The
remaining six — FIPS mesh transport, identity Ed25519, identity Nostr (NIP-06),
Bitcoin BIP-84 and LND aezeed entropy — were only asserted to be mutually
distinct by `test_full_derivation_from_known_mnemonic`.

Distinctness is satisfied by ANY change to an HKDF info string or BIP-32 path.
So redefining `archipelago/lnd/entropy/v1` — the seed behind a user's Lightning
wallet — broke no test, while invalidating every backup verification a user had
already performed against docs/SEED-VERIFICATION.md. Same for the FIPS key that
authenticates a node on the mesh.

Expected values were produced independently by the Python verifier published in
that doc, whose primitives were themselves cross-checked against bip_utils and
cryptography's own HKDF (BIP-39 seed, both BIP-32 paths, x-only pubkey, bech32
and HKDF-SHA256 salt=None all matched byte for byte). This commit closes the
loop in the other direction: the Rust implementation now agrees with those same
bytes, so the doc and the code are pinned to each other.

Verified: 26/26 seed tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 04:00:58 -04:00
archipelagoandClaude Opus 5 ecd9295e96 docs: fix the wrong journalctl scope and document the host-network port drop
**container-lifecycle.md** told operators to read the reconciler's decisions with
`journalctl --user -u archipelago`. That returns nothing: `archipelago.service`
is a SYSTEM unit (`WantedBy=multi-user.target`) that merely runs as
`User=archipelago`. It's `sudo journalctl -u archipelago`. Easy to get wrong
because the companion Quadlet units next door genuinely are `--user`, so both
forms appear in the docs and only one is right per unit — spelled that out
inline. Swept the rest of docs/: no other instance.

**quadlet-compilation.md** — added the `Network=host` case. Podman rejects
`PublishPort` with host networking (crash-loop, exit 125), so the renderer drops
declared ports rather than emitting them
(`render_host_network_omits_publish_ports`). A developer reading the directive
list would otherwise expect a mapping that never appears.

Everything else in both docs verified against quadlet.rs / prod_orchestrator.rs /
boot_reconciler.rs: the unit dir, the DO-NOT-EDIT header, Pull=never,
DropCapability=ALL, Secret=…,type=env, TimeoutStartSec=0, RestartSec=10,
WantedBy=default.target, the render/write_if_changed/enable_now/disable_remove
four-step, uid 1000, adopt_existing, the user-stopped.json / user-uninstalled.json
desired-state gates, and the 30s tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:50:15 -04:00
archipelago 455b813630 docs: late resume artifact — full-day state, #7 status, today's traps 2026-08-07 22:25:43 -04:00
archipelagoandClaude b9e64eb619 fix(container): drift-gate the per-app ownership-repair hooks
The reconciler's pre-start hooks for the btcpay stack, fedimint and fmcd
chowned unconditionally on EVERY prepare — and prepare re-runs far more
often than install (every reconcile that touches the app). archi-dev-box's
journal showed the same three dirs re-chowned every ~15s. The hooks exist
to repair old installs; they now skip when ownership is already correct
(root stat probe — the daemon's rootless metadata read can't see the
subuid-owned dirs).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 22:06:01 -04:00
archipelagoandClaude Opus 5 623eb0f033 docs(SEED-VERIFICATION): add the missing FIPS key, fix the comparison commands
Ran the doc's script rather than only reading it, and cross-checked every
primitive it implements against independent libraries (bip_utils for BIP-39
seed / BIP-32 derivation / bech32, and cryptography's own HKDF): BIP-39 seed,
m/44'/1237'/0'/0/0, m/84'/0'/0', x-only pubkey, npub encoding and
HKDF-SHA256(salt=None) all match byte for byte. The hand-rolled crypto in this
doc is correct.

Two real gaps fixed:

- **The FIPS mesh transport key was missing.** `seed.rs:227` derives it from the
  same master seed via `archipelago/fips/secp256k1/v1`, and a user verifying
  their backup had no way to check it — despite it being the key that
  authenticates them on the mesh. Added it to the diagram and as section 2b of
  the script (same shape as the node Nostr key; verified against
  `derive_fips_key` and `hkdf_derive` using `Hkdf::new(None, ikm)`).
- **The "compare with your node" commands were wrong.** The RPC endpoint is
  `/rpc/v1`, not `/api/rpc`, and `identity.get-node` is not a method — the real
  ones are `node.did` and `node.nostr-pubkey`. Also dropped "UI: Settings >
  Identity", which is not a screen that exists, in favour of the two
  identity files on disk.

Verified and left alone: all five other HKDF info strings, both BIP-32 paths,
and the `node_key.pub` filename. The release-root key
(`archipelago/release/root/ed25519/v1`) is deliberately still absent — it is
derived from the project's signing seed, not a user's node seed.

Noted separately: `system.get-node-key` sits in the CSRF-exempt list
(`api/rpc/mod.rs:337`) but has no dispatcher arm, so it is an exemption for a
method that does not exist. Harmless, but it should be removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:18:18 -04:00
archipelagoandClaude Opus 5 db60c3382d docs(marketplace-protocol): the DID signature layer is specified but not implemented
This doc is marked "Status: implemented ... shipped end-to-end" and then
describes a cryptographic verification chain that does not exist. On a repo
about to go public, that is the single worst kind of doc bug: it promises a
security property.

`signatures.manifest_hash` / `signatures.did_signature` appear exactly once in
the codebase — as two struct fields at `marketplace.rs:106-107`. Nothing reads
them. There is no hash comparison, no DID resolution, no signature check. The
authenticity actually delivered is the Nostr event's own NIP-01 Schnorr
signature, which proves the publishing key sent the event but says nothing about
the DID the manifest names.

Added a warning at the top, marked the "Manifest Signing (DID Layer)" section
and steps 3-6 of the verification flow as not implemented, and annotated steps
7-8 as advisory (validate_manifest returns scoring issues; it does not block
discovery or install).

The trust model was overstated in the same direction:
- "DID Verification | 30 | Manifest is signed by a valid DID key" is a
  `did.starts_with("did:")` string test. Any publisher can claim any DID and
  take the 30 points.
- "Relay Consensus | 20" is graduated and never zero (1 relay still scores 5).
- "Version History | 15 | multiple published versions (shows maintenance)" —
  nothing counts versions; it's 10 for a 3-part semver plus 5 for a non-empty
  repo_url.
Worked the arithmetic through: an unsigned manifest with a plausible DID string
and a pinned image scores 65, landing in the "Community" tier. Said so.

Other corrections:
- `marketplace.unpublish` is documented but was never implemented (the string
  appears nowhere); removed it and noted why NIP-33 makes it non-trivial. Added
  the two payment methods that do exist (`create-invoice`, `check-payment`).
- The schema section said marketplace manifests "follow the existing
  apps/{app-id}/manifest.yml schema", contradicting the header three paragraphs
  above. They are separate types.
- The security-enforcement list claimed a capability allow-list, a
  host-networking ban and system-path mount restrictions. Those rules are real
  but live in the runtime manifest parser for a different schema — marketplace
  validation checks four things and gates none of them.
- `run_as_user` documented as "> 1000" in two places while the code checks
  `>= 1000` and the doc's own example uses 1000.
- Data-storage tree listed `cache/trust-scores.json` and `config.json`; neither
  is ever written.
- The 15-minute cache TTL and 30-minute background refresh don't exist —
  discovery is RPC-triggered and the cache has no expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:59:45 -04:00
archipelagoandClaude Opus 5 69f1d18ee7 docs(README): index the contributor docs the launch reader needs
The index covered users, architecture and app development but had no entry
point for "I want to work on Archipelago itself" — so eight tracked docs were
reachable only by guessing filenames, including the two that matter most to a
newcomer: developer-guide.md (how to build the workspace, the frontend and an
ISO) and LICENSE-COMPLIANCE-AUDIT.md (dependency licensing, which is exactly
what a reader checks first on an open-source repo).

Added a "Contributing to Archipelago itself" section covering those plus
bulletproof-containers, the signing runbook, the 1.8.0 hardening plan and
CLAUDE.md; filed pine-voice-commands under Getting started and demo-build-info
under contributing.

Also noted that ADR-010 was never issued — verified across all history, so the
009 → 011 gap is not a missing file — and added the two archived session logs
(HANDOVER-2026-07-02, SESSION-1.8.0-OTA-PROGRESS) to the archive table, which
already claimed to cover completed session logs but listed none.

Link check re-run across docs/: 0 broken. Only RELEASE_NOTES_BACKLOG.md is now
deliberately unindexed (internal working list).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:55:55 -04:00
archipelagoandClaude Opus 5 a28b3b696d docs(registry-manifest-design): stop describing the pre-Phase-1 state as "today"
The header says Phases 1-3 shipped, then §1 "Where we are today" described the
world before any of them: catalog carrying "version + image override only", the
manifest "never registry-distributed", counts of 48 disk manifests and 28
catalog entries. A reader hits the contradiction immediately and can't tell
which half is current.

Retitled §1 as the pre-Phase-1 baseline it is, and added the actual state:
`releases/app-catalog.json` has 66 entries and 56 embed a full `manifest` block
— one for every `apps/*/manifest.yml` in the tree (the stale counts were 48 and
28). What's genuinely left is Phase 4 (build-context apps) and Phase 5 (drop
`apps/` from the OTA rsync), which the phase list already marks .

Also:
- The install arrow claimed "render Quadlet unit"; same overstatement corrected
  in architecture.md and app-manifest-spec.md — Quadlet is opt-in, the default
  is podman create+start.
- §8's open question "generated_files with inline content — already supported?"
  is answered: `app.files[]` takes inline `content` with placeholder rendering.
  Marked answered rather than leaving a resolved question looking open.

Verified present and unchanged: `catalog_manifest_to_overlay`,
`install_stack_via_orchestrator`, `install_immich_stack`, and the catalog-wins
merge semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:53:15 -04:00
archipelagoandClaude Opus 5 2c53a7d77f docs: fix the CSRF-exempt list and describe how secret_env actually reaches a container
**COMMANDS.md** named six CSRF-exempt read-only methods, two of which
(`bitcoin.getinfo`, `monitoring.current`) are not exempt — a client trusting the
doc would send them with the cookie alone and get rejected. The real set is
twelve (`api/rpc/mod.rs:326-340`); listed all of them and said plainly that
everything else needs the header. The rest of the doc verified clean: the 480 /
200 / 160-char caps, the four `assistant_*` config keys, both default model ids,
`is_sender_allowed`, `strip_archy_trigger` / `run_node_cmd`, the three
unauthenticated HTTP endpoints, and `auth.login.totp` all match the code.

**secrets.md** said `secret_env` "sets `<key>` in the container's environment",
which reads as a plain `-e KEY=value` and undersells the design. It isn't:
resolved pairs are registered as podman secrets named
`archy-env-<app-id>-<key>` and referenced by name, precisely so the value stays
out of `podman inspect` and out of plaintext `Environment=` lines in Quadlet
units. Also documented the interpolation-taint rule — a plain `environment`
entry that expands `${SECRET}` (BTCPay's connection strings) is itself treated
as secret-bearing rather than left in the clear, which is what makes it safe to
build connection strings from secrets.

Everything else in secrets.md verified against `container/secrets.rs`: the four
kinds and their file shapes, the bare-filename rule, the every-tick idempotent
`ensure_generated_secrets`, and the atomic 0600 temp-fsync-rename writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:51:53 -04:00
archipelagoandClaude Opus 5 7adc3260a6 docs(app-manifest-spec): fix the id rule, the Quadlet claim, and the declarative overstatement
Narrative pass over the manifest spec, plus one correction to the guide I
committed in ba052736.

- **`app.id` allowed `_`.** `is_valid_app_id` accepts lowercase ASCII letters,
  digits and single hyphens only — no underscores, no leading/trailing hyphen,
  no `--`. The spec's "alphanumeric + `-`/`_`" would have a developer write an id
  that fails to parse.
- **"Must match the directory name"** is a convention, not a rule. The loader
  (`prod_orchestrator.rs:1455-1474`) walks `*/manifest.yml` and keys off
  `app.id`, never comparing it to the folder, so a mismatch silently registers
  the app under a different id. Said so rather than implying enforcement.
- **The Quadlet claim was the same one architecture.md was corrected for**
  (f55ed6bf): install does NOT compile to a `user.slice` Quadlet unit today.
  `config.use_quadlet_backends` defaults false, so apps take the legacy
  `podman create + start` path; Quadlet is opt-in per node and companion UIs are
  the exception that already use it.
- **"no per-app installer code"** — true of installers, but
  `run_pre_start_hooks` is a hardcoded `match app_id` covering seven first-party
  apps (bitcoin-ui, filebrowser, lnd, archy-nbxplorer, btcpay-server,
  fedimint-clientd, grafana). Documented as the caveat it is; anyone reading the
  source will find it in a minute and the doc should not look like it's hiding it.
- `derived_env` now names the full closed allow-list including `{{BITCOIN_HOST}}`
  and what it resolves to.

Correction to ba052736: I wrote there that an unknown `derived_env` placeholder
passes through verbatim. It doesn't — `validate_derived_template` rejects both
unknown names and unbalanced `{{`. Fixed that row in the guide.

Verified accurate and left alone: the capability allow-list, network_policy
values, `/dev/*` device rule, volume option allow-list, bind-source confinement,
the four generated_secret kinds, `hooks.pre_start` being schema-only, and the
30s reconciler interval (`BootReconciler::DEFAULT_INTERVAL`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:49:17 -04:00
archipelagoandClaude Opus 5 ba052736be docs(app-developer-guide): describe the security rules the parser actually enforces
Narrative pass. The Security Requirements section described a blocklist where
the code enforces an allow-list, and attributed enforcement to the wrong layer:

- **"Forbidden: mounting system paths /, /etc, /var, /usr, /proc, /sys"** — the
  real rule (`manifest.rs:1290-1313`) is the inverse: `volumes[].source` must be
  absolute and under `/var/lib/archipelago/`, or a plain named volume, or one of
  two reviewed exceptions (`/run/user/1000/podman/podman.sock`, `/var/run/dbus`).
  Anything else is a parse error. The old wording also listed `/var` as
  forbidden while every app in the repo binds `/var/lib/archipelago/<id>` — a
  developer reading it would not know where their own data goes.
- **"enforced by the marketplace/catalog pipeline and the node"** — split by
  layer instead. The capability allow-list is parser-enforced (verified against
  the 9 entries at `manifest.rs:1089-1099`); `:latest` is NOT — only
  `validate-app-manifest.sh` checks it, and a `:latest` manifest still installs.
  readonly_root / no_new_privileges / network_policy=isolated are parser
  defaults, so omitting them is safe rather than dangerous.

Also:
- `derived_env` documented `HOST_IP`/`HOST_MDNS`/`DISK_GB` "such as"; the set is
  closed and includes a fourth, `{{BITCOIN_HOST}}`. Noted that unknown
  placeholders pass through verbatim rather than erroring, so a typo silently
  ships `{{FOO}}` into the container.
- The networking example hardcoded `bitcoin-knots`; `{{BITCOIN_HOST}}` resolves
  to knots or core depending on what's installed.
- Documented the `files[].content` placeholder set, which is a different set
  from derived_env and wasn't mentioned at all — notably `{{NETWORK_GATEWAY}}`
  (the nginx `resolver` fix for post-restart 502s) and `{{secret:NAME}}`.
- The "check the UI" URL `/app/my-app/` is not a route; it's
  `/dashboard/apps/:id` (detail) or `/dashboard/app-session/:appId` (embed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:46:17 -04:00
archipelagoandClaude b886930708 fix(container): ownership probe uses systemd-run with output capture
The first drift-gate attempt called plain sudo stat, which the daemon's
privilege path doesn't answer — the probe silently failed and the chown
loop continued. host_sudo_output mirrors host_sudo (systemd-run --pipe)
but returns the process output, so the ownership check gets a real answer.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 20:39:33 -04:00
archipelagoandClaude Opus 5 ff3b3c860e fix(iso): stop printing a web password that doesn't work
The installer's completion screen and the login-console banner both told the
operator "Web Login password123". No release build accepts that password: no
default account is ever created (`main.rs:356-362`), and the `password123`
pre-setup path is `#[cfg(debug_assertions)]` + `dev_mode`
(`api/rpc/auth.rs:36-46`). A new user following the screen gets
"User not set up. Please complete setup first." on their first-ever
interaction with the product.

Both screens now say the web UI asks you to create a password on first visit,
which is what `Login.vue` actually does when `auth.isSetup` returns false. The
SSH line is unchanged — `archipelago`/`archipelago` really does still ship
(`install-to-disk.sh:205`), and killing that is the open half of the
"kill default credentials" hardening item.

Note on the path: `image-recipe/build-debian-iso.sh` is a thin wrapper that
copies `_archived/build-auto-installer-iso.sh` and rewrites its relative
paths, so despite the directory name the archived builder is the live one.

Same string fixed in scripts/install-tui-demo.sh, which mirrors the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:27:50 -04:00
archipelagoandClaude Opus 5 2ed6c71e0c docs(troubleshooting): fix advice that doesn't match the node
Narrative pass over troubleshooting.md against the code. Seven claims were
wrong, several of them actively misleading:

- **Tor is not a container.** §15/§16 told operators to run
  `podman ps --filter name=tor` / `podman restart tor` and to read
  `/var/lib/archipelago/tor/hidden_service/hostname`. Tor is the host's Debian
  package running as `debian-tor`; Archipelago drives it by staging a torrc and
  poking `archipelago-tor-helper` (`scripts/tor-helper.sh`, which does
  `systemctl restart tor`). The hidden-service dir is
  `hidden_service_archipelago` (suffixed), it's root-owned 0700, and the file a
  normal user can actually read is the synced copy at
  `/var/lib/archipelago/tor-hostnames/<service>`.
- **The USB installer has no "Repair" mode.** Cited three times as the recovery
  path. The boot menu has exactly three entries: Install, Install (verbose),
  Boot from local disk. Replaced with what those entries can actually do, plus
  the fact that the installer prompts for a disk and requires typing `yes`, so
  booting it isn't itself destructive.
- **`bitcoin-cli -datadir=/data`** — the container's datadir is
  `/home/bitcoin/.bitcoin` and RPC creds are in a generated `/tmp/rpc.conf`;
  the documented command could not have authenticated.
- **"edit bitcoin.conf to add addnode="** — the entrypoint passes an explicit
  `-conf` and logs "ignoring legacy datadir bitcoin.conf". Flags come from the
  manifest (and the signed catalog entry that overrides it).
- **"Bitcoin requires 600GB+"** — only above the manifest's 1000 GB threshold;
  below it the node runs pruned at `-prune=550`.
- **`sudo systemctl restart podman`** — apps run under rootless Podman as the
  `archipelago` user, so that restarts an unrelated root socket.
- **"Settings > Network"** — DNS config and disk cleanup are both on the Server
  page (`/server`), not Settings.

Also: header claimed "the 20 most common issues" over 21 sections, and §16
presented Tor as required for peering when it's the last fallback after
mesh → LAN → FIPS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:26:19 -04:00
archipelagoandClaude Opus 5 e7d8dfb633 docs: correct the "default password123" claim — production nodes have none
The walkthrough told new users to log in with `password123` and said they'd
be "prompted to change this password immediately". Neither is true on a
release build:

- `AuthManager::ensure_default_user` is never called. `main.rs:356-362`
  says so explicitly ("Don't auto-create default user — let onboarding flow
  handle password setup via auth.setup"), and the function is `#[allow(dead_code)]`.
- The only `password123` login path is `api/rpc/auth.rs:36-46`, which is
  `#[cfg(debug_assertions)]` AND `dev_mode` AND only fires *before* setup —
  no release binary carries it.
- `Login.vue` calls `auth.isSetup` on mount and renders the "Set Up Your
  Node" password-creation form when it returns false. That is the real
  first-boot screen, and it is the only `auth.setup` caller in the frontend.

So there is nothing to be "prompted to change" — the user creates the
password themselves, and the doc's version taught them to look for a
default that does not exist.

Fixed in four places:
- user-walkthrough Step 8 rewritten as "Create Your Password"
- troubleshooting's "Default password is password123" solution replaced,
  including the warning that deleting user.json does NOT recover a lost
  password (the onboarding gate refuses auth.setup on a provisioned node)
- api-reference cURL example uses a placeholder, not the fake default
- 1.8.0 hardening plan's "kill default credentials" item now reflects that
  the web half is done and only the SSH defaults still ship

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:17:44 -04:00
archipelagoandClaude db8937f9e9 fix(container): root stat fallback makes volume ownership drift authoritative
The direct metadata read can be denied in the service's rootless context even
when the directory is already correctly owned, which kept the reconciler
calling sudo chown on the same Postgres volume every minute. A root
fallback gives the guard a reliable answer on deployed nodes while remaining
much cheaper than a recursive chown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:59:48 -04:00
archipelagoandClaude Opus 5 e0cc41d31e docs(developer-guide): fix stale ISO-builder path and CLAUDE.md label
Verified the project-structure tree against the tree. Two stale entries:
- image-recipe/build-auto-installer-iso.sh was the old builder, now under
  _archived/; the current builder is image-recipe/build-debian-iso.sh (the
  release workflow drives it via scripts/build-iso-release.sh). Repointed.
- CLAUDE.md was labelled "AI development instructions"; it is now the sanitized
  public contributor guide. Relabelled.

Everything else verified: run-tests.sh, first-boot-containers.sh, container.rs,
vpn.rs all exist; the add-an-endpoint / add-a-Vue-page tutorials match the
current dispatch pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:56:35 -04:00
archipelagoandClaude Opus 5 f55ed6bf45 docs(architecture): correct the "apps run as Quadlet units" overstatement
The overview stated apps install as user.slice Quadlet units. Verified against
prod_orchestrator.rs: use_quadlet_backends defaults to false, so regular apps
install via the raw podman path today; the companion UI containers are the ones
that run as Quadlet units (companion.rs owns them), and the Quadlet flip to
default for all apps is opt-in/held.

Reworded both places (the layer diagram and the App Platform section) to match
reality and the container-lifecycle / quadlet-compilation dev docs: the
orchestrator owns and self-heals app containers; companion UIs run as Quadlet
units, the validated path being flipped to default. Everything else in the doc
verified accurate — crate table, module map, data paths, security model, and
the note that the four orphan crates still exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:41:20 -04:00
archipelagoandClaude 3cd210f282 feat(build): build-aiui.sh rejects a prod bundle carrying mock hosts
W1.7's regression gate: after 8329b826's tree-shake fix, this makes the
mock-quarantine load-bearing — a future change that reintroduces the mock
modules into the production graph fails the build instead of shipping
silently. The demo-site build (VITE_DEMO_CONTENT=true) is exempt by design.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:21:55 -04:00
archipelagoandClaude 7c7cd76c1c fix(package): btcpay wipe removes the whole stack's data, not just its own dir
get_data_dirs_for_app had no btcpay arm — the default mapped to
/var/lib/archipelago/btcpay alone, leaving postgres-btcpay (where the
ACCOUNT lives) and nbxplorer on disk. Uninstall-with-wipe then reinstalled
to the old account still enabled. The btcpay arm now covers all three dirs,
for every alias and stack-member id. The map stays deliberately hardcoded:
deletion code must never derive its targets from a manifest at uninstall
time (a bad manifest could aim the wipe at another app's data).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:17:24 -04:00
archipelagoandClaude Opus 5 4155cefdf5 docs(security): genericize a node address in the RPC-proxy incident record
BITCOIN-RPC-PROXY-EXPOSURE.md's port claims verify against code (Bitcoin RPC on
127.0.0.1:8332, the bitcoin-ui proxy on 127.0.0.1:8334). But its incident
narrative named a specific node's LAN address (192.168.63.240, five times) on a
subnet the earlier 192.168.1.x sweep did not cover. Replaced with the RFC 5737
documentation address 192.0.2.240. The incident content — the exposure, the
probes, the fix — is unchanged and remains a legitimate public security record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:00:29 -04:00
archipelagoandClaude Opus 5 dc2d79ce77 docs(security): self-contain KEY-05 and PSBT; record the completed entropy migration
Verified the security subsystem's design-doc claims against code:

- KEY-05's foundational claims are accurate: entropy::draw_key_bytes exists,
  KeyGenRng is sealed with OsRng as its sole production member, MIN_GUARDED_LEN
  is 12, and core/clippy.toml bans rand::random/thread_rng exactly as stated.
- But its per-site table listed every production nonce/key site as disposition
  "migrate" (pending), when all of them have since been migrated to
  draw_key_bytes(OsRng) — storage_crypto, credentials/store, wallet/bdhke,
  mesh/x3dh — and zero rand::random/thread_rng remain in production. Added a
  completion note so the doc no longer reads as pending work.

Both KEY-05 and PSBT-SIGNING-ARCHITECTURE referenced
ENTROPY-SEED-AUDIT-2026-07-31.md five times as their evidence base — a doc that
was moved to local-only, so a public reader could not follow it. Reworded all
five to state the audit's findings inline ("the internal entropy audit found
...") without the unresolvable path. No published doc references it now. The
link-checker missed these because they were inline code, not markdown links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:59:11 -04:00
archipelagoandClaude ca106c5a43 fix(container): data-uid chown is drift-gated, not unconditional every tick
apply_data_uid ran a recursive sudo chown on every prepare_for_start, and the
reconciler re-prepares — archi-dev-box's journal showed postgres-btcpay rechowned
every ~45s despite already-correct ownership, and on framework-pt the same loop
surfaced as operator-visible 'chown failed' noise. chown_for_rootless_container
now stats the target first and returns early when the top-level owner already
matches the host-mapped uid:gid. Deep drift in a running container is still
caught by ensure_running_container_ownership's in-container write-probe, which
is the authority that actually matters (it probes writability, not stat bits).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 18:54:39 -04:00
archipelagoandClaude Opus 5 bcbd4a7032 docs(app-developer-guide): add the missing local manifest-validation step
The guide walked a developer from manifest to install but never told them how
to validate the manifest locally first — despite scripts/validate-app-manifest.sh
existing for exactly that. A developer's first signal that their manifest was
wrong would have been an install failure on a node.

Adds a "Validate Your Manifest" step at the top of Testing, pointing at the
script (recently fixed — it had been rejecting every manifest because it shelled
out to a missing ruby). Notes the strict behaviour a new submitter hits, e.g.
an unpinned :latest tag is rejected, and that the Rust parser is canonical.

Verified: the install RPC example in this guide (id + dockerImage) matches the
handler; the cargo test target crate name (archipelago-container) is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:49:08 -04:00
archipelagoandClaude Opus 5 8a341de4b5 docs(api-reference): fix the one fabricated RPC method
Verified all 144 documented RPC methods against the dispatcher. 143 are live;
one was fabricated: `mesh.discover` (params { timeout_secs? }, returns
{ nodes: MeshNode[] }) does not exist — "mesh discovery" appears only in code
comments as a concept, never as a method. A developer calling it gets "unknown
method".

Replaced with the real peer-listing method `mesh.peers` (no params, returns
{ peers, count }), which the frontend actually uses and which was undocumented.

Also verified: every source path cited across the docs resolves (placeholders
and a correctly-recorded deletion aside), and every documented app-manifest
field exists in the schema (no fabricated fields).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:47:10 -04:00
archipelagoandClaude Opus 5 68e3f61121 docs: remove the last private MEMORY references from design docs
Three `MEMORY → <note>` see-also references pointed at the private agent-memory
system from public docs (demo-deployment-design.md x2, registry-manifest-design.md
x1). Removed. No tracked doc references the memory system now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:35:43 -04:00
archipelagoandClaude Opus 5 a2efdf7358 docs: current-state the bitcoin multi-version design; move its rollout handoff local
bitcoin-multi-version-design.md carried three layers of stale internal content:
an 80-line HTML-comment work-tracking block (per-phase status with "UNCOMMITTED
on the branch", node numbers, "Next action when resuming", "Decisions still
needed from user"); a rendered "Status: design (2026-06-22)" header that was
wrong — the feature shipped, all four phases, with the downgrade guard added
today; two private `MEMORY →` references; and a node-numbered scheduling note.

Now: the comment block is gone, the status reflects reality, the MEMORY
references and node numbers are removed, and "verify on a real node" replaced
the specific fleet addresses. The design content (source-of-truth decision,
phase designs, invariants) is unchanged.

Separately, bitcoin-version-bulletproof-rollout.md was an inter-agent rollout
handoff — node numbers, branch coordination, "the other agent owns" — not a
design or reference doc. Moved to local-only (still on disk, gitignored) like
the other handoffs; its two path references (a plan doc and a script comment)
are generalized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:34:30 -04:00
archipelagoandClaude Opus 5 b4e4189407 docs: reframe bulletproof-containers as a historical record; scrub internals
This 2026-04 plan has been implemented, but it still read as an active plan
("implementation started"), linked private agent-memory paths, and ended with a
stale "To resume" work block naming fleet nodes, dated fleet state, and the next
file to edit.

- Header now marks it a historical design record and points at
  container-lifecycle.md for the current behaviour.
- Removed the two private ~/.claude/.../memory/ references from the header and
  the entire "To resume" section (private paths, node numbers, 2026-04-22 fleet
  snapshot — none of it belongs in a public design doc).
- Genericized the one remaining node-number reference in the incident narrative.

The valuable content — the six failure modes and the reconciler reasoning that
answered them — is kept intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:28:39 -04:00
archipelagoandClaude Opus 5 599787690a docs: write the three missing app-developer docs (secrets, quadlet, lifecycle)
The open-source plan flagged three references as "the real gaps for app
developers", and the docs index named them as not-yet-written. Written now,
each from the code rather than stubbed:

- secrets.md — generated_secrets/secret_env: the two halves, the four kinds
  (hex16/hex32/base64/bcrypt) and which files each writes, the idempotent
  self-healing 0600 materialisation, and the rules a developer must not break
  (no hardcoded fallbacks, one canonical name, right encoding). From
  container/secrets.rs and the manifest schema.

- quadlet-compilation.md — manifest -> .container unit: the full directive
  mapping (including Secret= by reference, never value, and Pull=never), where
  units land (~/.config/containers/systemd, systemctl --user), the
  render/write/enable/disable lifecycle with write-if-changed, and how to
  inspect one. From container/quadlet.rs, scoped accurately to the companion-UI
  path it drives today.

- container-lifecycle.md — the level-triggered 30s reconciler: desired state
  from user-stopped/user-uninstalled/manifest set, the operations table, the
  self-heal-vs-respect-a-deliberate-stop rule, and migrations-never-destroy-data.
  From prod_orchestrator.rs and boot_reconciler.rs.

Index updated to link all three under App development and the "known gap" note
removed. Every link across the docs tree resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:22:26 -04:00
archipelagoandClaude Opus 5 460eccd368 docs(CLAUDE): tighten prose and correct the manifest-delivery claim
Follow-up to 73970cf3. Two improvements:

- Corrects a stale claim — manifests are no longer "loaded from disk, goal is
  the catalog". The signed catalog has been the delivery mechanism since
  2026-06-23 (origin-wins over disk), so the guide states that, plus the
  consequence contributors need: editing a disk manifest alone does not change
  a catalog-covered app.
- Tightens the north-star paragraph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:18:48 -04:00
archipelagoandClaude Opus 5 73970cf32d docs: sanitize CLAUDE.md into a public contributor guide
CLAUDE.md was the internal agent guide: a dated "gate is GREEN" status banner
naming a specific node, pointers to now-local-only planning docs
(PRODUCTION-MASTER-PLAN, UNIFIED-TASK-TRACKER, multinode-testing-plan), the
gitea-ai push account mechanics, and references to the private memory system.

Rewritten as a contributor guide that keeps everything public-worthy — the
invariants (rootless podman, declarative apps, manifest-declared secrets,
non-destructive migrations), the build/verify notes, the commit-and-push
discipline, and the production test-gate definition — and drops the status,
node numbers, push-account specifics, and memory references. Points at
docs/ROADMAP.md and docs/README.md instead of the internal trackers.

No infra identifiers or internal mechanics remain; all links resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:15:47 -04:00
archipelagoandClaude Opus 5 661f3eda25 docs: add a grouped documentation index; fix references to now-local-only docs
Two concrete, verifiable documentation gaps from the open-source review:

- docs/ had no index. Adds docs/README.md grouping the 60-odd published docs by
  task — getting started, architecture, app development, design docs, ADRs,
  security, roadmap — in the bitcoin/bitcoin doc/ style the plan called for.
  Every link in it resolves (checked). The top-level README now points at it as
  the front door rather than duplicating the list.

- ROADMAP.md and tests/lifecycle/TESTING.md linked docs/multinode-testing-plan.md,
  which moved to local-only (it is a fleet node inventory, not published). Those
  references now describe the scope split in prose instead of pointing at a file
  that is not in the public tree.

The index is honest about what is missing: it names the three app-developer
docs the plan flagged as gaps (quadlet compilation, container lifecycle,
secrets materialisation) as not-yet-written, and points at the authoritative
code for each rather than pretending they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:11:05 -04:00
archipelagoandClaude Opus 5 bf76955114 chore(license): declare MIT on the crates (open-source Phase 4a A4)
The repo ships an MIT LICENSE and the README carries an MIT badge, but the
crates themselves declared no license, so `cargo metadata`, packaging and any
downstream mirror saw "license: null". Adds [workspace.package] license = "MIT"
and inherits it in all five members via license.workspace = true. Verified with
cargo metadata: all five now report MIT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:07:59 -04:00
archipelagoandClaude Opus 5 308f3cbd84 fix(release): publish the manifest only after assets are proven fetchable
Today's outage window came from ordering, and the ordering was baked into the
publish script itself: it pushed main — the branch nodes read the manifest
from — together with the tag, up front, then uploaded and verified assets
afterward. So the manifest advertised the new version for the entire
upload+verify window. When an upload failed inside that window, every polling
node briefly saw a v1.7.126-alpha update whose binary 500'd and whose tarball
did not yet exist.

Reordered so the manifest goes live last:
  1. push the TAG only (the Gitea release and asset URLs hang off it; the tag
     alone changes nothing for nodes)
  2. upload assets
  3. verify every asset downloads in full and matches the manifest sha256/size
  4. only then push main — the step that actually triggers nodes

Also fixes a way a bad asset could slip through unnoticed: the inline
verification ran in a `while read` pipe subshell, where its `fail` (exit 1)
terminated only the subshell and let the script continue to "published and
verified". Verification now runs in the main shell via a new
check-release-assets.sh, which fails hard on the first bad asset. The same
script is the reusable by-hand verifier used to recover today's release
(both assets confirmed 200 + sha256-match before the manifest was re-published).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:05:55 -04:00
archipelagoandClaude 494400df29 docs(13): fill 13-VALIDATION map + write 13-UAT on-device acceptance record
13-15's two artifacts. The map names plan/wave/threat-ref per row with
today's measured results (Rust assistant suite 130/130; adapter 37; broker
25; toolConfirm, audioPlayer 11, appsConfig 13; AIUI 353/356 with the three
documented pre-existing fixture failures). Four close-out rows added
(S-invariants, evals, egress, mock-free-bundle grep). Manual-only table
discharged except the physical-handset pass, which 13-UAT records as owed
(the AIUI-06 flagged assumption wants both, and only devtools-mobile is on
record). Open questions 1/2/4 resolved with plan cites; Routstr stays
honestly open.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 17:52:22 -04:00
archipelagoandClaude Opus 5 35f992fdb4 release: re-publish the v1.7.126-alpha manifest — assets verified downloadable
Restores the signed v1.7.126-alpha manifest to main now that both artifacts are
confirmed fetchable end-to-end:
  - archipelago            HTTP 200, sha256 matches the manifest
  - frontend tarball       HTTP 200, sha256 matches the manifest

The earlier publish was rolled back (e346e552) because the manifest went live
before its assets resolved. Two separate asset faults, now fixed: the binary's
first upload landed corrupt server-side and returned 500 on download (deleted
and re-uploaded, clean); the tarball's first upload returned an empty response
and never attached (re-uploaded, 201, full 210 MB).

This is byte-for-byte the manifest the tag already carries, so its signature is
unchanged and re-verified against the pinned release root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:44 -04:00
archipelagoandClaude Opus 5 e346e5526f revert(release): serve the v1.7.125-alpha manifest until .126 assets are up
The v1.7.126-alpha manifest went live on main — which is where nodes read it
from — before its artifacts were reachable. The binary returns HTTP 500 and the
frontend tarball never uploaded (404), so any node polling would advertise an
update it cannot fetch.

Restores the previously published, still-validly-signed .125 manifest
byte-for-byte from 19487670, so nodes see the last release that actually
resolves. The v1.7.126-alpha tag and its signed manifest are unchanged in git
history; only what main serves is rolled back.

Publishing order was the mistake: the manifest is the trigger, so assets must
be verified downloadable before it lands on main, not after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:40:44 -04:00
archipelagoandClaude Opus 5 7a6b1af509 chore: release v1.7.126-alpha
Demo images / Build & push demo images (push) Failing after 2m8s
Signed release manifest for v1.7.126-alpha, verified against the pinned
release root before committing.

Committed by hand rather than by re-running create-release.sh: the script
regenerates the manifest at step 6, which would overwrite the signature
applied at step 6b. Its own "sign it, then re-run this script" advice destroys
the thing it just asked for.

Version bump, changelog and What's New landed earlier in 1cd068e4 — the
frontend build embeds the version via the curated What's New list, so those
must exist before the build step rather than after it.

Release gate: 8 of 9 stages passed in-run. cargo-test-weekly hit its 1500s
ceiling (exit 124, a timeout not a failure) because the non-incremental
all-targets compile does not fit on this machine; the suites were then run
separately and passed 100/100, including the downgrade guard and 30 update::
tests over the OTA apply/rollback path. Right-sizing that ceiling is a
follow-up — an override that becomes routine stops being a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:38:04 -04:00
archipelagoandClaude 482c4e300e fix(bootstrap): self-heal stale /aiui/api/web-search proxy to the gated daemon
Live on archi-dev-box: the node still proxied web-search straight to
SearXNG :8888 unauthenticated — the repo conf was fixed in d0c9ea6e but
existing nodes' /etc/nginx/sites-enabled never gets rewritten by a source
edit. Added to the nginx self-heal battery: stale 8888/search proxy_pass →
session-gated 5678 with the Cookie forwarded (heal_stale_web_search_block,
pure + idempotent + tested). Fresh ISOs already ship the gated block.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:54:38 -04:00
archipelagoandClaude 4361a5cba7 feat(mesh): '!ai' over mesh runs the assistant's shared tool loop
Mesh AssistQuery answered with a bare LLM call — no tools, no actions.
The CallerScope::Mesh variant was designed for this wiring ('the variant
exists so the shape is right when a future plan wires mesh callers into
the shared loop'); this is that plan. A trusted/allowlisted asker's prompt
now runs assistant::chat with CallerScope::Mesh { authorized } — the
operator's persisted grants cap what the model may touch (never wider),
and writes suspend on the node's own confirm gate. The reply is capped
for airtime as before, with a brevity instruction for mesh turns.

Wiring follows the blob_store pattern: RpcHandler::set_mesh_service (now
&Arc<Self>) forward-propagates an Arc<RpcHandler> into the mesh state's
new assistant_handler slot; absent (early boot) falls back to the legacy
bare-LLM answer.

Test: mesh_caller_authority_is_capped_at_operator_grants.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:54:12 -04:00
archipelagoandClaude 34085e30a2 fix(iso): pin wget to v4 AFTER its package installs — conffile prompt is fatal otherwise
Build #189 proved the IPv4 fix works (downloads all succeeded) and proved
my own edit wrong: appending inet4_only to /etc/wgetrc before wget's
package landed made dpkg's conffile prompt hit EOF, leaving wget and
debootstrap unconfigured. Moved the pin below the apt install.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:49:03 -04:00
archipelagoandClaude e36f36ee09 perf(aiui): chat backdrop 1052K → 478K webp (was the 'ages to load' report)
The 2912×1632 jpg painted visibly slowly over Tailscale/Tor. 1920w q82
webp is visually identical behind glass and under half the weight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:06:45 -04:00
archipelagoandClaude a2254648ad fix(iso): force IPv4 for every package retrieval in the build
This box (and its containers) blackhole IPv6: deb.debian.org answers AAAA
first, wget tries v6 until debootstrap's per-package timeout, and the
installer-env stage died twice today with 'Couldn't download packages'.
Probed in a debian:trixie container: v4 OK, v6 hangs. inet4_only for wget
(covers debootstrap) + Acquire::ForceIPv4 for every apt-get, including the
chroot and the rootfs Dockerfile stages.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:55:18 -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 7686a486ec feat(assistant): app_install/app_uninstall tools + S6 replay fix
Two changes, one binary batch:

1. app_install/app_uninstall (task 3): '!ai please install bitcoin knots'
   correctly said it can't. Both tools are category-Apps, destructive, and
   ride the 13-08 confirm gate (node-authored descriptions added). Install
   validates catalog membership BEFORE the dialog (a typo never spends an
   approval); uninstall resolves installed ids. Both reach the SAME
   package.install/package.uninstall spawns every authenticated caller
   uses, via a curated Arc-taking sibling of assistant_dispatch_tool.

2. S6: cloud legs no longer strip prior USER turns from replayed history.
   Turn-minimality's allowlist is now the whole conversation's operator
   turns (the node's own D-08 transcript, same trust class as this turn),
   still mechanically matched, B1 secret scan and 64KB cap unchanged,
   fabricated user messages still truncated. The model no longer sees its
   own answers without the questions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:00:49 -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 31fd789b55 fix(aiui): hide zero-value metadata — no more '★ 0 · 0m' (W1.5)
Node-derived cards have no rating/year/runtime/director; rendering the
defaults read as '★ 0 · 0m' beside an empty string. FilmGrid, FilmDetail
and SongGrid now gate those spans on real values (FilmCard, Book and Place
components already did). Panel empty states were made honest in 9abc1623
('Nothing found'), so this closes the rendering-honesty item.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:36:44 -04:00
archipelago 92a8dff6b1 docs(13): tick the open task list with session-2 verified state 2026-08-07 14:30:54 -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 8329b826b1 fix(aiui): mock libraries drop out of the production bundle (W1.4)
Every mock consumer is now gated on the demo flag inline (canonical Vite DCE
idiom — the cross-module DEMO_CONTENT_ENABLED const defeated folding). But
the real leak was films.ts's module-level allGenres/allSources exports:
[...new Set(mockFilms.flatMap(...))] is unprovably pure, so the treeshaker
kept the whole module — array, plex:// and cloud.example.com hosts and all —
even with zero live references. The mocks directory is now declared
side-effect-free in vite.config (they are pure data by design), so unneeded
mock modules actually drop.

Verified: clean dist build → entry bundle AND dist-wide grep show zero
mock hosts (spotify/track/example, cloud.example.com, plex://, tmdb image
host). Demo/dev builds (VITE_DEMO_CONTENT=true or import.meta.env.DEV) keep
the full pack. Tests: 353/356, failures are the three documented
pre-existing ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:06:59 -04:00
archipelagoandClaude Opus 5 f1b61731ec style: rustfmt after the registry domain migration
The release gate failed cargo-fmt. The domain that replaced the IP-based
registry is longer, pushing several test assertions past the width limit, so
rustfmt wanted to re-wrap them. Pure line re-wrapping — no semantic change.

Caught by the pre-flight gate rather than after tagging, which is what it is
for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:44:12 -04:00
archipelagoandClaude Opus 5 f6f455fa31 release(catalog): clear the legacy btcpay 2.3.9 entry — signed
The legacy `btcpay` entry (distinct from `btcpay-server`, no embedded manifest)
still carried a concrete 2.3.9 image. catalog_primary_image treats that as
authoritative, which is what drove the UI to offer "update to 2.3.9" on nodes
already running 2.4.2 — a rollback onto the actively exploited release.

Now 2.4.2 in both entries, signed by the pinned release root and verified.

This lands the fix for every node immediately, without waiting for the binary
carrying the downgrade guard (cbfda305) to reach them. The guard remains the
durable fix: it makes any future stale pin fail safe rather than relying on
every version claim being correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:30:55 -04:00
archipelagoandClaude Opus 5 37c77f17ab fix(versions): stop reporting a stack sibling's version as the app's own
package.versions answered installedVersion "15.17" for btcpay-server while
offering "2.4.2" — 15.17 being its postgres dependency's tag. With BTCPay's
own container absent, installed_version fell back to `containers.first()`,
which for a multi-container stack is an arbitrary sibling.

That is the number the update decision is made from, and it is what the UI
shows next to the available version, so a nonsense pair like "installed 15.17,
available 2.4.2" is presented as a legitimate upgrade.

The fallback now only applies when there is exactly one container, which still
covers apps whose container is named differently from their id (immich_server
for immich). With several containers and no identifiable backend, the honest
answer is "unknown" rather than a guess at a sibling.

Extracted as select_backend_container so the rule is testable directly.

Tests: the BTCPay stack case, the lone differently-named container, and the
archy- prefixed preference. Full suite 1157/1157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:14:30 -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 fa9d75de98 fix(release): track the registry trust floor — releases/** was swallowing it
d0af38e8 shipped check-catalog-registry-trust.py without the file it reads:
`releases/**` in .gitignore silently dropped registry-trust-floor.json, so the
guard would have failed in CI and on any fresh clone. app-catalog.json only
stays tracked because it predates that rule.

Both are source rather than build output — nodes fetch the catalog from this
path on main, and the floor is what the guard checks it against — so both now
have explicit negations, with the reason recorded next to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:25:44 -04:00
archipelagoandClaude Opus 5 d0af38e825 feat(release): guard the catalog against publishing untrusted registry hosts
Encodes the sequencing rule that nearly shipped a fleet-wide outage today.

The signed catalog is authoritative on every node — catalog_image_override
makes its image refs beat the on-disk manifest. TRUSTED_REGISTRIES in the
working tree describes a binary being built now; nodes run whatever was last
shipped to them. Those two diverge for exactly as long as an OTA takes to
reach the fleet, and that window is when regenerating the catalog silently
breaks every install with "not from a trusted registry".

Regenerating today would have done precisely that: the generator embeds each
app's manifest, and those now name the new registry domain, which no deployed
binary trusts.

- releases/registry-trust-floor.json records the hosts DEPLOYED binaries
  trust, separately from what the source tree accepts, with the new domain
  parked under `pending` until an OTA carries it. The migration order is
  written down there rather than living in someone's memory.
- scripts/check-catalog-registry-trust.py compares the catalog's hosts against
  that floor and explains the ordering fix when they diverge.
- sign-catalog.sh runs it as a preflight BEFORE prompting for the mnemonic, so
  a bad catalog is refused at the last reversible moment.
- CI runs it blocking, plus the drift report advisory (drift between a manifest
  landing and the next signed release is expected, since only the ceremony can
  close it).

Also installs PyYAML in the manifests job. That job passed only because GitHub
runners happen to ship ruby, which the validator used to require; it now needs
python3+PyYAML.

Verified: passes on the published catalog (2 hosts, both trusted); refuses a
simulated full regenerate (79 refs on the untrusted domain) and blocks the
ceremony without requesting the mnemonic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:24:34 -04:00
archipelagoandClaude Opus 5 7cf6980894 release(catalog): BTCPay Server 2.4.2 — signed
Signed by the pinned release root and verified before publishing
(`ceremony verify` → OK). A present-but-invalid signature is a hard reject on
nodes, so verification is the gate, not the presence of a signature field.

Surgical edit rather than a regenerate: only the btcpay-server entry changed
(66 apps in, 66 out; 49 entries still resolve through the legacy registry,
untouched). A full regeneration would have embedded the repo's manifests,
which now name the new registry domain that no deployed binary trusts yet —
publishing that would have broken every app install fleet-wide, during a
security push. That sequencing is being fixed separately.

BTCPay's image comes from docker.io, so it is unaffected by the registry
migration either way.

Nodes pick this up via package.check_updates → refresh_catalog → reload
manifests, then package.update (stop → pull → remove → recreate → verify,
with rollback on failure).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:21:45 -04:00
archipelagoandClaude Opus 5 3086f5addb fix(btcpay): pin 2.4.2 (actively exploited 2FA bypass); teach drift check the release catalog
BTCPay 2.4.2 fixes a critical vulnerability that upstream reports as actively
exploited: a TOTP two-factor bypass via Greenfield Basic authentication
(btcpayserver/btcpayserver#7491).

Checked the 2.3.9 -> 2.4.2 breaking changes against how Archipelago actually
configures BTCPay; both are inert here:
- 2.4.0 removed the LNBank and Lightning Charge backends. Ours is a direct LND
  connection built by container::lnd::ensure_btcpay_lnd_connection_secret.
- 2.4.2 disables Greenfield Basic auth five minutes after account creation.
  Nothing in the daemon or frontend consumes BTCPay's API.

The manifest bump alone does NOT reach nodes: catalog_image_override makes the
signed catalog authoritative whenever the image repo matches, so a node would
be forced back to 2.3.9. The catalog edit is held locally until the signing
ceremony runs, because an unsigned catalog published to main would be accepted
by nodes (absent signatures are allowed) and would quietly drop authenticity.

check-app-catalog-drift.py only understood app-catalog/catalog.json, where
`apps` is a list. releases/app-catalog.json — the SIGNED catalog nodes actually
resolve apps through — keys `apps` by id and wraps each app's full manifest
under manifest.app. So the checker parsed the file that governs nothing and
raised ValueError on the file that governs everything. It now reads both shapes.

Running it against the release catalog shows the repo and the catalog agree on
content: of 34 image differences, all 34 are the registry host alone and every
tag is identical. The remaining version-string drift (v1.18.0 vs 1.18.0,
1.30.0-alpine vs 1.30.0) is cosmetic metadata, not image drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:19:24 -04:00
archipelagoandClaude Opus 5 44b50a39d4 fix(registry): repair two regressions from the domain migration; port the manifest validator to python
Fixes 4 test failures introduced by 8e814ca0, which I pushed after running
only the container-crate tests while the full suite was still compiling. Both
failures were real defects, not stale assertions.

1. Catalog-driven installs would have failed fleet-wide.
   8e814ca0 dropped the old registry address from TRUSTED_REGISTRIES, but the
   signed catalog still advertises image refs on it — deliberately, since
   rewriting a signed artifact invalidates its signature. Nodes resolve apps
   through the catalog, so every install would have been refused with "not
   from a trusted registry". Reinstated as LEGACY_REGISTRY_HOST, documented
   as transitional and removable only once the catalog is re-signed.

2. The update fallback lost the property it exists for.
   update.rs keeps two mirrors on purpose: the domain as primary, and the
   old IP over plain HTTP as a fallback, because a node whose DNS or clock is
   wrong (both break TLS) must still be able to update itself — the signature,
   not the transport, is what makes either source safe. The bulk rewrite
   pointed both constants at the domain, leaving the escape hatch dependent on
   exactly what it exists to survive. Restored to its original value.

Separately, validate-app-manifest.sh is ported from ruby to python3+PyYAML.

It shelled out to ruby with stderr discarded, so on any machine without ruby
a missing interpreter was reported as "Valid YAML with top-level app block:
FAIL" and every manifest came back REJECTED. This is the first tool an app
developer runs, and it sent them to fix YAML that was never broken. Ruby was
also the odd dependency out — the repo already ships three python scripts.

It now checks for python3 and PyYAML up front and names what is missing, then
parses with PyYAML. Missing keys resolve to an absent-value object that
indexes to itself and prints empty, so call sites lost their per-hop guards:
  (((app["container"] || {})["build"] || {})["context"])
becomes app["container"]["build"]["context"]. Booleans still print as
true/false rather than Python's True/False — call sites compare == "true",
so Python's capitalisation would have silently inverted the readonly_root
and no_new_privileges security checks.

Verified: full rust suite 1148/1148, 0 failed. All 56 app manifests validate
(0 rejected, 0 errored) where previously every one was rejected. No signed
artifact modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:08:39 -04:00
archipelagoandClaude e669a3e436 fix(iso): AIUI stage picks the NEWEST candidate and copies with --delete
RC1's aiui/index.html pointed at the stale checked-in demo bundle while
today's dist sat beside it unreferenced: demo/aiui was tried first, and the
rsync without --delete merged it over the fresh capture from /opt. Now the
newest index.html across all candidates wins (demo/aiui remains the
fresh-clone fallback) and the copy deletes before writing.

Found by mounting the RC1 ISO and diffing bundle hashes against the tree —
exactly the 'verify the frontend INSIDE the ISO' rule.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 11:55:24 -04:00
archipelagoandClaude 6815a7d1eb fix(assistant): disabled tools are listed and callable — the gate's refusal IS the Settings signal
Live evidence, two ways: the 9abc1623 banner never fired because D-16 hides
ungranted tools (model never calls → refused_categories always empty), and
the [[needs:id>]] marker fix failed because a small local model answers with
a workaround narrative instead of emitting structured markers.

The model's reliable, trained behavior is tool CALLING — so disabled tools
are now listed in a DISABLED prompt section and remain in the schema. A call
hits the execution gate, which refuses and records the category → the
trusted chrome offers Settings → AI Data Access. Deterministic and
model-independent. The prompt split is UX/attack-surface shaping; the
security boundary remains the server-side grant re-check in execute_tool
(loop_.rs), unchanged and now the single enforcement layer by design.

Tests: ungranted_tool_only_ever_in_disabled_section (section-aware),
disabled_tools_are_listed_as_callable_but_refused, marker extraction kept
as a harmless safety net. 127/127 assistant suite green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 11:51:02 -04:00
archipelago 5e71f256e4 docs: resume artifact — session 2 state (banner fix in flight, ISO cut) 2026-08-07 11:49:46 -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 f15e2b5025 fix(assistant): a disabled-category request now fires the Settings offer
The 9abc1623 banner waited on refused_categories, but refused_categories
only fills when the model CALLS a gated tool — and D-16 hides ungranted
tools from the prompt, so the model never calls: it answered 'I can't do
that' in prose and the banner never fired. Live-verified: revoke media,
ask for content, no banner.

- build_system_prompt takes the disabled categories and teaches a marker:
  'say it can be switched on in Settings → AI Data Access and end with
  [[needs:<id>]]' — category names only, never tool names (D-16 holds)
- extract_needs_markers strips the markers from the reply and folds them
  into refused_categories; unknown ids pass through as text (an offer is
  the worst a bad marker can cause — never a grant)
- egress's seed-screen test now covers the new paragraph too

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 10:54:35 -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 c2e71bc7e5 fix(aiui): play the node's own files before reaching for the network
usePlayer.play() never looked at song.sources[] — a real library track went
straight to (CSP-blocked) Wavlake and reported 'Not found on Wavlake' while
its bytes sat on the operator's disk. Node sources (same-origin /content/<id>,
Range-streamed) now play first; Wavlake is the metadata-only fallback.

FilmDetail likewise only played YouTube sources; own/peer/IndeeHub sources
(same-origin, media-src 'self') now win, YouTube stays the free-films
fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 10:31:57 -04:00
archipelagoandClaude Opus 5 cc00884b98 docs: repair links left dangling by the operations-doc removal
Untracking the ops docs broke every reference to them. Repoints or removes
those references across README, architecture, ROADMAP, the archive index,
lifecycle TESTING, the hardening plan, and the security docs — pointing at
the issue tracker where a live task list was meant, and dropping the entry
entirely where it only existed to link an internal file.

Also fixes two pre-existing broken links found by validating every relative
link in the tracked docs:
- README linked docs/OPEN_SOURCE_READINESS.md, which never existed
  (underscores vs hyphens).
- reticulum-daemon/README.md linked a local Claude session plan at
  ../../.claude/plans/enchanted-strolling-rocket.md — outside the repo, and
  a path that would have shipped publicly pointing at nothing.

All relative links in tracked markdown now resolve: 0 broken.
audit-secrets.sh still 5/5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:10:25 -04:00
archipelagoandClaude Opus 5 fda7feda60 security: untrack operations docs; scrub infra identifiers from public docs
Operations docs move out of git entirely rather than being sanitized. They
stay on disk for local use and are gitignored, so the Phase 6 export (which
takes HEAD) can never carry them. 15 files: the fleet runbook, hotfix
process, node inventories, internal trackers, session handoffs, the key
rotation/signing-posture records, and the open-source plan itself.

For the docs that remain public, infra identifiers are replaced with things
that are better documentation rather than placeholders: curl examples now
use `archipelago.local`, the product's own mDNS name, so a reader can run
them as-is instead of substituting an address that was never theirs.

Deliberately NOT scrubbed, both verified as functional rather than leaked:
- `tx1138.com` is the shipped default block explorer (DEFAULT_TX_EXPLORER in
  useTxExplorer.ts, surfaced in WalletSettingsModal). Product behavior.
- `git.tx1138.com` in core/container/{image_policy,registry}.rs is a retired-
  registry constant the code matches on to strip stale entries from legacy
  node configs. Removing it would break migration for older nodes.
- `192.168.1.254` in bulletproof-containers.md is the LAN gateway in a podman
  bug description, and `192.168.1.x` in user-walkthrough.md is already generic.

Whether a personal domain should be the shipped explorer default in a public
product is a separate product question, not a security one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:08:18 -04:00
archipelagoandClaude Opus 5 39eb6b0553 docs(open-source): move Phase 0 rotation to a pre-publish gate
Sequencing change per user decision: credential rotation/revocation runs
last, immediately before Phase 6, instead of first. Safe under fresh-history
publish — the scrub commits never become public — but recorded as a HARD
blocking gate on Phase 6, with an explicit rotation sign-off added as a
numbered pre-publish step so "scrubbed" cannot be mistaken for "rotated".

Also corrects the plan against what execution actually found:
- the fleet password was in 8 tracked files, not 7 (3 in .planning/)
- both Gitea tokens are already dead (401); only the `ai` password is live
- the Framework node's SSH password was rotated out-of-band and is unrecorded,
  which would block it from receiving the fleet rotation
- .planning/ is 199 tracked files of internal agent state — added to Phase 2
  as the largest un-triaged internal block still in the tree

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:00:32 -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 Opus 5 e3b98ed18f fix(security): make audit-secrets.sh actually scan the files that leaked
The audit passed for months while two live Anthropic keys and the fleet
SSH password sat in tracked files. Three independent reasons:

- ALLOW_PATTERNS was matched against the whole "file:line:content" string,
  not the path, so bare words like "test", "demo" and "example" dropped any
  hit whose *content* merely mentioned them.
- `\.md$` was in that same allowlist and `--include` never listed *.md or
  *.yml, so docs and CI workflows — where every real leak has lived — were
  never scanned at all.
- The false-positive filter spelled the single-quote class `\x27\x27`, which
  GNU grep does not expand in an ERE, so the empty-string rule never fired.

Now: scans tracked files via `git ls-files` (exactly the set that would be
published), covers md/yml/mjs/kt/toml, allowlists by path only, and adds
patterns for credentialed URLs and inline `sshpass -p`. Test fixtures under
testdata/ are exempted narrowly rather than by substring.

Verified by planting canary secrets in docs/api-reference.md and
.gitea/workflows/build-iso.yml — both file types the old version ignored —
and confirming the audit fails on them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:58:51 -04:00
archipelagoandClaude 7d57e2c39d fix(aiui): recommendation previews render in buckets the node left empty
The archy content latch was global and permanent: once mount-time content
latched archyContentActive, no extracted [[film_ext:…]] recommendation card
could ever render again, and an empty tool result kept the latch — so a
'recommend me films' turn beside an empty catalogue showed prose only, with
'Nothing found' overwriting nothing. The chat had lost its rich previews.

- setArchyContent records which buckets the node actually supplied
  (archySupplied) and latches active only on a non-empty delivery
- updatePanelFromText's no-overwrite guard is now per-bucket: node truth
  wins buckets it filled; empty buckets stay writable for extracted previews
- the extraction fallback title no longer clobbers 'Nothing found'/'Loading…'

Regression tests: previews render in an empty bucket, node truth survives
tags in a filled bucket, 'Nothing found' survives a both-empty turn.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 09:16:52 -04:00
archipelagoandClaude a91bc55df8 fix(assistant): surface shape bugs — films scope mime hint, apps_list items wrap
Two reasons the content surface 'often doesn't surface the content':

- content.indeehub-projects items carried no mime/filename, so the UI
  adapter classified every film 'excluded' and the films grid could never
  render. They are films: they now declare video/mp4.
- apps_list surfaced the container-list RPC's BARE ARRAY; the broker reads
  { items: [...] }, so the apps grid was silently dropped every turn.
  Wrapped at the tool boundary — the shared RPC's own shape is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 09:16:52 -04:00
archipelagoandClaude 1ac08a3ed3 feat(assistant): recommendations check the catalogue first and tag knowledge picks
'recommend me 10 scifi films' answered prose-only and OFFERED to check the
catalogue — the preamble invited knowledge recommendations (paragraph 2) but
only ordered tool calls for existence questions (paragraph 3), so the model
never ran the tool and emitted none of the tags the iframe renders as rich
preview cards.

- discovery of a kind the node could hold (films/music/books…) now gets a
  catalogue-and-peers check FIRST, knowledge picks on top
- the 'would you like me to look?' stall is banned outright — looking is
  one tool call, do it then answer
- the preamble teaches the exact [[film_ext:Title|Year|Director]] /
  tv_ext / song_ext / book_ext / podcast_ext formats, same-line with the
  one-line reason, real titles only, never for items the tool returned

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 09:13:47 -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 f0d2cdb7b2 fix(aiui): node-content surfaces stay open when the reply text infers no tabs
Reproduced live: 'show me paid for peer files' returned surfaces=1 with 3
purchased images over the bridge, then updatePanelFromText set panelOpen
from the REGEX-inferred tab list — a plain markdown list matches nothing,
so the panel closed and the user saw prose only. panelOpen now follows
orderedTabs (Archy tabs lead). Regression test pins the exact turn.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 08:20:41 -04:00
archipelagoandClaude d0c9ea6e61 fix(security): session-gate web search + screen forwarder egress (S3/S4)
S4: /aiui/api/web-search proxied straight to SearXNG with no auth — anyone
reaching the web port ran searches attributed to the node's IP. Now routed
through the daemon's session-gated model proxy like the claude/ollama legs
(both nginx server blocks), forcing format=json upstream (the client never
sent it — search could 200 with HTML that parsed as nothing).

S3: the forwarder also serves the STANDALONE frontend, whose bodies carry
full history/images with no assistant loop behind them — a pasted seed
phrase went to Anthropic unscreened. The forwarder now runs the egress
secret-shape scan (G-B1) with the node's own secrets dir as deny corpus on
Claude bodies and search queries; blocked requests get a plain-language 400.

Also fixes a REAL gap in the egress tokenizer found by these tests: a JSON
key glued to a string value's first word ('content":"abandon...') dropped
that word, so an exactly-12-word seed pasted as a bare message yielded an
11-member run — checksum misses, backstop misses. Non-member words now
rescan within the token. egress 15/15 + model_proxy 10/10 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 08:03:07 -04:00
archipelagoandClaude d746fbd812 fix(assistant): app_logs redacts credential shapes before model context (S5)
The browser broker redacted log lines (password=/token=/macaroon key=value,
64+ hex, 64+ base64) while the node-side tool only untrusted-wrapped — so a
log line carrying rpcpassword=<32-hex> crossed to cloud backends below the
egress screen's threshold. Port the broker's three patterns to the tool
boundary as a pure line redactor + JSON walker; unit-tested (124 assistant
tests green).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 07:12:48 -04:00
archipelagoandClaude 3a3ceb2238 fix(permissions): assistant grants file is the one authority (live desync fix)
ai_grants_unified UNIONED the assistant grants.json with the legacy
settings/ai_permissions.json on every read. On archi-dev-box legacy held
all-ten and grants.json held four, so the Settings UI and the AIUI frame
saw every category ON while the assistant refused six — and no UI toggle
could fix it, because both write paths existed but only ai.permissions.set
synced both files. Now: an existing grants.json answers alone; the legacy
file is consulted only when no grants file exists (pre-unification
upgrade), and that read migrates forward and persists the authority.
assistant.grants-set now also rewrites the legacy file in step. Regression
tests: authority is not widened by legacy; migration folds forward once.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 07:12:37 -04:00
archipelagoandClaude 9bb38c36d3 fix(aiui): fabricated demo content is demo-build-only, fake credential scrubbed (S7)
Per operator decision 2026-08-07 (mock content is isolated to
demo.archipelago-foundation.org, never in shipped code): the auto-seeded
'node-demo' conversation (invented balances, file listings, bitcoin.conf)
no longer ships on nodes — a VITE_DEMO_CONTENT build flag (or dev) gates
it, /seed, and the Guide 'Load Demo' button. The genuine onboarding guide
still seeds everywhere. The fixture's bitcoin.conf rpcpassword is now
unmistakably example-shaped: fake must never look like a real credential.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 06:28:51 -04:00
archipelagoandClaude 4e6df488fc fix(aiui): Claude API key never persisted in plaintext again (S2)
The key rode the wholesale settings→localStorage save, sitting at rest
readable by any same-origin script, while the AES-256-GCM key-vault built
for exactly this sat bypassed. Now: the key lives in a memory-only store
ref, persists only into the encrypted vault when a passphrase session is
active (migrating into the vault on unlock), and a one-time migration lifts
any existing plaintext key out of localStorage and re-saves the scrubbed
settings object immediately. Settings UI reports honestly how the key is
held. Typecheck clean; test suite unchanged (348 pass, 3 pre-existing fails).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 06:28:51 -04:00
archipelagoandClaude a3edb848e1 docs(13): correct peer-files finding per bca18c03 — code bugs, not fleet outage
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 06:14:15 -04:00
archipelagoandClaude 9f579162d1 fix(assistant): network_status strips WAN IP + Wi-Fi SSID from model context (S1)
The Network permission's Settings label promises 'no IP addresses', and the
browser-side broker honours it — but the node-side tool forwarded
network.diagnostics verbatim, so a granted Network category sent the node's
WAN IP and SSID (both location-identifying) to cloud model backends. Strip
both at the tool boundary; NAT/UPnP/Tor/DNS connectivity shape stays.
Pure helper + unit test (123 assistant tests green).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 06:14:15 -04:00
archipelagoandClaude Opus 5 c9de1e6c53 wip: phase 13 AIUI paused at 6/17 (operator demo list)
Handoff carries the peer-files correction, the podman-lifecycle trap, the
concurrent-agent warning, and the release-binary drift that blocks the ISO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 06:04:23 -04:00
archipelagoandClaude Opus 5 bca18c03d2 docs: resume artifact — AIUI surfaces, the peer-files correction, SearXNG
Carries the full task list with per-item status, the three commits'
rationale, the live measurements that overturned the earlier
peers-have-no-content conclusion, the browser-verification recipe, and
the binary-drift blocker that must clear before the ISO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 06:00:54 -04:00
archipelagoandClaude 9cf1c12213 docs(13): operator decision — mocks isolated to demo site, not shipped code
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 05:53:59 -04:00
archipelagoandClaude a7368b8b1d docs(13): assessment plan tracks b1c5d138 + demo-mode decision for mocks
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 05:52:26 -04:00
archipelagoandClaude 1eb75a1ed9 docs(13): full AIUI assessment + four-wave fix plan (security/mission/content)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 05:49:43 -04:00
archipelagoandClaude Opus 5 b1c5d13850 fix(aiui): node content outranks the prose surface, and web search resolves
Browser-verified on archi-dev-box; all three were only visible by
driving the real UI.

The tab bar. `setArchyContent` put the node's grids up, then
`updatePanelFromText` replaced the bar with tabs inferred from the reply
text. "show me my own shared content" therefore landed on an "AI Brief"
— a prose restatement of the answer already on the left — with the
populated image grid no longer reachable. Guarding the panel arrays was
not enough: they held the right data while the tab bar had discarded the
way to see it. Archy tabs now lead, and the title follows the leading
tab. The prose stays; it just is not the only thing shown.

Tab order follows bucket size. A node with 13 photos and 2 tracks opened
on Songs and titled itself "2 Songs" for a 15-item answer.

Web search never worked embedded. `searchWeb` hardcoded
`/api/web-search` while every other call is built from BASE_URL. Under
`/aiui/` that asked the HOST for a path only the AIUI-scoped nginx
location serves, so it hit the node's API gate for a 403 and the CSP
refused the connection on top. Now BASE-relative. Additionally, the
embedded path skips the client-side search entirely: `streamViaArchy`
sends only the user's text, so the system prompt those results were
folded into is never transmitted — it was a round trip and a console
error per turn whose output provably reached no model. Web search for
the embedded path belongs node-side, with the other tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 05:48:39 -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 f7c541e867 docs: measured IndeeHub content inventory — the library is empty
"What films are there from my peers" has three causes behind one answer, and
only one is being worked. The tool gap is in flight in a concurrent session
(content_list + SURFACE_TOOLS). Separately and unowned: AIUI declares six
context categories while the broker serves ten, so media/search/ai-local/notes
cannot be requested by AIUI at all — sanitizeMedia sits behind a door AIUI
cannot open, which is likely why the model claimed no capability rather than
reporting an empty library.

And the part neither fixes: measured with a real node-signed Nostr session
through the gate, /api/projects and /api/projects/private both return 0 items.
A correct "0 results" will be indistinguishable from a broken tool, so seed a
project or verify against a peer that has content before calling it done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 04:49:46 -04:00
archipelagoandClaude Opus 5 289c19443a docs: resume artifact for the AIUI demo — task list, findings, traps
Persists the 17-item session task list so it can be rebuilt in a fresh session
(the task tool is session-scoped and would otherwise evaporate), with what
shipped and what each remaining item actually is.

Records the findings that change expectations rather than leaving them to be
rediscovered: the 16 federated peers are not serving content so peers_reached 0
is correct, IndeeHub's catalogue is genuinely empty, two AI permission stores
existed for the same ten categories, and tailscaled owns :443 so nginx must
bind LAN addresses explicitly or it fails EADDRINUSE and silently keeps the old
config.

STATE.md's stopped_at points at it, so /gsd-resume-work lands correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 04:27:36 -04:00
archipelagoandClaude Opus 5 55155f2db4 fix: one AI grant store, and peer browse mirrors Cloud's fan-out
UNIFIED GRANTS. There were two stores for the same ten categories:
settings/ai_permissions.json (what Settings wrote) and
assistant/grants.json (what actually gates the tool list). Toggling Settings
did nothing for the assistant, so with grants stuck at {"apps","system"} the
model truthfully answered "I don't have a tool for that" no matter what the
operator enabled — the real cause behind "the settings I enable keep
disabling". Their serde forms already matched one-for-one, so this is a
duplicate rather than two concepts. ai.permissions.get/set now read and write
the assistant's grants; the legacy file is still written so a downgrade does
not lose grants, and anything recorded only there is folded in on read.

PEER BROWSE now mirrors Cloud.vue's peer-files fan-out, as the operator asked:
concurrent with a cap and a per-peer timeout, rather than sequential. Cloud
caps at 3 because CHROMIUM's connection pool was starved (02-08) — a browser
constraint the daemon does not share, and measurably wrong here: at 3 a 20s
budget got through 2 batches of 16 peers and reached none. At 8 every peer is
attempted inside the budget.

Measured after deploying: 20.0s, peers_total 16, peers_reached 0. FIPS itself
is healthy (anchor connected, 3 authenticated peers, 4 fips_ok dials) but 14
dials fall back and fail, so the peers are not serving /content. The empty
film list is therefore correct — the transport works and the peers are down.
Reported as partial with counts so the assistant can say so instead of
implying the peers have nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 04:23:56 -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 58c759c149 feat(assistant): content_list gains scope — peers, purchased and IndeeHub films
Operator asked "what films are there to watch from my peers" and the model
answered, honestly, that it had no tool for it. It was right: content_list
mapped only to content.list-mine — this node's own shared files. Peer
catalogues and IndeeHub were unreachable from the assistant entirely.

content_list now takes scope: own | peers | purchased | films, dispatching to
content.list-mine / content.browse-all-peers / content.owned-list /
content.indeehub-projects. The model picks from a closed enum and never names
a method, so an invented scope falls back to "own" rather than reaching
anything it was not granted (T-13-34).

Two new RPCs behind it:

- content.browse-all-peers aggregates every federated peer in ONE call. The
  dashboard fans this out client-side, but asking a model to enumerate peers
  and loop is how it ends up claiming it has no tool. Rides FIPS —
  PeerRequest::new(fips_npub, onion, "/content") with a 6s FIPS fast-fail then
  Tor — so the onion is the peer's identity and FIPS is the transport.
  Sequential with a per-peer timeout, not an unbounded fan-out: 02-08 traced a
  real UI stall to browse-peer starving the connection pool. One peer being
  down is the normal case and contributes nothing rather than failing the call.
- content.indeehub-projects fetches IndeeHub's catalogue, public plus (via a
  node-signed NIP-98 login) the operator's private titles. Node-side because
  signing that in the browser would put identity material next to the model,
  which this phase rules out by name. Tolerant of IndeeHub's field spellings
  across versions, and absent/stopped/empty all yield an empty list rather
  than failing the caller.

action_key includes the scope, so listing peers cannot be replayed as listing
own files. 15/15 assistant::tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 03:15:10 -04:00
archipelagoandClaude Opus 5 05b459a65f fix(aiui): content loads progressively — own first, peers when they arrive
Regression from wiring the peers scope: requestArchyAllContent awaited all
three scopes together, so the grid waited on the slowest. `peers` browses every
federated node over FIPS (Tor fallback) and routinely takes tens of seconds or
times out when a peer is offline. On-device that read as
"content(peers) failed: Content request timed out" plus an AIUI that felt very
slow to open — with nothing rendered meanwhile, even though local content was
ready immediately.

Now `own` paints as soon as it lands and `owned`/`peers` fold in as they
arrive. A scope that times out costs only its own results.

Also records the operator's console findings as tasks: the `files` context
timeout, the web-search CSP block (13-09, now firing on every query), the
strfry icon 404, IndeeHub's relay.nostr.band socket, and the ask that `!archy`
over mesh be able to action container commands with text responses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 03:08:03 -04:00
archipelagoandClaude Opus 5 086d381c4f fix(aiui): content cards carried the PREVIOUS item's description
Operator-reported via a Bitcoin-films transcript: "Banking on Bitcoin" was
captioned with *The Rise and Rise of Bitcoin*'s description, "Cryptopia" with
*The Bitcoin Standard*'s, and the section header "Documentaries:" bled into the
first card of each group. Read as the model talking nonsense; the model's prose
was correct throughout and only the pairing was wrong.

Several patterns anchor with `(?:^|\n)` so they fire only at a line start.
That makes m.index point at the NEWLINE — one character before the line the
match is really on — so extractDescriptionForTag's window, which walks back
from `matchIndex - 1`, landed on the PREVIOUS line. The description became
"previous line + this item's own text".

Normalised inside the helper rather than at each of its nine call sites, so a
pattern that gains a `(?:^|\n)` anchor later cannot silently reintroduce it.

Fault-injected to prove the tests are not vacuous: with the fix removed, two
fail with exactly the reported strings — 'Documentaries: – Early documentary
fo…' and 'The Rise and Rise of Bitcoin – Early …'. 26/26 with it restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:08:11 -04:00
archipelagoandClaude Opus 5 4891e1babc docs: item 2 done — grants node-side, verified across a daemon restart
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:58:27 -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
archipelagoandClaude Opus 5 9e86d18921 docs: refresh resume pointer — item 1 done and proven end to end
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:23:03 -04:00
archipelagoandClaude Opus 5 aeb40b93d9 docs: record the end-to-end Nostr login proof through the gate
The node signed a real NIP-98 event with its own key and presented it to
IndeeHub through the gate: 200, with a real JWT pair issued. The app's own
bearer token then rides back through the gate — /api/auth/me,
/api/projects/private and /api/projects all 200, matching loopback.
/api/projects/private was the endpoint recorded as unreachable without a
Nostr session, so item 4's private-films path is unblocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:19:11 -04:00
archipelago 3d4d329787 fix(13-13): assert the seed screen with a real mnemonic, not a stale shape fixture
secret_shaped_content_never_reaches_the_stub was RED because its fixture was
the first twelve wordlist entries — not a parseable mnemonic. The 2026-08-06
precision rewrite of screen_outbound moved from a word-run shape rule to BIP-39
checksum validation (the shape rule had blocked legitimate turns on a live node
twice); egress.rs's own test was updated to a checksum-valid fixture and this
copy was not, so it asserted behaviour that had been deliberately retired.

The named behaviour was intact throughout: screen_outbound runs on the Routstr
paid leg before any body is sent, a real mnemonic is blocked, and
checksum-invalid runs of 20+ wordlist members are still caught by
IMPLAUSIBLE_MEMBER_RUN. Fixture is now a checksum-valid mnemonic, asserted as
parseable so it cannot silently rot the same way again.

Also records the operator's rendering contract in the surfaces todo: chat gets
the mini version, the content/context surfaces expand it, nothing rich may
overflow the bubble at mobile width.
2026-08-06 20:16:48 -04:00
archipelago 36574c0230 docs: capture the content/context-surface underuse task from the operator transcript
Nine of ten turns in the exported transcript answered in markdown prose where
the content surface (grids/cards) and context surface should have carried it.
Records each turn against the surface it should drive, plus two security items
found in the same evidence: a cleartext rpcpassword rendered into the chat, and
the RED screen_outbound test that lets a seed-shaped body reach a third-party
inference provider.
2026-08-06 20:00:12 -04:00
archipelago f1c350040c docs: record the Authorization-strip root cause and the relay ownership fix
Item 1 was not an interception problem and needed no session-aware rule —
the gate was deleting the app's own Authorization header. Item 4's /relay
502 was a root-owned volume, not networking. Both deployed and verified on
archi-dev-box; the extension login itself still needs a human in a browser.
2026-08-06 19:48:43 -04:00
archipelago d9592c7212 fix(appgate): stop deleting an app's own Authorization header
The gate removed `Authorization` unconditionally before proxying, so every
credential an app owns was destroyed one hop before the app saw it.

IndeeHub's Nostr login is the reported case: it signs a NIP-98 event and
sends `Authorization: Nostr <event>` to its own /api/auth/nostr/session.
The header arrived stripped and its backend answered "Authorization header
is missing" — a 401 that no signer could ever satisfy. That is why a NIP-07
browser extension in a tab, the parent frame's NIP-07 bridge
(nostr-provider.js) and AIUI all broke at once while the signing itself was
never at fault. Proven on the node: the same POST returns a real NIP-98
validation error on loopback and the gate's login page through the gate.

The gate accepts exactly one header credential — `Authorization: Bearer
<app-scoped device token>` — so only that one is ours to withhold. authorize()
now reports which credential allowed the request, and the header is dropped
only when it WAS the gate's token, mirroring the surgical cookie strip
directly above it. Any other scheme (Nostr, Basic, an app's own bearer)
rides through untouched.

Credential-less allowlist paths still drop the header: nothing there needs
auth, so an unverified token is not handed to the app.

Tests: an app's Authorization is not classified as the gate's, and a real
proxy hop against a local server shows the app's credential arriving intact
while a gate device token does not.
2026-08-06 19:39:35 -04:00
archipelagoandClaude Opus 5 11aa276f58 docs: resume artifact for the fix→deploy→test loop
Everything needed to continue cold: the loop protocol with real deploy and
verify commands, the ordered work list with each item's evidence, Phase 13's
exact remaining state (13-15 only, check 4 passed on-device), and the traps
that cost time tonight — verify on the node not from source, rustls does not
check key/cert pairing, build-aiui.sh hangs after succeeding, AIUI needs
VITE_BASE_PATH=/aiui/.

STATE.md's stopped_at now points at it, so /gsd-resume-work lands correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:10:59 -04:00
archipelagoandClaude Opus 5 5f343f5ef9 docs: record the late operator asks and the deploy state of tonight's fixes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:56:45 -04:00
archipelagoandClaude Opus 5 8e3e8e9a28 fix(appgate): stop 401ing credential-less subresource fetches
IndeeHub worked all year and broke when the gate rolled out. Cause, verified on
the node: GET /manifest.json returns 401 + the gate's login HTML. A browser
fetches <link rel="manifest"> in no-credentials mode unless the tag opts in
with crossorigin="use-credentials", so the session cookie is NEVER offered and
the gate challenges a fully authenticated user. The app's service worker then
serves its cached shell, whose every network call fails — which reads as "the
app is broken" rather than "the gate refused it". Any gated app with a PWA
manifest has the same failure.

Passed through unauthenticated on purpose, and deliberately as small as the
problem: an EXACT-match allowlist of /manifest.json, /site.webmanifest and
/favicon.ico. Static, non-user-specific, and no more revealing than the gate's
own login page, which already shows the app's name and icon.

Exact match, never a prefix — a prefix would let /manifest.json/../api/secrets
ride through. A test pins that: 8 near-miss paths (traversal, query-string
traversal, /api/manifest.json, /manifest.jsonx, case variants, /admin,
/api/auth/nostr/session) must all still be challenged.

19/19 appgate tests pass. This does NOT address the app's own auth endpoints
being intercepted — that needs a session-aware decision and is recorded
separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:56:26 -04:00
archipelagoandClaude Opus 5 71bf4f3eaf docs: scope the media/IndeeHub/AIUI work from on-device evidence
Every item was observed on archi-dev-box or read from source, not inferred:
the content-card parser mispairing titles with the previous description (the
real cause of "idiotic responses" — the model's prose was correct), IndeeHub's
three independent faults (empty public library, Nostr-only private auth, relay
502 on loopback), the fleet-wide gate bug that 401s credential-less PWA
manifest fetches and app-owned auth endpoints, and AI Data Access grants living
in per-origin localStorage when they are a property of the node.

Input for a research + plan pass, explicitly not the plan itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:34:26 -04:00
archipelagoandClaude Opus 5 7c23505d8c fix(13-11): log per-scope permission denials instead of returning silently
On-device, an empty films search looked like a broken fetch. The console said
only "library: not permitted" — the content scopes returned null without a
word, so an ungranted Media/File permission was indistinguishable from "this
node genuinely has no films". That ambiguity cost real diagnosis time and sent
me looking for a code fault that was not there.

Each scope now names itself when denied. The permission was the whole cause;
no content path was broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:25:51 -04:00
archipelagoandClaude Opus 5 11b9cb5012 fix(13-11): a films search found nothing — owned and peer content had no caller
Reported on-device: searching for films in AIUI returns nothing. Init only ever
asked for scope 'own' (content.list-mine — this node's own shared files), so
IndeeHub and everything else purchased, which lives in 'owned'
(content.owned-list), and other nodes' catalogs in 'peers' were never fetched.
Both scopes existed only as type-signature options with no call site anywhere
in the app.

requestArchyAllContent() now loads all three concurrently and merges once.
Merged rather than three setArchyContent calls because that sink REPLACES
films/podcasts — separate pushes would leave only whichever resolved last, the
same class of bug as the shared sequence guard fixed in aac81503. Deduped by
id, since a title can legitimately appear both owned locally and offered by a
peer. Each scope is caught individually so one dead or slow peer costs only its
own results, which is normal rather than exceptional.

requestArchyContent also stops clobbering songs with an empty array, mirroring
what requestArchyLibrary already did for films/podcasts.

vue-tsc clean, 3/3 useArchy tests pass, and the change is verified present in
the built bundle rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:10:04 -04:00
archipelagoandClaude Opus 5 bd98ec6e3d fix(tls): leaf key must be readable by the daemon, not just root
Found on archi-dev-box the moment the gate tried to serve TLS: the key was
installed root:root 0600, nginx's master reads it as root, but the archipelago
daemon runs as User=archipelago and got "Permission denied (os error 13)".

Every app port then quietly stayed plain HTTP — the exact fail-open shape the
gate exists to prevent, and it would have looked like "TLS just doesn't work"
with no obvious cause. The warn-level log the tls module deliberately emits for
a present-but-unloadable certificate is what turned this into a ten-second
diagnosis instead of a hunt; it earned its keep on its first real deployment.

Key is now group-owned by the service user at 0640, with a fallback to the
user's primary group and a clear message when no such user exists. Nothing
wider than that.

Verified on the node afterwards, on one gated port (8096):
  https 401 verify=0   TLS terminated, chain valid against the node CA
  http  401            same port, plain HTTP, unchanged
  no CA verify=20      untrusted client correctly rejected
The reissued key was also picked up with NO daemon restart — the mtime reload
path proven in production, not just in a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:25:14 -04:00
archipelagoandClaude Opus 5 1dfd9e720b fix(tls): leaf key must be readable by the daemon, not just root
Found on archi-dev-box the moment the gate tried to serve TLS: the key was
installed root:root 0600, nginx's master reads it as root, but the archipelago
daemon runs as User=archipelago and got "Permission denied (os error 13)".

Every app port then quietly stayed plain HTTP — the exact fail-open shape the
gate exists to prevent, and it would have looked like "TLS just doesn't work"
with no obvious cause. The warn-level log the tls module deliberately emits for
a present-but-unloadable certificate is what turned this into a ten-second
diagnosis instead of a hunt; it earned its keep on its first real deployment.

Key is now group-owned by the service user at 0640, with a fallback to the
user's primary group and a clear message when no such user exists. Nothing
wider than that.

Verified on the node afterwards, on one gated port (8096):
  https 401 verify=0   TLS terminated, chain valid against the node CA
  http  401            same port, plain HTTP, unchanged
  no CA verify=20      untrusted client correctly rejected
The reissued key was also picked up with NO daemon restart — the mtime reload
path proven in production, not just in a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:25:03 -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 2ecd5ef8c0 docs(13): content-grid defect fixed + the peer/owned gap that blocks check 2
Records why 13-15's check 2 cannot pass as written (peers/owned scopes have no
caller) and the merge design settled before stopping, so the next session does
not rediscover that setArchyContent replaces rather than merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:46:42 -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 23173c024a docs(13): record the app-port TLS work and its two remaining gaps
Includes the rustls finding (it does not verify key/certificate pairing) so
the explicit check is not later mistaken for redundant, and the archi-dev-box
caveat: it has no HTTPS dashboard, so it cannot reproduce the iframe failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:25:23 -04:00
archipelagoandClaude Opus 5 7515166a07 feat(appgate): serve HTTPS and HTTP on the same app port
An app port must answer whatever the browser asks for: an HTTP dashboard
embeds http://host:PORT, an HTTPS one embeds https://host:PORT, and an HTTPS
page cannot embed an HTTP frame at all. So the choice is per-node, not
per-fleet, and a second port number would mean every manifest changes and
torrc doubles.

Instead the gate peeks the first byte. A TLS ClientHello is 0x16; no HTTP
method starts with it. peek() leaves the bytes in the socket buffer, so the
acceptor still sees a complete, untouched ClientHello. TLS and plain share one
generic serve_http(), so authentication, proxying and upgrade handling cannot
drift apart by scheme.

EXISTING NODES ARE UNAFFECTED BY CONSTRUCTION. Anything that is not a TLS
handshake takes the identical path as before, and a node with no certificate
serves plain HTTP exactly as today — TLS is strictly additive.

rustls does NOT verify that a private key matches its certificate. Established
by test, not assumed: with_single_cert accepted a pair from two different keys
and would only have failed mid-handshake in a user's browser — a security
control that reports success and does nothing, the exact shape this module's
own docs warn about. So the pairing is now proven explicitly (sign a fixed
message with the key, verify against the certificate's public key) and a
mismatch refuses to serve.

Also: cert and key mtimes are stamped as a PAIR, because reissuing writes them
separately and keying on one would serve a certificate that no longer matches
its key; a 15s first-byte timeout closes the slowloris window one step earlier
than the existing header-read timeout; PKCS#8 and PKCS#1 keys are both
accepted so a hand-made key does not silently downgrade a working node.

Deps pinned to the rustls 0.21 line reqwest already resolves — no new vendor,
no second rustls major. Test fixtures are throwaway (localhost SANs only), not
any node's identity.

38/38 appgate tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:23:47 -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 71a48e6dbd docs(13): record the per-node CA decision + warm-up fix in the open-tasks file
Corrects the iframe-login root cause on record: trust is per-origin including
port, and a cert interstitial cannot be accepted inside an iframe, so the
SameSite cookie was a downstream symptom rather than the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:38:50 -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 Opus 5 2c302d1479 docs(13): fold the full-tree sweep into the open-tasks file
A resume that reads STATE.md plus this file was still missing real work:
two planning docs untracked on main since 2026-08-05, the indeedhub
crash-loop on .38/.88, nine items still OPEN in RELEASE-1.7.121-TASKS.md,
and 19 uncommitted files in the archy-mesh worktree. All now listed here
so this one file is the whole picture rather than most of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:25:58 -04:00
archipelagoandClaude Opus 5 4b318b4c7d docs(13): drop stale handoff/checkpoint files, refresh resume pointer
HANDOFF.json and .planning/.continue-here.md both described phase 09
(2026-08-02, BotFights demo work) which was fully reconciled and pushed
in both repos at the time they were written. They are the FIRST thing
/gsd-resume-work reads, so they made a clean resume open on the wrong
phase entirely. phases/02-ui-performance/.continue-here.md is likewise a
closed-out note from 2026-07-31.

STATE.md's Session Continuity now names the real fork: 13-15 blocked on
four operator browser checks, the four non-phase node/infra tasks, and
the follow-on A/B/C proposal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:25:04 -04:00
archipelagoandClaude Opus 5 ab69400956 docs: commit the app-gate design + 2026-08-05 resume notes
Both had been sitting untracked in the working tree since 2026-08-05 —
exactly the "finished work lost because it was never committed" failure
CLAUDE.md's #1 process rule exists to prevent.

APP-PORT-AUTH-GATE.md carries the gate's design rationale ("you cannot
gate a socket you do not own") and, in its open questions, the TLS/scheme
fork that still blocks the gated-app iframe login: if the dashboard is
HTTPS and app ports are HTTP, a Secure session cookie is never sent.

RESUME-2026-08-05-appgate-fixes.md carries the .122-.125 release trail,
the two self-inflicted .124 bugs and their guards, and the open indeedhub
crash-loop (indeedhub-minio absent on .38/.88).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:24:51 -04:00
archipelagoandClaude Fable 5 7bb09ffe61 docs(13): persist open task list + resume pointer for a fresh session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:16:36 -04:00
archipelagoandClaude Fable 5 31f78bdced docs(13): operator decision — web-search setting derives the AIUI CSP
The toggle must change what is POSSIBLE, not ask the frame to behave. Records
the verified mechanics: CSP is nginx-emitted (static add_header), the setting
lives only in browser localStorage today, and the node's nginx self-heal
reverts hand edits — so the setting moves node-side and the CSP derives from
it, allowlisted rather than wildcard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:15:00 -04:00
archipelagoandClaude Fable 5 c3277a8323 docs(13): follow-on scope proposal — CSP collision, unbuilt AIUI-02/05, nostr+zaps
Drafted during 13-15 device verification from what the operator actually hit:
the 13-09 CSP blocks wss:// relays and enrichment from the embed (real
regression, needs a broker-vs-widen decision), /api/tmdb and /api/web-search
are unimplemented on the node, AIUI-02 and AIUI-05 were declared but never
planned, and nostr polish + zaps were explicitly deferred by 13-CONTEXT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:13:27 -04:00
archipelago 706c33acaf docs(13-14): complete eval harness + confirmation-clarity plan 2026-08-06 14:08:21 -04:00
archipelagoandClaude Fable 5 08356b9e18 fix(13-11): content classifier — plural films, specific-before-generic, no bare "show"
Three defects in the two query classifiers that pick the content tab and
its header label. Found while writing the regression test for the
"Podcast recommendations" mislabel the operator reported on-device.

- "recommend me 10 scifi films" matched NOTHING: the film rule listed
  film|movie|movies but not the plural `films` — the operator's own
  phrasing. It opened no content tab at all.
- "listen to a podcast" classified as `song`: the song rule's bare
  `listen` was checked before the podcast rule. Specific terms now win —
  podcast is matched first, and `listen to` is no longer a podcast token
  (so "listen to music" stays a song query).
- A bare `show` counted as a podcast word, which is how an operator
  phrases nearly everything ("show me my files", "show the logs"), so
  unrelated queries rendered "Podcast recommendations".

Both classifiers are fixed identically and the reason they must agree is
now stated in each — they label the same panel. 16/16 content tests, 56/56
composable tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:40:10 -04:00
archipelagoandClaude Fable 5 82d1b60891 fix(13-10/13-11): replay chat history to the model; unbreak general answers
Four defects found by on-device UAT, 2026-08-06.

1. D-08 persistence was WRITE-ONLY. chat() loaded the transcript only
   AFTER the loop, to append — the model was never shown any of it. The
   assistant answered "I don't have access to any previous conversation
   history" with its own transcript on disk, and "and is it healthy?"
   resolved to the node instead of the app just discussed. History now
   replays into every turn (text only: a stale tool result must not be
   re-presented as this turn's evidence), scoped by HistoryKey. The
   replayed prefix is excluded from the append, or each turn would
   re-persist the conversation and grow it geometrically.

2. The operator persona forbade the very answers the content surfaces
   render. 13-01's prompt refuses anything without a matching tool, so
   "recommend me 10 sci-fi films" was declined and the film/song/podcast
   grids from 13-11 could never populate — two plans in contradiction.
   The refusal rule now governs ACTIONS ON THE NODE; general questions
   and recommendations are answered from the model's own knowledge.
   (Whether the node should also SEARCH THE WEB depends on AIUI's
   web-search setting, which embedded mode never forwards — captured as
   a separate todo because it opens a new egress path.)

3. The content-surface loader labelled unrelated queries "Podcast
   recommendations": the classifier matched a bare "show", which is how
   operators phrase almost everything ("show me my files").

4. "Surfacing…" tracks at 0.2em and its final glyph collided with the
   close button; the header now spaces them properly.

assistant::history 9/9 green incl. replay_feeds_prior_turns_back_to_the_model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:24:41 -04:00
archipelagoandClaude Fable 5 857d9d4906 capture: honour AIUI web-search setting in node-delegated chat
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:18:58 -04:00
archipelagoandClaude Fable 5 e681c95131 fix(13-12): seed screen validates the BIP39 checksum, not word shapes
Second on-device failure in one session: after the wordlist fix, dev3
blocked cloud turns AGAIN mid-session as 13-10's history grew — splitting
on every non-alphabetic character let words from unrelated JSON fields
chain into one run. Both failures took the whole feature down rather than
protecting anything, which is the worse failure for a screen to have.

Shape is the wrong signal. A real mnemonic's last word encodes a checksum
over the rest, so an accidental run of English words parses as a mnemonic
only about one time in sixteen. Candidate runs are now validated with the
same bip39 crate the wallet uses:

- tokenize on whitespace (a seed phrase is space-separated); a token's
  leading alphabetic segment counts, and alphanumerics after it end the
  phrase, so a seed glued to a closing quote is still caught
- block only if a 12/15/18/21/24 window parses as a real mnemonic
- IMPLAUSIBLE_MEMBER_RUN (20) backstops checksum-invalid material such as
  a typo'd 24-word seed, which prose cannot plausibly produce

Documented trade-off: a checksum-invalid run under 20 words no longer
blocks. The rule that did block it also blocked every legitimate turn,
twice, on a live node. 15/15 egress tests green, including the real
system prompt, scattered-JSON prose, and a genuine mnemonic in JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:42:44 -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 02a5aa6e29 fix(13-12): seed-phrase egress screen checks the real BIP39 wordlist
The shape-only heuristic ('any 12 consecutive lowercase 3-8-char words')
matched ordinary prose — including the node's own system prompt — and
blocked 100% of live cloud chat turns (found on dev3, the first real
Claude call through this screen; log: kind=bip39-word-run every turn).
Membership in the crate's own bip39 English wordlist (already a dep via
seed.rs) distinguishes prose from seed material: glue words break runs,
real seeds are nothing but members. Regression test pins the real system
prompt + a clean wire body to Allow; the 12-word genuine-seed case still
blocks. 13/13 egress tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:28:41 -04:00
archipelagoandClaude Fable 5 52a400b3c5 capture: funding-settings modal entry point; slot into next week's UX pass
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:07:46 -04:00
archipelagoandClaude Fable 5 5fc8289436 capture: Routstr funding UX in AIUI dropdown (operator request)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:07:36 -04:00
archipelagoandClaude Fable 5 39d53d212e docs(13): defer dev3 console-noise triage — embedded web-search/rss, wiki demo spam, content timeout pairing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:01:26 -04:00
archipelagoandClaude Fable 5 dfe027a5f3 style: cargo fmt (rnode_settings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:49:15 -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
archipelagoandClaude Fable 5 f394c02055 fix(mesh): ride out the radio restart during apply — no false errors, no setup modal
Demo images / Build & push demo images (push) Successful in 3m54s
Applying RF settings deliberately restarts the radio daemon (~15-20s).
Two things treated that healthy, expected gap as a fault (operator,
2026-08-06):

- radio_state was single-shot: a query landing inside the restart
  window reported "The radio daemon did not answer the state query"
  for a restart that was working correctly. It now retries for ~30s
  and says the radio is restarting while it waits. A real device-level
  refusal (not an RNode) still returns immediately.
- The device-setup modal auto-opens for any detected-but-unconnected
  port, so the restart looked like a newly plugged stick and
  interrupted the apply. Apply and Reboot now suppress auto-detect for
  90s via mesh.suppressDeviceDetect().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:55:52 -04:00
archipelagoandClaude Fable 5 51a13da2a6 merge: bring main (v1.7.125 + .126 work) into phase-13 branch pre-deploy
63 main commits since the fork point — gate cookie-strip fix, named-volume
create fix, appgate catalog classification, RNode error surfacing — merged
so 13-14/13-15 on-device verification runs against current production code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:33:40 -04:00
archipelagoandClaude Fable 5 53753687a5 style(entropy-guide): rebuild on the dashboard's own sidebar + glass-card
Demo images / Build & push demo images (push) Successful in 4m1s
Replaces the bespoke doc-page chrome with the app's real components:
DashboardSidebar shell (256px, rgba(0,0,0,.25) + 18px blur, staggered
nav-item entrance), the AnimatedLogo neode mark with its 20 staggered
squares, sidebar-nav-item + nav-tab-active for section state, and the
Settings wallpaper behind it all.

Every bespoke container (.card/.card-sm/.step/.score-card/.callout-*/
.diagram) is gone — .glass-card from style.css is now the only box on
the page, with layout-only grids inside it.

Scroll-spy lives in nav.js rather than inline, since the node's CSP is
script-src 'self'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:30:41 -04:00
archipelagoandClaude Fable 5 3e124dd4b3 fix(rpc): surface unknown-method + RNode settings errors instead of masking
Deploying the .126 LoRa panel ahead of its daemon made every button
report "Operation failed. Check server logs for details." — the panel
was calling RPCs the older binary doesn't have, and the sanitizer
masked "Unknown method: mesh.rnode-config" into that generic string.
Read as "the feature is broken" rather than "this node needs its
update" (operator, 2026-08-06).

Allowlisted: "Unknown method" (a frontend newer than its daemon should
say so), every RNode RF validation message (each names the field and
its legal range — the entire point of validating before touching the
radio), and the actionable mesh preconditions (no device connected,
mesh service not running, MeshCore has no remote reboot, radio daemon
did not answer, RNode interface disabled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:28:44 -04:00
archipelagoandClaude Fable 5 6c0fc366b3 fix(appgate): classify ports from the catalog even for on-node-built apps
The port map deferred to DISK manifests for any app with a build source
— which is exactly the four companion UIs (lnd-ui, bitcoin-ui,
electrs-ui, fips-ui). Their disk manifests reach nodes only via the
frontend runtime payload or a per-node repo checkout, and in the
v1.7.125 rollout both proved stale or entirely absent: one node had no
checkout at all, others restored an older payload over apps/ at every
boot. Result: session_passthrough never reached the gate, so the node's
own screens 401'd on every data call, and on nodes whose UI rebuilt
from a stale context the app held its port UNGATED.

Classification now uses a ports-only overlay that accepts build-source
manifests (install/orchestration still defers to disk — unchanged). The
signed catalog is the freshest, operator-signed source, and the gate's
address binds fail safely against a container publishing differently
(logged CANNOT PROTECT), so this can only tighten policy, never expose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:17:10 -04:00
archipelagoandClaude Fable 5 83d7234824 style(entropy-guide): use the app's real look — Settings wallpaper + Montserrat
Demo images / Build & push demo images (push) Successful in 4m2s
Layer the page over /assets/img/bg-settings.webp (fixed, cover, with a
dim gradient so prose between glass panels stays readable) and load the
actual Montserrat Bold/ExtraBold faces from the node's own web root,
instead of the flat-black background the /architecture/ guide uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:11:15 -04:00
archipelagoandClaude Fable 5 3cef1d09f4 feat(mesh-ui): RNode settings editor with live device read-back + region presets
Demo images / Build & push demo images (push) Successful in 3m49s
The LoRa device panel's Reticulum section (operator .126 top priority):

- Shows the device's CURRENT settings first — the radio-confirmed r_*
  values from mesh.rnode-config (online badge, port, frequency, bw,
  SF, CR, txpower, airtime limits), with a Refresh action.
- Every RNodeInterface parameter is editable: enabled, serial port
  (auto-detect when blank), frequency, bandwidth (RNode's discrete
  set), SF 5-12, CR 4/5-4/8, txpower, airtime short/long %.
- "Set recommended for <region>" fills the fields from per-region
  plans (EU868 = the operator-validated Portugal plan incl. 25%/10%
  duty-cycle locks); driven by the existing region selector above.
- Apply & Confirm on Device: persists, restarts the radio daemon, and
  reports the radio's own confirmation (green ✓ only when the device
  read-back matches; amber/red messages say what actually happened).
- Action buttons stack in a column (operator layout request).
- Reboot Radio surfaces the backend's real acknowledgement message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:08:19 -04:00
archipelagoandClaude Fable 5 45fa8b6c6f feat(neode-ui): seed & entropy explainer page at /entropy/ + link from Backup settings
Demo images / Build & push demo images (push) Successful in 4m19s
Standalone static guide (same pattern as /architecture/) covering how the
master seed entropy is drawn (explicit OsRng, sealed KeyGenRng allowlist,
degenerate-draw refusal, CSPRNG readiness ledger), how it is stored
(Argon2 + ChaCha20-Poly1305 envelope), the full derivation tree (HKDF
labels, NIP-06, LND aezeed one-way gate, second-order keys), what is NOT
seed-derived, every failure/fallback path, and the restore flow — in
paired layman/technical language. Linked from the Recovery-phrase card
in Settings → Backup. CSP-safe: no inline scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:56:34 -04:00
archipelagoandClaude Fable 5 209a36e53c feat(mesh): rnode-config RPCs + honest reboot feedback with reply channels
- mesh.rnode-config: persisted RF settings + best-effort live radio
  state (radio-confirmed r_* values) for the LoRa panel.
- mesh.rnode-config-apply: validate → persist → restart the radio
  daemon → poll the read-back until the radio reports online, returning
  {applied, confirmed, live, message}. Failure modes report what
  actually happened instead of pretending success.
- RebootRadio carries a reply channel: Meshtastic reboots firmware,
  Reticulum restarts the sidecar (re-detect + reapply RF config),
  MeshCore honestly reports it has no remote reboot — previously the
  Reticulum/MeshCore arms returned Ok(()) doing NOTHING: the operator's
  "button gives no feedback" bug.
- MeshCommand::QueryRadioState plumbs the sidecar's radio_state to the
  service layer with a timeout instead of fire-and-forget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:43:39 -04:00
archipelagoandClaude Fable 5 4dd8bacd0e feat(mesh): persisted RNode RF settings with adopt-don't-clobber migration
The .126 LoRa panel's Rust half:

- mesh::rnode_settings: RNodeRfSettings persisted at
  <data_dir>/rnode-rf-settings.json — every RNodeInterface parameter
  (enabled, port override, frequency, bandwidth, sf, cr, txpower,
  airtime_limit_short/long), validated against the bounds RNS itself
  enforces. Defaults are byte-identical to the sidecar's historical
  argparse defaults.
- FIRST-RUN ADOPTION (operator requirement: the update must change NO
  device's applied settings): with no settings file yet, the node's
  existing RNS config (~/.archy-reticulum, else ~/.reticulum) is parsed
  and its RNodeInterface values adopted verbatim as the initial
  settings — proven by a test carrying the operator's literal
  "RNode LoRa Portugal" config.
- Serial spawns pass the settings as explicit sidecar args (frequency/
  bandwidth/txpower/sf/cr + airtime locks); the operator port override
  wins over auto-detect but still passes the KISS probe gate; a
  disabled interface refuses to open with a readable error.
- ReticulumLink::query_radio_state(): asks the sidecar for the live
  RNodeInterface state (radio-confirmed r_* values) — the panel's
  apply-confirmation read-back source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:28:45 -04:00
archipelagoandClaude Fable 5 2afeafc92e feat(reticulum-daemon): airtime-limit args + radio_state RPC for live read-back
Groundwork for the .126 LoRa settings panel (operator top priority):

- --airtime-limit-short/--airtime-limit-long (percent duty-cycle locks,
  e.g. EU868 25/10) written into the RNode interface config when set;
  default None writes nothing — identical to older daemons.
- New socket RPC {"cmd":"radio_state"} returns the live RNodeInterface
  state: requested config values AND the radio-confirmed r_* values
  (r_frequency/r_bandwidth/r_txpower/r_sf/r_cr/r_st_alock/r_lt_alock,
  online, port, airtime utilisation). The r_* values are what the RADIO
  reported after detect/configure — the settings panel's proof that the
  device is actually using what was applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:04:15 -04:00
archipelagoandClaude Fable 5 44522fc94a chore(scripts): one-shot node-side companion-manifest repair script
Curl-and-pipe repair for nodes whose companion-UI manifests are stale
(no session_passthrough): fetches the four current manifests from the
public repo, installs them into /opt/archipelago/apps AND the frontend
runtime payload (which restores over apps/ at every boot), restarts,
and reports the gate probe. Long paste-blocks kept mangling in the
operator's terminal — this replaces them with one short line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 07:59:34 -04:00
archipelagoandClaude Fable 5 171d75dc01 docs(13): STATE — 13-14 at Task 3 human-verify gate (comprehension study)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 07:04:33 -04:00
archipelagoandClaude Fable 5 27aa5ccd10 feat(13-14): the harness — offline, deterministic, per-backend, zero footprint on a node
assistant/evals.rs: a test-gated in-crate module (#![cfg(test)] here AND
#[cfg(test)] pub mod evals; in mod.rs — never compiles into the shipped
binary, asserted by a release-binary string grep). load_cases/case_by_id
read the 18-case JSONL fixture by path; run_case drives the REAL run_loop/
execute_tool/ConfirmGate choke points end to end against a case's grants,
seeded untrusted content, and scripted backend turns, returning a
CaseOutcome that observes ToolCall/ToolResult/confirm-gate transitions
in-process rather than inferring them from prose. evaluate_case asserts
must_not_execute/must_not_claim at threshold zero (E-01's security and
integrity halves) and confirmations/turns at exact match, every failure
message naming the case id and the offending tool/term.

Parameterized over the Backend trait (CountingBackend wraps any real
Backend to measure turns used; a BudgetExhaustedStubBackend drives EV-17's
S-12 stop-without-retry path) so scripted, Ollama, Claude or Routstr can
all run the same 18 cases. report_by_backend/parity_requires_two_backends
refuse to record a cross-backend parity pass from fewer than two backends
(E-07). Live-backend runs are opt-in via ARCHY_EVAL_BACKENDS and #[ignore]d
so a plain `cargo test` never touches the network. write_trace_jsonl writes
one plain JSONL file per run under core/target/assistant-evals/ (gitignored
build output) — no exporter, no collector, no listening port.

All 18 cases pass against ScriptedBackend (23/23 assistant::evals:: tests);
full crate suite 1258/1258; release binary contains zero eval-fixture
strings; no phoenix/promptfoo/ragas/opentelemetry references anywhere in
assistant/; no new CI job (ci.yml untouched — picked up by the existing
`cargo test --all-features` step); zero new packages (T-13-SC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 07:03:50 -04:00
archipelagoandClaude Fable 5 d419141a86 feat(13-14): the eighteen reference cases — the specification of what the loop must refuse
EV-01..EV-18 (four happy reads, four confirmed writes, five injection cases,
three authority-ceiling cases, one budget case, one privacy case) per
13-AI-SPEC.md §5's schema, written against the real tool registry
(assistant::tools::registry()) and the real wrap_untrusted() boundary shape
rather than against the spec's description of them. EV-11's payload carries
a forged closing boundary in the exact `{label}_DATA_{token}_END` shape
untrusted.rs emits, proving why the per-call random token (not the wording)
is what makes the boundary hold. README.md records the per-bucket
reviewer-role labeling from §5's Labeling table (engineer for EV-01..EV-08,
security-minded red-teamer for EV-09..EV-16, non-technical reviewer for the
EV-05/EV-06 confirmation-copy judgment) so a later contributor knows whose
judgment each case's expect block encodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 06:53:25 -04:00
archipelagoandClaude Fable 5 1948767083 chore: release v1.7.125-alpha
Demo images / Build & push demo images (push) Successful in 4m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 06:27:02 -04:00
archipelagoandClaude Fable 5 e875f15fc5 docs: curated changelog for v1.7.125-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 06:10:39 -04:00
archipelagoandClaude Fable 5 63fa8b4558 docs(13-13): complete Routstr backend / D-05 budget ceiling plan
D-04's chain complete (Ollama -> Claude -> Routstr); D-05's prepaid
allowance is a hard arithmetic ceiling, verified by fault injection.
Task 1 decision: proceed-docs-with-probe-first (0/9 protocol claims
independently confirmed; first live call doubles as the capability probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 05:39:02 -04:00
archipelagoandClaude Fable 5 8ba6041251 feat(13-13): D-05 prepaid budget — arithmetic ceiling, hard stop, D-04 chain complete
Completes D-04's backend chain: Ollama -> Claude -> Routstr (budget-gated),
and wires D-05's operator-set prepaid allowance as a hard, arithmetic stop
a prompt-injected model can never cross.

- assistant/mod.rs: `AssistantBudget` (allowance_sats/spent_sats,
  persisted 0600 under data_dir/assistant/budget.json, mirroring
  Grants::load/save exactly — a missing/corrupt file defaults to a ZERO
  allowance, D-16's "default closed" applied to money). `payment_policy()`
  builds a `PaymentPolicy` from ONLY these two persisted fields — no
  parameter accepts anything model/tool/provider-influenced, which is what
  makes the ceiling arithmetic rather than a policy an injected model
  could argue with. `record_spend()` persists a successful payment and
  raises a one-time 80%-threshold owner notice (AI-SPEC §7b). New typed
  `BudgetExhausted` error (downcastable via anyhow) is the signal
  `loop_.rs` distinguishes from an ordinary transport error.
- assistant/loop_.rs: `run_loop` downcasts a `BudgetExhausted` out of the
  backend's `Err` and returns `Ok` with a plain-language stop message —
  no retry, no re-price, no partial spend, no fall-through to a different
  provider at a different price. Verified to actually matter: temporarily
  replaced the terminating `return` with `continue` and confirmed
  `zero_budget_stops_loop_without_retry` goes red (the backend gets
  retried 8x to MAX_TURNS and the turn errors instead of stopping
  cleanly); restored and reconfirmed green (13-13-SUMMARY.md records the
  observed failure).
- assistant/backends/mod.rs: `select_backend` now takes `&RpcHandler`
  (was `&Path`) to also read the Tor-proxy config; completes the D-04
  chain — Routstr never selected when the operator's allowance is zero
  (Claude alone instead), otherwise chained as Claude's fallback
  (Ollama -> Claude -> Routstr, each leg reached only when the priors are
  unavailable). New `BackendId::Routstr` variant.
- assistant/backends/routstr.rs: the payment-decline arm now returns the
  typed `BudgetExhausted` (was a plain bail in Task 2's commit, per the
  plan's own "handled in Task 3" note); a successful payment records spend
  against the persisted budget immediately (the Cashu proofs are already
  committed at that point, regardless of whether the subsequent chat HTTP
  call itself succeeds).
- api/rpc/assistant_chat.rs: `assistant.budget-get`/`assistant.budget-set`
  RPCs (routed through the existing single `assistant.` dispatcher arm —
  dispatcher.rs untouched) and a `nostr_tor_proxy()` accessor for
  select_backend's onion-preference decision.

Named tests (assistant::tests::): zero_budget_stops_loop_without_retry (S-12),
zero_allowance_never_selects_routstr, ceiling_is_not_a_function_of_model_output,
injection_loop_against_low_budget_does_not_overspend (EV-17) — all pass.
Full assistant:: suite: 91/91. Full crate suite: 1235/1235 (2 pre-existing
ignored, unrelated). dispatcher.rs and Cargo.toml untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 05:33:27 -04:00
archipelagoandClaude Fable 5 ed062481c3 style: cargo fmt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 05:32:53 -04:00
archipelagoandClaude Fable 5 a3521e5eeb feat(13-13): Routstr backend adapter — Nostr discovery, OpenAI chat, Cashu payment attach
Task 1 decision (proceed-docs-with-probe-first, operator-selected via
AskUserQuestion 2026-08-05): 13-ROUTSTR-FINDINGS.md observed 0 of 9 cited
protocol claims (no live provider was announcing on any of the 3 default
relays in a 30s window on 2026-08-03; relay reachability itself WAS
confirmed). This backend is written against docs.routstr.com's cited shape,
with the first live chat-completions call doubling as the capability probe:
a non-success HTTP status or a response missing the expected
choices[0].message shape fails loudly (bails with the real status/body)
rather than silently degrading.

- assistant/backends/routstr.rs (new): RoutstrBackend implements the
  Backend trait — discover_providers subscribes for kind-38421
  provider-announcement events over the existing Tor-proxy-aware Nostr
  client (nostr_discovery::build_nostr_client, never a second relay
  client), process-cached with a 5-minute TTL; select_provider picks the
  globally cheapest affordable (provider, model) price across every
  discovered provider (Routstr has no fixed target model the way
  Ollama/Claude do — CONTEXT.md delegates provider selection strategy to
  Claude's discretion), preferring an onion endpoint when Tor is up;
  attach_payment calls the existing budget-capped auto_pay_token verbatim
  (never hand-rolled); parse_openai_tool_calls parses the one
  string-encoded function.arguments shape exactly once at this adapter's
  edge; screen_outbound (G-B1/G-B2) runs before any body leaves the node,
  exactly as it does for Claude; ROUTSTR_MAX_TOKENS caps every request
  explicitly.
- assistant/egress.rs: message_is_turn_own gains "system" and "tool" role
  handling plus an OpenAI tool_calls-sibling-field check — the pre-existing
  function was written only against Claude's wire shape (system as a
  top-level field, tool results wrapped in role:"user") and would have
  silently stripped Routstr's system prompt and tool-result context out of
  every outbound request via G-B2's fail-closed default arm. Fixed with 4
  new regression tests pinning both wire shapes.
- assistant/backends/mod.rs: registers `pub mod routstr;`. select_backend's
  actual wiring of the Routstr leg (budget-gated, per D-05) is Task 3's
  commit, once AssistantBudget exists — this task's own acceptance criteria
  do not require select_backend integration, only the adapter itself.

30/30 assistant::backends:: tests pass (17 new in routstr.rs, 3 new in
egress.rs's OpenAI-shape regression tests were run separately at 12/12).
Zero new packages (nostr-sdk/reqwest already in-tree); dispatcher.rs and
Cargo.toml untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 01:26:59 -04:00
archipelagoandClaude Fable 5 d8748ee7ba fix(orchestrator): map container uids into the subuid range in the chown fallback
chown_for_rootless_container prefers `podman unshare chown` (which maps
container uid N through the userns), but when that failed once it fell
back to `sudo chown -R <literal>` — writing e.g. host uid 999 for
container uid 999 and reporting success. Host-999 maps to nobody inside
the userns, so the app could not open its own data while everything
claimed the chown worked: botfights on framework-pt crash-looped every
10s on SQLITE_CANTOPEN over a data dir the daemon itself had just
"fixed".

The sudo fallback now translates container ids (1..99999) to
subuid_base + id - 1 (fleet base 100000; container root maps to the
service user, 1000). Already-mapped ids and uid 0 pass through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:48:36 -04:00
archipelagoandClaude Fable 5 8469af5f4e fix(wallet): surface LND sweep refusals as readable errors
A sweep of 92 unconfirmed/dust sats failed with LND's debug-flavored
"insufficient input to create sweep tx: input_sum=0 BTC, output_sum=
0.00000092 BTC" — and the RPC sanitizer then masked even that behind
"Operation failed. Check server logs." (framework-pt, 2026-08-06).
The sweep mechanics are untouched (balance minus fee, as always) —
this only makes the refusal say WHY in plain language.

- lnd.sendcoins translates the sweep refusal: balance below Bitcoin's
  dust minimum or not yet confirmed, so no transaction can be built
  (LND's original message kept in parens).
- "Failed to send" joins the sanitizer's user-facing allowlist — the
  same lesson as "Insufficient balance"/"Payment failed" before it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:41:35 -04:00
archipelago 8548544db9 docs(13-12): complete injection-boundary/egress-screen/rate-limit plan 2026-08-05 23:13:23 -04:00
archipelagoandClaude Fable 5 f1e50fbfd8 feat(13-12): G-B3 rate limit + read-only injection loop bound and owner notices
rate_limit.rs: assistant.chat gets its own request log keyed by
AUTHENTICATED SESSION (not client IP, per 13-AI-SPEC.md §6 G-B3's own
spec — an operator's session can roam across IPs within one sitting), on
the SAME EndpointRateLimiter struct rather than a second limiter type.
check_session/record_session_request enforce a hard ceiling (60/5min);
session_soft_threshold_reached (30/5min) is checked separately so the
call site can raise an owner notice before the hard refusal ever fires.
Wired into assistant_chat.rs's handle_assistant_chat (Rule 3 — the plan's
own declared intent, "assistant.chat is rate-limited per authenticated
session," has no other call site to reach the real RPC surface) and into
the existing 5-minute cleanup task in api/rpc/mod.rs.

loop_.rs: run_loop now tracks whether D-10-wrapped untrusted content is
present in context (seeded and re-checked as new tool results arrive
mid-loop), counts grant refusals split by that flag via
AssistantCounters::note_grant_refusal (a burst WITH untrusted content
raises a Security notice — something in shared content may be trying to
trigger actions; the same burst WITHOUT it raises a Ux/config notice
instead, so probing is never confused with misconfiguration, T-13-83),
counts turns-per-request, and counts MAX_TURNS-reached (3+ in one session
raises an owner notice) right before the loop's own bail — this is EV-13's
read-only injection loop, the one case the confirm gate structurally
cannot see because reads never confirm.

mod.rs: ToolExecCtx gains a `counters: Arc<AssistantCounters>` field
(defaulting to the process-wide global_counters(), overridable per-test via
with_confirm_gate_and_counters) so loop_.rs's counting has somewhere to
write and tests can assert against an isolated instance without polluting
concurrently-running tests.

read_only_injection_loop_terminates_and_is_counted (EV-13) and
grant_refusals_with_untrusted_content_are_a_security_signal (T-13-83) both
pass. Full `cargo test --package archipelago` (1211 tests) green — the
existing rate-limited RPC methods are unaffected by the new session-keyed
limiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:09:51 -04:00
archipelagoandClaude Fable 5 92cffe9d46 fix(reconciler): recreate an absent stack member when its siblings are live
The periodic reconcile runs ExistingOnly — merely listing a catalog
manifest must never install an app — and its only absent-container
recovery keyed on the last running-names snapshot, which ages out after
a few daemon restarts. An absent member of an installed stack then stays
absent forever: .38 ran indeedhub with no minio/postgres for days, nginx
down on 'host not found in upstream "minio"', and nothing ever put the
members back.

A live sibling container is proof the stack is installed on this node,
so an absent member is now treated as a hole to repair, not a choice to
respect: the recovery guard also fires when another member of the same
stack (app_ops::stack_member_app_ids) has a container in any state.
A stack with no containers at all is left untouched, and sibling app ids
resolve through the loaded-manifest container names (immich-postgres
runs as immich_postgres).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:50:42 -04:00
archipelagoandClaude Fable 5 fde7b1572d feat(13-12): G-B1/G-B2 cloud-egress secret scan and turn-minimality screen
assistant/egress.rs: screen_outbound(body, ctx) -> EgressVerdict runs on
every request body about to leave this node for a cloud backend. G-B1
scan_secret_shapes checks for macaroon-shaped hex runs, BIP39-length word
runs, ecash/Nostr-key-shaped strings, and the literal contents of files
under data_dir/secrets — a hit fails closed (BlockFallBackLocal), logging
only the match's kind, never the value. G-B2 assert_turn_minimal checks the
outbound body against a mechanical allowlist of this turn's own fields (the
user's turn, this turn's granted tool names, this turn's own tool results);
an unrelated earlier tool result or content wrapped for a different turn is
truncated out rather than eyeballed. An unparsable/ambiguous body also fails
closed. MAX_OUTBOUND_CONTEXT_CHARS caps body size independent of minimality.

Wired into backends/claude.rs's send() before the outbound HTTP request (on
a block, send() errors before anything is sent — Rule 3, outside this
task's originally-declared file list but structurally required to give
screen_outbound a real caller); never wired into ollama.rs — nothing leaves
the node on that leg, so paying the scan cost would be pointless.

mod.rs: AssistantCounters/OwnerNotice — grant refusals, validation
failures, turns-per-request, untrusted-content-present,
cloud-escalation-while-local-up, blocked-egress and MAX_TURNS-reached
counters, each raising an owner_notice() at its own AI-SPEC §7b threshold.
Local and owner-facing only: no exporter, no /metrics, no OTLP anywhere in
assistant/ or rate_limit.rs. backends/mod.rs's select_backend raises a
cloud-escalation-while-local-up notice when Ollama is reachable but its
configured model isn't tool-capable (Rule 3, same file-scope reasoning).

9/9 assistant::egress:: tests pass in this task's own isolated state
(Task 1's 56 plus these 9 — ToolExecCtx's counters field and its loop_.rs
call sites are Task 3's own commit, since nothing in this task's behavior
needs them yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:22:15 -04:00
archipelagoandClaude Fable 5 d5ef4ef76e chore(catalog): sign catalog with session_passthrough + indeedhub-redis caps
Carries the two manifest-side halves of the .125 fix batch: the four
companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui) declare
session_passthrough on their gated ports so the gate forwards the node
session their nginx proxies to the daemon, and indeedhub-redis gains
CHOWN+DAC_OVERRIDE so its entrypoint can traverse its own data dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:16:13 -04:00
archipelagoandClaude Fable 5 265ba5ab19 feat(13-12): D-10 untrusted-content boundary — wrap_untrusted, per-call random token
assistant/untrusted.rs: wrap_untrusted(label, text) wraps peer-supplied text
(filenames, log lines, mesh/peer status) in a delimiter block whose token is
freshly randomized on every call via the in-tree rand crate — never a module
constant, never derived from content. A forged closing boundary using a
guessed/fixed token cannot terminate the real block early (EV-11).

tools.rs: wrap_tool_result_if_untrusted wires this in for content_list,
app_logs and mesh_status (the tools whose results carry peer-authored text);
every other tool result passes through unwrapped. loop_.rs's execute_tool
calls it at the exact point a successful ToolResult is constructed, before
that content ever becomes part of a ChatMessage.

No pattern-stripping or keyword-blocklist filter was added (D-10 rejects
that approach by name) — the delimiter and D-11's confirm gate are two
independent layers. Four scripted-worst-case tests in mod.rs prove the gate
still holds even when a compromised model acts on an injected imperative
(injected_instruction_does_not_grant_authority), a forged closing delimiter
plus fake operator turn (forged_closing_delimiter_does_not_escape_block), or
an injected mislabel attempting to hide the real action from the human
(injected_mislabel_still_confirms_real_action) — plus
wrap_untrusted_token_is_per_call (tools.rs) asserting the per-call token
itself. Zero packages added — rand 0.8.5 already in-tree.

56/56 assistant:: tests pass in this task's own isolated state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:00:20 -04:00
archipelagoandClaude Fable 5 6110a7a9a7 test: update stale drift guards (login-page A mark, 25 exempt ports)
Both predate this session's changes and were masked by the release
gate's cargo-test-weekly compile timeout:
- login_page_sources_its_art_from_the_gate still asserted the retired
  wordmark (logo-archipelago.svg); the login page ships the sidebar A
  mark (favico-black-v2.svg) since the 2026-08-05 rework.
- unauthenticated_ports_are_all_accounted_for lagged at 17; the
  v1.7.123 port-policy round grew the rationale-carrying exempt set
  to 25 (reviewed and enumerated in the test comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:49:19 -04:00
archipelagoandClaude Fable 5 c972419840 fix(wallet): blank send/receive on every open; sweep shows amount; camera option stays visible
Demo images / Build & push demo images (push) Successful in 3m31s
Operator-reported (2026-08-05):

- Send/Receive modals reset to a blank slate on every open. Stale state —
  destination, amount, memo, and above all an armed "send all funds"
  toggle — silently carried into the next payment.
- Arming "send all funds" now shows the swept balance in the (disabled)
  amount field instead of a confusing 0; disarming or leaving the
  on-chain tab clears it.
- The scan modal no longer hides "Scan with camera" on plain-http desktop
  (browsers only allow getUserMedia on secure origins): the option stays
  visible with a one-line explanation, and choosing it surfaces the HTTPS
  requirement with photo/paste fallbacks. The companion app's native
  scanner path is untouched and still takes priority.
- What's New entry for v1.7.125-alpha.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:22:42 -04:00
archipelagoandClaude Fable 5 d0d9c032de fix(apps): session_passthrough on companion UI ports; indeedhub-redis caps
- lnd-ui/bitcoin-ui/electrs-ui/fips-ui declare session_passthrough: true
  on their gated ports — their nginx forwards the browser's node session
  to the daemon's authenticated endpoints, which the gate's cookie strip
  was discarding (every data call 401'd behind the gate).
- indeedhub-redis gains CHOWN + DAC_OVERRIDE: the alpine entrypoint runs
  as capability-stripped container-root and could not traverse the 0700
  appendonlydir owned by the redis uid — crash-looped ~4k restarts on
  archi-dev-box under the quadlet migration.

These reach nodes via the signed catalog re-sign (manifest overlay).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:22:07 -04:00
archipelagoandClaude Fable 5 ada59acdd5 fix(appgate+container): stop stripping app cookies; create named volumes correctly
Two daemon bugs, one debugging arc (2026-08-05, operator-reported):

1. The app gate removed the ENTIRE Cookie header before proxying. That
   broke the data plane of every first-party companion UI behind the gate
   (lnd-ui/bitcoin-ui/electrs-ui/fips-ui render their shell, then every
   /proxy/* and /lnd-connect-info call 401s — observed as "LND UI
   unreachable"), and silently logged users out of every gated app with
   its own cookie login (vaultwarden, nextcloud, gitea) on each request.
   The gate now strips only its own cookie pairs (session, csrf_token);
   a new per-port manifest opt-in `session_passthrough: true` forwards
   the node session to first-party UIs whose nginx proxies the daemon's
   authenticated endpoints. Undeclared ports never get passthrough.

2. podman_client::create_container sent named volumes to the libpod API
   as bind mounts with the bare volume name as source, so creating any
   manifest app with a `type: volume` mount failed. On .38 the reconciler
   removed indeedhub-postgres/-minio for env drift and then could never
   create their replacements, leaving the stack half-missing forever.
   Named volumes now ride the spec's `volumes` field ({Name, Dest,
   Options}). Also: the reconcile-failure log now prints the full anyhow
   chain — `%e` showed only "create_container X" and hid the real error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:21:52 -04:00
archipelagoandClaude Fable 5 4ace62fad9 chore(catalog): sign catalog with the bitcoin and fedimint fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:21:21 -04:00
archipelagoandClaude Fable 5 e7592dc9c9 fix(fedimint): stop declaring 8175 — it belongs to the UI companion, not fedimintd
Demo images / Build & push demo images (push) Successful in 3m32s
Declaring the Guardian UI port on the fedimint app made the orchestrator try
to publish 8175 from fedimintd, colliding with archy-fedimint-ui which
already holds it: start_container failed on every reconcile and fedimint
crash-looped (100.82.34.38). The companion's nginx pinned to 127.0.0.1 is
what actually closes that port; the gate reports it rather than fronting it.

Also: app-login page uses the sidebar's 'A' mark instead of the full
wordmark, is pinned to the small viewport so it stays centred and the
keyboard overlays rather than scrolls it, and the install-version modal
icon uses object-contain so a non-square icon is no longer cropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:12:36 -04:00
archipelagoandClaude Fable 5 188411b79c chore(catalog): sign catalog with the repaired bitcoin start script
Unbreaks Bitcoin on every node running the 1.7.124 catalog: the embedded
start script had a shell syntax error, so bitcoind never launched and the
app vanished. Delivered by catalog rather than a release because manifests
reach nodes through the signed catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:54:37 -04:00
archipelagoandClaude Fable 5 e88c51d80b fix(bitcoin): repair the startup script I broke in 1.7.124, and gate against it
The bitcoin app vanished from updated nodes: the container exited instantly
with 'sh: Syntax error: "fi" unexpected'. My 1.7.124 change added an
explanatory comment INSIDE the manifest's folded YAML scalar (>-), where
'#' is not a comment — it is literal text that reaches the shell. Folding
joins lines with spaces, so the comment swallowed the 'if ... then' while
the more-indented echo survived as its own line, leaving an orphan 'fi'.
bitcoind never ran, the container exited, and the app disappeared from the
UI because detection is container-based.

Explanations now live above the '- >-' line where YAML really treats them
as comments. The loopback-conf tolerance (-allowignoredconf=1) is unchanged
and still needed.

Adds scripts/check-manifest-shell.py to the release gate: it runs 'sh -n'
over every embedded manifest script and rejects '#' inside these scalars.
Nothing validated this shell before — no YAML parse or Rust test could have
caught it, and it only failed on the node, after signing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:45:02 -04:00
archipelagoandClaude Fable 5 686616375c fix(13-08): confirm-response listener TTL follows the 300s gate timeout
130s TTL predated the UAT timeout bump — it disarmed the approve/deny
listener while the dialog was still legitimately open (self-healing via
the next poll's re-announce, but a click in the gap dropped silently).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:36:03 -04:00
archipelago c08f8f0e83 docs(13-11): complete music library + share MIME fix plan 2026-08-05 18:35:10 -04:00
archipelago d25aea125f feat(13-11): wire requestArchyLibrary + fire content/library fetch from a live init-time event (GAP-FOUND)
AIUI asks for the library the same way it asks for content, and the
fetch now actually fires without anyone typing a magic phrase:

- useArchy.ts: requestArchyLibrary(scope) sibling of requestArchyContent
  (13-06), same bridge call with kind: 'library', routed through the
  existing setArchyContent so the songs bucket fills exactly the way
  films already does.
- init() now calls both requestArchyContent('all','own') and
  requestArchyLibrary('own') once, fire-and-forget, immediately after
  archyBridge.init() — the GAP-FOUND fix. 13-06 built the whole
  content:request/content:push machinery and unit-tested it end to end,
  but nothing in the live UI ever called it (13-06-SUMMARY.md's Known
  Limitations); the fetch is now triggered by a real init-time UI event,
  not merely callable.
- useContentPanel.ts's setArchyContent now also opens the panel and
  populates availableTabs/activeTab/panelTitle when Archy supplied
  non-empty content — previously only the data refs were set while the
  tab bar and panelOpen stayed whatever the last regex-driven chat turn
  left them, so real content could sit fully populated and still never
  render. An empty bundle never force-opens the panel.

Deviation (Rule 2, mirrors 13-06's own archyBridge.ts precedent): kind:
'library' genuinely needs a different node-side RPC (music.list-tracks,
real tag-extracted metadata) than content.* (ContentItem has no artist/
album/duration field at all) — contextBroker.ts's handleContentRequest
gained one branch (fetchLibraryContent) to route it, and
aiui-protocol.ts's AIUIContentRequest.kind union gained the 'library'
literal, and archyBridge.ts's requestArchyContent kind param widened to
match. No second channel, no new message type, no new listener — the
existing content:request/content:push channel and its kind discriminator
carry this exactly as 13-06 designed it to. Full detail in the SUMMARY.

neode-ui: 926/926 tests green, vue-tsc -b clean. aiui: 341/344 (3
pre-existing, documented failures unrelated to this plan — 13-06/13-10
already recorded them), vue-tsc --noEmit clean.
2026-08-05 18:29:17 -04:00
archipelago abe77ebe7e fix(13-11): ShareModal's MIME map stops filing m4a/aac/opus/wma as Documents
Adds the four missing audio extensions to ShareModal.vue's extension-to-
MIME map (m4a->audio/mp4, aac->audio/aac, opus->audio/opus, wma->audio/
x-ms-wma), extracted to an exported module-scope SHARE_MIME_MAP so it's
directly fixture-testable (useFileType.test.ts convention). All three
maps agree that these eight extensions are audio/*: SHARE_MIME_MAP,
archyContentAdapter.ts's classifyByMime (13-06), and content.rs's
auto-filing check, which is prefix-only (mime_type.starts_with("audio/"))
so any correct audio/* value here already satisfies it. Existing four
entries (mp3/flac/ogg/wav) and the generic-fallback behavior for unknown
extensions are unchanged. Whole neode-ui suite green (924/924).
2026-08-05 18:20:51 -04:00
archipelago 7bea8f6ba4 feat(13-11): map music.list-tracks records onto AIUI's Song shape
adaptLibraryTracks/adaptLibraryAlbums in archyContentAdapter.ts: real
tag-extracted title/artist/album/duration from the music.* index (13-07),
artist falls back to album_artist then '', order preserved from the
index's own deterministic sort (never re-sorted browser-side), no
cover-art URL (Track carries no artwork field — SongGrid's no-artwork
state renders), own-library tracks resolve through the existing
FileBrowser raw-file route, peer tracks through the existing Range-
streaming proxy, no credential ever in a query string. 34/34 tests green.
2026-08-05 18:17:54 -04:00
archipelagoandClaude Fable 5 dba3aecf26 docs(13-10): complete Ollama backend + node-side history plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:06:50 -04:00
archipelagoandClaude Fable 5 3da9928cc9 feat(13-10): node-side chat history, scoped by caller, compacted not truncated
history.rs persists the ChatMessage transcript under data_dir (D-08),
keyed by a HistoryKey derived from CallerScope so an operator's AIUI
session and a mesh peer's transcript are structurally distinct files, not
two rows a filter could forget. Writes are atomic (temp sibling + rename,
matching music/index.rs::save_atomic's precedent) and 0600, following
grants.rs's convention.

Tool results longer than MAX_TOOL_RESULT_CHARS are truncated with a
visible marker before entering history -- a new, assistant-scoped
constant, never assist.rs's LoRa-airtime-tuned reply cap. Once the
transcript exceeds KEEP_VERBATIM_TURNS, older turns fold into a running
summary extended incrementally as turns age out, never regenerated from
the full transcript. Wallet/files-category tool-call arguments are never
persisted (AI-SPEC §7b's field policy applied to storage, not only
tracing) -- categories are resolved by the caller from the same tools
registry execute_tool uses, so history.rs never re-derives a second,
driftable category list. Nothing reachable from confirm.rs's pending-
confirmation state has a parameter path into this module at all (S-09
stays true structurally).

assistant.history / assistant.clear-history route through 13-01's
existing assistant.* dispatcher arm (dispatcher.rs untouched), each
scoped to the calling session's own HistoryKey.

run_loop (loop_.rs) now returns (answer, full_history) instead of just
the answer string -- structurally necessary so chat() (mod.rs) can
persist the tool-call/tool-result messages the loop built internally, not
only the user question and final answer (Rule 3, mirroring 13-05's
precedent of touching a file outside its own plan's files_modified list
when the plan's own intent requires it). chat() persists this turn after
run_loop returns; it does not yet feed prior persisted turns back into
live model context -- a documented, deliberately scoped follow-up (see
mod.rs's chat() doc comment and the plan SUMMARY).

8 new tests under assistant::history::tests::, including
operator_and_mesh_transcripts_are_separate and
wallet_tool_arguments_never_reach_the_transcript (asserted against both
the deserialized struct and the raw on-disk bytes). Full assistant::
suite: 50/50 (42 baseline-after-Task-1 + 8 new); confirm::tests::
restart_drops_pending_not_executes still passes -- S-09 not weakened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:03:55 -04:00
archipelagoandClaude Fable 5 821d8700ce feat(13-10): Ollama tool-calling backend, first in the D-04 chain
backends/ollama.rs implements the Backend trait against Ollama's
POST /api/chat (messages + tools arrays, message.tool_calls response) --
never mesh/listener/assist.rs::call_ollama's older single-shot prompt
endpoint, which has no tool-calling support at all. Ollama's per-call
tool-call ids (absent on the wire) are synthesized; its already-parsed
function.arguments object is passed through without a second string-parse
(the OpenAI-shape normalization would be wrong here). Every request sets
an explicit generation-length cap and runs non-streaming.

model_supports_tools queries Ollama's /api/show and caches the answer for
the process lifetime, turning AI-SPEC's [ASSUMED] note about
qwen2.5-coder's tool capability into a runtime fact: a non-tool-capable or
unreachable Ollama falls through to Claude with a logged reason, never a
silent tools-free degrade.

select_backend (backends/mod.rs) is now async and reuses the existing
detect_ollama() probe (mesh::assistant, bumped to pub(crate) for this
reuse) rather than re-probing. A new FallbackChain wraps the Ollama leg so
a transport error mid-turn falls through to Claude for that same call
instead of failing the turn outright.

13 new tests under assistant::backends::{ollama,}::tests::, exercised
against a local hyper-based HTTP stub (no mock-HTTP crate exists in this
workspace). Full assistant:: suite: 42/42 (29 baseline + 13 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:42:24 -04:00
archipelago c9f1d87dd6 chore: release v1.7.124-alpha 2026-08-05 16:42:57 -04:00
archipelagoandClaude Fable 5 4f8c76c67e style: rustfmt the regenerated app_ports list
generate-app-catalog.py writes APP_LAUNCH_PORTS one entry per line; rustfmt
packs it. The release gate checks formatting, so the generated file has to
be formatted after regeneration or every catalog sync fails the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:30:13 -04:00
archipelagoandClaude Fable 5 d5ca612e2b chore(catalog): sync catalogs to the manifests for 1.7.124
Demo images / Build & push demo images (push) Successful in 3m42s
Portainer's image reaches the public catalog (the release gate caught the
manifest and catalog disagreeing), and fips-ui 8336 joins the mesh relay's
port list now that it declares a port — it is auth: gated, so the relay
withholds it rather than bridging it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:19 -04:00
archipelagoandClaude Fable 5 0a374c80a6 style: rustfmt the merged PR #125 hunks and the mirror test; sync Cargo.lock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:24:31 -04:00
archipelagoandClaude Fable 5 a245e5d29f docs(13-08): summary — confirm gate + trusted chrome complete, checkpoint approved
Task 3 (checkpoint:human-verify, blocking) approved by the operator after a
full on-device pass on archi-dev-box: deny/approve/read-only/fail-safe-timeout
all verified with a real Claude 4.5 Haiku backend against a real container.
cargo assistant:: 29/29 green (incl. declined_action_never_reprompts_same_turn),
vitest toolConfirm/contextBroker/chatAiuiEmbed 40/40 green. STATE.md/ROADMAP.md/
REQUIREMENTS.md updated (9/15 plans, AIUI-01/AIUI-04 marked complete for this
plan's contribution). Next: wave 4 (13-10, then 13-11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:18:34 -04:00
archipelagoandClaude Fable 5 6668359875 chore: bump to 1.7.124-alpha ahead of the release run
Demo images / Build & push demo images (push) Successful in 3m42s
Pre-bumped so the release gate compiles the test profile at the final
version — create-release bumps after the gate, so the gate would otherwise
run on the old version and the bump would invalidate the cache, timing out
cargo-test-weekly on the compile rather than the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:10:49 -04:00
archipelagoandClaude Fable 5 cc709fd7da docs(1.7.124): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m46s
Leads with the update that switched nodes off and left them unable to
switch back on — the one an operator most needs to understand, and the
reason to take this release promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:58:28 -04:00
archipelagoandClaude Fable 5 077a098dbf docs(13-08): Task 3 on-device evidence — deny/approve transcript + read-only no-dialog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:58:12 -04:00
archipelagoandClaude Fable 5 2d1f09d8c8 fix(13-08): declined actions never re-prompt + timeout chain covers the human wait
Two more on-device UAT findings:

1. Deny-retry loop: the model, told 'the user declined', simply called the
   tool again — each retry minted a fresh pending and re-opened the dialog
   (T-13-50 habituation, mechanized). ToolExecCtx now remembers declined
   actions for the turn, keyed by confirm::action_key — the same canonical
   (tool_name, validated_args) identity the nonce binds — and execute_tool
   refuses a re-ask before the gate, minting nothing. Regression test
   declined_action_never_reprompts_same_turn.

2. Timeout chain: rpcClient's 15s default aborted every confirmable turn
   client-side while the node kept the pending alive — the next turn then
   re-announced it (modal over and over) and every wait read as 'timed
   out'. assistant.chat now rides a 420s timeout; AIUI's bridge goes
   180s→430s so the host's error path (which also expires the dialog)
   always fires first. Declined ToolResult text now also tells the model
   to stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:50:17 -04:00
archipelagoandClaude Fable 5 9dd02359e4 feat(settings): session timeout is configurable from the UI
Demo images / Build & push demo images (push) Successful in 3m43s
auth.session-policy.get/set plus a card under Account. Presented as two
plain questions rather than the token mechanism underneath, because the
distinction that matters to an operator is which control actually ends a
session: the dashboard polls constantly, so an idle timeout alone never
fires on an open tab — the absolute cap is what guarantees it.

Values are clamped server-side and the stored result is echoed back, so
the bounds are discoverable instead of an error. Presets rather than a free
number field: a box accepting '5' invites locking yourself out. A short
idle choice warns that it is the payments-industry posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:29:17 -04:00
archipelagoandClaude Fable 5 44c864ac14 docs(13-08): defer AIUI-over-host background regression on mobile/companion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:15:05 -04:00
archipelagoandClaude Fable 5 31f9a4d5bf fix(13-08): confirm timeout 120s→300s + chrome closes an expired dialog
On-device UAT: the operator was timed out mid-read (120s), the chat turn
returned 'declined' while the dialog was still up, and their Approve then
hit a dead entry ('no such pending confirmation', 13:37:12 log). Nothing
executed — the gate failed safe — but the UX was a lie in both directions.

- CONFIRM_TIMEOUT 120s→300s: human-speed per T-13-51's own rubric.
- ContextBroker dispatches aiui:tool-confirm-expired when a pending action
  vanishes node-side (poll) or the turn ends; Chat.vue closes the modal on
  it. Same host-only CustomEvent discipline; iframe has no path to it.
- Two new tests; 21/21 green across toolConfirm + chatAiuiEmbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:56:49 -04:00
archipelagoandClaude Fable 5 81033ed6f5 fix(ota): repair a stale Restart=on-failure unit that leaves nodes dead after update
austin-sapien (100.70.96.88) sat dead for over two hours after taking
v1.7.122 — 'server starting' in the UI, service inactive, exit status
0/SUCCESS. It did not crash: the in-process updater replaces the binary and
exits cleanly for systemd to restart it, and that node's unit still carried
Restart=on-failure from an older install. systemd read the clean exit as
success and left it stopped. Every node with the old unit has this waiting
for it on the next update.

self-update.sh does refresh units, but the in-process update path never
runs it, so nothing was repairing them. The daemon now checks its own unit
at boot and rewrites only the Restart= line, so a node that starts even
once ends up with a policy that survives the next update.

Also carries the session-policy wiring: validate() now honours the
configured idle and absolute limits and the per-device class, instead of
the single hard-coded 24h constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:50:16 -04:00
archipelagoandClaude Fable 5 44f552cc3d fix(13-08): system prompt — call tools directly, never text-ask for confirmation
On-device UAT hit an infinite politeness loop: 'every write requires a human
confirmation you cannot bypass' read to the model as 'collect consent in text
first', so it never called restart_app, the confirm gate never engaged, and
each stateless turn (history is 13-10) dropped the user's 'confirmed' into a
void. The preamble now states the intended contract: the node presents the
trusted dialog the moment the tool is called; a text pre-ask stalls the action
and trains rubber-stamping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:34:23 -04:00
archipelagoandClaude Fable 5 830b77af18 fix(13-09): verify-aiui-deploy false-negative — pipefail EPIPEs curl on grep -q early exit
curl | grep -q under set -o pipefail: grep's first-match exit EPIPEs curl
(exit 23) whenever the marker precedes the tail of a >64KB chunk, so a
genuine deploy read as FAIL (bit during 13-08's AIUI redeploy — marker at
27% of a 416KB chunk failed 3/3 runs). Fetch to a temp file, then grep.
Negative control still fails as it should.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:05:42 -04:00
archipelagoandClaude Fable 5 d8647f6576 fix(mesh): tabbed tools column on very wide screens; add configurable session policy
Demo images / Build & push demo images (push) Successful in 3m36s
Mesh right panel: a >=2560px screen hid the tab bar and stacked all five
tool panels in fixed grid rows. On a real display that clipped the Bitcoin,
Dead Man and AI headings to a few pixels each, letterboxed the map, and
pushed Radio Settings into a scroll — more screen producing a worse view.
Very wide now uses the same tabbed column as every other desktop width,
with the selected panel filling the column and the map running edge to edge
(it is the one panel with nothing to scroll).

Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag,
persisted and clamped. Two tokens already existed — a session token and a
30-day login token — so the knob changes how long a quiet tab stays usable
without putting a long-lived credential on every request. Kiosk screens are
exempt from the idle timeout (nobody is there to log a TV back in) but keep
the absolute cap so a stolen box does not stay authenticated forever. The
cap is not optional theatre: idle alone never fires on a polling dashboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:03:31 -04:00
archipelagoandClaude Fable 5 91bbe4faa1 fix: portainer pin, bitcoin conf tolerance, gate login UI, named OTA origin
Portainer: nodes have been running :latest — which is 2.39.1 — while the
manifest pinned 2.19.4 from two years ago. The port migration recreated the
container onto that old pin and Portainer refused to start: it migrates a
database forward, never backward, so an existing install died with 'schema
version does not align' and My Apps showed 'app is not responding'
(100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so
existing databases keep working and older ones migrate up.

Bitcoin: complements PR #131. That removes the code which kept writing a
datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing
one non-fatal, so a node already carrying the file recovers on restart
instead of crash-looping until something reinstalls it.

App gate login: rebuilt against the dashboard's own design — rotating
intro backgrounds served from the gate, the glass panel, the Archipelago
mark in its gradient ring, the app's icon as a My Apps tile, and the glass
button. Crucially it no longer sends X-Frame-Options: DENY, which made
every gated app render as unreachable inside My Apps' embedded frame;
frame-ancestors expresses 'only this node may frame me', which
X-Frame-Options cannot.

OTA origin: primary mirror is now source.archipelago-foundation.org over
TLS instead of a bare IP on plaintext. The IP stays as an automatic
fallback for nodes whose DNS or clock is broken — both break TLS, and the
signature, not the transport, is what establishes trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:44:57 -04:00
archipelagoandClaude Fable 5 08a725ba12 Merge PR #131: stop writing a datadir bitcoin.conf that conflicts with -conf
Root cause of the Bitcoin crash-loop on 100.82.34.38: since a597c1d9
bitcoind launches with -conf=/tmp/rpc.conf and never reads the datadir
bitcoin.conf, but write_bitcoin_conf / ensure_bitcoin_rpc_config /
run_bitcoin_rpc_repair kept writing one on every install and restart.
Bitcoin Core's own datadir-conflict check then refuses to start at all.

Conflict resolved in favour of the PR: HEAD still carried
write_bitcoin_conf, whose deletion is the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:25:26 -04:00
archipelago a9f9be2f0d Merge PR #125: FIPS last-known-good endpoint fallback for direct peering
LAN -> last-known-good -> anchor-tree escalation, npub-keyed endpoint store
persisted with 30-day retention.
2026-08-05 12:22:22 -04:00
archipelago e5612fff0f Merge PR #132: translate Cashu NUT error codes into plain-language messages
Mint failures surfaced raw JSON ({"detail":"proofs already spent"}) to
the user; now the top-level message is actionable while the raw body stays
in logs via {:#}.
2026-08-05 12:22:22 -04:00
archipelagoandClaude Fable 5 cfbbd268e5 test(13-08): satisfy vue-tsc -b build-mode checks in test files
npm run build runs vue-tsc -b (project references, noUncheckedIndexedAccess),
stricter than the flat --noEmit used during Task 2 verification: indexed
CustomEvent accesses need non-null assertions, the suspended-chat Promise
needs an explicit <unknown> ctor, and one unused import. 41/41 still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:05:02 -04:00
archipelagoandClaude Fable 5 8a19e8d3f8 docs(13): STATE frontmatter — 13-08 at Task 3 blocking human-verify gate
Tasks 1+2 verified complete on HEAD (ae042db9, record commit fc09d7a2);
plan closes only after operator's on-device dialog inspection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:49:00 -04:00
archipelagoandClaude Fable 5 ae042db951 docs(13-08): verify Task 1 + Task 2 complete on continuation, checkpoint at Task 3
Continuation of the operator-restarted 13-08 session. Verified rather than
reshaped, per the pushed-history constraint on fc09d7a2/1a664be1:

- fc09d7a2's tools.rs/grants.rs/backends/mod.rs diffs confirmed rustfmt-only
  (line-wrap reformatting), no behavior change.
- Task 1 re-verified green on current HEAD: 28/28 assistant:: tests pass,
  approval_nonce_binds_to_exact_action passes individually, dispatcher.rs
  untouched (git diff --exit-code clean).
- Task 2 was already complete in fc09d7a2's uncommitted-state snapshot: all
  10 toolConfirm.test.ts cases pass (one per <behavior> bullet including
  iframe_message_cannot_open_or_resolve_confirmation), pre-existing
  contextBroker.test.ts + chatAiuiEmbed.test.ts (28 tests) still green,
  vue-tsc --noEmit clean, and every acceptance-criteria grep passes
  (Teleport to="body", zero postMessage/v-html in the modal, distinct
  aiui:tool-confirm-request event pair not reusing aiui:install-request,
  assistant.pending RPC-fetch, ToolConfirmModal mounted in Chat.vue).

fc09d7a2 stands as the commit of record for both Task 1 and Task 2 — no new
source changes were needed. STOPPING at Task 3 (checkpoint:human-verify,
gate=blocking): the anti-spoofing and clear-signing properties are visual/
judgement calls that require a human on archi-dev-box, not cargo test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:47:10 -04:00
archipelagoandClaude Fable 5 1a664be113 docs(13): checkpoint 13-08 mid-Task-1 for operator session restart
Task 1 test-green (28/28) at fc09d7a2; resume via continuation executor,
Task 3 remains a blocking human-verify gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:39:44 -04:00
archipelagoandClaude Fable 5 fc09d7a292 wip(13-08): checkpoint before operator session restart — Task 1 GREEN (28/28), Task 2 in progress
Executor stopped deliberately for a session restart (bypass-permissions relaunch).
Executor's final report: 'cargo test assistant confirm-gate suite 28/28 green,
individual nonce test passes; committing Task 1 next — first verify the
tools.rs/grants.rs/backends diffs are formatting-only.'

Task 1 (D-07/D-11 confirm gate, backend) is implemented and test-green but this
checkpoint is verbatim-uncommitted-state, NOT the reviewed atomic Task 1 commit:
continuation executor should verify diffs, then reset --soft or commit-on-top
into proper feat(13-08) task commits. Task 2 (ToolConfirmModal.vue trusted
chrome, Chat.vue + contextBroker.ts wiring) is partially built, tests written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:34:31 -04:00
archipelagoandClaude Fable 5 4ec53a9805 fix(ota): republish the .122 manifest — the rotation stranded every pre-.122 node
The manifest advertises exactly one version, so publishing .123 (new-key
signed) removed the only stepping stone across the rotation. A node on
.121 pins the OLD root, fetches the .123 manifest, fails signature
verification and refuses — permanently, because .122 is no longer offered
anywhere. Reproduced against the live URL: 'signed_by does not match the
pinned release-root anchor'. archy-shorty-s (.228) is on 1.7.121-alpha-dev
and in exactly this state.

Restoring the old-key-signed .122 manifest as the OTA pointer lets those
nodes take .122, which installs the new pin; .123 is republished once the
fleet has crossed. Safe in both directions: is_newer() is a strict
greater-than on the version triple, so a node already on .123 sees .122 as
older and does not downgrade.

The .123 release itself is untouched — tag, assets and catalog stand; only
the pointer moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 10:24:09 -04:00
archipelagoandClaude Fable 5 9b30daaf9c fix(security): restart a companion whose image was rebuilt underneath it
A rebuilt image never reached a running companion. ensure_image_present
rebuilds in place under the same tag, so the quadlet body is identical,
write_if_changed reports no change, and enable_now is a no-op on a running
service — the container keeps the old layers indefinitely.

That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and
Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images
to bind loopback: correct images on disk, three-day-old containers still
running. Closing those ports needed a manual 'podman rm -f' per container,
which no other node would ever get. Compare the running container's image ID
against the built one and restart when they diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 10:17:58 -04:00
archipelago ea4c072183 chore: release v1.7.123-alpha
Demo images / Build & push demo images (push) Successful in 3m58s
2026-08-05 09:35:47 -04:00
archipelagoandClaude Fable 5 cfd1b4c731 chore(trust): flip the signing checks to the new release root
v1.7.122-alpha was the last release signed with the old root — it is the
release that installed the new pin on every node. From v1.7.123 the new
root signs, and a node running .122+ rejects an old-key signature. The
ARCHY_RELEASE_ROOT_PUBKEY override is no longer needed either: the signer
built from this tree pins the same key we now sign with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:31:28 -04:00
archipelagoandClaude Fable 5 27c1b151f8 docs(1.7.123): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m59s
Leads with the honest version: five screens were open and the previous
release's own audit reported them as fine, found by scanning from another
machine rather than asking the node. States plainly that what leaked was
the page, not credentials — the macaroon path was verified, not assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:08:07 -04:00
archipelagoandClaude Fable 5 9c736f20b6 fix(security): publish the loopback-pinned UI images and pin the new tags
Fresh installs pull *-ui images from the registry, so the source fix alone
left a newly flashed node serving the Bitcoin, LND, Electrs, FIPS and
Guardian screens with no login. All five rebuilt and pushed to
146.59.87.168:3000/lfg2025 as 1.7.123-alpha AND :latest — both tags,
because first-boot resolves the pinned tag from image-versions.sh while the
daemon's companion installer hardcodes :latest, and a stale :latest would
have quietly undone the fix on exactly the path that rebuilds companions.

Verified by pulling each image back from the registry anonymously and
reading /etc/nginx/conf.d/default.conf inside it — a private package would
make fresh nodes fall back to a stale local image without saying so.

Also fixes the FOURTH copy of bitcoin-ui's listen directive
(scripts/reconcile-containers.sh wrote 'listen 8334' into the rendered
nginx.conf on every reconcile, which would have re-opened the port after
the image and template were both corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:07:05 -04:00
archipelagoandClaude Fable 5 abf0f56afc fix(security): close the five host-networked app UIs the audit could not see
Scanning archi-dev-box from OUTSIDE found five ports serving their screens
with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui
50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's
unprotected list. They are host-networked, so Podman publishes nothing to
pin and their manifests declared 'ports: []'; the gate builds its map from
declared ports, so it neither protected them nor reported them. An audit
that reports success while five screens are open is worse than no audit.

Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is
declared 'auth: gated' so the daemon owns the outside. 'bind:' on a
host-networked app is a statement of where the container listens, not a
publish instruction — quadlet already skips PublishPort in host mode.
Guardian 8175 is declared on the fedimint app because its companion has no
manifest, and the gate keys on port, not container.

Credential paths were NOT exposed and are verified so: /lnd-connect-info,
the /proxy/lnd/ passthrough, container logs and every RPC method through
these screens all return 401 unauthenticated. What leaked was the page
shell.

Also fixes the delivery gap that would have made this unshippable: only
bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to
/opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes
through no path at all. All five now sync; the two whose rebuilds the
daemon owns are synced without being handed to container-specs.

Every remaining undeclared port is now declared with a stated reason —
gated: botfights 9100, router 8084, pine 10380; exempt with rationale:
fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS +
own auth, and enrolled devices cannot hold a session), pine TLS 10381,
lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports
remain across all 56 manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 08:46:09 -04:00
archipelago 634640944c chore: release v1.7.122-alpha 2026-08-05 07:57:07 -04:00
archipelagoandClaude Fable 5 b92e16abc0 fix(release): sign v1.7.122 with the OLD root — the rotation moved the checks a release early
Demo images / Build & push demo images (push) Successful in 4m12s
The rotation commit pointed create-release.sh and publish-release-assets.sh
at the NEW root in the same commit that pins it in the binary. But the
release CARRYING the rotation must be signed with the OLD root: every node
is still running the previous binary, which pins the old key. So the
tooling would have rejected the only signature the fleet can accept, and
the signature it demanded would have ended OTA fleet-wide.

Both checks now expect the old DID for this cycle, with the flip to the new
one called out for v1.7.123+. sign-manifest.sh documents the
ARCHY_RELEASE_ROOT_PUBKEY override needed because the signer built from
this tree already pins the new anchor and would fail to verify its own
correct output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 07:48:51 -04:00
archipelagoandClaude Fable 5 db11c625c8 test(13-08): failing tests for the D-07/D-11 confirm gate (RED)
- confirm.rs: ConfirmGate/PendingConfirmation/Confirmed/PendingSnapshot/
  ResolveRefusal API skeleton (request/resolve/mint_nonce/build_description
  still todo!()) plus the five named confirm tests: S-02 nonce binding,
  S-03 no-model-text, S-08 distinct resources, S-09 restart drops pending,
  timeout declines, and the no-shared-lock-across-the-wait case
- mod.rs: ToolExecCtx gains the confirm gate (global by default, injectable
  for tests) and the S-01 destructive_tool_requires_confirm test with a
  seeded installed-app snapshot
- verified RED: 7 new tests fail (todo! cores + unfilled destructive branch)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 07:12:12 -04:00
ssmithxandClaude Sonnet 5 53b158ce5a fix(wallet): translate Cashu NUT error codes into plain-language messages
Mint HTTP failures (swap/melt/mint-quote) were surfacing raw JSON bodies
like {"detail":"proofs already spent","code":11001} straight to the
user. Add a translator for the NUT-02/03/04/05 transaction-validation
error codes (10001-11017, 12001-12003; see
https://github.com/cashubtc/nuts/blob/main/error_codes.md) and layer it
onto the mint_client bail sites via anyhow context, so the top-level
message is actionable while the raw status/body stays available via
{:#} for logs. receive_token now surfaces the real reason (e.g. "This
ecash has already been redeemed") instead of a generic "Failed to
receive any proofs from token" when every mint in a token fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 09:41:44 +00:00
archipelagoandClaude Fable 5 c35e33d0a7 docs(1.7.122): curate release notes and add the in-app What's New block
Leads with what changes for the operator: app screens now require the node
password across LAN, Tailscale, mesh and Tor; the wallet/protocol ports that
must stay open stayed open; the mesh leak found during on-node verification;
nodes repairing their own legacy containers; and the signing-key rotation.
Known gaps disclosed, including the eleven still-undeclared ports and that
non-browser clients will now meet the login page.

The new block uses <strong> rather than the literal ** markers in earlier
entries, which render as asterisks in the modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:33:03 -04:00
archipelagoandClaude Opus 5 3f4b5524b1 chore(trust): rotate the release root to z6Mkfu5LT…DLWT
DO NOT MERGE INTO A RELEASE SIGNED WITH THE NEW KEY. See below.

The previous release root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed
in a chat transcript and is treated as compromised. It signs both OTA
manifests and the app catalog, so anyone holding it could sign updates
the fleet would install.

Pins the new key in trust::anchor and moves EXPECTED_DID in all three
signing/publishing scripts.

ORDERING IS CRITICAL — nodes pin the OLD key:

  * The release CARRYING this commit must be signed with the OLD key.
    That is the only signature a node running the previous binary will
    accept, and it is what installs the binary pinning the new key.
  * Only the release AFTER that may be signed with the new key.
  * Signing this release with the new key makes every node reject it,
    ending OTA fleet-wide and requiring hands-on recovery per node.

sign-catalog.sh moves in the same commit, so the app catalog must also be
re-signed with the new key once this ships, or nodes accept the binary
and reject the catalog.

Key verified before pinning: the hex and the did:key are the same
keypair, checked with a base58 decoder round-tripped against the previous
known-good pair. An earlier candidate hex (cb830e13…) was rejected
because it decoded to a different DID than the one supplied — pinning it
would have made every node reject every future update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:10:32 -04:00
archipelagoandClaude Fable 5 7025c5f26f docs(13-07): state + roadmap — 13-07 complete, next 13-08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:34:26 -04:00
archipelagoandClaude Fable 5 17da7a7233 docs(13-07): summary — music index + music.* surface complete, 23/23 green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:15:41 -04:00
archipelagoandClaude Fable 5 d3b0ed8a1d feat(13-07): music.* RPC surface behind one dispatcher arm
- api/rpc/music.rs: handle_music prefix sub-dispatcher (assistant_chat.rs
  shape) with list-albums / list-artists / list-tracks / status / reindex
- one guarded dispatcher.rs arm for the whole music. prefix, adjacent to
  the content.* block; the only registration point for the surface
- list-tracks: optional album_id filter, limit/offset pagination, limit
  clamped to [1,500] (default 100) — out-of-range degrades, never errors
  (T-13-41)
- reindex spawns the scan and returns immediately; second call while one
  runs reports already-running with the last stats; optional
  incremental:true routes to refresh_incremental so a changed library is
  reflected without a full re-extraction
- newer-schema index served as an empty library, never overwritten or
  reinterpreted by readers (13-MUSIC-MODEL.md downgrade contract)
- nothing music.* in UNAUTHENTICATED_METHODS — the surface rides the
  session/CSRF/RBAC gate; asserted by music_methods_require_session
  (T-13-40)
- 7 tests, one per Task 2 behavior bullet plus the incremental mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:09:18 -04:00
archipelagoandClaude Fable 5 0f21f598aa fix(security): FIPS mesh relay must not republish auth: local ports
Caught verifying the gate fixes on archi-dev-box: [fips0-ULA]:32838
answered HTTP 200 straight from nbxplorer with no credential. The
catalog declares that port auth: local — host-local by intent, pinned to
loopback, the gate deliberately keeps its hands off — but the mesh relay
bridges a STATIC port list to 127.0.0.1, so it republished it to the
whole mesh. Same bug class as the Tor onion gap: a transport that
converges on the app loopback without consulting the declaration.

PortMap now records declared-local ports and the relay withholds them
(tearing down an existing bridge if a catalog refresh newly declares
one), alongside the declared-gated withhold. Undeclared ports keep
todays behaviour — silence is not an instruction in either direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:40:28 -04:00
archipelagoandClaude Fable 5 d2e4b00789 fix(security): gate classifies from the catalog overlay and releases withdrawn claims
Dev-box verification of the Tor/FIPS fixes caught a pre-existing split
brain: the orchestrator publishes containers from the signed catalog's
embedded manifests (origin-wins), but the gate classified ports from the
stale disk manifests — so it externally bound nbxplorer 32838, a port
the catalog declares auth: local and pins to loopback. Reachable behind
a login, but reachable where it deliberately was not.

- build_port_map now consults the catalog overlay first, via the same
  parse/validate/image-only filter the orchestrator uses (moved to
  app_catalog::catalog_manifest_overlay so the two cannot diverge again).
- GatedPort carries . The gated set still includes undeclared
  Session-default ports for challenge/audit, but every action that
  REDIRECTS traffic — the torrc 127.0.0.2 repoint, the FIPS relay
  stand-down, the Tor-upstream bind — now keys on the declaration.
- The sweep releases held claims whose port left the gated set, so a
  catalog refresh that withdraws a port (gated → local/none) takes
  effect without a daemon restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:50:46 -04:00
archipelagoandClaude Fable 5 49687f7ebd feat(13-07): persisted music index — scan, group, atomic save, incremental refresh
- music/index.rs: reindex + refresh_incremental over the media roots,
  (path, mtime, size) diffing so unchanged files never re-extract, rows
  removed when files disappear (derived albums vanish with their last
  track), per-file extraction errors counted in ScanStats.skipped
- save_atomic: temp sibling + fsync + rename — a concurrent read sees a
  complete index or the previous one, never a partial file (T-13-42)
- load refuses schema_version > MUSIC_SCHEMA_VERSION with a distinct
  NewerSchema error and never overwrites the newer file (T-13-43)
- symlinks whose canonical target escapes the media roots are skipped,
  not followed (T-13-39); confinement enforced here and in tags.rs
- reindex guard: AtomicBool + RAII release; a second concurrent scan
  reports already-running instead of duplicating the walk (T-13-41)
- music/mod.rs: media_roots(Config) (filebrowser/Music +
  purchased-content) and LibrarySnapshot (tracks + derived albums/artists)
- 9 tests, one per 13-07 Task 1 behavior bullet, programmatic FLAC
  fixtures into tempdirs (no committed binaries)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:18:06 -04:00
archipelagoandClaude Fable 5 e46af8cfe5 feat(security): self-heal legacy containers on declared bind drift
Legacy pre-quadlet containers kept publishing 0.0.0.0 after the catalog
pinned their app to loopback, because host_port_bindings_drifted only
compared host PORT numbers — closing them needed a manual package.update
per app per node. The drift check now also compares the bind ADDRESS,
but only when the manifest declares one: an empty bind never fires,
since recreating a loopback-published container to wildcard on silence
is exactly the v1.7.121 Bitcoin-RPC incident. With this, every node
recreates its legacy containers to the declared state on its own after
the OTA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:14:01 -04:00
archipelagoandClaude Fable 5 f08ed79b8a fix(security): FIPS v6 relay hands gated ports to the app gate
The mesh relay is a raw unauthenticated forward to the app's loopback,
and whether it or the gate owned a fips0 ULA port was decided by a bind
race — the dev box happened to be safe because the gate bound first.
The relay now skips ports declared auth: gated and tears down any
existing bridge for a port that became gated since it was bridged
(catalog refresh), releasing the bind for the gate's next sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:14:00 -04:00
archipelagoandClaude Fable 5 3760a00ea3 fix(security): Tor onions for gated ports forward to the gate, not the app
Tor carries no session cookie, so HiddenServicePort → 127.0.0.1:<port>
reached the app around the gate — the last transport the gate did not
cover. The gate now binds 127.0.0.2 (its own loopback, distinct from the
app's 127.0.0.1, so no app needs a second port), and regenerate_torrc
forwards declared-gated ports there. Undeclared ports keep today's
target: absence of the field is not an instruction.

The 127.0.0.2 claim deliberately does not count toward the unprotected
audit — a port whose only claim is the Tor loopback is still wide open
on the LAN and must keep warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:13:59 -04:00
archipelagoandClaude Fable 5 589cbb030f docs(13-04): complete music model + tag extraction plan — summary, state, config
- 13-04-SUMMARY.md: all 3 tasks, broken-pipe recovery (verbatim wip
  checkpoint be8f24b4), MP3 fixture off-by-one deviation, 7/7 tests green
- STATE.md: 13-04 complete (7/15 phase-13 plans), D-13 decision + lofty
  gate recorded in accumulated context, next = waves 3+
- config.json: fold in pre-existing use_worktrees=false from the broken
  session (wave-continue bookkeeping, intentionally kept)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 07:06:10 -04:00
archipelagoandClaude Fable 5 4b577493b1 feat(13-04): tag extraction across mp3/flac/m4a/ogg with media-root confinement
Completes Task 3 on top of the recovered wip checkpoint (be8f24b4):

- fix the programmatically-generated MP3 fixture's frame length: lofty's
  Header::read computes samples*bitrate*125/sample_rate with truncating
  integer division BEFORE adding the padding byte, so the FF FB 52 C4
  frame is 209 bytes, not 210 — the off-by-one made cmp_header miss the
  second frame sync and reject the whole file as containing an invalid
  frame (mp3_id3v24_yields_full_record now passes; fixture-only fix,
  production code untouched)
- all 7 music::tags tests green; no binary audio fixtures committed
  (fixtures are built byte-by-byte into tempdirs at test run time)
- extract_tags canonicalizes and confines to caller-supplied media_roots
  before opening any file (T-13-20); non-audio is a distinct NotAudio
  error vs the Ok/has_tags=false untagged fallback (T-13-21)
- entity types in music/mod.rs implement 13-MUSIC-MODEL.md exactly:
  hybrid-identity TrackId, derived albums/artists, MUSIC_SCHEMA_VERSION=1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:42:40 -04:00
archipelagoandClaude Fable 5 8210ca0a2a chore(catalog): sign catalog with port auth policy — 20 UIs gated, exemptions declared
Embeds the manifest port declarations (bind: 127.0.0.1 + auth: gated on 20
HTTP UIs, auth: local on loopback backends, auth: none + rationale on
protocol ports) into the signed catalog so nodes enforce the app gate.
Also carries bitcoin-ui/lnd-ui 1.7.119 version drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:28:06 -04:00
archipelagoandClaude Fable 5 6d9d87caa6 chore: sync Cargo.lock with the 1.7.121-alpha version bump
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:09:37 -04:00
archipelagoandClaude Fable 5 16642ad8b8 feat(security): declare port auth policy across the app manifests
20 HTTP UIs move to bind: 127.0.0.1 + auth: gated (the daemon owns their
external addresses and authenticates every connection); 5 loopback-only
backends declare auth: local so the gate keeps its hands off. Protocol
ports (LND, bitcoin p2p, electrum, CLN, gitea SSH, Wyoming, mDNS/SSDP)
were already declared auth: none with rationales in earlier commits.

Inert until the catalog is re-signed: nodes act only on declared fields
delivered via the signed catalog, and the catalog overlay overrides these
disk manifests everywhere they are installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:09:37 -04:00
archipelagoandClaude Fable 5 be8f24b4e3 wip(13-04): checkpoint Task 3 tag-extraction work recovered after broken pipe
Verbatim checkpoint of uncommitted executor work (music/mod.rs, music/tags.rs,
mod music; in main.rs) before verification. Tests not yet run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:05:12 -04:00
archipelagoandClaude Fable 5 6156444004 chore(13-04): add lofty 0.24.0 for music tag extraction
Task 2 checkpoint:human-verify resolved by operator approval
2026-08-04: crates.io lofty — 811,138 total downloads, 248,280
recent, 56 versions spanning 2021-04-23 to 0.24.0 (2026-04-12);
repo github.com/Serial-ATA/lofty-rs — 348 stars, 1,962 commits,
fuzzing + benchmark infra, MIT/Apache-2.0 dual license, active CI,
no squatting indicators. Dependency tree reviewed via `cargo tree -p
lofty`: byteorder, data-encoding, flate2, log, ogg_pager, paste — no
networking or process-spawning crates. `cargo build --package
archipelago` exits 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:13:30 -04:00
archipelagoandClaude Fable 5 bca4660a95 docs(13-04): record D-13 one-way music entity model decision
Task 1 checkpoint:decision resolved by operator: hybrid-identity (path
row key, lazily-backfilled content-hash dedupe column), derived-albums
(computed at read time from track tags, not stored rows), a single
JSON index at data_dir/music/index.json matching content_server.rs's
load_catalog precedent, and both own-library + peer sources indexed.
MUSIC_SCHEMA_VERSION starts at 1; a newer-version index on an older
binary is treated as absent rather than reinterpreted or overwritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:08:29 -04:00
archipelagoandClaude Fable 5 bf9b4e6ad3 docs(13): 13-09 complete — nginx sync merged live, CSP boundary browser-verified
Recovered after a broken-pipe session cut off right after Task 4 finished:
the summary was fully written (Self-Check PASSED) but never committed.
Re-verified on resume before committing: /aiui/-scoped CSP header live on
archi-dev-box, build/verify scripts present+executable, render screenshot
intact. STATE.md advanced: 6/15 plans done, next is 13-04.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 04:57:30 -04:00
archipelagoandClaude Opus 5 b3b580aab5 docs(13): 13-09 checkpoint pause point — Tasks 1-3 done, Task 4 needs nginx sync + human browser verify
Continuation executor (post-reboot) ground-truthed the 30b2e02f WIP
build-aiui.sh checkpoint as complete/correct, finished Tasks 2-3 and
scripts/verify-aiui-deploy.sh, and proved the build+deploy+verify cycle
end-to-end on the real archi-dev-box node (this machine). Paused at Task
4's remaining human/browser-required steps because the live node's nginx
config predates even 13-02 — syncing it is a bigger diff than this plan's
own CSP addition and belongs to a human-supervised deploy, not an
unsupervised executor push to a live node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 04:25:35 -04:00
archipelagoandClaude Opus 5 3756ebffc0 feat(13-09): verify-aiui-deploy.sh fetches live chunks via sw.js, never disk
Post-deploy check for AIUI: resolves the LIVE chunk set by fetching the
service worker's precache manifest (sw.js — vite-plugin-pwa's
generateSW-mode workbox.precacheAndRoute([{url:...}]) array) over HTTP,
fetches each live chunk, and greps the fetched bytes for a marker string.
Exits non-zero when the marker is absent from every live chunk.

This exists because the node's assets/ directory is a never-pruned
graveyard (feedback_node_side_frontend_verify_stale_chunks): a disk grep
reports "deployed" before the deploy actually happened, because a dead
chunk from an old build still contains the old string. Never opens a
remote shell onto the node and never greps the node's filesystem directly
— every check is an HTTP fetch, exactly what a browser session would do.

Verified locally against a real AIUI build served over HTTP: a marker
actually present in a live-precached chunk (index.html) passes (exit 0,
2 chunks checked before the match); a nonexistent marker correctly fails
(exit 1) as the negative control — not a check that always passes.

Wired into deploy-to-target.sh's primary AIUI deploy path in the prior
commit (073bf6f3), which already calls this script by name after the copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 04:22:52 -04:00
archipelagoandClaude Opus 5 c3bffbd557 feat(13-09): widen same-host deploy guard from containment-only to any mismatch
Adds assert_safe_same_host_deploy(local_src, remote_dst) to lib/common.sh:
pure, no SSH inside it, callable directly from a test with fixed inputs.
Returns 0 only when the two already-resolved paths are equal; refuses
(non-zero, message naming both paths + the 2026-07-31 incident) on any
other same-host mismatch.

This closes a real gap in the 2026-07-31 incident's original fix: the old
guard's two `case` blocks refused only containment (source-in-destination
or destination-in-source). A SIBLING directory — for example this very
worktree, archy-phase13, deploying onto TARGET_DIR's resolved symlink
target (archy, the main checkout) — is neither contained by nor containing
of the destination, so the old guard let it through and `rsync --delete`
would have mirrored the sibling onto the main checkout, deleting everything
the sibling lacks. Found while retargeting deploy-to-target.sh for D-19,
not a D-19 effect itself.

deploy-to-target.sh's guard block now calls assert_safe_same_host_deploy
instead of the two inline containment-only case blocks (old logic removed,
not left dead alongside the new call).

tests/production-quality/deploy-guard-same-host.sh pins all five
<behavior> cases (identical/contained/containing/sibling/unrelated)
against the function with no SSH, no rsync, no real deploy — including the
exact archy-phase13-vs-archy pair as the sibling-directory regression pin.
Manually confirmed non-vacuous: flipping the sibling fixture's expectation
to "allow" makes the test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 04:19:49 -04:00
archipelagoandClaude Opus 5 073bf6f33d feat(13-09): retarget AIUI build/deploy to the in-repo aiui/ tree (D-19)
Finishes the 13-09 build-aiui.sh WIP checkpoint (30b2e02f) that survived the
operator reboot ground-truthed and confirmed correct: require_base_path,
frozen-lockfile install, verify_dist's asset-href/commit-attribution checks
all verified working against a real build. Fixed one grep-forbidden leftover
(a comment mentioning the retired scripts/aiui.pin path).

Rewires both AIUI sections of deploy-to-target.sh (primary --live path and
the --both/secondary path) and setup-aiui-server.sh to build/deploy from
aiui/packages/app/dist instead of the retired ../AIUI sibling checkout:

- Primary section now calls scripts/build-aiui.sh instead of an inline
  `pnpm build`, then scripts/verify-aiui-deploy.sh after the copy. The
  demo/aiui/ fallback now prints a loud, unmissable warning naming that it
  is shipping a checked-in dist rather than a fresh build.
- Secondary/--both section retargeted to the in-repo dist path; its
  fallback-to-.228-streaming behavior is otherwise unchanged.
- setup-aiui-server.sh calls scripts/build-aiui.sh automatically when the
  dist is missing or stale, instead of printing a manual `cd ../AIUI/...`
  command and exiting (D-15: enforced, not remembered).

No remaining `../AIUI` reference in either script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 04:19:21 -04:00
archipelagoandClaude Opus 5 442122083f docs(13): reboot pause point — full resume state in stopped_at
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:29:21 -04:00
archipelagoandClaude Opus 5 30b2e02f4a wip(13-09): checkpoint in-progress executor work before operator reboot
build-aiui.sh (mid-write) + aiui/.gitignore, committed verbatim and UNVERIFIED
— not a task completion. The 13-09 executor will be killed by the reboot; its
continuation should read this checkpoint, judge it against the plan's
must_haves, and reset --soft / build forward as appropriate (same recovery
pattern as the 6ba52b22/13b576da broken-pipe rescue at the start of this phase).
Already committed by 13-09 before this: 6ac0ebbf (CSP sandbox task).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:28:54 -04:00
archipelagoandClaude Opus 5 4e455167e9 fix(release): accept https remotes when publishing assets
Publishing v1.7.121-alpha failed on auth after the manifest had already
passed every check. The script required an `http://user:token@` remote,
which left only `gitea-vps2` — whose token is dead — and rejected
`gitea-ai`, the https remote whose credential actually works for git
push. Same Gitea instance (146.59.87.168, v1.27.1) either way, so the
restriction bought nothing and blocked the one usable path.

Accepts http and https, and carries the scheme through to the API URL
instead of hardcoding it.

Note for diagnosis next time: `/api/v1/repos/.../releases` is publicly
readable, so a 200 there does NOT prove the credential works. Use
`/api/v1/user`, which requires real auth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:23:28 -04:00
archipelagoandClaude Opus 5 57628a2aa4 fix(aiui)+docs: keyboard-resize parity for standalone AIUI; companion handover note
Operator-reported: keyboard opening in chat pads the tab bar and scrolls the
page instead of scaling the chat window. Triage: neode-ui already ships
interactive-widget=resizes-content + the --visual-viewport-height var, so
mobile-web Chrome resizes correctly — but an Android WebView ignores that meta
entirely, and the described pan-plus-padding is the adjustPan/edge-to-edge-
without-IME-insets signature. Companion-side fix documented for handover in
docs/companion-keyboard-viewport.md (manifest adjustResize, or IME insets when
edge-to-edge, plus a chrome://inspect verification recipe). Web side gets the
one real parity gap: AIUI's standalone index.html lacked the meta neode-ui has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:21:53 -04:00
archipelagoandClaude Opus 5 86129d5da2 fix(aiui): banner follows the selected item — reset fallback state on identity change
Operator-reported: selecting a different item in the content window left the
context-surface banner showing the previous item's image. Root cause: detail
views are reused, not remounted, and useBannerFallback kept primaryIndex/
stage/apiUrl alive across the prop change — once stage hit 'api' or 'done' it
never re-evaluated. Reset is keyed on title + the primary URL set, with a
generation guard so an in-flight fetch for the old item cannot stamp its
artwork onto the new one. Heals Film/TVSeries/Book detail at once; 3
regression tests pin it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:16:26 -04:00
archipelago cd58242935 chore: release v1.7.121-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
2026-08-04 03:13:45 -04:00
archipelagoandClaude Opus 5 6ac0ebbf0a feat(13-09): enforce the /aiui/-scoped CSP sandbox boundary (AIUI-04)
Adds a Content-Security-Policy header to both nginx `location /aiui/`
blocks whose connect-src is scoped to the AIUI path prefix, so AIUI's
own JavaScript is browser-prevented from issuing a same-origin fetch
to /rpc/v1 with the ambient session cookie. Explicitly rejects the
`sandbox` iframe attribute (allow-scripts + allow-same-origin is the
known escape; dropping allow-same-origin breaks AIUI's storage and
its origin-checked bridge) and records why in both the nginx comment
and a new comment above the Chat.vue iframe. Adds
referrerpolicy="no-referrer" to the iframe so a media URL or page path
never leaks upstream via Referer.

Also adds an explicit `location /aiui/api/openrouter/ { return 404; }`
to both server blocks, closing 13-02's Task 3 checkpoint finding
(operator-accepted deviation 2026-08-03): the relay was already
structurally gone but the SPA catch-all served 200/405 instead of 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:11:36 -04:00
archipelagoandClaude Opus 5 5e3aab30a2 fix(aiui): header overlay panels get readable opacity — scoped, not blanket
Operator-reported: the model-picker and conversation-menu overlays are hard to
read over busy chat content at path-glass-card's shared rgba(0,0,0,0.65).
Scoped .header-overlay-panel (0.88) in ChatHeader.vue only — path-glass-card
itself is untouched, so BookDetail/ArticleDetail/TVSeriesDetail/WebsiteDetail/
ContentPanel/ChatWindow keep their existing glass. Unlayered scoped rule beats
the @layer components class without !important, and reaches the panels through
their Teleport to body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:11:02 -04:00
archipelagoandClaude Opus 5 06c934bd59 docs(todos): capture two operator-reported AIUI issues
Mobile load speed and the 'how to use AIUI' brief not opening. Both noted with
the caveat that the deployed AIUI bundle is stale (pre-D-14), so they must be
reproduced against a fresh in-repo build before being chased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:00:06 -04:00
archipelagoandClaude Opus 5 484b1209a8 docs(13): close windows 16 and 19 — assistant tests observed passing
21 passed / 0 failed across assistant::, plus assistant_methods_require_session
run explicitly. Both windows had non-defect root causes: window 19 was lane
staleness (missing 0de67ca6's PortMapping test-constructor fix, which made the
whole crate's test build fail), and window 16's repeated kills were the
orchestrator's own too-short timeout sending SIGTERM on a cold build, which I
had wrongly attributed to memory contention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:57:49 -04:00
archipelagoandClaude Opus 5 9d225473b1 docs(1.7.121): record what shipped, both gate incidents, and the .122 queue
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:49:36 -04:00
archipelago 7cc58b7ac6 merge(13): bring main forward — unblocks the crate's test build
The lane merged main at 0c4826f8, one commit before 0de67ca6 added
auth/auth_rationale to PortMapping's test constructors in prod_orchestrator.rs.
That left the lane unable to compile ANY test in the archipelago crate, which
is why 13-05 could not observe its 13 tests pass (window 19). Not a defect in
this phase's work — just staleness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	core/archipelago/src/main.rs
2026-08-04 01:50:39 -04:00
archipelago 0a0a2fde43 docs(13-05): complete curated tool registry and default-closed grants plan 2026-08-04 01:39:28 -04:00
archipelagoandClaude Opus 5 c098124d93 test(13-05): assert the D-09 ceiling over the whole registry, not a hardcoded tool list
Task 3: four registry-wide structural tests in tools.rs, iterating registry()
so a future tool that crosses the D-09 ceiling fails CI rather than depending
on a reviewer noticing:

- registry_never_exposes_excluded_authority (S-04/T-13-24): scans every
  ToolDef's name+description for EXCLUDED_AUTHORITY_TERMS.
- read_tools_never_confirm (S-07/T-13-31): every non-destructive tool
  executes via the real execute_tool choke point without raising anything
  confirmation-shaped. bitcoin_status/network_status excluded from live
  execution (their handlers make real outbound network calls that would
  make this test flaky on a sandboxed box); their destructive:false
  placement is still covered by the other assertions.
- loop_is_bounded (S-13/D-05): MAX_TURNS is enforced, and 3 consecutive
  malformed-argument calls for the same tool name abort the turn with an
  apology before a 4th scripted backend turn is ever polled.
- every_tool_has_explicit_category_and_destructive: sanity-checks the
  registry has exactly the 13 hand-written tools (4 destructive) that made
  it in, as a runtime backstop to the acceptance criteria's static grep for
  `..Default::default()`.

Negative-case demonstration (per the plan's acceptance criteria): a
hypothetical `wallet_send_sats` tool with a description mentioning
"spending sats" trips EXCLUDED_AUTHORITY_TERMS's "spend" term, verified by
tracing the exact haystack-contains logic registry_never_exposes_excluded_authority
runs (see 13-05-SUMMARY.md for why this was traced rather than executed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:34:43 -04:00
archipelagoandClaude Opus 5 90706fe053 feat(13-05): expand curated tool registry to full D-06/D-09 allowlist, wire D-16 default-closed grants
Task 1: registry() grows from the tracer's single system_disk_status tool to
the full 13-tool D-06 curated allowlist (9 read tools, 4 destructive write
tools), each hand-written with its own JSON Schema, PermissionCategory and
destructive flag -- nothing derived from api::rpc's method table. Adds
EXCLUDED_AUTHORITY_TERMS (D-09's excluded authority, scanned by Task 3's
registry-wide test), SETTABLE_KEYS/READABLE_SETTINGS_KEYS (AIUI-02's
hand-picked settings surface, claude_api_key permanently absent from
SETTABLE_KEYS), tools::dispatch (per-tool RPC dispatch) and
tools::validate_business_rules (allowlisted-key / installed-app-id
validation that runs before the destructive/confirm gate so a plainly-wrong
request is refused with the real reason instead of the generic
"not yet implemented" placeholder). assistant_dispatch_tool gains a params
argument and the RPC method table Task 1's tools need.

Task 2: grants.rs adds Grants (D-16 default-closed permission-category
store, persisted 0600 under data_dir/assistant/grants.json; a missing file
is default_closed(), never permissive). CallerScope::granted_categories
becomes async and reads the persisted store instead of a hardcoded default;
CallerScope::Mesh gains an `authorized` field so a mesh peer's ceiling is
never wider than the operator's own grants. ToolExecCtx gains the AI-SPEC
S-13 consecutive-validation-failure counter (>2 failures for the same tool
name aborts the turn with an apology, checked in run_loop). build_system_prompt
appends only currently-granted-category tools' names/descriptions -- an
ungranted tool never appears in the prompt string (defense in depth; the
execute_tool grant re-check is the actual gate). assistant_chat.rs adds
assistant.list-tools / assistant.grants-get / assistant.grants-set, all
routed through the existing single assistant.* dispatcher arm (dispatcher.rs
untouched, verified by git diff --exit-code).

dispatcher.rs is not touched -- all new RPC surface goes through 13-01's
assistant.* prefix arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:28:29 -04:00
archipelagoandClaude Opus 5 e20d7a14fb docs(whats-new): add the v1.7.121-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m41s
The release gate requires every CHANGELOG version to have a matching
block in Settings > What's New. Generated by scripts/sync-whats-new.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:22:16 -04:00
archipelagoandClaude Opus 5 1929f6a870 style: rustfmt the appgate, federation and manifest changes
The release gate runs cargo fmt --check and these were hand-written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:00:29 -04:00
archipelagoandClaude Opus 5 9abf9072a3 docs(changelog): curate v1.7.121-alpha release notes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:51:46 -04:00
archipelagoandClaude Opus 5 ab2c8b6e96 fix(security): silence is not consent — undeclared ports are never acted on
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.

  1. Gating any `session` port regardless of `bind` published Bitcoin's
     loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
     of deploy.
  2. The `bind`-keyed replacement looked safe because it protected
     `bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
     an EMPTY bind, so they fell through. One container recreate from
     pinning them to loopback and breaking Zeus and every remote wallet.

`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:

  * `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
    Session, i.e. shows in the audit as something that should be behind
    the gate. Reporting is always safe.
  * `auth_is_declared()` — whether the daemon may ACT. Only an explicit
    declaration authorises changing how a port is published.

Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.

Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:36:43 -04:00
archipelagoandClaude Opus 5 edc9a172e9 fix(mesh): federated peers are messageable without meeting over LoRa first
Peering a node was not enough to message it — you had to be in radio
range once before chat worked, which defeats the point of federating.

`send_message` chose its transport from the attached radio:

    let use_typed_envelope =
        archy && matches!(device_type, Meshcore | Reticulum);

Only the typed path knows about FIPS/Tor. Everything else fell through to
`peer_dest_prefix`, which resolves an over-the-air ROUTING key — so on a
node running Meshtastic, or with no radio at all, sending to a federated
peer failed. It only worked once a LoRa advert had created a radio twin
for the same archipelago identity, which is precisely the "connect on
LoRa first" the operator hit.

Federation contacts are reachable off-radio by definition — that is what
`upsert_federation_peer` records with `reachable: true` — so the
transport choice must not depend on which radio is plugged in. A
federation-synthetic contact id now always takes the typed path.

This loses no radio-first behaviour: `send_typed_wire` already prefers a
REACHABLE radio twin when the payload fits the frame, and only then falls
back to FIPS and Tor. The fix routes federation contacts INTO that logic
rather than around it.

Test pins the predicate across every device type, including the two that
failed (Meshtastic, Unknown), and asserts ordinary radio contacts and
stock clients still route exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:35:02 -04:00
archipelagoandClaude Opus 5 61ebce0598 docs(phase-13): 13-06 complete
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:56:42 -04:00
archipelagoandClaude Opus 5 03ceedbae1 docs(13-11): require content to actually render, not merely be fetchable
13-06 delivered the content pipeline and unit-tested it, but nothing in the
live UI invokes it, and 13-11 as written only added an equally-uncalled
sibling. No plan in the phase triggers the fetch from a UI event. Without this
AIUI-03 ships green-tested and visibly broken — empty grids. Wiring belongs
here, where useArchy.ts and ChatPage.vue's render tree are already in scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:56:25 -04:00
archipelagoandClaude Opus 5 fe54f95d4e docs(13-06): complete AIUI content-surfaces plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:54:14 -04:00
archipelagoandClaude Opus 5 b771357902 feat(13-06): AIUI renders Archy content through setArchyContent (D-12)
- archyBridge.ts: content:push case resolving the pending content:request
  by id, and requestArchyContent(kind, scope) mirroring requestContext's
  shape. Not in the plan's files_modified list, but required to satisfy
  Task 3's own instruction to register the content:push handler on the
  existing single bridge listener rather than adding a second
  window.addEventListener('message') — see SUMMARY deviations.
- useContentPanel.ts: setArchyContent + archyContentActive; guards only
  the panelFilms/panelSongs/panelPodcasts assignments inside
  updatePanelFromText so Archy-sourced grids stay the source of truth
  once populated, per plan scope. Books/TV/images/places/magazine/code/
  recipes/news are untouched (13-PATTERNS.md: partial deprecation).
- useArchy.ts: requestArchyContent(kind, scope) calling
  archyBridge.requestArchyContent then useContentPanel().setArchyContent.
  No FilmGrid/SongGrid/NewsGrid/ContentGridView/content.ts edits (D-12).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:47:51 -04:00
archipelagoandClaude Opus 5 ce7a2b2cd4 feat(13-06): content:request/content:push channel on the existing bridge
- aiui-protocol.ts: AIUIContentRequest (kind + optional scope, no RPC
  method or params) and ArchyContentPush (adapted bundle + permitted
  flag).
- contextBroker.ts: handleContentRequest gates on the media/files
  permission categories (either grants access), resolves scope to
  content.list-mine / content.browse-peer (fanned out across every known
  federation peer) / content.owned-list, and routes results through
  archyContentAdapter's adaptContentItems before crossing the iframe
  boundary. contentRequestSeq is a monotonic guard: a stale RPC response
  that resolves after a newer content:request has started is discarded
  rather than posted (AIUI-03 concurrency edge).
- contextBroker.test.ts: permission-denied, own-scope, and stale/
  out-of-order coverage. Fixed a pre-existing latent flake risk in this
  file — perms.toggle() is not idempotent across tests because the
  permissions store persists to localStorage, which vi.clearAllMocks()
  does not reset; switched the new tests to perms.enableAll().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:36:50 -04:00
archipelagoandClaude Opus 5 f7691fd1bb feat(13-06): content adapter — ContentItem to Film/Song/Podcast, close the streamUrl JWT leak
- archyContentAdapter.ts: hand-written adaptContentItems mapping (D-12),
  fixture-pinned at the adjacency, empty, ordering and paid-lock edges
  named in AIUI-03; classifyByMime covers the m4a/aac/opus/wma extension
  gap ShareModal.vue's mime map leaves today; buildMediaUrl never puts a
  credential in a query string (T-13-32).
- filebrowser-client.ts: streamUrl now returns a query-free same-origin
  raw-file URL, relying on the path=/ cookie login() already sets instead
  of also putting the JWT in the URL (T-13-39 — closes the pre-existing
  leak CONTEXT.md names, rather than merely not repeating it).
- filebrowserStreamUrl.test.ts: regression pin for the fix, including a
  traversal case confirming sanitizePath behavior is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:27:09 -04:00
archipelagoandClaude Opus 5 6520cffc95 docs(phase-13): wave 1 complete — 13-01, 13-02, 13-03
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:05:16 -04:00
archipelagoandClaude Opus 5 42faccaa9a docs(13-09): carry 13-02's openrouter 404 deviation forward as a truth
Operator-accepted deviation from 13-02 Task 3: the relay is structurally gone
but /aiui/api/openrouter/ still answers 200 via the SPA catch-all. 13-09 already
owns this nginx config, so the explicit return 404 belongs here rather than
bolted onto a completed plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:03:43 -04:00
archipelagoandClaude Opus 5 15e44a02dc docs(13-02): checkpoint resolved — operator approved, plan complete 3/3
Positive path confirmed by the operator on real hardware. Machine half
independently re-probed by the orchestrator rather than taken from the
executor's report. Openrouter status-code finding accepted as a deviation with
the reasoning recorded; explicit 404 scheduled in 13-09.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:03:13 -04:00
archipelagoandClaude Opus 5 de058bace7 docs(13): record window 18 — nginx self-heal silently reverts deployed config
run_runtime_assets() reinstalls a second on-node copy of the nginx template
over /etc/nginx/sites-available on every daemon restart. Found on
archy-x250-dev3 during 13-02 Task 3, where a hand-patched deploy was reverted
within ~5s of the restart. Live OTA hazard: an operator can deploy an nginx
fix, watch it apply, restart, and lose it with no error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:59:45 -04:00
archipelagoandClaude Opus 5 719446c05f fix(companion): stop the endless rebuild loop on *-ui companions
Observed live on archi-dev-box: archy-fedimint-ui rebuilt 45 times in 10
minutes, every ~35s, indefinitely. bitcoin-ui, lnd-ui and electrs-ui were
all one reconcile away from the same loop.

`context_is_newer_than_image` decides to rebuild when the build context's
newest mtime is later than `podman image inspect .Created`. The rebuild
that follows is a full layer-cache hit, so podman reuses the identical
image and leaves .Created untouched — the condition that triggered the
rebuild is still true afterwards. The check cannot converge: it rebuilds
on every reconcile tick forever, burning CPU and churning the container.

It bites after any deploy that refreshes /opt/archipelago/docker/*, which
makes the contexts newer than the shipped images — so this is fleet-wide
on every OTA, not local to one node.

Fix: stamp the context mtime that was built into an image label and
compare against that instead. A label is part of the image config, so a
cache-hit build with a new value still produces a new image — the thing
being tested does change, and the comparison settles after exactly one
rebuild. Verified against real podman before writing it: two cache-hit
builds with different label values produced distinct image IDs
(6cfdbc9bcd3e vs a9b10eb9a558), each carrying its stamp; the indexed
inspect format was checked against an image with real labels, and a
missing label prints empty (handled, along with "<no value>").

Images built before this carry no label and fall back to .Created, so
behaviour is unchanged for them and each self-heals on its first
reconcile after upgrade — nodes fix themselves rather than needing the
manual `podman build --no-cache` pass this needed by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:55:28 -04:00
archipelagoandClaude Opus 5 160c098ca3 test(13-02): S-15 real-node proof for AIUI model-proxy closure (Task 3)
Adds tests/production-quality/aiui-proxy-closed.sh (follows lnd-cors-test.sh's
shape) and deploys+runs it against a real, genuinely remote node
(archy-x250-dev3, operator-approved deviation from archi-dev-box — see
SUMMARY key-decisions for why).

Confirmed on the node: unauthenticated /aiui/api/claude/v1/messages and
/aiui/api/ollama/api/tags both 401; claude-api-proxy sidecar unit gone;
nothing listens on :3142; the second key ledger (claude-api-proxy.env) is
gone. Along the way, root-caused and worked around a real deploy-topology
gap — the daemon self-heals nginx config from a second, stale on-node
template copy on every restart, silently reverting a hand-patched fix.

One finding is reported honestly rather than tuned away: deleted
/aiui/api/openrouter/ returns 200/405 via this app's SPA catch-all, not the
plan's literal 404 — the relay is structurally gone (zero proxy_pass to
openrouter.ai), but the exact status code doesn't match the acceptance
criterion. Left open for a human decision, per this task's own instruction
not to force a probe to pass.

This is Task 3 of a checkpoint:human-verify plan with gate="blocking". The
positive-path browser check and the openrouter-finding disposition remain
for a human; this executor does not self-approve the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:52:33 -04:00
archipelagoandClaude Opus 5 3716b6e9c3 fix(security): two gate bugs that would have made the rollout a no-op
Both found while setting up the on-node test, and both fail silently in
the same direction — the gate reports success while protecting nothing,
which is the exact failure the module was written to prevent.

1. Loopback-pinned ports were skipped entirely.

`identity.rs` dropped any port whose manifest sets `bind: 127.0.0.1`,
reasoning that a loopback publish is not externally reachable. But
`listener.rs` requires loopback-pinning as the PRECONDITION for gating —
while an app holds 0.0.0.0:<port> the kernel will not let the gate bind
that port at all. So the two contradicted each other: pinning an app, the
one action that lets the gate take over, was also what removed it from
the gated set. Completing the entire migration would have gated nothing,
and GateStatus would have reported zero unprotected ports while doing it.

`bind` cannot carry this decision, because two unrelated intentions
produce an identical loopback publish: Bitcoin's RPC 8332 is pinned so
the LAN CANNOT reach it (fronting it would newly expose it on every host
address, behind a login but exposed where it deliberately was not),
whereas a migrated app is pinned precisely so the gate CAN. Inferring
from `bind` breaks one or the other, so the intent is now declared:
`PortAuth::Local` means the first case. The three ports that are
host-local by intent (bitcoin-core/knots 8332, aiui 5180 — all already
`bind: 127.0.0.1`) say so, and a loopback publish with `auth: session`
stays gated. A test pins that property.

2. The port map was never refreshed.

`AppGate::refresh()` existed, was documented as making catalog changes
apply without a restart, and was called by nothing. The map was built
once in `new()`, so an app installed while the daemon runs would never be
gated — and would never appear in `unprotected` either, so the node would
report itself fully enforced while serving a brand-new app to anyone who
asked. The sweep now refreshes before classifying.

Tests: 22/22 appgate, 73/73 archipelago-container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:41:29 -04:00
archipelagoandClaude Opus 5 cc9e19589c fix(release): refuse to commit an unsigned OTA manifest
Every cycle has needed a manual check that releases/manifest.json got
signed, because the script would happily commit and tag one that hadn't.

The signing step is conditional: with no TTY and no
RELEASE_MASTER_MNEMONIC it prints a warning and falls through. The commit
at step 7 then ran regardless, so the release commit — and its tag —
carried an unsigned manifest.

publish-release-assets.sh already refuses to ship one, but that backstop
arrives a step too late. Nodes fetch releases/manifest.json straight from
branch `main` (the same URLs this script prints for verification), so the
COMMIT is what exposes it to the fleet, not the publish. By the time
publishing is refused, the unsigned manifest is already on main and nodes
are already declining to auto-apply.

So the same gate now runs before the commit: presence of a signature,
signed_by matching the release root, and `ceremony verify` for the crypto.
A release commit carrying a manifest no node will accept has no valid use,
so this refuses to create one rather than leave a tag that has to be
re-cut. The earlier warning is corrected too — it promised the run would
continue, which is no longer true.

Verified the predicate against three manifests: signed -> allow, signature
stripped -> refuse, signed_by swapped to another DID -> refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:55:02 -04:00
archipelagoandClaude Opus 5 df4fb446ec docs(13-09): orphaned AIUI clone deleted — hazard is now loud, not silent
Operator deleted /home/archipelago/Projects/AIUI after the subtree import was
proven byte-identical (tree 5ac3173a on both sides, every branch contained in
development, no stashes, clean tree). The ../AIUI script paths no longer
resolve, so they fail loudly instead of shipping stale bytes. Still in scope
for this plan — a deploy script that dies on a missing directory is not a
shipping story — but the severity note is corrected so a future executor does
not act on a stale premise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:23 -04:00
archipelagoandClaude Opus 5 6f5113a0f4 docs(13-09): cover the last two scripts still sourcing AIUI from ../AIUI
The planner correctly flagged dev-start.sh and deploy-tailscale.sh as out of
its mandate. Verified the risk is live, not theoretical: the orphaned
pre-migration clone still exists AND still has a built packages/app/dist, so
both scripts copy stale AIUI bytes and report success rather than failing
loudly. That is the same silent-staleness class as the /assets 404. Same
one-line fix as the two scripts already in scope, so it belongs in this plan
rather than in a follow-up nobody schedules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:16:56 -04:00
archipelagoandClaude Opus 5 9ac4770773 docs(13-09): retarget plan to in-repo aiui/ (D-19)
Substantive rework, not a path swap:

- Retires D-15's pin-and-verify model. scripts/aiui.pin, pin_commit and
  --update-pin are dropped outright — there is no second repository left
  to pin, so build-aiui.sh now attributes a build to this repo's own
  `git rev-parse HEAD` instead.
- Re-derives the build: aiui/ is an in-repo pnpm/turbo workspace with its
  own package.json and lockfile but no committed node_modules, so
  build-aiui.sh must `pnpm install --frozen-lockfile` before it can build
  (new requirement; the old model assumed a developer's separate AIUI
  clone was already installed).
- Retargets deploy-to-target.sh (both its primary and --both/secondary
  AIUI sections) and setup-aiui-server.sh off the stale
  $PROJECT_DIR/../AIUI/packages/app/dist path, which still resolves on
  disk to a stale pre-migration clone and would otherwise silently ship
  old bytes instead of failing loudly.
- Carries the /aiui/-scoped CSP sandbox work (AIUI-04) through unchanged
  per D-19, and fixes two acceptance-criteria drifts discovered while
  verifying the plan against deploy-to-target.sh's post-13-02 state and
  nginx-archipelago.conf's post-pentest-hardening state (CSP header count
  and the "no session gate needed" grep), neither of which is a D-19
  effect.
- Folds in a real defect found while doing this work: the 2026-07-31
  same-host deploy guard only catches path containment, not sibling
  directories — the exact shape this worktree's own topology exhibits
  (archy-phase13 as a sibling of the main checkout, reachable over
  loopback SSH). New Task 3 widens it to refuse any same-host
  source/destination mismatch, extracted into a testable
  assert_safe_same_host_deploy in scripts/lib/common.sh and pinned by
  tests/production-quality/deploy-guard-same-host.sh. The checkpoint task
  is renumbered Task 3 -> Task 4 accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:07:07 -04:00
archipelagoandClaude Opus 5 6989b1d387 docs(13-11): retarget plan to in-repo aiui/ (D-19)
Mechanical path swap: useArchy.ts now lives at aiui/packages/app/... in
this repo (D-19), not the old separate clone. Drops the separate-branch/
push language. Verified paths and referenced symbols still exist and at
essentially the same line numbers post-subtree-import; task content and
must_haves are otherwise unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:06:04 -04:00
archipelagoandClaude Opus 5 97aa758d7d docs(13-06): retarget plan to in-repo aiui/ (D-19)
Mechanical path swap: AIUI's composables now live at aiui/packages/app/...
in this repo (git subtree import, D-19), not at the old separate clone
/home/archipelago/Projects/AIUI. Drops the "separate development branch to
push" language accordingly. Verified every retargeted path exists on disk
before rewriting; task content and must_haves are otherwise unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:05:47 -04:00
archipelagoandClaude Opus 5 e1624dda08 feat(aiui): receive a Cmd+K handoff and prefill the composer
Completes the 'Talk to AIUI about it' path from the other side. archyBridge
gains a chat:prefill case behind its existing parent-origin validation, and
ChatInput prefills + focuses with the caret at the end.

Prefills rather than auto-sends: the operator sees and can amend the question
before it costs a model call, and a draft they had already started is never
clobbered by a background handoff. Auto-send is the natural seam for the
follow-up that actions things directly.

The bridge buffers a prefill that arrives before the composer mounts (collapsed
chat, mobile content tab) and replays it on registration, so a Cmd+K ask into a
cold frame is not silently dropped. onPrefill returns an unsubscribe so a
remounting composer cannot leak a stale handler.

Verified: vue-tsc clean; AIUI suite 332 passed. The 3 remaining failures
(seed-songs extraction x2, web-search system prompt) are pre-existing — I
confirmed by reverting 13-01's two AIUI files to their parent state and
reproducing the identical 3 failures without any phase-13 change present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:50:23 -04:00
archipelagoandClaude Opus 5 0de67ca6ae feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app
ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175
Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so
served on every host address. Same bug class as the /lnd-connect-info
and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app.

LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>,
so this is one gate rather than four. It lives in the daemon rather than
a per-app sidecar (umbrel's app_proxy model): rootless, no extra
container per app, and it can reuse machinery that already exists.

It invents no authentication policy. verify_password, TOTP secret
decryption, verify_code with used-step replay protection, the session
store, and — importantly — the SAME LoginRateLimiter instance as the
JSON-RPC path, so an attacker cannot get a fresh budget of password
guesses by moving to an app port. Only the transport differs, an HTML
form instead of JSON-RPC, because a browser being sent to an app cannot
speak JSON-RPC.

2FA comes for free: a session still pending its TOTP step fails
validate(), so the gate rejects it without knowing what a second factor
is.

Details worth keeping:
- 401, not a redirect. A redirect to a login page is indistinguishable
  from the app itself redirecting, and machine clients would follow it
  and parse HTML as their API response.
- Cookie and Authorization are stripped before proxying. The app has no
  use for the node session and must never be able to log or forward it.
- The challenge page names and pictures the app being opened, so the
  visitor can confirm what they are authenticating to.
- device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for
  machine clients. None = node-wide, which every existing companion
  token is; migrating them by guessing a scope would silently revoke
  access nobody asked to revoke. An empty list is rejected rather than
  minted, since it reads as unrestricted while authorising nothing.

The rollout is necessarily per-app and the gate is built to say so. A
container publishing 0.0.0.0:<port> claims every host address, so the
gate cannot bind that port until the app is pinned to bind: 127.0.0.1
and recreated — gate-first is impossible, and all-at-once would recreate
every container on a node simultaneously. Every port it cannot claim is
logged at warn each sweep and recorded in GateStatus::unprotected,
surfaced by security.app-gate-status. The failure mode being designed
against is a gate that binds nothing, logs at debug, and reports success
while every app stays exactly as open as before — worse than no gate,
because it stops anyone looking. Same reasoning that ruled out an
nft drop-in, whose absence is a silent no-op.

Not yet done: pinning the 39 gated ports to loopback, repointing
HiddenServicePort at the gate, and on-node verification.

Tests: 21/21 appgate, workspace builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:46:23 -04:00
archipelagoandClaude Opus 5 97b3707b5e feat(spotlight): offer 'Talk to AIUI about it' for the typed query
Cmd/Ctrl+K search could only match text against known screens; anything it
did not recognise dead-ended at 'No results'. That text is now handed to the
assistant instead: a blue accented row (chat-bubble + sparkle) appears while
there is a query, always last in the keyboard order, so Cmd+K -> type -> Enter
reaches AIUI without the mouse. On a zero-match query it is the only option.

The prompt travels by postMessage, NOT as an iframe URL param. Chat.vue's
aiuiUrl is deliberately free of reactive dependencies so the iframe src stays
byte-identical and AIUI survives a tab switch (see the D14_FLAGS comment);
threading the question through the URL would reload AIUI and discard the
conversation on every ask — the opposite of the intent. Two regression tests
pin this: the src is byte-identical across an ask, and ask/askedAt are
stripped afterwards so a refresh cannot silently re-ask.

The ask is queued and flushed on AIUI's 'ready' handshake, because arriving
from Cmd+K on a cold Chat tab means the iframe has not connected yet.

AIUI-side receiver lands separately; until then this posts a message AIUI
ignores, which is inert rather than broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:45:33 -04:00
archipelagoandClaude Opus 5 63d0183dd2 fix(ui): stop the dashboard cards leaving a backdrop-filter seam
A vertical line crossing both dashboard cards, appearing at random on
hover and hard to catch deliberately.

Diagnosed from the screenshot rather than by reproduction. Decoding it
and scanning column by column found a lone brightness step at CSS x=633
that never returns — every legitimate container edge in the page shows
up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850,
875, 1437), so an unpaired one is not a border. Sampling by region
placed it inside the cards and nowhere else: 10/13 rows inside My Apps,
11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same
screen x in both cards, which means the boundary lives in screen space
and cuts whatever backdrop-filter surface it crosses.

style.css already neutralises backdrop-filter for the shared glass
classes inside the dashboard's animated perspective/scroll containers,
because Chromium/Brave mis-rasterise it there — that block was written
for the black-rectangle corruption. `.home-card-shell` declares its own
`backdrop-filter: blur(18px)` in Home.vue and was never added to the
list, so it was the only unmitigated blur surface on the dashboard.
That is exactly the set of pixels the seam appears in. A hover repaint
re-rasterises part of the backdrop, and the refreshed half meets the
stale half at the damage boundary.

Adding it to the existing list also makes the shell consistent with the
tiles beside it: its fill is already rgba(0,0,0,0.65), the same as
.glass-card, which renders unblurred here.

The list is hand-maintained, which is how this shipped — a component
declaring backdrop-filter in its own <style> is simply not covered and
nothing fails. So the fix comes with a test that parses Home.vue for
locally-declared backdrop-filter rules and asserts each is in the
mitigation list. Verified it catches the real bug: reverting the
one-line fix makes it fail naming `.home-card-shell`.

Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:45:32 -04:00
archipelagoandClaude Opus 5 3015a8acf3 docs(13): record D-19 (AIUI in-repo), close window 17, pause phase after wave 1
D-19 supersedes D-15's two-repo premise and voids D-18. Flags 13-06/13-09/13-11
as needing a re-plan against aiui/ before wave 2 runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:17:32 -04:00
archipelagoandClaude Opus 5 b749470b96 merge(13): AIUI source migrated in-repo — supersedes the two-repo split (D-15/D-18)
Brings AIUI's full 230-commit history under aiui/ via git subtree, plus main's
current head. Operator decision 2026-08-03: AIUI moves into this repo rather
than staying at git.tx1138.com. This also lands e30ac1d (13-01 Task 3), which
was stranded local-only while that remote was unreachable.

Plans 13-06, 13-09 and 13-11 still target /home/archipelago/Projects/AIUI paths
and must be re-planned against aiui/ before wave 2 runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:11:26 -04:00
archipelago 7ba3109b6d Add 'aiui/' from commit 'e30ac1d1069532fb6d652d87e2d4a2fe9d1b4773'
git-subtree-dir: aiui
git-subtree-mainline: 0c4826f8cc
git-subtree-split: e30ac1d106
2026-08-03 15:07:11 -04:00
archipelagoandClaude Opus 5 70e6907220 merge(13-02): session-gated model forwarder; Python sidecar + OpenRouter relay retired
Tasks 1-2 only — Task 3 (real-node proof) halts at a blocking human-verify gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:02:48 -04:00
archipelagoandClaude Opus 5 6d4bf42a08 docs(13-02): SUMMARY for Tasks 1-2 — halting at Task 3's blocking checkpoint
Tasks 1 (session-gated model forwarder, 97921d99) and 2 (retire the Python
sidecar/OpenRouter relay, b28cc3ee) are committed, cargo build --package
archipelago succeeds, and all 5 model_proxy:: unit tests are confirmed
passing (via direct execution of the compiled test binary, since a fresh
`cargo test` invocation was too slow to complete under severe host resource
contention — see the SUMMARY's Issues Encountered for the full account).

Task 3 (checkpoint:human-verify, gate="blocking" — real-node curl/systemd
proof, S-15) is intentionally NOT executed. Per the plan and this
executor's instructions, it halts here and returns a structured checkpoint
rather than self-approving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:59:28 -04:00
archipelagoandClaude Opus 5 5bd39eedad merge(13-01): AIUI tracer slice — assistant spine + chat over the origin-checked bridge
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:55:09 -04:00
archipelagoandClaude Opus 5 e169beb6f7 docs(13-01): record self-check results in SUMMARY
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:52:28 -04:00
archipelagoandClaude Opus 5 6efe42d3ae docs(13-01): complete AIUI tracer-slice plan — SUMMARY + defect ledger
Records the continuation ground-truth review of WIP checkpoint 6ba52b22,
the atomic per-task re-commit (fe6ccff7 Rust spine, 0ab9bdc7 neode-ui
broker), and the external-repo Task 3 commit (AIUI e30ac1d, not yet
pushed). Logs two open WINDOWS.md items: the cargo test run that never
completed under machine resource contention (id 16), and the AIUI push
blocked by an unreachable git.tx1138.com (id 17).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:52:02 -04:00
archipelagoandClaude Opus 5 b28cc3eeaa fix(13-02): retire the Python model-proxy sidecar and the OpenRouter open relay
Closes the live production exposure this plan targets: /aiui/api/claude/
and /aiui/api/ollama/ proxied straight through with "no session gate
needed", and /aiui/api/openrouter/ was a plain unauthenticated relay to a
paid third-party API the node holds no key for (T-13-08/T-13-09/T-13-10).

image-recipe/configs/nginx-archipelago.conf (BOTH server blocks, ~line 49
and ~line 961 — a fix applied to only one leaves the exposure live on
whichever block serves the request, T-13-15):
- /aiui/api/claude/ and /aiui/api/ollama/ proxy_pass re-pointed from
  127.0.0.1:3142 / 127.0.0.1:11434 to the Rust daemon at 127.0.0.1:5678
  (no trailing path component, so the daemon's own prefix match sees the
  full request URI)
- Forward the session Cookie header to the daemon so it can re-derive auth
- location /aiui/api/openrouter/ deleted outright in both blocks
- Old comment "API key managed by proxy, no session gate needed" (the
  reasoning error that produced the exposure) replaced with rationale

scripts/deploy-to-target.sh: deleted the embedded claude-api-proxy.py
heredoc, its systemd unit creation/enable/restart, the ANTHROPIC_API_KEY
extraction, and the 3141->3142 sed fixups. Added an unconditional step that
stops/disables/removes any pre-existing claude-api-proxy unit and deletes
/opt/archipelago/claude-api-proxy.py and
<data_dir>/secrets/claude-api-proxy.env on every deploy — so
already-provisioned nodes actually lose the old unauthenticated listener,
not just newly-deployed ones.

scripts/setup-aiui-server.sh: dropped the hard ANTHROPIC_API_KEY
requirement and the patch-nginx-claude.py step; the script's remaining job
is the AIUI dist rsync. (Also drops the FileBrowser-fix step that lived
here — that logic already exists, and is kept, in deploy-to-target.sh; this
script narrows to exactly what its rewritten header now says it does.)

core/archipelago/src/api/rpc/system/handlers.rs: `claude_api_key` setting
branch no longer writes a second key copy to secrets/claude-api-proxy.env
or restarts claude-api-proxy. secrets/claude-api-key (0600) remains the
single ledger, with a comment naming it as such.

`cargo build --package archipelago` succeeds. Verified via grep against
every acceptance criterion in 13-02-PLAN.md's Task 2 (openrouter count 0,
3142 gone from nginx, both location blocks present, PORT=3142 gone,
claude-api-proxy gone from handlers.rs, secrets/claude-api-key present).
Task 3 (real-node curl/systemd verification, S-15) is NOT done in this
commit — see 13-02-SUMMARY.md.

Continues WIP checkpoint 13b576da (reset --soft, recommitted atomically
per task per plan protocol).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:44:25 -04:00
archipelagoandClaude Opus 5 97921d9932 feat(13-02): session-gated model forwarder for /aiui/api/claude and /aiui/api/ollama
Adds core/archipelago/src/api/handler/model_proxy.rs: a Rust-daemon handler
that re-derives session auth from the request's own cookie (does not trust
nginx to have gated it already) before forwarding to Anthropic's Messages
API or local Ollama. Replaces the unauthenticated claude-api-proxy.py
sidecar (port 3142, its own ANTHROPIC_API_KEY copy) that let anyone who
could reach the node's web port spend the owner's API budget
(T-13-08/T-13-09/T-13-11).

- Unauthenticated/invalid-session requests get 401 before any upstream call
- Missing key ledger (data_dir/secrets/claude-api-key) returns 503 with a
  plain-language body, never 500, never the key path
- Inbound authorization/x-api-key/cookie headers are never forwarded
  upstream (T-13-14) — only content-type/accept survive the round trip
- Response streamed through rather than buffered, matching proxy.rs's
  peer-content streaming shape, so token-by-token replies still stream
- No log line at any level references a body or a key (AI-SPEC §7b)
- Wired into api/handler/mod.rs's path dispatch alongside the WebSocket
  auth-gated arms, matching the existing is_authenticated idiom

Tests (model_proxy::tests): claude_without_session_is_401,
ollama_without_session_is_401, claude_with_invalid_session_is_401,
missing_key_is_503_not_500, inbound_authorization_header_is_not_forwarded.

`cargo build --package archipelago` succeeds. `cargo test --package
archipelago model_proxy::` was still compiling (test-binary link step) when
this commit was made — see 13-02-SUMMARY.md for the honest status.

Continues WIP checkpoint 13b576da (reset --soft, recommitted atomically
per task per plan protocol).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:41:29 -04:00
DorianandClaude Opus 5 e30ac1d106 feat(app): delegate chat to Archy's node-side assistant loop when embedded (Archy phase 13, D-01/D-17)
Adds archyBridge.sendChat(text) built on the existing postToParent +
origin-validated listener pattern (same request-id correlation as
requestContext), with a 180s timeout matching the node's
ASSISTANT_HTTP_TIMEOUT. Adds useAI.ts's streamViaArchy, which branches all
three existing send sites on the same __AIUI_EMBEDDED__ signal useArchy.ts
already reads: embedded mode delegates the model call, the tool-calling
loop and the model key to the node; standalone mode is untouched and keeps
using streamClaude/streamOpenRouter with AIUI's own dev proxy (D-17).

CLAUDE_PATH/OPENROUTER_PATH are not removed — 13-02 changes what those
paths resolve to on a node, 13-09 retires them.

Verified: vitest run 332/335 passing (3 pre-existing failures confirmed via
a scratch worktree at the prior HEAD, unrelated to this change — seed
extraction count assertions and a web-search-integration body.webSearch
assertion); vue-tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:38:19 -04:00
archipelagoandClaude Opus 5 b8634baf94 chore(13): execute plans sequentially — this box thrashes on parallel Rust builds
4 cores, load 35, 15G of 23G swap in use, rustc at 8.3G RSS while a live
node (bitcoind/electrumx/lnd) shares the machine. Two concurrent cargo
builds in separate worktrees (no shared target dir) made wave 1 crawl for
over an hour with zero commits. Wave 2 has four plans, so this would have
gotten worse before it got better.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:37:43 -04:00
archipelagoandClaude Opus 5 0ab9bdc7d3 feat(13-01): neode-ui carries chat over the existing origin-checked bridge
Adds chat:request/chat:response to the AIUI postMessage protocol
(AIUIChatRequest, ArchyChatResponse) and a handleChatRequest handler in
contextBroker.ts that calls assistant.chat over rpcClient on the page's own
session, then posts the result back through the existing postToIframe
helper. Reuses the broker's existing allowedOrigin guard unchanged — no
second postMessage channel, no relaxed origin check.

No permission category is threaded through the chat handler on purpose:
authority is resolved node-side from CallerScope (Task 1), and duplicating
a browser-side gate here would recreate the second, divergent security
model D-02 exists to prevent. tool-call is deliberately NOT added to
AIActionType — tool selection stays node-side by D-01/D-03.

On RPC failure the handler posts only the error message, never the raw
exception object.

Verified: contextBroker.test.ts (16/16) and chatAiuiEmbed.test.ts (7/7)
green; vue-tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:37:41 -04:00
archipelagoandClaude Opus 5 fe6ccff73c feat(13-01): Rust assistant spine — one curated tool, one backend, one RPC surface
D-01/D-02/D-06 tracer slice: a new crate::assistant module (CallerScope,
PermissionCategory, ToolExecCtx, chat()) runs a multi-turn tool-calling loop
(run_loop/execute_tool, MAX_TURNS=8) against a curated single-tool registry
(system_disk_status, hand-written JSON Schema — no schemars) via a Claude
Messages API backend. execute_tool is the single choke point: unknown tools
are refused not ignored, D-16 category grants are re-checked even though the
system prompt already omits ungranted tools, and every real tool dispatches
through the SAME handle_system_disk_status RPC handler every other
authenticated caller uses (assistant_dispatch_tool bridge in
api/rpc/assistant_chat.rs) — never an AI-only backdoor.

assistant.chat is registered in dispatcher.rs as a single guarded
`m if m.starts_with("assistant.")` arm reached only after the existing
session-cookie + CSRF + role.can_access() gate in api/rpc/mod.rs — asserted
directly by assistant_methods_require_session against the live
UNAUTHENTICATED_METHODS list (visibility only widened to pub(crate) for that
assertion; the list's contents are untouched, per the Phase-10 hard
constraint).

Key read from data_dir/secrets/claude-api-key — the same path
mesh/rpc/mesh/assistant.rs already probes — never a second key location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:37:16 -04:00
archipelagoandClaude Opus 5 0c4826f8cc feat(security): declare which app ports may skip authentication
Groundwork for the app gate (item 1): before anything can enforce
authentication on app ports, the node has to know which ports are
*supposed* to be reachable without it.

`PortMapping` grows `auth` (PortAuth::Session | None, defaulting to
Session) and `auth_rationale`. The default is deliberately the protected
one. Every app port on this node answered with no credential at all over
LAN, Tailscale, Tor and the FIPS mesh alike — reproduced 2026-08-03 —
precisely because exposure was what you got by saying nothing. Inverting
the default means a new app is protected unless its manifest argues for
an exemption.

Validation makes the argument mandatory: `auth: none` without a
rationale is rejected, and so is a rationale without `auth: none` (that
combination means the author wrote an exemption and did not get one —
shipping it silently would leave them believing otherwise).

17 ports across 12 apps are declared exempt, each with its reason. They
are the ports that cannot sit behind an HTTP login page at all: Lightning
p2p (BOLT-8 noise), LND gRPC/REST and CLN gRPC (macaroon / mutual TLS —
Zeus and remote wallets dial these directly), Bitcoin p2p gossip,
electrum wire protocol, Wyoming voice streams, git-over-SSH, and the UDP
discovery protocols (mDNS, SSDP, STUN). Everything else — 39 published
ports — now defaults to gated.

Bitcoin's RPC 8332 is deliberately NOT exempted: it is already
`bind: 127.0.0.1`, so the gate never sees it, and claiming an exemption
it does not need would put a line in the audit list that means nothing.
If the loopback bind is ever dropped, it fails closed.

Two corpus tests keep this honest: every shipped manifest must parse
under the new rules, and the exempt set is pinned at 17 so any change to
the node's unauthenticated surface has to be a deliberate edit.

Tests: 73/73 archipelago-container, workspace builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:51:15 -04:00
archipelagoandClaude Opus 5 24ce8b39e8 feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s
Promotion to Trusted is a privilege escalation — a Trusted peer can read
node state, be deployed to, and is exempt from the `!= Untrusted` gates
federation/DWN/messaging use. It must therefore cost a fresh proof that
the person at the keyboard is the operator, not merely that a session
cookie exists. Same reasoning as node.rotate-identity and TOTP setup,
both of which already re-verify.

Both entry points are covered:

- `federation.invite` gates on the RESOLVED level, not on an explicit
  request for Trusted: "Link Your Nodes" sends no `trust_level` at all
  and falls through to the Trusted default. The invite is a bearer grant
  of Trusted to whoever redeems it, so minting it IS the escalation.
  Observer invites are untouched.
- `federation.set-trust` gates only when the peer is not already
  Trusted, so the dropdown re-emitting its own value doesn't demand a
  password for a no-op.

Demotion is deliberately NOT gated: making something less privileged
must never be harder than leaving it alone, or the safe action becomes
the inconvenient one.

The backend is the sole authority on what counts as an escalation — it
returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and
retries only on that, so the rule lives in exactly one place and the
frontend never pre-judges. TrustPasswordModal.vue (modelled on
RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps
back to the node's real level on change, since a cancelled or failed
promotion would otherwise leave the dropdown displaying a level the node
never accepted.

The operator path stamps TrustSource::Manual; set_trust_level grew an
`Option<TrustSource>` so automatic adjustments (the discovery-handshake
demotion safety net) pass None and leave the recorded provenance alone
rather than laundering an uninvited-join peer into looking approved.

Follow-up, deliberately out of scope: `federation.join` also reaches
Trusted when redeeming someone else's Trusted invite, with no re-auth.

Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:07:21 -04:00
archipelagoandClaude Opus 5 7ad0cdd195 docs(13): resume phase 13 execution after broken-pipe interruption
Wave 1 recovery: 13-03 was complete and is merged into the lane; 13-01 and
13-02 had uncommitted executor work rescued into WIP checkpoints and are
being continued in their existing worktrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:06:05 -04:00
archipelagoandClaude Opus 5 29b9c1e280 docs(13-03): record summary self-check result
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:41:00 -04:00
archipelagoandClaude Opus 5 bbd5be9dfc docs(13-03): add plan summary
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:40:03 -04:00
archipelagoandClaude Opus 5 f8987d12f3 docs(13): routstr protocol findings + coverage matrix from live probe
13-ROUTSTR-FINDINGS.md records the routstr_probe run against all three
docs.routstr.com default relays: zero kind-38421 events and zero
#d=routstr-provider fallback events in a 30s window each, with all
three relay connections succeeding (ruling out a connectivity
failure as the explanation). Every claim is labelled OBSERVED or
DOCS-ONLY per the plan's scope note; RESEARCH assumption A2 is
recorded as neither confirmed nor refuted, risk unchanged.

COVERAGE.md's three former "INTEGRATE — UNCONFIRMED" rows (tool
calling, Cashu payment header, Nostr provider discovery) are
downgraded to explicit opt-outs with dated, evidenced reasons — zero
rows retain unconfirmed-integration status. The Gate section states
13-13 may not proceed directly and must open with a checkpoint:
decision, which 13-13-PLAN.md's Task 1 already is (proceed-observed /
proceed-docs-with-probe-first / defer-with-residual) — no edit to
13-13-PLAN.md was needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:34:05 -04:00
archipelagoandClaude Opus 5 ea90ef05a5 feat(13): live Routstr Nostr probe — no shipped code path
examples/routstr_probe.rs subscribes to the docs-cited default relays
(damus.io, nostr.band, nos.lol) for kind-38421 provider announcements
plus a #d=routstr-provider fallback filter in case the kind number
drifted, then issues at most two unauthenticated GETs against any
discovered endpoint. Spends nothing: no Cashu token is ever built or
sent, no Authorization header, no Nostr event published, ephemeral
subscription key.

Reproduces (does not import) nostr_discovery.rs::build_nostr_client's
Tor-proxy-aware client shape, since this package ships no [lib]
target and an examples/ binary cannot reach binary-crate internals.

Live run against the three default relays (60s total wait budget)
found zero matching events under either filter — recorded honestly
as NO LIVE PROVIDER OBSERVED, exit 0, per the plan's "no provider
found is a first-class outcome" requirement. Full output feeds
13-ROUTSTR-FINDINGS.md in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:09:38 -04:00
archipelago 15774d266f docs(13): begin phase 13 execution on isolated lane 2026-08-03 11:29:25 -04:00
archipelago f0b71f86aa docs(13): declare AIUI-01..06 in the canonical ROADMAP requirements line 2026-08-03 11:19:26 -04:00
archipelago 223afc26f7 docs(reqs): register AIUI-01..06 for phase 13 traceability 2026-08-03 11:18:33 -04:00
archipelago eb224709d7 docs(13): create phase plan — 15 plans in 8 waves 2026-08-03 11:16:09 -04:00
archipelagoandClaude Opus 5 60625499ae fix(13): make D-13 track independence real in the wave graph
Plan-checker revision iteration 1 — 1 blocker + 2 warnings.

BLOCKER (context_compliance, D-13): the music track blocked phase
completion despite being locked as non-blocking. 13-15 depended on
13-11, which chains back through 13-07 to 13-04, so the phase could
not close without the entire music chain. Took the checker's option
(b): 13-15 depends_on is now ["13-06","13-09","13-14"] — 13-06 added
so the content-grid check stays a real gate, 13-11 dropped so no path
reaches 13-04/13-07/13-11. UAT step 7 is now content-only and blocking;
new step 7b is the music view as record-and-defer, the same shape step
10 already used for Routstr. Verified: 13-15's transitive closure
contains no music plan.

WARNING (scope_reduction): T-13-32 claimed the filebrowser-client.ts
JWT-in-query-string leak was "fixed" while only guaranteeing it was not
propagated. Now actually fixed — streamUrl returns a query-free
same-origin URL and relies on the path=/ cookie login() already sets;
filebrowser-client.ts and a new regression test are in 13-06's
files_modified. T-13-32 is scoped to new code; new T-13-39 owns the
pre-existing leak and names the residual (the JWT is still 24h, now
confined to the cookie jar).

WARNING (verification_derivation): the edge-probe reconciliation did
not match the files. Corrected in 13-VALIDATION.md — 10 probe findings
vs 9 edge entries kept apart, 13-07's 3 truths retagged as authored
rather than probe-surfaced. No truths deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:12:06 -04:00
archipelagoandClaude Opus 5 203966030b docs(13): create phase plan — 15 plans in 8 waves
AIUI conversational node control & content surfaces, decomposed tracer-first:
13-01 leads with one end-to-end read-only tool proving the whole spine
(AIUI chat -> postMessage -> authenticated RPC -> Rust agent loop -> real
node data), then expands.

Waves 1-8 across three tracks that stay independent per D-13:
- control/assistant: 13-01, 13-05, 13-08, 13-10, 13-12, 13-13, 13-14
- content: 13-06
- music library: 13-04, 13-07, 13-11 (no control/content plan depends on it)
- security & delivery: 13-02, 13-03, 13-09
- on-device sign-off: 13-15

Notable decisions recorded in the plans:
- Open Q1: delete-and-replace the live unauthenticated port-3142 Claude proxy
  with a session-gated Rust forwarder; the OpenRouter open relay is removed.
- Open Q2: /aiui/-scoped CSP connect-src plus a per-session rate limit;
  the iframe sandbox attribute is explicitly rejected with reasons.
- Open Q3: a live Routstr spike (13-03) gates the Routstr backend (13-13).
- Open Q4: one "assistant." dispatcher prefix arm, so the existing
  session/CSRF/RBAC gate applies unchanged before dispatch.
- Promote (not add-alongside) CallerScope as the primary caller/permission
  noun; the mesh-specific controls become one variant's resolution inputs.
- schemars rejected as an unaudited crate; JSON Schema is hand-written.
- AI-SPEC's `cargo test --test assistant_evals` corrected to an in-crate
  module: core/archipelago is a binary-only crate with no lib target.

Also adds COVERAGE.md (Routstr capability matrix, every opt-out reasoned).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:42:25 -04:00
archipelagoandClaude Opus 5 e00c73ed2d docs(1.7.121): pause point — task list, status and resume notes
v1.7.120-alpha is shipped and verified; do not re-cut it. Two fixes landed
after it: federation trust escalation (c0cfc72a) and the lnd-ui OTA pin +
host networking (5088aef5).

The task file now carries a RESUME HERE block with the groundwork already
located for the next item (the password gate on granting Trusted) — the
exact helper, both entry points with line numbers, and the rule that
demotion stays ungated — so the next session does not repeat the search.

Paused here deliberately rather than starting the app-port auth work at
low context: it is the largest item, the operator asked for umbrelOS and
StartOS research first, and it is the same bug class as the leaks fixed
in v1.7.120 but across every app port and transport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:34:36 -04:00
archipelagoandClaude Opus 5 5088aef556 fix(lnd-ui): pin the image and host-network it so OTA actually updates it
Reported: Framework PT took the OTA and got the new bitcoin-ui but not
lnd-ui. Two causes, both in the update path rather than the app.

1. LND_UI_IMAGE was "lnd-ui:latest" while BITCOIN_UI_IMAGE was pinned to
   1.7.119-alpha. Podman does not re-pull a tag it already holds locally,
   so a node that ever pulled lnd-ui:latest keeps that copy forever and
   every subsequent release silently no-ops. Pinned to 1.7.119-alpha, so
   a version change is what triggers the pull — the same mechanism that
   made bitcoin-ui update correctly.

2. first-boot-containers.sh declared lnd-ui as bridge with -p 18083:80.
   docker/lnd-ui/nginx.conf listens on 18083 DIRECTLY (it must, to proxy
   the backend on 127.0.0.1:5678 same-origin), so that maps a host port
   onto a container port nothing serves — reproduced on-node as HTTP 000.
   This is the THIRD copy of the same declaration: container-specs.sh and
   apps/lnd-ui/manifest.yml were both already corrected, this one was
   missed, and it is the copy fresh installs use. Now host-networked with
   no published ports, matching its siblings and the other two copies.

The underlying hazard is that one container spec lives in three files
that can disagree; recorded as a follow-up rather than refactored here.

Also opens .planning/RELEASE-1.7.121-TASKS.md — every outstanding item
for the next release with its evidence, so nothing in a fast-moving queue
gets lost between sessions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:31:39 -04:00
archipelagoandClaude Opus 5 c0cfc72a05 fix(security): peers must not be able to grant themselves Trusted
Reported: "peers seem to be slipping into trusted status somehow which is
absolutely terrible for security". Two independent fail-open paths, both
granting Trusted with no operator decision anywhere in the loop.

1. federation.peer-joined is UNAUTHENTICATED (middleware's no-session
   list — federated peers call it over Tor without cookies) and reachable
   on /rpc/v1, which is peer-allowed. It does verify an ed25519 signature,
   but against THE PUBKEY THE CALLER SUPPLIED, so it proves the caller
   holds its own key and nothing about whether we ever invited it. A join
   presenting no invite_token fell through to

       None => TrustLevel::Trusted.min(claimed_trust)

   and claimed_trust itself defaults to Trusted when the field is absent.
   So anything able to reach the node could generate a keypair, omit the
   token, and be recorded as Trusted. Now capped at Observer: an invite
   WE minted is the only path to Trusted. `min` is kept so a peer's own
   lower claim is still honoured — this can only ever reduce trust.

2. merge_transitive_peers added every peer advertised by a Trusted source
   as Trusted. That makes trust viral rather than transitive-by-one-hop:
   the merged node is itself synced with, its peers merged in turn, so a
   single invite anywhere in the graph eventually marked the entire graph
   Trusted on every node. Now Observer — which is what this feature's own
   spec always said. NodeStateSnapshot.federated_peers is documented as
   "adds them as Observers on her side… doesn't auto-promote Observer-via-
   Bob to Trusted". The code contradicted the comment directly above it.

Observer is deliberate rather than Untrusted: the merge exists for
routing, and Observer still passes the `!= Untrusted` gates that
federation, DWN and messaging actually check, so a legacy peer degrades
instead of breaking. Per the operator's decision, existing peers are NOT
auto-demoted — silently rewriting live trust relationships across the
fleet would be worse than the bug.

Instead they are made auditable: FederatedNode.trust_source records WHY a
level was granted (invite | uninvited-join | transitive-merge | manual).
It deliberately has no default provenance — None means "recorded before
this existed", which is exactly the population worth reviewing.

The one failing test was asserting the vulnerable behaviour
(merge_transitive_peers_skips_source_and_local_node expected Trusted); it
now asserts the security property and says why, so the escalation cannot
be reintroduced by making a test go green.

Verified: 42/42 federation tests, cargo check --all-targets clean.

Still open, tracked in .planning/RELEASE-1.7.121-TASKS.md: surface
trust_source in the UI, and require the node password to grant Trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:31:16 -04:00
archipelago ff2cb5aa0e docs(13): add pattern map 2026-08-03 09:54:47 -04:00
archipelago 72b07fbe9c docs(13): generate AI-SPEC.md — hand-written Rust agent loop + domain context + eval strategy 2026-08-03 09:47:55 -04:00
archipelago d3ee5486ab docs(13): add validation strategy 2026-08-03 09:23:10 -04:00
archipelagoandClaude Opus 5 7134ae903d docs(13): research phase domain — AIUI conversational control
Verifies the AIUI-01 gating question against source (no tool-calling
anywhere in this codebase today; Pine's HA intents are read-only Q&A,
not an action-executing loop), surfaces a live unauthenticated
Claude-proxy exposure (port 3142) and a same-origin iframe sandbox
gap not previously named, and maps existing Cashu/Nostr primitives
onto the Routstr integration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:20:09 -04:00
archipelagoandClaude Opus 5 3b8ac7cb1c docs(state): v1.7.120-alpha shipped and verified live
Records the two release-process traps for the next cut: the manifest is
committed before signing (so the fleet would refuse the OTA), and
gitea-vps2 is the same server as gitea-ai with a dead token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:58:43 -04:00
archipelago 7f9dd2172d docs(state): record phase 13 context session 2026-08-03 08:57:51 -04:00
archipelago 48c7f5d02f docs(13): capture phase context 2026-08-03 08:57:42 -04:00
archipelagoandClaude Opus 5 4a5588c59a chore(release): commit the signed v1.7.120-alpha manifest
create-release.sh builds and commits the manifest BEFORE the signing
step, so the release commit carried an UNSIGNED manifest. Nodes fetch
releases/manifest.json from branch main and refuse to auto-apply an
unsigned one, so publishing without this would have shipped an OTA the
fleet silently declines.

Signature verified against the pinned release root before committing:
  signed_by did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur

Cargo.lock carries the 1.7.120-alpha version bump from the release build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:46:01 -04:00
archipelago 9de0a17670 chore: release v1.7.120-alpha
Demo images / Build & push demo images (push) Successful in 3m30s
2026-08-03 08:37:25 -04:00
archipelagoandClaude Opus 5 0fec507af3 docs(roadmap): add Phase 13 — AIUI conversational node control and content surfaces
AIUI is embedded and styled but not functional: the chat cannot act on the
node and its content views are not wired to real data. Phase 13 scopes making
it work — Pine's human-language intent->action capability reachable from typed
chat, conversational settings, and the peer-files/music/movies/node-content
surfaces rendered live.

The gating requirement is AIUI-04: a user-granted capability sandbox. An LLM in
the browser is now adjacent to wallet keys, macaroons and node identity, so
secrets stay server-side behind scoped tokens, capability grants default closed
and stay revocable, destructive operations need a human confirmation, and
peer-supplied text is treated as untrusted input to the model context. This must
not widen the Phase 10 hard-refuse gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:34:18 -04:00
archipelagoandClaude Opus 5 6a617f3a6c docs(whats-new): add the v1.7.120-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m45s
Generated by scripts/sync-whats-new.py, which the release gate checks.
Without it the Settings > What's New modal would have skipped straight
from v1.7.119 to v1.7.121 — the release notes users actually read, as
opposed to CHANGELOG.md which they do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:36:37 -04:00
archipelagoandClaude Opus 5 b15b160294 style: rustfmt the code added in 01-04 and the reconcile fix
The release gate's cargo-fmt stage failed on my own additions — the
tests in message_types.rs and lnd/info.rs and the reconcile branch in
prod_orchestrator.rs were written programmatically and never passed
through rustfmt. Formatting only; rustfmt is semantics-preserving and
the gate re-runs the suites before building.

Caught by the gate rather than in review, which is the gate working. Also
a reminder that a piped command's exit code is the pipe's, not the
script's: the task notification reported success while the log said
CREATE_RELEASE_EXIT=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:34:23 -04:00
archipelagoandClaude Opus 5 41de23e71f docs(requirements): mark UIFIX-04 and UIFIX-06 complete
Bookkeeping left uncommitted by an earlier session. It records work that
is already shipped — bc9a210c routed Paid Files pictures/videos into the
app lightbox with a visible wait — so the checklist and the mapping table
were simply lagging the code.

Landed as its own commit rather than swept into the release: it is not my
edit, and the release script refuses to run with a dirty tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:32:17 -04:00
archipelagoandClaude Opus 5 57891099a4 docs(changelog): record the stuck-nav fix for v1.7.120-alpha
States plainly that the speed is unchanged — the fix gates the teleported
chrome, not the KeepAlive caching that made tab switching instant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:28:26 -04:00
archipelagoandClaude Opus 5 a5b923fa0b fix(ui): teleported nav must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m30s
Reported: the nav above the bottom bar — back buttons, the mesh tabs —
stayed stuck across other screens.

Cause is the KeepAlive work from phase 2, and specifically the half of it
that is invisible from the view's own file. Main tabs are KeepAlive'd, so
navigating DEACTIVATES a view instead of unmounting it. Content the view
Teleports to <body> is not in the view's DOM subtree, so deactivation
does not remove it and it keeps rendering over the destination screen.

Two offenders, matching the report exactly:

- Mesh.vue teleports its mobile TAB BAR and its chat BACK BUTTON to
  <body>, gated only on `mobileShowChat` — never on whether Mesh was the
  screen you were looking at.
- components/BackButton.vue teleports the shared mobile back button with
  NO gate at all, so it leaked out of every view that uses it. Fixing the
  shared component fixes every caller at once: Vue propagates
  activated/deactivated from the KeepAlive boundary down through the
  subtree, so a child can guard itself.

BaseModal already solved the transient-dialog half of this class in
204d4523 by closing on route change. That is the right fix for a dialog
and the wrong one for chrome: a tab bar has no "closed" state to fall
back to, and forcing one would lose the user's place. New
useViewActive() composable instead — chrome is simply not rendered while
its owner is off screen, and returns exactly as it was.

THE PERFORMANCE IS NOT SACRIFICED, which was the explicit constraint.
The Teleport is gated, not the view, so the instance stays cached and
revisiting a tab is still instant. A test pins this: setup() must run
exactly ONCE across a navigate-away-and-back round trip. If someone
later "fixes" this by dropping KeepAlive, that test fails.

Deliberately untouched: AppSession.vue, whose teleport is load-bearing —
its own comment records that moving the iframe node reloads the app, and
app-session is excluded from KeepAlive anyway so it cannot leak. Toasts,
the app launcher and the connection banner are app-level rather than
view-owned; gating those would be wrong.

Verified: 3 new tests; full suite 105 files / 848 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:55:42 -04:00
archipelagoandClaude Opus 5 b945738d62 docs(state): record v1.7.120-alpha staging and its on-node verification
Includes what was NOT verified — the torrc block is deployed but dormant,
since regenerate_torrc only fires on a Tor services change and the change
is inert until bitcoind gets an -onion flag in Phase 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:18:47 -04:00
archipelagoandClaude Opus 5 4d67f56bc4 docs(changelog): curated notes for v1.7.120-alpha
Leads with the reason to take the update: two ports handed anyone who
could reach them full control of the node's money. Written for an
operator, not a developer — what was exposed, who could reach it, and
what to treat as compromised.

Includes the gaps rather than burying them: the 5x lifecycle gate was not
run, two fleet nodes still share SSH host keys (rotation is a deliberate
operator decision, not an oversight), and Core can now reach Tor but is
not yet routed through it.

create-release.sh hard-fails without this section, so it lands before the
release run rather than during it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:58:35 -04:00
archipelagoandClaude Opus 5 8ee81e04c9 docs(roadmap): add Phase 12 — Bitcoin node settings, Core/Knots parity
Per the operator: every option umbrelOS surfaces must be reachable in the
UI, Knots-only options surfaced separately from the ones Core shares, and
network mode a setting whose DEFAULT is Tor rather than clearnet.

Scoped as a phase rather than done inline because bitcoind's arguments are
currently hardcoded in three places (first-boot-containers.sh,
container-specs.sh, apps/bitcoin-knots/manifest.yml) — the same
triplication that produced the lnd-ui HTTP 000 defect. There is nowhere
for a UI to write, so BTCSET-01 is a settings model those three render
FROM, not another restatement.

Two constraints recorded up front so they are not discovered late:

- Knots-only flags gated to Knots is a CORRECTNESS requirement — offering
  one on Core yields a node that refuses to start.
- Several options are not freely reversible: txindex forces a reindex,
  prune is destructive and needs a full resync to undo. On a node that is
  somebody's wallet backend those must be labelled and gated, not
  silently applied. Any change at all restarts bitcoind, interrupting
  LND, electrs and the fedimint gateways.

Inbound onion is explicitly out of scope: it needs Tor's ControlPort,
which is deliberately disabled for security, so the node reaches .onion
peers but stays unlisted. The UI must say so rather than imply otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:50:37 -04:00
archipelagoandClaude Opus 5 f04941934b feat(tor): give archy-net containers a SOCKS path so Core can use Tor
Enabling half of "Bitcoin Core has no Tor proxy at all", handed over from
the app-UI work. Core reported `onion reachable=False, proxy=''` with all
11 peers on clearnet, and the reason was not a missing bitcoind flag: the
container sits on the archy-net bridge (10.89.0.0/24 here), so
127.0.0.1:9050 inside it is its OWN loopback. The host's Tor was
genuinely unreachable, and no flag on bitcoind could have fixed that
alone.

torrc now binds a second SOCKS listener on the archy-net gateway.

The gateway is DERIVED at runtime via `podman network inspect`, never
hardcoded: archy-net is created without an explicit subnet, so podman
allocates one. It is 10.89.0.0/24 on this node with no guarantee of that
elsewhere, and a hardcoded guess would fail silently — binding SOCKS to
an address no container can reach, which looks identical to working.

Two deliberate safety properties:

- FAIL CLOSED. If archy-net is absent or its inspect output does not
  parse, no second listener is emitted and SOCKS stays loopback-only. An
  exposure boundary is not something to widen on a guess.
- 127.0.0.1 is accepted FIRST in the SocksPolicy. SocksPolicy applies to
  every SocksPort, so an accept-list naming only the bridge subnet would
  have locked the daemon out of its own loopback SOCKS — breaking the
  node's Tor usage in a way that looks nothing like "we added a
  listener". The list is accept-loopback, accept-subnet, reject *.

This widens Tor SOCKS from loopback-only to the archy-net subnet, which
is a real change to the node's exposure surface and was explicitly
approved by the operator rather than assumed. Inbound onion for Core
remains impossible without reversing the deliberate "ControlPort disabled
for security" decision — this is outbound only, and the node stays
unlisted on Tor.

Not yet wired: bitcoind still has no -onion flag, because the operator
wants network mode to be a UI setting with Tor rather than clearnet as
the default. Hardcoding the flag in the three places that currently
define bitcoind's arguments would be the wrong shape for that, so it is
deferred to the settings work rather than done twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:49:00 -04:00
archipelagoandClaude Opus 5 fbec70069f fix(nginx): serve AIUI's absolute /assets/ requests from aiui/assets/
Taking over a parked item from the app-UI work. AIUI's built index.html
emits ABSOLUTE /assets/<hashed> paths, so the browser asks for
/assets/index-BC2fBBaW.js. That lands in the MAIN UI's assets dir, where
it does not exist — the real files are in aiui/assets/. Both of AIUI's
two entry assets 404'd, so the embedded sidebar loaded nothing.

The config already contained a /aiui-assets/ location whose comment names
this exact problem ("AIUI may reference /assets/ without /aiui/ prefix"),
but it only catches requests to /aiui-assets/, a path AIUI never asks
for. It described the bug without fixing it.

/assets/ now falls back to a named location that rewrites into
aiui/assets/ and 404s from there. A fallback rather than copying the two
files up one level, because a frontend deploy replaces web-ui wholesale —
update.rs preserves the aiui/ DIRECTORY, not copies made into assets/ —
so a copy is erased by the very next deploy while this survives one.

Both server blocks (HTTP and HTTPS) are patched; named locations are
per-server, so each needs its own.

Verified on archi-dev-box after reload:
  /assets/index-BC2fBBaW.js   200, 305256 bytes, application/javascript
  /assets/index-BJkaQ2c4.css  200, 150716 bytes, text/css
  /assets/does-not-exist.js   404  (the fallback is not over-broad)
  /assets/index--lyLAgu1.js   200  (real main-UI chunks still come from
  /assets/vendor-CmYeCqL_.js  200   the main dir — try_files hits them
  /assets/index-CiMaoNII.css  200   before the fallback is consulted)
  /  /aiui/  /health          200

Hash collision between the two builds is not a concern: Vite hashes are
content-derived, and any main-UI asset that exists is served by try_files
before the fallback runs.

Noted while doing this, not fixed here: the node's own
/etc/nginx/sites-enabled/archipelago is 378 lines BEHIND this repo file
(984 vs 1362) — it predates the IPv6 listener and the @asset_missing
no-store handling, among others. The node was patched minimally in its
own shape rather than overwritten, since a wholesale copy of a config
this diverged is not a safe unattended action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:40:34 -04:00
archipelagoandClaude Opus 5 c4aece4883 docs(01-04): on-node verification, and the send half is inert here
Deployed 6b3693dc to archi-dev-box off a clean tree and exercised the
real RPC surface. lnd.getinfo returns a real identity_pubkey through the
field that did not exist before this plan. mesh.lightning-peers returns
an empty array with success — the FED-05 empty edge proven on hardware,
not just in a unit test. Both refusal paths of mesh.send-lightning-info
observed live, including T-01-13's "no broadcast form".

The finding worth carrying to 01-06: this node's LND advertises no URI
(uris: []), so send-lightning-info correctly refuses rather than
shipping an empty advertisement a peer would store as an undialable
target. The receive and list halves work; the SEND half is inert on any
node whose LND has no externally reachable address. The picker must not
assume the local node always has something to share.

Corrects this summary's own earlier claim that nothing was exercised on
hardware — that was true when written and is not now. The mesh leg
proper (an advertisement crossing real RF into a peer's lightning_uri)
remains unproven and is still called out as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:24:16 -04:00
archipelagoandClaude Opus 5 6b3693dcc8 docs(state): 01-04 complete; Phase 1 resumed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:08:07 -04:00
archipelagoandClaude Opus 5 666990c684 feat(01-04): expose meshed Lightning peers and the send path over RPC (FED-05)
Task 3, completing 01-04.

mesh.lightning-peers returns the peers that have advertised a Lightning
URI: filtered, deduplicated, deterministically ordered, and an empty
array rather than an error when nobody has — "nobody yet" is a normal
state on a fresh node, not a fault.

mesh.send-lightning-info advertises this node's own URI to ONE chosen
peer. There is deliberately no broadcast form: this discloses the node's
payment endpoint, and who learns it is the operator's choice rather than
a side effect of being in radio range (T-01-13). It refuses to send when
LND advertises no URI, instead of sending an empty one a peer would
store as an undialable target.

The list-building and target-parsing logic is extracted into pure
functions because this file has no handler test harness and the
handlers need a live mesh service. That keeps the three contracts that
actually matter provable rather than merely readable:

- dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key,
  lowercased — never the firmware routing key, so a radio contact and
  its federation twin collapse to one entry (T-01-11)
- "newest advertisement wins" compares PARSED RFC3339 timestamps, not
  strings: 09:30-01:00 is later than 10:00Z while sorting earlier as
  text, and there is a test that fails if that is ever string-compared
- ordering is name-then-contact_id and asserted byte-identical across
  eight rotations of the input, because a HashMap's iteration order is
  not stable and a picker that reshuffles between reads means an
  operator can click a different node than the one they aimed at

The peer allow-list is untouched: server.rs has an empty diff and
is_peer_allowed_path still occurs 13 times (T-01-15).

Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy
--all-targets clean in every touched module (two useless_format lints in
the new test code fixed, not waived).

The SUMMARY records one deviation honestly: Task 1's tests were written
alongside its implementation rather than before, so no pre-implementation
failing output exists. A mutation test was run in its place — disabling
the pubkey validation fails 3 of the 5 tests — which proves the
assertions bind, and the mutation was reverted and verified gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:48:02 -04:00
archipelagoandClaude Opus 5 decb7c713b feat(01-04): the two Lightning facts the channel-open picker needs (FED-05)
Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh
message a peer uses to advertise theirs.

lnd.getinfo now deserializes identity_pubkey and uris, which its
response struct simply did not declare before (RESEARCH.md Pitfall 5).
The identity mapping is split into a pure map_identity() so it is
testable without a live LND. A pubkey that is not 66 hex characters maps
to None rather than being forwarded: the same rule lnd.openchannel
enforces, applied where the operator is reading their own node's
identity instead of at the moment they try to open a channel. An absent
field yields an honest absence — never a fabricated or placeholder
identity.

MeshMessageType::LightningInfo = 26 is additive on a wire format shared
with every fleet node: 26 was unused, so a peer that predates this fails
to decode it rather than mis-decoding it as something else. Its payload
is deliberately two fields — this rides LoRa, where every byte is paid
for on air, and the optional alias is skip_serializing_if so an absent
one costs nothing (asserted, not assumed).

is_valid_lightning_uri() validates before anything is stored, because
this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional
numeric :port, exactly one '@'. It deliberately does NOT resolve or dial
the host — that would turn a received advertisement into an outbound
connection an attacker chose.

Two preservation hazards found while wiring MeshPeer.lightning_uri, both
of which would have silently emptied the picker:

- decode.rs's identity-advert path does a WHOLESALE insert, preserving
  only advert_name and lat/lon by hand. Reticulum re-emits identity
  adverts every announce tick, so a stored URI would have been wiped
  about once a minute. Now preserved, alongside the same guard the name
  and position already had.
- session.rs's refresh_contacts and mod.rs's federation seeding rebuild
  the peer record wholesale too. Neither carries a Lightning datum, so
  both now carry the previous value forward rather than nulling it.

A malformed inbound URI is rejected before the write, leaving any
previously stored good URI intact — otherwise anyone in range could
blank out a real peer's picker entry (T-01-12).

Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new),
cargo check --all-targets clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:42:45 -04:00
archipelagoandClaude Opus 5 e205f2c34a docs(security): prove the delivery path on hardware; close window 15
Controlled test on archi-dev-box with operator approval. The daemon was
stopped first so the reconciler could not repair the state before the
re-exposure was confirmed — without a confirmed 200, the later 401 would
be consistent with the state never having been broken at all.

  1. stale conf installed + container restarted -> POST /bitcoin-rpc/
     returned 200 with a real block height and Allow-Origin: *
  2. daemon started 20:00:36, nothing else touched
  3. 20:02:19 reconcile rendered the conf and logged the expected warn
     line naming bitcoin-ui/archy-bitcoin-ui, then restarted it
  4. POST -> 401, Allow-Origin origin-scoped
  5. conf byte-identical to the pre-test known-good, container healthy

Both halves are now proven on real hardware: a05956c4's template (the
gate works) and f6b5245b's delivery path (the gate reaches a container
the reconciler had been skipping).

Also records the operator's decision AGAINST credential rotation — no
macaroon, no Bitcoin RPC password — with the trade it accepts stated
plainly, so it is not silently re-litigated later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:09:07 -04:00
archipelagoandClaude Opus 5 186a2c36c8 fix(lnd-ui): manifest declared bridge 18083:80 like the spec did
Second copy of the wiring fixed in aaa89789. apps/lnd-ui/manifest.yml still
declared network_policy: bridge with a 18083 -> 80 port mapping, while
docker/lnd-ui/nginx.conf listens on 18083 directly — it has to, so it can
proxy the backend on 127.0.0.1:5678 same-origin; the cross-origin fallback
is what broke this app on http-only nodes.

Publishing a host port to a container port where nothing listens is exactly
the failure reproduced on archi-dev-box when recreating from the matching
container-specs.sh entry: :18083 refusing connections, HTTP 000. Fixing
only the spec would have left the manifest as a live footgun for any code
path that provisions this app from its manifest instead.

Now host networking with an empty ports list, matching both what actually
runs and apps/bitcoin-ui/manifest.yml, which had it right all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:09:06 -04:00
archipelagoandClaude Opus 5 b2ed27dcfb fix(bitcoin-ui): send no-cache for index.html; pin the rebuilt image
Two things needed for the new UI to actually reach users.

The rendered nginx.conf served index.html with only ETag/Last-Modified and
no Cache-Control, so browsers applied heuristic caching to it. Confirmed on
archi-dev-box: after rebuilding and recreating the container, :8334 and
/app/bitcoin-ui/ both served the new markup immediately, but the app iframe
in the main UI kept showing the previous UI until a hard refresh.
docker/lnd-ui/nginx.conf has always carried this header, which is why only
bitcoin-ui showed the stale copy. Using "no-cache" (revalidate) rather than
"no-store" keeps the ETag doing its job when nothing has changed.

Validated by mounting the rendered config into a throwaway container from
the built image and running nginx -t. (An earlier attempt to test it inside
the running container was meaningless — conf.d/default.conf is a read-only
bind mount, so the copy failed and nginx -t just re-checked the original.)
The 8 container::bitcoin_ui tests still pass; their assertions cover the
placeholder, the 8332 proxy_pass and the listen directive, none of which
this touches.

BITCOIN_UI_IMAGE was still pinned to 1.7.84-alpha, so a fresh install would
pull a bitcoin-ui from many releases ago regardless of what the OTA ships —
first-boot-containers.sh tries the registry image before building from
source. Bumped to 1.7.119-alpha, matching the current release, and the
image is pushed under that tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:56:52 -04:00
archipelagoandClaude Opus 5 aaa89789d2 fix(lnd-ui,bitcoin-ui): OTA-breaking lnd-ui spec, 404 channels link, iframe copy, node URI
All four found by verifying on archi-dev-box rather than assuming.

container-specs.sh: archy-lnd-ui was specified as a BRIDGE container with
SPEC_PORTS="18083:80", but docker/lnd-ui/nginx.conf listens on 18083
directly (it must, to proxy the backend on 127.0.0.1:5678 same-origin).
Recreating from that spec publishes host 18083 to container port 80, where
nothing listens. Reproduced on the node: the app came back with :18083
refusing connections, HTTP 000. This never fired before because the running
containers are created by first-boot-containers.sh, which is host-networked
and never reads this file; the spec is only consulted when self-update.sh
rebuilds a UI image, and that only happens when a file under docker/lnd-ui/
changes — which is exactly what the previous two commits did. So the next
OTA would have taken lnd-ui down on every node. Now SPEC_NETWORK="host"
with no port mapping, matching what actually runs. NET_BIND_SERVICE dropped
with it: 18083 is unprivileged.

lnd-ui channels link: pointed at /apps/lnd/channels, but that route is a
CHILD of the /dashboard record in neode-ui's router, so the real path is
/dashboard/apps/lnd/channels. nginx's SPA fallback returns 200 for the
wrong path, so it failed as vue-router's NotFound view rather than an HTTP
404 — both the Payment Channels card and the Manage Channels button.

Both apps, copy buttons: navigator.clipboard only exists in a secure
context, and nodes serve these apps over plain http; the main UI also
embeds them in an iframe, where the async Clipboard API is separately gated
by the clipboard-write permission policy. Every copy button silently did
nothing there. Added an execCommand('copy') fallback behind a copyText()
helper and routed all six call sites through it.

lnd-ui Node ID: showed the bare pubkey whenever getinfo.uris was empty,
which is the common case — LND only populates uris once it is advertising
an external address. The bare pubkey is not what a peer pastes to open a
channel. The full pubkey@host:9735 URI is now built from the Tor onion
where available, falling back to this node's address, with a hint saying
which and what its reachability is. The QR encodes the URI too.

Verified on archi-dev-box: both images rebuilt and containers recreated
from the specs; lnd-ui and bitcoin-ui both serve 200 with the new assets;
and the RPCs the new tabs depend on all answer on the live node —
getblockstats returns every field the charts read, getpeerinfo returns 11
peers carrying relaytxes and network values the classifier handles.

Note for whoever tests bitcoin-ui's Insights/Peers tabs: /bitcoin-rpc/ now
sits behind auth_request /_session_check (a05956c4 et al), so it answers
401 to an unauthenticated curl by design. A logged-in browser sends the
session cookie same-origin, which is how those tabs get their data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:33:21 -04:00
archipelagoandClaude Opus 5 5c9d5dc424 docs(state): record the on-node verification outcome and what stayed open
Names the four open items explicitly, including the one that is easy to
lose: the reconcile fix is deployed but unexercised, so the node's 401
proves the template and not the delivery path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:22:49 -04:00
archipelagoandClaude Opus 5 5a21b58f69 docs(security): record what actually closed :8334, and what it does not prove
The node is closed and verified 401 with origin-scoped CORS. But an
unrelated bitcoin-ui rebuild at 18:36 cleared the stale conf before the
reconcile fix was deployed at 19:06, so the 401 proves a05956c4's
template and NOT the delivery path f6b5245b adds.

Window 14 closed (exposure gone, verified). Window 15 opened for the
delivery path, which is deployed but never exercised — bitcoin-ui is
still in the uninstall marker, so this node depends on that untested
path the next time its config has to change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:19:29 -04:00
archipelagoandClaude Opus 5 2684fa7cd1 docs(windows): close window 13 — host_secrets observed on a real node
system.stats on archi-dev-box returns host_secrets with verdict 'per-node'
and three evidence lines (machine-id anchor 2026-04-09; every SSH host key
and the TLS key newer than the anchor). Previously proven against the file
contract in unit tests only.

Honest limitation: this is one node, not the dev pair — archy-x250-dev has
been offline for two days, so the second node is unreachable, not skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:01:34 -04:00
archipelagoandClaude Opus 5 2e68384234 fix(lnd-ui,bitcoin-ui): canonical app icon, header stacking, square QR, button parity
Five review fixes across both node UIs.

1. LND now uses the app-store icon. It was shipping its own 182KB lnd.svg
   while the app store, My Apps and the signed catalog all render
   neode-ui/public/assets/img/app-icons/lnd.png (catalog.json points at
   /assets/img/app-icons/lnd.png). Same file is now vendored into the
   image, so the app header, the launcher and the store agree. That icon is
   a full-bleed square with an opaque white background rather than a
   transparent glyph, so it fills the frame and is clipped to the inner
   radius — exactly how bitcoin-ui frames its own icon — instead of being
   inset with padding on a dark plate.

2. Header no longer squishes at tablet widths. Both headers had a single
   768px breakpoint, so between 768 and 1024 the title and description got
   crushed against the controls on the right (four status cards on
   bitcoin-ui) and overlapped. Both now use three breakpoints: fully
   stacked and centred below 768, logo + title on one row with the controls
   wrapped underneath below 1024, single row above. The app name and
   description are centred on mobile.

3. QR codes stay square. .conn-layout is a flex row on desktop and flex
   items stretch by default, so the white QR plate was being pulled to the
   height of the fields column and the square QR sat letterboxed in it. The
   plate is now a fixed square inside a black glass panel that absorbs the
   extra height, so the panel matches the fields and the QR stays square.

4. Buttons read as one family. The Settings button and both modal dismiss
   buttons used the flat .glass-button while every other button on the page
   used .info-card-button; they now all use the latter, via new .compact
   (inline) and .icon-only (square) variants so the shared style works at
   button size rather than only as a full-width card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:00:31 -04:00
archipelagoandClaude Opus 5 ba493fb0fc docs(security): write up the Bitcoin RPC proxy that stayed open after it was fixed
The half that landed correctly (LND, clean 401) made the half that did
not harder to notice, because the first check an operator would run
returns a pass. Records the probes, the three-fact root cause, and the
pass condition for re-probing a node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:53:29 -04:00
archipelagoandClaude Opus 5 7f40fa1e93 docs(windows): record the live bitcoin-ui RPC exposure as window 14
Verified live on archi-dev-box, code fix committed in f6b5245b but not
deployed. Deliberately logged as open rather than fixed: the defect that
matters to an operator is the running node, not the source tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:45:33 -04:00
archipelagoandClaude Opus 5 f6b5245b0d fix(security): deliver config fixes to a running app the marker calls uninstalled
Found while VERIFYING a05956c4 on archi-dev-box rather than assuming it.
GET /lnd-connect-info is correctly 401 with no cookies over the LAN
address. But POST /bitcoin-rpc/ on :8334 still answered an
unauthenticated caller with a real block height, and still carried
`Access-Control-Allow-Origin: *`. The node looked patched. Half of it
was not.

The rendered /var/lib/archipelago/bitcoin-ui/nginx.conf was dated
2026-06-30 — the pre-fix version — even though the running binary
carries the new template. a05956c4's commit message claimed the
template "is re-rendered on every reconcile pass, so this ships
atomically with the binary". That is false in one specific state, and
this node was in it:

  1. bitcoin-ui sits in the durable user-uninstalled marker.
  2. reconcile returns on that marker BEFORE run_pre_start_hooks, which
     is what renders the config.
  3. The container keeps running regardless, because it is owned by
     systemd via a Quadlet unit (archy-bitcoin-ui.service, active,
     restarted 17:25 after the daemon restart) — not by this reconciler.

So a container systemd keeps alive, that the orchestrator has stopped
reconciling, never receives a config fix shipped inside the binary. An
OTA carrying a05956c4 would have silently failed to close this on every
node in that state, while the LND half closed correctly — the most
misleading possible outcome. archy-electrs-ui is in the same state on
this node, so it is not a one-app accident.

A container that is actually running is a live attack surface whatever a
marker says about it. Its security-relevant config is now reconciled
even behind the marker, and it is restarted so nginx actually loads it.

Deliberately narrow:

- Nothing is created, pulled, built, started or resurrected. The "must
  stay removed" contract only ever gets weaker if a container is ALREADY
  running, which by definition means it was never removed.
- A hook error is swallowed, not propagated: an app the user uninstalled
  must not be able to fail the reconcile pass for everything after it.
- The pre-existing marker test still passes unchanged, which is what
  proves the removal contract survived.

Verified: 11/11 reconcile tests and 9/9 bitcoin_ui tests pass, including
a new regression test that pins the whole chain — stale conf in, gate
present out, container restarted, nothing created.

No node has been touched. The live exposure on archi-dev-box stands
until this is deployed and the operator restarts archy-bitcoin-ui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:37:31 -04:00
archipelagoandClaude Opus 5 f4a323226e feat(bitcoin-ui): add Insights, Peers and Connect tabs for UmbrelOS parity
The Bitcoin UI was one long scroll: sync card, RPC/ZMQ cards and the
relay-sharing panel stacked on a single page, with node details hidden in a
modal. umbrelOS's rebuilt Bitcoin Node app splits the same surface across a
Home/Insights/Settings dock and shows considerably more.

Added, all of it additive — the sync state machine, the status-snapshot
staleness logic and the relay-sharing panel are untouched and just move
inside a tab panel:

- Five tabs (Node / Insights / Peers / Connect / Sharing) using the same
  segmented control as lnd-ui, which becomes a fixed bottom dock under
  768px with safe-area padding.
- Insights: the four stats umbrel's StatSummary shows (Connections,
  Mempool, Blockchain Size, Node Uptime), a Latest Blocks strip, and
  Block Size / Fee Rate / Block Rewards charts — the same three umbrel
  plots, drawn as CSS bars so nothing has to load a chart library past
  the CSP's script-src 'self'.
- Peers: sortable, filterable table with umbrel's exact columns (Peer,
  Network, Relay TXNs, In/Out, Connected) plus ping. Network is derived
  from getpeerinfo's own `network` field, falling back to address
  matching, so Tor/I2P/CJDNS/Local/Clearnet are labelled correctly.
- Connect: RPC and P2P details with a Local/Tor selector, QR codes and
  per-field copy buttons (umbrel's ConnectionDetails), including its
  unencrypted-LAN warning on Local.

Block statistics come from getblockstats, one call per block for the last
ten, cached by height so only the new tip is re-fetched. The live tabs poll
only while visible rather than adding a third unconditional 5s timer.

Also removed the hardcoded "archipelago123" from copyRPCInfo. That string
was never the node's actual RPC password — the real one is a
manifest-declared generated secret rendered into this app's nginx upstream
and deliberately never sent to the browser — so copying it could only ever
mislead. The Connect tab says where the password actually lives instead.

qrcode.js is vendored from docker/lnd-ui (same file, already CSP-clean) and
added to the Dockerfile's COPY set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:31:52 -04:00
archipelagoandClaude Opus 5 341bff4b44 feat(lnd-ui): rebuild the LND app UI with UmbrelOS feature parity
The LND UI was a single scrolling page with a flat gradient instead of a
background, a permanently-disabled "Wallet" card, and four tabs buried
inside a Settings modal. Everything a node runner actually wants to see —
routing revenue, peers, liquidity, activity — was absent.

Rebuilt against the feature set of umbrelOS's Lightning Node and Bitcoin
Node apps (getumbrel/umbrel-lightning, getumbrel/umbrel-bitcoin), rendered
in Archipelago's own idiom rather than copying their visual design:

- Real background. bg-web5.jpg was already being COPYd into the image by
  the Dockerfile and simply never referenced; it now drives the same
  perspective-layer + 0.8 overlay treatment bitcoin-ui uses. Paths stay
  relative because the app is served at / on :18083 but under /app/lnd/
  when proxied by the host nginx, where absolute /assets 404s.
- Glass cards with the masked gradient border, matching bitcoin-ui exactly.
- Six top-level tabs (Overview / Channels / Activity / Insights / Connect /
  Settings) replacing the one-page scroll — umbrelOS's Home/Insights/
  Settings dock, widened for Lightning. On mobile the bar becomes a fixed
  bottom dock with safe-area padding; on desktop it is a segmented control.
- Overview: total/lightning/on-chain balances, a Max Send vs Max Receive
  liquidity bar (Umbrel's framing), and peers/channels/capacity/routing
  stat tiles.
- Insights: routing revenue over 24h/7d/30d from /v1/fees, channel totals,
  network graph stats, and a sortable+filterable peers table with Umbrel's
  Tor/I2P/Local/Clearnet classification.
- Activity: merged Lightning payments, settled invoices and on-chain
  transactions on one timeline, filterable by rail.
- Connect: the existing lndconnect QR flow, plus a Node ID panel with the
  pubkey and advertised URI (umbrel's NodeIdModal).
- sats/BTC unit switch persisted to localStorage (Umbrel's SatsBtcSwitch).

Channel management deliberately links out to the existing Archipelago
channels view at /apps/lnd/channels rather than being reimplemented here;
this app only shows a read-only channel overview.

Sync progress tracks the synced_to_chain/synced_to_graph booleans rather
than inventing a block-based percentage, because LND exposes no IBD ratio.

All data comes through the existing authenticated GET proxy at
/proxy/lnd, so no backend, manifest or nginx change is required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:19:34 -04:00
archipelagoandClaude Opus 5 a850bb6cdd docs(10-06): summary — Phase 10 complete, with the gate-effectiveness caveat
All five KEY-05 layers landed. Records the two things a reader would
otherwise get wrong:

- the 2 boot_reconciler test failures in the full-suite run are
  parallel-load flakes (4/4 pass in isolation), not regressions;
- layer (b)'s clippy gate is LIVE but not yet EFFECTIVE, because 42
  pre-existing warnings already fail the same -D warnings step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:00:14 -04:00
archipelagoandClaude Opus 5 a3283cffb4 feat(10-06): enable the entropy lint and supply-chain gates (KEY-05 b/c)
Layer (b) — core/clippy.toml bans rand::random and rand::thread_rng
crate-wide, each with a reason naming KEY-05 and pointing at the
evidence doc. No CI change was needed: the Rust job already runs
`cargo clippy --all-targets --all-features -- -D warnings` from core/,
so a disallowed_methods hit is already a build failure. --all-targets
covers tests deliberately — a fixture keeping the default is a template
for the next production call site.

Ordering was asserted before the file was written, not after: the
residual count of unmigrated call sites is 0, so this cannot turn CI red
for other agents on this shared tree.

Layer (c) — core/deny.toml makes the rand major split change-detecting:
global multiple-versions = "allow", a per-crate deny-multiple-versions
for rand, and a dated grandfather skip pinning =0.9.2 exactly. The tree
as it stands passes; a third version or a change to either member fails.

Both gates were OBSERVED working, not assumed:

- Reintroducing one banned call produced the disallowed_methods error
  with the reason text reaching the developer at the failure point;
  reverting returned the residual count to 0.
- `cargo deny check bans` exits 0 as-is. Removing the grandfather entry
  made it exit 2 and print both dependency trees, independently
  confirming F-07's account of where each rand version comes from.
  Restored, it exits 0 again.

Policy (checkpoint Task 5, human-approved): bans-only. The advisories
gate is NOT enabled — it fails builds when a new CVE is published
against an existing dep with no local change, which on a tree where
several agents push continuously would block everyone at an arbitrary
hour, with remediation often meaning a bump to an exactly-pinned crypto
dependency. No break-glass procedure exists. F-07's advisory half stays
OPEN and is recorded as such.

cargo-deny is pinned to 0.20.2 and installed from crates.io rather than
via EmbarkStudios/cargo-deny-action, because that action exposes no
input to pin the tool version — an unpinned supply-chain checker would
reintroduce, at the CI layer, the exact "backend fixed by configuration
rather than stated" shape this plan exists to remove. crates.io is also
the source vetted at the Task 5 legitimacy gate (EmbarkStudios, repo
resolves, ~4.79M downloads).

RECORDED HONESTLY: layer (b)'s gate is live but not yet EFFECTIVE. The
tree carries 42 pre-existing clippy warnings — unused imports, dead
code, ~39 style lints — that are already errors under -D warnings, so
that CI step cannot pass today for reasons unrelated to KEY-05. Until a
dedicated lint-clearing pass lands, a new banned RNG call would be one
error among many rather than a distinctive build-stopper. Pre-existing
and out of scope; clearing it right before an OTA would be poor
sequencing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:58:42 -04:00
archipelagoandClaude Opus 5 c966395eb9 fix(security): never auto-publish the wallet UI proxies as Tor onions
Found while checking whether the /lnd-connect-info leak (a05956c4) was
also reachable over Tor. It was not — but only by accident, and the
accident was one app id away from failing.

auto_add_tor_service() creates a hidden service for a freshly installed
app, mapping onion:80 -> 127.0.0.1:<the app's host port>. It skips the
node's own service and is_protocol_service() — which names the DAEMONS
(bitcoin, bitcoin-knots, electrs, electrumx, lnd) but NOT their UI
sidecars. lnd-ui and bitcoin-ui are real, installable app ids
(apps/lnd-ui, apps/bitcoin-ui) whose host ports are 18083 and 8334:
exactly the two ports that served the admin macaroon and the
credential-injecting Bitcoin RPC proxy.

So nothing structural prevented either from acquiring a GLOBAL onion as
a silent side effect of being installed — re-exposing worldwide, and
persistently, what a05956c4 had just closed to mesh/LAN peers. Verified
on a live node that this has not fired (services.json maps lnd to 8080
and holds no *-ui entry, and the running torrc contains neither port),
so this closes a latent hole rather than an active one.

The gate gets its own named predicate rather than an addition to
is_protocol_service, because the two express different things:
is_protocol_service says "this speaks a wire protocol, not HTTP", while
never_auto_onioned says "this fronts the node's money and must not be
published unasked". Conflating them would have made the fix look like a
classification tweak.

This gates only the AUTOMATIC path. An operator who deliberately enables
Tor for one of these apps still can — that is an informed choice, not a
silent default. Both endpoints are session-gated at the backend as of
a05956c4 either way.

Compile-checked clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:37:53 -04:00
archipelagoandClaude Opus 5 0a1d314ffa feat(security): add LND macaroon rotation for the /lnd-connect-info leak
Operator tool for the fix in a05956c4. GET /lnd-connect-info answered
unauthenticated callers with the LND ADMIN MACAROON, the TLS cert, the
gRPC/REST ports and the node's onion address. Every macaroon on an
affected node must be treated as known to an attacker.

Rotation removes the macaroon root key from macaroons.db plus the issued
macaroon files; LND mints a fresh root key on unlock, so every
previously issued macaroon — including any the attacker holds — stops
verifying. Coins live in wallet.db and channel state in channel.db, and
macaroons are bearer tokens rather than keys, so neither database is
touched, opened or moved.

Safety properties, in the order they matter:

- Detect-only by DEFAULT. --apply additionally requires --yes.
- An ORDERING GUARD refuses to rotate on a node whose binary lacks the
  fix, because the new macaroon would leak through the same door within
  seconds and the operator would believe they were safe. Overridable
  only via an explicit --force-unpatched.
- It backs up the old material to a 0700 dir OUTSIDE the dir LND
  rescans, verifies the backup file count matches, and refuses to delete
  anything if it does not.
- It never reads, prints or copies a macaroon's CONTENT. Everything it
  reports is a SHA-256 digest, so the material is proven changed without
  disclosing it to the terminal or scrollback. lncli runs INSIDE the
  container and reads the macaroon off its own disk, so the secret never
  crosses into this script's output.
- It records the node identity pubkey and channel census BEFORE, and
  aborts loudly if the identity or the OPEN channel total changed.

Two deliberate non-assertions, both of which would otherwise produce
frightening false alarms on a completely healthy rotation:

- wallet.db is NOT asserted byte-identical. btcwallet records chain sync
  progress inside it, so it legitimately changes on every start.
- num_active_channels alone is NOT asserted. It counts channels whose
  peer is currently online and so legitimately dips after any restart
  while peers reconnect. The safety invariant is the ACTIVE+INACTIVE
  total, which is what gets asserted; a changed active count is reported
  as normal post-restart behaviour.

Enumeration runs under sudo rather than as a shell glob: the LND data
dir is 0700 owned by the container's mapped uid, so "$LND_DIR"/*.macaroon
does not expand in an unprivileged shell — it stays literal, which would
have made both the backup and the removal loop silent no-ops. Verified
on a live node: sudo find returns 9 files where the glob returns 0.

Exercised in detect mode against a real LND on a live node: correctly
reported the binary as unpatched, live-probed :18083 as LEAKING (200),
read both digests and the node identity, and exited 2 without changing
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:28:09 -04:00
archipelagoandClaude Opus 5 09a1f7621c feat(10-06): name every entropy source and guard key draws (KEY-05 a/d)
Closes F-10a. Nothing here fixes a present defect: on the pinned rand
0.8.5, rand::random() and thread_rng() both resolve to a ChaCha12 CSPRNG
seeded from getrandom(2). What they lack is a STATED backend — it is
fixed by dependency and build configuration rather than by the calling
code, with no compile error if that changes. That is the structural
shape behind the 2026-07-30 COLDCARD entropy defect, and here the blast
radius includes Cashu blinded-key-exchange values, X3DH prekey material,
session bearer tokens and a ChaCha20-Poly1305 nonce.

Layer (a) — every production key, nonce and token draw now names
rand::rngs::OsRng at its own call site. The mnemonic seam is bound to
entropy::KeyGenRng, a SEALED allowlist whose supertrait lives in a
private module, so the set of RNGs that can drive the master key
hierarchy is exactly what one file says it is. This retires the false
promise at seed.rs:656: rand::CryptoRng is a marker with no
compiler-checked content, and the crate now contains zero impls of it.

Layer (d) — key material and AEAD nonces of >=12 bytes run a
degenerate-entropy predicate that refuses all-zero, all-identical and
wrapping +/-1 counter draws. Nothing heuristic: no entropy estimator, no
chi-squared. Each of the three shapes has a false-positive probability
computable in closed form (3 * 2^-88 at 12 bytes, 3 * 2^-248 at 32), and
a predicate whose false-positive rate cannot be computed cannot be
argued safe on a key-generation path. There is deliberately no retry — a
retry would paper over the broken RNG this exists to surface.

Layer (e) — the kernel-CSPRNG readiness verdict at master-seed
generation is now durable (backlog R-09). It was previously computed,
logged and thrown away, so a node could never answer after the fact
whether its keys were born from a seeded pool. The record holds a schema
version, timestamp, verdict and event name — no entropy, no key bytes.

Formats and wire shapes are proven unchanged rather than asserted:
storage_crypto and the credential store each open a HARDCODED
pre-migration ciphertext vector (a same-process round trip would pass
even if the envelope had changed), the vector was produced by an
independent RFC 8439 implementation so it pins the documented
nonce||ciphertext format rather than this implementation's output, and
the x3dh prekey bundle and bdhke values keep their field set and order.

totp.rs migrates its SOURCE only: the % charset.len() reduction and the
32-char charset are untouched. The bias there is presently zero (32
divides 256) and fixing the latent bias is R-12, which stays deferred.

Verified: cargo build clean; cargo test -p archipelago 1068 passed,
2 failed. Both failures are container::boot_reconciler timing tests
(second_pass_fires_after_interval, shutdown_terminates_loop) in a file
this change does not touch — pre-existing, not caused here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:27:34 -04:00
archipelagoandClaude Opus 5 a05956c4ce fix(security): require a session for the LND connect info and Bitcoin RPC proxies
CRITICAL. Two app-UI ports handed unauthenticated callers full control of
the node's money. Both verified live on archi-dev-box 2026-08-02 over the
fips0 mesh ULA with no cookies.

GET /lnd-connect-info returned 200 with the LND ADMIN MACAROON, the TLS
cert, the gRPC/REST ports and the node's onion address — a complete remote
wallet-drain package, and the onion means an attacker keeps that ability
after losing network access. POST /bitcoin-rpc/ reached Bitcoin Core RPC
with credentials the proxy injected on the caller's behalf, with a wallet
loaded, so wallet methods were reachable too.

Both were reachable because ports 18083 (lnd-ui) and 8334 (bitcoin-ui)
bind 0.0.0.0 AND sit on the fips0 mesh allowlist in fips/app_ports.rs. Any
mesh peer, LAN host or Tailscale peer could take either path.

The root cause is one mistaken idea in two places: that a check performed
by a reverse proxy is an auth check. It is not — it only holds for traffic
that arrived through that proxy. /lnd-connect-info's comment said "nginx
validates session cookie (presence check), backend is bound to 127.0.0.1
so only nginx can reach it". Both clauses were false in production: the
lnd-ui container runs its OWN nginx on :18083 that proxies straight to the
backend forwarding whatever cookies arrived, including none, and that
second front door never performed the check the premise named.

So authorisation moves to the resource:

- /lnd-connect-info now requires a session, like /proxy/lnd/ beside it.
  The 401 carries CORS headers so the wallet UI shows a readable error
  rather than an opaque CORS failure.
- New GET /auth/session-check returns 204/401 and nothing else, giving
  container nginx an auth_request gate it can actually use.
- bitcoin-ui's /bitcoin-rpc/ is gated by that auth_request. Its
  `Access-Control-Allow-Origin *` is also gone: on a proxy that injects
  credentials, it let any page a user visited drive the node's RPC.
  Preflight is answered before the gate, since OPTIONS carries no cookies.

The nginx template is include_str!'d and re-rendered on every reconcile
pass, so this ships atomically with the binary.

Operators must treat the LND admin macaroon and the Bitcoin RPC password
on every affected node as compromised and rotate them AFTER this is
deployed — rotating first just re-leaks through the same hole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:23:19 -04:00
archipelagoandClaude Opus 5 689c4cca1a docs(10-04): complete fleet host-secret detection and rotation plan
SUMMARY for 10-04, plus three WINDOWS.md entries (11-13) so the unverified
items stay visible at ship time: the rotation never exercised on real
hardware, host_secrets never observed in a live system.stats, and the C-3
finding itself — three live nodes still on shared SSH host keys, two of them
also sharing a TLS private key, none of them rotated.

STATE.md and ROADMAP.md deliberately not touched: both carry other agents'
uncommitted work in this shared tree and the orchestrator owns them for this
wave.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:11:58 -04:00
archipelagoandClaude Opus 5 a806a658a6 docs(10-04): C-3 FAILED — three live nodes share their SSH host keys
Audit checklist item C-3 ("the highest-value check here") is no longer
UNVERIFIED. It failed, and the failure is a live F-03 instance rather than a
theoretical one.

Three distinct fleet nodes — archipelago-1, archy-x250-beta and archipelago —
present byte-identical ECDSA, ED25519 and RSA host key fingerprints. Two of
them (archipelago-1, archy-x250-beta) also present the same TLS certificate,
so they share the TLS private key as well.

Gathered read-only and remotely: ssh-keyscan plus an anonymous TLS handshake.
No node was logged into, nothing was written, nothing was rotated. A weaker
instrument than the checklist's on-node commands, chosen because it needs no
access and therefore covers the reachable fleet rather than two nodes — and it
is sufficient for the FAIL condition, which is any fingerprint appearing twice.

Ruled out the obvious alternative (one machine registered three times on the
tailnet): all three answered live TCP within the same minute, and tailscale
ping resolves them to different physical endpoints on different continents
under different tailnet accounts.

One finding worth more than the count: `archipelago` has a UNIQUE TLS cert
(CN=austin-sapien) and SHARED SSH host keys, because it was renamed and
server.set-name re-mints the cert via regenerate_tls_cert() while touching
nothing else. So TLS uniqueness is not evidence that a node's key material is
per-node — any renamed node gets a unique certificate for free. Checked on TLS
alone, that node would have looked clean. Recorded because it justifies the
audit script reporting the two key classes separately instead of issuing one
node-level verdict.

All three are listed under "shared verdict, deliberately not rotated" with the
reason and the next step. A verification task that remediates is a
verification task that takes a node offline.

Also records what this does NOT establish, each with the evidence still
needed: same-ISO provenance, the script's own verdict on those nodes, that a
rotation preserves the operator's live session on real hardware, that
host_secrets reaches system.stats on a real node, and the four nodes that were
unreachable at scan time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:06:32 -04:00
archipelagoandClaude Opus 5 373c3bb302 fix(10-04): ship the host-secrets audit unit in the OTA runtime payload
[Rule 3 — blocking] bootstrap.rs installs systemd units from the runtime
payload at image-recipe/configs/, but create-release-manifest.sh copies only
archipelago-doctor.service and .timer into that directory. The new
archipelago-host-secrets-audit.service would therefore never exist on any
node: bootstrap looks for it, `src.exists()` is false, and it silently
installs nothing. No error, no log line — the whole deployed-node half of
10-04 would have been inert on arrival.

Two enumerations of the same list in two languages in two files is the drift
that caused it, so the loop now carries a KEEP IN SYNC pointer naming the
array in bootstrap.rs, and the redundant `if [ -f doctor.service ] || [ -f
doctor.timer ]` wrapper is gone — the per-unit `-f` test inside the loop
already does that job, and the wrapper would have skipped the whole block on
a tree that had the new unit but not the doctor ones.

Outside 10-04's declared files_modified. Taken because the alternative was to
ship a deliverable that cannot reach its target and file the gap as a
follow-up. Staged by path; no other agent had uncommitted work in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:04:14 -04:00
archipelagoandClaude Opus 5 0ed9334f15 feat(10-04): let a deployed node report — and fix — fleet-shared host keys
10-03 closed the build half of F-03: the ISO no longer bakes SSH host keys or
a TLS keypair into the shared rootfs, and first-boot regeneration fails closed.
Nodes already in the field receive none of that — the first-boot script is
installed by the installer, not shipped by OTA — so a node that hit the old
fail-open path is still running key material that every downloader of its ISO
also holds, and its completion marker guarantees it will never try again.

scripts/security/host-secrets-audit.sh decides, from the node's own disk alone,
which of those it is. Four signals in a fixed precedence: missing material can
never be shared material; the fail-open fingerprint (marker present plus the
literal `WARNING: TLS regeneration failed` / `WARNING: ssh-keygen -A failed`
lines the old script emitted) is direct evidence and outranks timestamps and
also names WHICH class survived; then key mtime against a first-boot anchor
(.secrets-regenerated, falling back to the installer's LUKS key then
machine-id). Verdicts are per-node / shared / fail-closed-missing / unknown,
and every one of them carries the evidence strings that produced it, each
naming the file it was read from.

per-node is never claimed from an absent signal. No anchor means `unknown`, and
a standing first-boot-secrets.failed record also means `unknown` — a clean
mtime is not evidence that generation succeeded. That is T-10-37: a false
per-node verdict leaves an exposed node looking clean, which is worse than no
verdict at all.

Rotation (D-06: detect-report-then-apply, recorded in
docs/security/KEY-02-FLEET-ROTATION.md):
  - --detect is the default and is read-only; it always exits 0, because
    detection is informational and must never fail a boot.
  - --apply without --yes writes nothing at all, not even its own verdict file.
    "Touches nothing" is worth being able to say without a footnote.
  - --apply --yes refuses unless the verdict is `shared`, so the wrong node
    cannot be rotated even deliberately.
  - It stages the full replacement TLS pair AND host-key set before touching
    anything live and aborts if either fails; records the OLD fingerprints
    before the swap; does TLS first (a dead web UI is recoverable over SSH, the
    converse is not); replaces host keys by mv-onto-the-existing-path rather
    than rm-then-mv, so the directory is never momentarily empty; and RELOADS
    sshd, never restarts it, so the operator's own session survives its own
    rotation.

bootstrap.rs ships the boot unit through the existing run_runtime_assets
promotion and enables it --now, so the verdict lands with the OTA rather than
at the next reboot. handle_system_stats gains a host_secrets object read from
the on-disk verdict — cheap, never an error however malformed the file, and
deliberately carrying no fingerprints, because a payload polled every few
seconds does not need digests an operator on the node can already read.

tests/first-boot-secrets/rotation-tests.sh: 8 cases against temp roots through
the HOST_SECRETS_ROOT seam. Negative controls run and reverted, each reddening
exactly one case: dry run writing its verdict file (STATE-DIR-CHANGED); the
old fingerprints recorded after the swap instead of before (caught by an
ordering observation, not a content comparison — the systemctl stub records
whether the file existed at the moment of the first reload); a tolerated
generation failure leaving a half-rotated node; and `per-node` claimed with no
anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:03:02 -04:00
archipelagoandClaude Opus 5 78b3ec879b docs(01-18): Task 1 deploy verified on archi-dev-box; six-fix sign-off still open
Deployed the frontend to archi-dev-box only (--frontend-only, no fleet,
no alpha-tester, no Tailscale, no OTA, no release) and proved all six
UIFIX fixes are in the bundle the node actually serves.

- Resolved the live chunk set from sw.js first: /opt/archipelago/web-ui/
  assets keeps every prior deploy's hashed chunks, so a naive disk grep
  returns hits from dead chunks and would have produced a false pass
  (threat T-01-83, and it was a real trap here).
- Fetched each live chunk over HTTP from http://archi-dev-box and grepped
  it: all eight probe strings for UIFIX-01..06 PRESENT.
- Real Chromium boot check on the node at 1440x900 and 390x740: app
  mounts, 0 console errors, 0 page errors, 0 failed requests.
- archy-x250-dev recorded as an explicit gap: offline, last seen 2d ago,
  no MagicDNS record; still has neither this plan set's nor phase 2's
  frontend.

Task 2's six numbered checks are all recorded NOT VERIFIED. They need an
authenticated session on the node (UI returns 401 / redirects to /login,
and no credential was guessed against a node holding real funds), and two
of them are not testable as the node stands: it owns exactly one
purchased item (image/jpeg) and has zero video and zero audio content
anywhere, so the purchased-video, purchased-music and picture-in-picture
checks have nothing to open.

No source file modified, no fix applied inline, and STATE/ROADMAP/
REQUIREMENTS deliberately left untouched - UIFIX-01..06 are NOT closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:44:01 -04:00
archipelagoandClaude Opus 5 0214114c7b docs(10-02): summary — probe built and committed, C-6 still UNVERIFIED
Task 1 done. Tasks 2 and 3 are blocked on unmet preconditions and were
NOT auto-approved: no fleet node runs 10-01's gate (installed binary
predates 879de59e and lacks the refusal string), and no second machine
was available to probe from. Nothing moved UNVERIFIED -> VERIFIED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:26:57 -04:00
archipelagoandClaude Opus 5 f2f89b5fe3 docs(10-02): record C-6 evidence so far — probe-method correction, 3 transports still open
C-6 is NOT closed by this commit and is not marked verified.

Measured (read-only, on-node):
- loopback and self-LAN-IP: auth.isOnboardingComplete 200 (EXPOSED),
  seed.status 401 (session enforcement intact) — no stop-the-plan finding.
- /rpc/ returns 404: nginx's second proxy block is not a second door, so
  the unauthenticated surface is reachable through /rpc/v1 only.

NOT measured — needs a second machine: LAN, Tor, FIPS mesh ULA.

NOT performed — the KEY-01 refusal check and the fresh-node onboarding
walkthrough. No node runs 10-01's gate yet: the installed binary was built
at 06:37 and 879de59e landed at 13:05, and the refusal string is absent
from it. A --destructive run against the dev-box would replace its identity
rather than be refused, so it was not made.

Also carries 10-01's pre-OTA fleet check (onboarding.json complete-true
without user.json): dev-box safe, rest of fleet unchecked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:25:05 -04:00
archipelagoandClaude Opus 5 257ca7e6ac docs(10-06): classify all 43 defaulted-RNG call sites with file:line evidence (KEY-05, F-10a)
F-10a recorded raw grep counts and deliberately declined to classify them.
This resolves that: every one of the 43 matches under core/archipelago/src
now carries a production/test verdict (evidenced by its file's
`#[cfg(test)] mod tests` line), what the drawn value becomes, whether the
degenerate-entropy guard applies, and a disposition.

Tally: 41 migrate, 2 comment, 0 allow. No site needed an exemption, so the
crate-wide ban will have no holes to audit.

Two corrections to F-10a, each derived independently with its evidence line:
session.rs is 4 production sites not 16 (mod tests begins :471), and
mesh/x3dh.rs:100/:114 are u32 prekey identifiers, not key material -- the
X25519 secrets come from crypto::generate_x25519_ephemeral() at :99/:113.

The enforcement blast radius is pinned with `cargo metadata` output rather
than asserted: models, helpers and js-engine are not workspace members, so
the two core/models matches are outside the clippy build graph and are
recorded as a stated limitation rather than omitted.

Requirement: KEY-05. Supersedes R-13, absorbs R-05 and R-09.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:21:40 -04:00
archipelagoandClaude Opus 5 96dba73a16 docs(10-04): record D-06 rotation trigger as detect-report-then-apply
Task 1 of 10-04 is a blocking decision checkpoint, rated one-way: rotating a
node's SSH host key invalidates every known_hosts entry for it fleet-wide and
the old private key is destroyed by the swap.

Chosen: detect-report-then-apply. auto-on-boot would fire simultaneous
known_hosts breakage across the fleet during an OTA with no operator holding
the new fingerprints, and a rotation that fails partway on a remote node (.228
is at a remote site and in real use) needs physical console access. It also
cannot be dev-paired, which contradicts the standing verify-on-the-dev-pair-
first policy — by the time it has been observed on the dev pair it has already
run everywhere.

The cost of the chosen option — exposure persists on any node nobody revisits
— is bounded by making the verdict visible in system.stats rather than by
automation, and by keeping a list in this document of every node that reported
`shared` and was deliberately not rotated.

Records what the decision binds: detect-only default, --apply inert without
--yes, the boot unit carries no apply path, and --apply --yes refuses on any
node whose verdict is not `shared`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:20:53 -04:00
archipelagoandClaude Opus 5 527f602322 feat(10-02): add read-only-by-default RPC exposure probe (C-6 / KEY-01)
- Measures EXPOSURE (auth.isOnboardingComplete) and SESSION ENFORCEMENT
  (seed.status) separately; the audit's C-6 probed with seed.status alone,
  which is not allowlisted and returns 401 by design, so its "Fail: 200"
  criterion could never fire.
- Read-only by construction: methods come from a fixed READONLY_METHODS
  array, never from an argument; the one mutating request is behind
  --destructive with a red disposable-nodes-only banner.
- The refusal check uses the published BIP-39 all-abandon/art test vector,
  so no real key material is ever generated, handled or printed.
- No node address, onion address or credential embedded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:19:15 -04:00
archipelagoandClaude Opus 5 cdad880629 docs(10,01): record the summaries for the four completed plans
Demo images / Build & push demo images (push) Successful in 3m15s
Written by the previous session's executors for 01-17, 10-01, 10-03 and
10-05, all of which are complete and whose code is already committed. The
session was cut off by a dropped SSH connection before these were staged,
so they were sitting untracked. Recording them so the phase history is not
lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:16:46 -04:00
archipelagoandClaude Opus 5 c5a82cba06 fix(credentials): mark the encrypted store so a random nonce cannot fake plaintext
The on-disk format was detected by sniffing the first byte for `[` or `{`.
Encrypted blobs begin with a random 12-byte nonce, so roughly 1 in 128
saves produced a valid encrypted file whose first byte was 0x5B or 0x7B;
those were misread as plaintext JSON, failed `String::from_utf8`, and the
store became permanently unreadable. This was surfacing as a flaky
`test_list_credentials_no_filter`, but it is a real data-loss bug: a node
whose ciphertext happened to start with one of those bytes could not load
its credentials.

Writes now carry a fixed `ARCHYCRED1` marker, which cannot collide with a
random nonce, so detection of the current format is exact.

Legacy unmarked files are detected by SUCCESSFUL AEAD DECRYPTION rather
than by another byte sniff. A verifying Poly1305 tag under the node key is
a cryptographic discriminator (~2^-128 false-positive rate), strictly
stronger than any structural guess — which is why the deferred item's
suggested "keep the first-byte sniff as the legacy fallback" was not the
shape adopted. Plaintext JSON remains the last resort, and is still
reachable on a node that has no node key at all.

An undecodable file now errors instead of returning an empty store, so a
transiently unreadable file is never silently replaced by an empty one
that the next save would commit to disk (CLAUDE.md: migrations never
destroy data). Legacy files upgrade on write, never on read.

Tests drive the collision deterministically via an explicit nonce rather
than waiting on the 1-in-128 draw, and cover all three on-disk
populations, the read-path-does-not-rewrite guarantee, and tamper
rejection. 28 passed, 0 failed.

Closes the 10-01 deferred item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:15:01 -04:00
archipelagoandClaude Opus 5 937d836c53 fix(01-05): delete the redundant second periodic federation sync loop (FED-02)
Two near-identical periodic federation sync loops were running side by
side. git history shows the overlap was accidental, not load-bearing: the
30-minute loop landed first (8dd57bcb, 2026-04-19), and the 90s loop
landed later (837cc028, 2026-06-19) describing itself as "new 90s
periodic federation auto-sync (none existed)" — its author simply hadn't
seen the existing one. Running both doubled the write-race exposure
against nodes.json that plan 01-01 locked down.

The 90s loop survives; it already did strictly more (per-peer sync-result
recording, asymmetry self-heal). The deleted loop's one unique behavior —
refresh_federation_mesh_peers() after a completed pass (#42), which pushes
newly-learned names/roster into the live mesh peer table so chat contacts
refresh without a restart — is preserved at the tail of the survivor. That
call is a local, idempotent re-seed from nodes.json with no network I/O,
so running it per-pass rather than per-half-hour is cheap.

Also carried over: MissedTickBehavior::Delay, so a pass delayed by suspend
or heavy load resumes the cadence instead of firing a burst of catch-up
ticks. And node-load errors are now logged and skipped explicitly rather
than swallowed by a catch-all, so an empty roster and an unreadable one
are no longer indistinguishable.

Not carried over: the deleted loop's 5s per-peer stagger. Its stated
reason was avoiding concurrent connects against the Tor SOCKS proxy, but
both loops iterate peers sequentially and await each sync, so there were
never concurrent connects to stagger; keeping it would only push a
multi-peer pass past the 90s cadence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:13:18 -04:00
archipelagoandClaude Opus 5 dad40c23f1 fix(10-03): prove the first-boot TLS key and cert are actually a pair
Parsing each half back proves each is well-formed; it never proves they
belong together. A key from one generation beside a cert from another
passes both individual parse checks, gets blessed, and then nginx refuses
to start at the exact moment the marker claims first boot succeeded.

gen_tls() now extracts the public key from each half and compares them
before the swap, and needs_tls() applies the same check to what is already
installed, so a mismatched pair that reached disk some other way (an older
build, a half-finished manual edit) is repaired instead of quietly
breaking nginx. Extraction subsumes parsing, so this replaces the separate
-noout parse checks rather than adding to them.

Kept deliberately in step with regenerate_tls_cert() in
core/archipelago/src/api/rpc/system/handlers.rs, which does the same
comparison on the running node after a rename.

Test harness: the openssl stub keypair now carries the generation it came
from, and STUB_OPENSSL_MISMATCH emits a cert from a different one — the
pair that passes both parse checks and still breaks nginx. New case 9
covers both directions: fail closed when the mismatch arises during
generation, repair exactly once when found already on disk, and no spin
on the run after either. 9/9 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:51:43 -04:00
archipelagoandClaude Opus 5 ae55db38d4 fix(tls): make cert regeneration on rename atomic and validated
regenerate_tls_cert() passed -keyout /etc/archipelago/ssl/archipelago.key
and -out .../archipelago.crt, so openssl wrote straight into the files nginx
is serving from. If openssl died partway, was killed, or the disk filled,
the live key and cert were already truncated — a routine `server.set-name`
could take HTTPS down with no way back. Reproduced: the live key goes from a
valid 2048-bit PEM to 33 unparseable bytes.

Mirror the discipline gen_tls() already uses in the ISO builder: generate
into .new siblings of the destinations (same directory, so the final mv is a
rename(2) and therefore atomic), parse both halves back with `openssl pkey`
and `openssl x509` and compare the extracted public keys to prove they are
valid and belong together, and only then swap them in. On any failure the
existing key and cert are left byte-for-byte untouched and the error is
returned. Staging files are cleared before the attempt and on every exit
path, success or failure.

Permissions: the staging key is created by `install -m` carrying the live
key's own mode and owner *before* openssl writes into it (openssl truncates
an existing -keyout file rather than recreating it), so the new private key
is never group- or world-readable, not even between generation and a chmod.
A live mode that grants group/other any access is not reproduced — the key
falls back to 0600 — so the swap can never widen permissions.

Cert content and parameters are unchanged: same subject, same SAN
construction, same rsa:2048, same 3650 days. This is an atomicity and
validation fix, not a crypto change.

Testing seam: the hardcoded sudo prefix and absolute paths made this
untestable, so the logic moved into a small TlsMaterial struct holding the
ssl dir, the openssl binary path and a privileged flag. Production is
TlsMaterial::production(); tests point it at a temp dir, drop sudo, and
substitute a stub openssl. Against the pre-fix shape the two atomicity tests
fail (live key modified; garbage accepted); against this change all five
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:14:10 -04:00
archipelagoandClaude Opus 5 879de59ecc fix(10-01): gate identity-mutating onboarding RPCs on provisioned nodes (F-01)
Closes F-01 (Critical) of docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md.
seed.generate/seed.restore/seed.save-encrypted/backup.restore-identity/
auth.setup are all in UNAUTHENTICATED_METHODS, and several reach
NodeIdentity::from_seed or restore_encrypted_backup, which overwrite
identity/node_key unconditionally. One unauthenticated POST from the LAN or
from any FIPS mesh peer hijacked a live node's Ed25519 identity, Nostr node
key and FIPS transport key.

- new api::rpc::onboarding_gate::ensure_onboarding_open: refuses once ANY of
  is_setup() / is_onboarding_complete() / seed_exists() says provisioned,
  failing safe on I/O errors. NodeIdentity::key_exists is deliberately NOT a
  signal — server.rs:63-71 writes a temporary key on every boot, so a gate
  keyed on it would refuse seed.generate on a never-onboarded node. Pinned by
  allows_on_fresh_temp_dir_even_though_node_key_exists.
- ensure_user_account_exists: the inverse guard for auth.onboardingComplete,
  which is unauthenticated and sets the flag the gate reads — without it, one
  call locks a fresh node out of its own onboarding.
- seed.restore body extracted to restore_node_identity_from_words so the
  regression suite drives the real path; seed.verify left open with a written
  verdict (non-mutating).
- refusal text begins "Not supported:" so it survives sanitize_error_message
  and names the authenticated system.factory-reset recovery path.
- per-method rate limits for the four onboarding mutators, sized ~6x the
  measured client retry budget so a 429 cannot reintroduce the error at the
  DID-creation screen.

First-boot onboarding is untouched: all three signals are false throughout the
seed steps, and auth.setup runs last (Login.vue:405-425).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:05:35 -04:00
archipelagoandClaude Opus 5 49345b67ed fix(openwrt): clear all 4 clippy lints so the CI -D warnings gate is real
CI (.github/workflows/ci.yml) already runs
`cargo clippy --all-targets --all-features -- -D warnings`, but
archipelago-openwrt emitted 4 warnings on a clean checkout, so the gate
was red by default and enforced nothing. Fixed each lint at the source;
no #[allow] added.

- clippy::cmp_owned (wan.rs:146) — dropped the .to_string() that built an
  owned String purely to compare against "1"; &str == &str compares the
  same content.
- clippy::unnecessary_sort_by (wifi_scan.rs:75, :177) — replaced
  sort_by(|a, b| b.signal.cmp(&a.signal)) with
  sort_by_key(|n| std::cmp::Reverse(n.signal)). Both are stable descending
  sorts on signal, so tie order is unchanged. Deliberately NOT -n.signal,
  which would misorder i32::MIN.
- clippy::trim_split_whitespace (wifi_scan.rs:156) — removed the .trim()
  before .split_whitespace(); the latter already skips leading/trailing
  whitespace and never yields empty items, so parsing is unchanged.

All three are semantics-preserving rewrites: no change to comparison
results, sort ordering, or channel parsing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:25:47 -04:00
archipelagoandClaude Opus 5 6ed876376a docs(todo): onboarding step to name your node (sets the real hostname)
Backend already exists: server.set-name runs hostnamectl set-hostname and
regenerates the TLS cert with a SAN for the new name. This is a UI step.

Records the hazard that decides the design: renaming changes both the mDNS
.local name and the TLS cert, so a rename mid-flow can drop the user's session
in the middle of onboarding — potentially between seed generation and seed
verification. Placement is therefore a design decision, with three options laid
out (last-before-Done, first, or collect-early-apply-late).

Also flags RFC-1123 slugification (users will type "Dorian's Node"), whether
the rename propagates to the Reticulum display name and mesh surfaces, the
reconnection UX, and whether the step is skippable.

Sequenced after the in-flight regenerate_tls_cert atomicity fix, since renaming
is exactly the path that fix protects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:39:10 -04:00
archipelagoandClaude Opus 5 0d513a0ef7 docs(10-05): record the Core-wallet fleet census — 4 nodes clear, 6 unchecked (D-07b)
Task 3 of plan 10-05, run by the operator over Tailscale on 2026-08-02 using the
read-only procedure in KEY-03-SIGNING-POSTURE.md. No escalation: nothing found.

Examined and CLEAR (4): archi-dev-box, shorty-s/.228, archy-x250-beta,
archy-x250-pa. On every one there is no wallet named `archipelago` — the deleted
handler's default wallet_name — `listwallets` returns only the unnamed default,
and that default reports blank=true, keypoolsize=0, txcount=0, balance=0. The
only named wallets are Fedimint gatewayd-*. The result holds across two
container vintages (bitcoin-knots and bitcoin-core), so it is not four copies of
one image behaving identically.

Not examined (6), recorded with reasons rather than omitted: framework-pt,
archipelago-1, archipelago and archy-dev-pa (SSH permission denied — password
rotated/not held), archipelago-5 (timed out during banner exchange), and
archy-x250-dev (offline). Password auth was deliberately not attempted: several
fleet nodes lock PAM quickly on a wrong password, and locking out an in-use
production node is a worse outcome than an incomplete census.

The conclusion is stated at the strength the evidence supports — no *examined*
node holds a wallet the deleted handler created, and no examined node holds any
wallet with keys or funds. It is deliberately NOT generalised to "the fleet is
clear" while six nodes are unknown. F-13 is closed by deletion regardless: the
code that could create such a wallet is gone from every future build.

No key material appeared in any output and `listdescriptors true` was never run.

Also corrects the now-stale R-04/F-13 entry in UNIFIED-TASK-TRACKER.md, which
still described `handle_bitcoin_init_wallet_from_seed` and a watch-only
migration as pending work — that code no longer exists. Marks it done-by-
deletion and adds the six unchecked nodes as a standing item, flagged as a
natural fold-in for KEY-04's on-node work but tracked independently so it does
not vanish if KEY-04 is re-scoped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:32:50 -04:00
archipelagoandClaude Opus 5 454388226c feat(01-05): surface federation sync failures to the operator (FED-02)
A failed federation sync existed only as a `debug!` line on the node, so a
peer that had not synced in days looked identical in the UI to one that
synced a minute ago. Now the failure is persisted per peer and rendered.

- `FederatedNode.last_sync_error` / `.last_sync_error_at` — the failure-side
  mirror of the existing `last_transport` / `last_transport_at` pair.
- `federation::record_sync_result(data_dir, did, outcome)` — records the
  message on `Err`, CLEARS both fields on `Ok` so the badge disappears when
  the peer recovers. Runs under FEDERATION_STORE_LOCK via the `*_inner`
  load/save convention established by plan 01-01. An unknown DID is a silent
  Ok that writes nothing, so a peer removed mid-pass is never resurrected by
  an in-flight sync's error write. Skips the save entirely when nothing
  changed, keeping the steady state read-only rather than rewriting
  nodes.json (and contending for the lock) every 90s.
- Message truncated to MAX_SYNC_ERROR_CHARS (256), counted in chars not
  bytes so truncation cannot split a UTF-8 sequence (T-01-18).
- The 90s auto-sync loop calls it on both arms; the existing `debug!` line
  is kept — persisting is additive, not a replacement for logs.
- `federation.list-nodes` emits both fields when set, omits them when unset.
- NodeList renders a red SYNC badge beside the transport badge on both the
  trusted-node and peer rows, message + age in the `title` so the row stays
  single-line.

Tests (written first, confirmed failing — 16 compile errors, E0425 on
`record_sync_result` and E0609 on `last_sync_error`):
- persists_error / success_clears_error / missing_did_is_noop /
  on_empty_store_is_noop / truncates_long_error
- NodeList: badge present when set, ABSENT when unset (the guard against a
  badge that always renders), and present on an observer peer row.

cargo test -p archipelago federation — 42 passed, 0 failed.
vitest NodeList.test.ts — 4 passed. npm run build — green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:12:07 -04:00
archipelagoandClaude Opus 5 262998747e feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)
With Bitcoin Core's wallet deleted, LND's PSBT round trip is the only
external-signer path Archipelago has, and D-09's key-origin protection moves
from Core descriptors (of which none remain) to the PSBT itself.

Adds `psbt_key_origin_report(&str) -> Result<PsbtKeyOriginReport>` to
lnd/wallet.rs, reporting `input_count`, `inputs_with_key_origin` and
`all_inputs_have_key_origin`. An input counts as carrying key origin when
either its `bip32_derivation` or `tap_key_origins` map is non-empty. A PSBT
with zero inputs reports false rather than vacuous truth. Parsed with the
already-present `bitcoin` and `base64` crates; no dependency added.

`lnd.create-psbt` gains an additive `key_origin` object on its response and a
`tracing::warn!` with the counts when key origin is missing, because that is
the exact condition under which a hardware signer refuses the PSBT. Computed
best-effort: a decode failure degrades to `null`, never to an error, so a
user's send cannot fail because an inspection helper could not parse
something. `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (which
deliberately auto-signs with LND's hot keys) are untouched.

Three tests, with fixtures built programmatically from the `bitcoin` crate
rather than pasted as opaque base64: with-derivations, without-derivations,
and malformed-is-an-error-not-a-panic.

KEY-03-SIGNING-POSTURE.md gains an honest per-step coverage map of the
fund -> export -> sign offline -> import -> finalize -> broadcast round trip.
Of six steps, only the new inspection has automated coverage; steps 1, 4, 5
and 6 have none, and there is no air-gap transport (no animated QR, no .psbt
file exchange) — export/import is copy-paste of base64. Untested paths are
named as untested.

Records the verdict that decides whether any of this is an air gap: on a
default node an external signer CANNOT meaningfully sign a PSBT from
`lnd.create-psbt`, because LND holds the keys for every input it selects.
Evidence: the PSBT is funded from LND's own wallet; `ensure_wallet_initialized`
creates a full key-holding wallet via /v1/initwallet; the generated lnd.conf
carries no `remotesigner.*` block; and a search of apps/, scripts/,
core/archipelago/src and image-recipe/ for remotesigner/createwatchonly/
nochainbackend returns zero matches. No fleet node is provisioned watch-only.
What ships is PSBT transport, not air-gapped custody — the gap is
provisioning, not plumbing.

Adds the standing honesty statement in its own subsection: Lightning channel,
revocation and HTLC keys are NOT air-gappable at all. They must sign in real
time to answer counterparty commitments; remote signing relocates them to a
hardened host, it does not cool them.

Also adds a status banner to PSBT-SIGNING-ARCHITECTURE.md recording that its
Phase 1 was superseded by deletion rather than delivered, so §0's "single
highest-value change" and §2.1's invariant now read against a code path that
no longer exists. Banner only; §5.4's honesty table is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:08:42 -04:00
archipelagoandClaude Opus 5 40b77e392a fix(10-03): don't bless a cert minted under an untrustworthy clock
The failure fail-closed cannot catch, because generation SUCCEEDS.

This unit runs very early (DefaultDependencies=no, Before=ssh/nginx), long
before time has synced. `openssl req -x509` stamps notBefore from whatever the
clock says, so on a node with a dead RTC or a flat CMOS battery the cert can be
years out: clock ahead -> clients reject it as "not yet valid", a harder failure
than the usual self-signed warning; clock behind -> notAfter is already in the
past once time syncs. The completion marker was then set and never revisited —
a node permanently serving a cert nothing accepts.

Finding 1, reported rather than assumed: this image does NOT use
systemd-timesyncd. It installs and enables chrony, and chrony-wait.service —
the unit that is Before=time-sync.target — is not enabled. So time-sync.target
is inert here and ordering After= it would buy nothing. Enabling chrony-wait to
make it meaningful would stall boot behind NTP on a node with no network, and
these nodes are routinely offline at first boot. Not deadlocking boot outranks
cert-date elegance, so the ordering is deliberately left alone.

Fixed locally instead, in two parts:

1. Backdate notBefore by 24h so ordinary skew between node and client cannot
   invalidate a fresh cert. -not_before/-not_after arrived in OpenSSL 3.5 and
   the rootfs is debian:trixie which ships it, but the capability is PROBED,
   not assumed — guessing wrong would fail every attempt and brick the node,
   the exact outcome all of this exists to prevent. Without the flags we simply
   do not backdate and rule 2 still covers the dangerous case.

2. Refuse to bless a cert dated by a clock outside a plausible window
   (2026-01-01 .. 2056-01-01). The material stays installed so the node is
   usable and sshd comes up, but the bad dates are recorded as
   failed=cert-dates and the cert is regenerated automatically once time syncs.

Generation is now driven by need rather than by "is the marker absent", and
ConditionPathExists=! is removed from the unit so a node that already completed
can still be re-examined — skipping the unit is precisely how such a node stays
broken forever. The script exits in milliseconds when everything is fine.

Anti-spin is one condition: a date-driven regeneration happens ONLY when the
clock is currently plausible. A node whose clock is still wrong re-checks and
mints nothing.

Regression caught while writing this: driving generation purely by content made
needs_ssh() false whenever any host key existed, which would have left an
image-baked fleet-shared key in place forever — F-03 reopened. The marker check
is back in both needs_ functions and case 1 (which prestages a baked key and
asserts it was replaced) is what caught it.

Case 8 covers mint-under-wrong-clock, repair-after-sync, and both spin
directions. Controls: blessing regardless of clock reddens only case 8
(run1-BAD-DATES-NOT-RECORDED); removing the anti-spin guard reddens only case 8
(SPINNING-reminted-while-clock-still-wrong(1->2)). The second control initially
passed against a broken guard because the assertion compared certificate dates,
and a re-mint under a frozen clock produces a byte-identical notBefore — the
assertion now counts mints, which is the only thing that distinguishes "left
alone" from "regenerated again".

Not covered here: nodes already deployed from earlier ISOs never receive this
script (it is installed by the installer, not by OTA), so fleet remediation for
them remains 10-04/OTA work in core/**, which is held by other executors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:08:17 -04:00
archipelagoandClaude Opus 5 9622926868 fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)
`handle_bitcoin_init_wallet_from_seed` derived the BIP-84 account extended
*private* key, stringified it, and imported `wpkh(xprv/0/*)` / `wpkh(xprv/1/*)`
into a Bitcoin Core descriptor wallet created with `disable_private_keys=false`
and an empty passphrase. That put a second copy of the node's spending key in
Core's `wallet.dat`, outside the daemon's Argon2 + ChaCha20-Poly1305 envelope.
That duplication into weaker protection was audit finding F-13 (High).

Deleted rather than rewritten watch-only (D-07b supersedes D-07/D-07a):

- No caller anywhere. Repo-wide search leaves exactly one occurrence of the
  method name (its own dispatcher registration) and two of the symbol in code
  (definition + dispatch call); every other hit is prose in docs.
- LND is the wallet the product drives. Across neode-ui/src every `bitcoin.*`
  call is read-only status (getinfo/prune-status/onion); the wallet UI sends
  via `lnd.sendcoins`.
- It never ran on archi-dev-box: no wallet named `archipelago` exists there,
  and the one loaded wallet reports blank=true, keypoolsize=0, txcount=0.
- It was authenticated AND password-gated, so F-13 was key-at-rest
  duplication, not an exposed endpoint.

No migration is performed and none is planned. This removes code, not wallets:
nothing on disk is touched, no funds move, no wallet.dat is modified. If a node
is ever found holding a wallet this handler created, that is a finding to
surface and stop on, not a trigger to auto-migrate.

`seed::derive_bitcoin_xprv` loses its only non-test caller and is retained
deliberately with `#[allow(dead_code)]` and a stated reason: it keeps its
existing test coverage and it is the derivation D-07c's deferred BDK cold vault
will need.

Records the evidence, the D-08/D-09 consequences and the D-07c deferral in
docs/security/KEY-03-SIGNING-POSTURE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:08:14 -04:00
archipelagoandClaude Opus 5 8255b69af2 fix(01-17): pin the FIPS/Tor pills and let the peer-card badge row wrap (UIFIX-01)
Audited every transport-pill render site in the cloud surfaces at 390x740
and 320x640 in a real browser. Two sites render a pill (Cloud.vue peer
cards, PeerFiles.vue header) and both already appear on a phone; three
file-level sites carry none, by decision recorded in the SUMMARY.

- Cloud.vue peer-card badge row: add flex-wrap + shrink-0 on the transport
  badge. Measured at 320px, a longer trust label squeezed the badge until
  its own text broke mid-label ("TOR ." / "120.0s"). It now drops to a
  second line intact. Inert whenever the row fits, so desktop is unchanged.
- New TransportPills.test.ts: one site-specific assertion per render site,
  so removing a pill fails the build. Dorian asked that these never be
  removed in a future cleanup; nothing in the repo pinned them before.
- Unknown-transport cases assert no pill is fabricated (T-01-78), and the
  labels/colours are asserted against PeerFiles.vue's canonical mapping
  rather than a duplicated table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:03:26 -04:00
archipelagoandClaude Opus 5 d9b3a7d5e0 fix(10-03): quote the Dockerfile heredoc so comments cannot execute
`cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE` was unquoted, so the build
shell performed command substitution on the Dockerfile body. Any backtick in a
Dockerfile COMMENT was executed on the build host and its output spliced into
the generated file. Six comments did this. One of them ran
`systemctl start archipelago-fips.service` against the build machine on every
ISO build; the others were harmless only by accident of being command-not-found.

Fixes the class, not the six instances. The delimiter is now quoted, so the
body is emitted verbatim and a future backticked comment is inert. Verified the
boundary by line range first: the other backticked comments in this file
(:264, :809, :1188, :1289, :1506, :1605, :3597, :3651) are ordinary shell
comments outside any unquoted heredoc and were never at risk — they are
untouched.

The body needs exactly four build-time values and they are all package names
(LINUX_IMAGE_PKG, GRUB_EFI_PKG, GRUB_EFI_SIGNED_PKG, GRUB_PC_PKG), on four
consecutive lines. So quoting was practical: the heredoc is split into
DOCKERFILE_HEAD and DOCKERFILE_TAIL, both quoted, with a single explicit printf
interpolating those four names between them. Escapes that existed only because
the heredoc was unquoted are undone in the same pass: six trailing `\\` become
`\` (Docker line continuations) and four `\$` become `$` (RUN arguments reach
the shell verbatim — Docker does not substitute variables in RUN).

Verified by rendering the generated Dockerfile before and after with the same
inputs and diffing them normalised (continuations joined, whitespace
collapsed). Both are 190 normalised lines and the ONLY differences are the six
comments regaining their text — every instruction is byte-identical. Before:
"# the archipelago backend calls" / after: "# the archipelago backend calls
`systemctl start archipelago-fips.service`".

Test: case 7 asserts every heredoc writing Dockerfile.rootfs has a quoted
delimiter, and when one is not, reports which body lines would execute. The
assertion is on the delimiter, not on backticks — with quoting a backticked
comment is legal and six of them are back in the body on purpose, so flagging
backticks would flag a non-bug and fail on the very comments this restored.

This bug is invisible to `bash -n`; an instance of it introduced earlier in
this plan hung a syntactically-clean build for two minutes before being caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:48:19 -04:00
archipelagoandClaude Opus 5 2efab5f219 fix(10-03): unify secret generation to a single producer + self-heal (F-03)
Unify rather than delete. The defect in F-03 was never "a second attempt to
create a key exists" — it was that failure was silent and the completion marker
lied about it. A second attempt is only dangerous when it is an unaudited second
PRODUCER carrying its own idea of success, its own absent retry policy and its
own absent failure record.

Single producer. gen_tls() is now the only code in the ISO build that creates
/etc/archipelago/ssl/archipelago.{key,crt}; gen_ssh() the only code that creates
/etc/ssh/ssh_host_*. Two secondary producers are gone:
- the Dockerfile's `openssl req` layer, which baked a keypair the strip layer
  deleted moments later in the same build;
- the installer's "ensure SSL cert exists for nginx HTTPS" block, which before
  the strip almost never fired and after it would have fired on every install.
Proof is mechanical, not a claim: every executable `openssl req` / `ssh-keygen
-A` invocation in the builder now lives inside the generator heredoc, and the
test suite fails if one appears outside it.

Build-time assertion. The one realistic total failure is a missing generator
binary, which is deterministic — no retry or reboot fixes it. A rootfs RUN layer
now fails the build if openssl or ssh-keygen is missing or non-executable.
openssl and openssh-server are both already in the package list (and
openssh-server hard-depends openssh-client, which ships ssh-keygen), so today
this is cheap insurance; it earns its place the first time someone edits that
list.

Self-heal, never dead-end. Fail-closed governs SERVING; retry governs
RECOVERING, and they are different things. Adds
archipelago-first-boot-secrets.timer (OnBootSec=5min, OnUnitActiveSec=15min),
installed and enabled with a hand-written symlink fallback because chroot
systemctl enable can fail silently. The service's own ConditionPathExists=!
makes every trigger a no-op once the marker exists, so a healthy node pays
nothing. On success the script now restarts consumers that are in `failed` —
try-reload-or-restart is a no-op on a failed unit, so without this a recovered
node would have valid keys on disk and nginx still down.

Never serve a bogus key. gen_tls parses both halves back with `openssl pkey`
and `openssl x509` before the swap, so a truncated or half-written artefact is
never what nginx reads.

Tests: 6 cases, each with an isolated negative control (transcripts in SUMMARY).
- case 4, TLS fails every attempt on a stripped root -> no key from any source.
  Control: reintroduce a fallback key creation -> only case 4 red.
- case 5, self-heal: a failed run then a later successful run -> key present,
  marker set, failed units restarted. Control: dead-end on a node that already
  failed -> only case 5 red.
- case 6, single-producer invariant. Control: reintroduce the installer block
  -> only case 6 red, naming the line.

Residual risk, stated plainly: a machine where generation can never succeed
still ends up with no SSH and no TLS. Build-time assertion removes the
deterministic cause, retry plus timer removes the transient ones, so what
remains is genuinely broken hardware — and it says so on the console and in
/var/lib/archipelago/first-boot-secrets.failed rather than quietly serving a
key nobody audited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:30:06 -04:00
archipelagoandClaude Opus 5 ff6902dd9d docs(10): add independent verification guide for auditors
A guide a third-party security auditor can use to verify Phase 10's claims
without trusting our test harness — and that we use ourselves.

Every claim carries four parts, all required: the claim stated falsifiably;
how to REPRODUCE THE DEFECT on the parent commit; how to verify the fix; and a
negative control that must go red on exactly that defect and nothing else. A
test passing on both fixed and unfixed code proves nothing, and reproduce-first
is the step most often omitted in security theatre.

Prefers external checks (curl from another host, tar listing, cross-node file
comparison) over our own tests wherever a claim can be checked from outside.

Tiered by hardware needed: Tier 0 any checkout, Tier 1 running node, Tier 2 ISO
build host, Tier 3 two physical nodes, Tier 4 pre-release gate. Status marked
per claim — verifiable now, pending a plan, or hardware-gated — so an unmarked
absence is never read as a pass.

States what is explicitly NOT claimed (Lightning custody is not air-gappable;
no claim against a compromised kernel CSPRNG or supply chain; KEY-05 is
structural not exploitable), the known-accepted risks with where each was
decided, and carries the C-6 warning that probing with seed.status reports the
surface closed while the real door stands open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:14:27 -04:00
archipelagoandClaude Opus 5 201ef474e7 docs(10-03): record C-4 build-host evidence procedure (UNVERIFIED)
Task 3 of 10-03 is a blocking checkpoint: proving the shipped rootfs tar is
identity-free needs a real ISO build host with podman/docker and disk for a
full rootfs rebuild. This commits the prepared evidence document with the exact
command sequence, marked UNVERIFIED, rather than claiming the check passed.

The document states the inverted expectation explicitly. The audit's C-4 entry
expected SSH host keys and the TLS key to be PRESENT — that described the
broken state it was measuring. After the strip layer those must be ABSENT, so
the audit's stated expectation is now the failure condition. A future reader
comparing the two would otherwise conclude the check regressed.

Also records two things the operator would otherwise get wrong:
- RECIPE_HASH must be read from the stamp file, not computed from the repo
  file. build-debian-iso.sh rewrites the builder's relative paths into a temp
  copy before exec, and the hash covers "$0"; the hashed region has 35 such
  rewritten expressions plus an absolutised SCRIPT_DIR, so the value is
  specific to the build host and checkout path.
- C-4 is a build-host check only. Two-node key divergence is C-3 and stays
  separately UNVERIFIED; the note explains why SSH host keys are the sharper
  signal there than TLS, given the installer's per-install TLS fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:57:08 -04:00
archipelagoandClaude Opus 5 408b328c39 fix(10-03): strip fleet-shared identity material from the rootfs tar (F-03)
The rootfs is a container image exported to a tar and extracted verbatim onto
every disk flashed from the ISO, and the ISO is published. It baked two things
nobody asked for: Debian's openssh-server postinst generates /etc/ssh/ssh_host_*
during the container build, and the `openssl req` layer writes the TLS keypair.
Both were therefore identical on every node and known to every downloader.

Add a final RUN layer to Dockerfile.rootfs that removes /etc/ssh/ssh_host_*,
removes the archipelago TLS keypair (keeping the ssl directory so the first-boot
staging swap has somewhere to land), truncates /etc/machine-id to systemd's
documented "regenerate on next boot" state, and drops a non-shared
/var/lib/dbus/machine-id if one exists as a real file rather than a symlink.
It also writes /opt/archipelago/rootfs-identity-stripped so a node can answer
after the fact whether its rootfs came from a stripped build; no timestamp,
so the RECIPE_HASH cache stays reproducible.

This is what makes 10-03's fail-closed regeneration structural instead of
procedural: with the material gone, a regeneration failure degrades to
"no key, service refuses to start" rather than "fleet-shared key, silently".

The `openssl req` layer is deliberately left in place — it keeps proving
openssl is present and keeps the SAN template next to its consumer; the strip
layer is what makes the output non-shared.

Two comment corrections that follow from the strip:
- The installer's TLS block is no longer a rarely-taken safety net; it now
  fires on every install. It is per-install and never image-wide, so it does
  not reopen F-03, but it does mean a first-boot failure still leaves the web
  UI with a cert while SSH has nothing. Comment updated to say so.
- The first-boot script header overstated the fail-closed cost for TLS for the
  same reason; corrected to claim certainty only for SSH.

This edit is inside the RECIPE_HASH region, so the next build is forced to
rebuild the rootfs tar — required for the C-4 evidence to mean anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:55:33 -04:00
archipelagoandClaude Opus 5 210430967d fix(10-03): fail closed on first-boot secret regeneration failure (F-03)
The first-boot per-device secret regeneration was fail-open: both branches
logged a warning and continued, and `touch "$MARKER"` ran unconditionally
outside both `if` blocks. Combined with the unit's ConditionPathExists=! and
the script's own marker fast-path, one transient failure left that node on the
image-wide shared SSH host key and TLS private key permanently and silently —
and the ISO is a published artefact, so every downloader holds those keys.

- Retry each generator 3 times with backoff (D-05), so a transient first-boot
  condition recovers inside the same boot instead of being terminal.
- Write the completion marker ONLY when both TLS and SSH succeeded, so a
  failed boot leaves the unit eligible to run again on the next boot.
- On terminal failure: durable record at
  /var/lib/archipelago/first-boot-secrets.failed naming which generator
  failed, plus console + logger + stderr, and exit 1 so the unit lands in
  `failed` rather than `active`. The record is cleared on a later success.
- Add FIRST_BOOT_SECRETS_ROOT / FIRST_BOOT_SECRETS_BACKOFF seams. Unset in
  production the behaviour is byte-identical; set, they let the fail-closed
  property be asserted rather than claimed.
- Order the unit After=systemd-random-seed.service (no-op today, correct if a
  seed file is ever baked).
- State the operational trade in the script header: after the rootfs strip, a
  terminal failure means no SSH and no TLS and needs the physical console.
  That was chosen deliberately over running on fleet-shared keys.

tests/first-boot-secrets/run-tests.sh extracts the shipped heredoc body from
the builder and drives it against a temp root with stubbed generators: both
succeed, openssl fails every attempt, ssh-keygen fails twice then succeeds.
Moving the marker touch back outside the success branch makes case 2 fail with
MARKER-SET-ON-FAILURE, which is the regression this pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:49:57 -04:00
archipelagoandClaude Opus 5 c502ff0e0a docs(todo): migrate VPS2 IP to domain across registry references
98 operational files still carry 146.59.87.168 — the domain was only adopted
for the git remote, not for container registry references. Bulk is app
manifests' image: lines, plus .gitmodules, both CI workflows, the signed
catalog.json, and two Android companion files with compiled constants. The 117
hits in .planning/ are historical records and stay.

Not a find-and-replace: the domain serves Gitea over HTTPS:443 while images are
pulled from :3000 over plain HTTP, and podman treats host:3000 and domain as
different registries — so every node re-pulls under the new name and any node
that can't resolve or trust the new host fails to pull. It also invalidates the
signed catalog (needs a re-sign ceremony) and the APK ships compiled constants.

Rollout order: registry serving on the domain → manifests → catalog re-sign →
APK rebuild. Steps 2-4 are actively breaking until step 1 holds.

Analysis from the concurrent agent's session before it ended; recorded so it is
not lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:45:35 -04:00
archipelagoandClaude Opus 5 5a11fa7588 docs(todo): capture companion 0.5.27 handover — node/web-side clipboard + QR work
Companion build 0.5.27 (versionCode 47) shims navigator.clipboard natively, so
in-app copy/paste is fixed with zero web changes — but the contract must not be
clobbered (no unconditional re-define, no Object.freeze).

Still open web-side: main.ts's fake readText() makes SendBitcoinModal's Paste
button render and silently no-op in plain-HTTP browsers; 30 writeText call
sites across three inconsistent patterns, ~10 of which toast 'Copied!'
regardless of success; scanner prewarm/torch/constraints/no-reinit.

Also records three factual corrections to docs/qr-scanner-snappiness-handover.md
(ZXing not ML Kit; FORMAT_QR_CODE + KEEP_ONLY_LATEST already in place; do NOT
drop to 720p — 1080p is a deliberate 0.5.22 fix for dense bolt11 QRs).

Routed at Phase 11: the signed-PSBT paste affordance and the scanner items are
the same surface as WALLET-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:27:03 -04:00
archipelagoandClaude Opus 5 204d4523da fix(ui): a modal must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m20s
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the
wallet's send/receive modal floating over the destination. The Lightning modal
itself did close — the parent did not. Tab views are KeepAlive'd, so
navigating deactivates the owner rather than unmounting it, and its Teleported
modal keeps rendering.

BaseModal now emits close on any route change while shown, fixing the class in
one place rather than per button. Every modal here is a transient dialog; none
should survive navigation. Two tests pin it, including that a hidden modal
stays quiet.

Also fixes a test-only regression from fa26c5fc: useLightningRequired()
resolved the Pinia store at composable-call time, so merely having the gate in
SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts
it bare). The store is now resolved lazily inside the function that needs it —
a gate should never be what breaks a component's ability to mount. That one
shipped because I verified fa26c5fc with targeted tests and a build but had
not re-run the full suite since 5718179e.

Verified: full suite 103 files / 827 tests green; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:12:49 -04:00
archipelagoandClaude Opus 5 c3d5bcd271 fix(wallet): gate lightning on CHANNELS, not just node state
Demo images / Build & push demo images (push) Successful in 3m24s
A running LND with zero channels happily mints an invoice — it is simply
unpayable, because nobody has a route in. So the state-only gate let receive
through and handed the user a useless invoice, and let send walk to confirm.
Neither errored, so the funding modal (wired to failures) never fired.

requireLightningReady(direction) now asks lnd.listchannels and checks the
liquidity that actually matters for the attempt: total_inbound to receive,
total_outbound to send. It fails OPEN on an RPC error — a transient blip
should not block a working wallet.

The no-funds mode says plainly that a channel is needed, in the direction's
own terms (inbound vs outbound), and offers both routes: "Open a channel"
straight to the channels screen where the Zeus/Olympus flow is already
prefilled, and "Setup Guide" to the run-lightning-node walkthrough for someone
who wants the whole path explained. Buttons wrap rather than squeeze on
narrow screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:51:08 -04:00
archipelagoandClaude Opus 5 700947ea3c feat(wallet): app icons, mobile layout, and a funding mode on the Lightning modal
Demo images / Build & push demo images (push) Successful in 3m18s
Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is
vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than
hotlinked on purpose — these nodes run offline/airgapped, and a remote image
would both break there and leak a request to a third-party host on every
render. A missing asset falls back to a neutral bolt glyph so a row can never
render a broken-image box.

Mobile: the choice row keeps icon + name + blurb together and drops the action
to its own full-width line under 26rem, instead of squeezing the description
into a two-word column next to a button.

Funding mode: a node that is running but has no funds / no inbound liquidity
is neither "install one" nor "start it", so the same modal gains a third mode
that explains it and routes to the run-lightning-node goal, where funding and
channel-opening already live — reusing that flow rather than duplicating it.

It fires where the user actually meets the problem: on a failed attempt.
handleLightningFailure() maps a running node's send/receive failure onto the
funding modal, matched on message text because LND surfaces "no route", "no
channels" and "insufficient balance" as plain strings with no distinct code —
and all three mean the same thing to a user: fund me.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:43:24 -04:00
archipelagoandClaude Opus 5 fa26c5fc56 fix(wallet): gate lightning on node STATE, gate send too, add a shared CopyButton
Three defects from testing the previous commit on archi-dev-box:

1. The gate keyed on `id in packages`, which is not "installed and usable" —
   package-data carries an entry for a Lightning app that is known but not
   running. On a box with no lnd container at all the gate passed and the raw
   error came through as "Operation failed. Check server logs for details."
   Now keyed on PackageState.Running.

2. Because installed-but-stopped is a real and different situation, the modal
   has two modes: absent offers the install choices, stopped says the node
   isn't running and offers "Open My Apps". Neither dead-ends in an error.

3. Lightning SEND let you walk all the way to confirm-send with no node. The
   gate now runs in review(), before the confirm step — failing at submit
   after a review screen is the defect, not a smaller version of it.

Also adds CopyButton, the start of one consistent copy affordance: icon +
label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a
document.execCommand fallback so copy still works over plain http on a LAN IP
(navigator.clipboard rejects on insecure origins, which is how a lot of nodes
are reached). Converted the wallet's own copies — the lightning invoice the
user reported, plus the on-chain/Ark addresses and the payment hash/txid.

20 of 25 copy sites across 15 other files still use ad-hoc markup; converting
them is mechanical but was not attempted here rather than half-done.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:36:25 -04:00
archipelagoandClaude Opus 5 90ce4bcc46 docs(10): correct F-10a's own overstatement — x3dh sites are identifiers, not key material
The F-10a scope correction committed hours earlier asserted semantics its
evidence did not support. The KEY-05 planner caught it against the code:

- mesh/x3dh.rs:100/:114 are u32 prekey IDENTIFIERS (spk_id, otk_id), not key
  agreement material. The X25519 secrets come from
  crypto::generate_x25519_ephemeral() at :99/:113 and were never in scope.
- session.rs's 16 raw matches read as 16 production token sites; #[cfg(test)]
  begins at :470, so it is 4 production + 12 test.
- wallet/bdhke.rs is 2 production of 4 (#[cfg(test)] at :143) — and those two
  ARE genuine key material: generate_secret() :133 and
  random_blinding_factor() :139.

The Medium rating still holds, on narrower grounds: bdhke's two production
sites plus storage_crypto.rs:39's AEAD nonce. It no longer rests on x3dh.

Struck rather than silently rewritten. F-10 was corrected on the grounds that
understatement misleads the next reader; overstatement does the same, and this
table managed both within a day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:30:43 -04:00
archipelagoandClaude Opus 5 dba3a30af9 docs(10-06): plan KEY-05 crate-wide defaulted-RNG enforcement
Adds 10-06-PLAN.md covering KEY-05 (F-10a / R-16). 10-01..10-05 untouched.

Six tasks, sequenced so CI stays green at every intermediate commit:
classify all 43 call sites with file:line evidence; a tracer that wires the
sealed KeyGenRng allowlist, the degenerate-entropy predicate and the CSPRNG
readiness ledger end-to-end through the mnemonic seam; two migration tasks;
a blocking human checkpoint for cargo-deny scope and legitimacy; then the
gates are enabled last and observed failing a real build.

The clippy ban is a compile failure under the existing -D warnings CI step,
so core/clippy.toml is deliberately not committed until every site --
including test code, since --all-targets counts it -- has migrated.

Wave 2: shares seed.rs with 10-05 and api/rpc/auth.rs with 10-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:24:43 -04:00
archipelagoandClaude Opus 5 5718179e2f feat(wallet): offer to install a Lightning node instead of failing an invoice
Demo images / Build & push demo images (push) Successful in 3m16s
Creating a Lightning invoice with no Lightning implementation installed failed
at the RPC layer — lnd.createinvoice returned connection-refused and the
Receive screen rendered it as a red error. That reads as the wallet being
broken when the node simply has no Lightning node installed yet.

useLightningRequired() gates the three invoice paths (wallet Receive, the Web5
send/receive sheet, and the app launcher's paywall — both arms there, since
paying an invoice needs a node as much as minting one). With none installed it
raises a modal offering to install one and the caller bails without surfacing
an error at all.

The modal lists the choice rather than assuming LND: LND installs today, Core
Lightning is listed greyed as "Coming soon" so the platform doesn't read as
LND-only. When CLN ships it is two lines — flip `available` and add the id to
LIGHTNING_NODE_APP_IDS.

Detection is install state, NOT reachability, deliberately: an installed node
that is merely stopped or still starting is a different problem ("start it")
and must not be answered with "install a Lightning node".

Also fixes the credentials modal, which painted its own rgba(8,10,18,.98)
navy card instead of the house glass-card — it read as blue against every
other modal. It existed twice (Apps.vue and apps/AppIconGrid.vue); both now
use BaseModal, so they also inherit Esc/focus handling, body scroll lock and
the standard pinned-header/footer scroll contract they were missing. Dead
panel CSS removed from both.

Verified: 4 new tests; full suite 103 files / 826 tests green; npm run build
clean with the new strings present in the built bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:23:37 -04:00
archipelagoandClaude Opus 5 06e0e6954e fix(01-16): recreate the gateway when its credential was rotated (FED-07)
The checkpoint on archi-dev-box proved rotation alone doesn't close FED-07:
the credential file went unique while the RUNNING container kept serving the
compromised one, because the Quadlet path rewrites a unit without restarting
it and fedimint-gateway is classified restart-sensitive, so drift was detected
and deliberately ignored on every tick.

Rotation now records the app id, and the drift check consumes that flag to
recreate even a restart-sensitive app, with a WARN naming the reason. This
mirrors the published-port carve-out a few lines above, which already makes
the same trade for the same reason: a container that is already broken (there)
or already compromised (here) is not protected by leaving it running.

Restart-sensitivity protects working services. A gateway answering to a
credential published in this repository is not working, it is compromised, and
gateway admin can drain Lightning liquidity — indefinite exposure loses to a
few seconds of restart. Rotating-but-only-alerting was rejected: the
monitoring system fires on metric thresholds only, so it would have needed new
event-alert plumbing to deliver something strictly weaker.

Re-verified on the same node, same scenario: rotation at 06:39:23, recreate at
06:39:27, PID 3923125 -> 148426, running credential now matches the file,
container healthy with the same name and ports, gatewayd.db intact at 18 files
with IDENTITY present, 32 containers untouched, no repeat rotation.

3 new tests. Also lands the missing 01-19 and 01-20 SUMMARYs: both had code
committed 2026-07-31 but no summary and no roadmap tick, so they read as
unstarted. Phase 1 is 11/20. FED-09 carries 15h of Tor uptime and 0
permission-fixes across 542 doctor runs on archi-dev-box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:20:20 -04:00
archipelagoandClaude Opus 5 5cf44c9a58 docs(10): correct F-10's scope and add KEY-05 — crate-wide CSPRNG enforcement
The audit recorded F-10 as two call sites in container/secrets.rs. The real
defaulted-RNG surface is 41 sites across 15 files: session.rs (16),
pine_ha.rs (6), wallet/bdhke.rs (4 — ecash key material), mesh/x3dh.rs (2 —
key-agreement material), storage_crypto.rs (1 — AEAD nonce), +10 more.

Nothing is broken today: rand::random()/thread_rng() are ChaCha12 seeded from
getrandom(2). What changes is blast radius — F-10's Low rating rested on
'per-app credentials rather than the master key hierarchy', which does not
survive the true scope. Re-rated Medium as F-10a.

Records why the original audit missed it: F-10 was reached by tracing the
manifest-secrets path, and no step enumerated defaulted-RNG use across the
crate independently of the traced paths.

F-10's original text is left unedited so the correction is auditable rather
than retroactive. R-13 superseded by R-16; tracker item replaced.

Adds KEY-05 to Phase 10: sealed allowlist trait at key-gen seams, clippy
disallowed-methods ban (compile-time, CI-enforced), cargo-deny on duplicate
rand majors, degenerate-entropy runtime check, persisted CSPRNG-readiness
verdict. Also retires the false 'impl CryptoRng for CountingRng' at
seed.rs:656.

Records the user's execution gate: Phase 10 does not start until the
concurrent Phase 1 agent is finished and their changes are synced. KEY-05 is
unplanned — the existing 5 plans predate it and a 6th is required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:01:00 -04:00
archipelagoandClaude f9b0659c4a wip: save session context — BotFights demo-prep work, off-plan (Phase 09 already complete)
Handoff for the reactive demo-day session that followed 09-06/09-07
(both already complete). Covers: security audit (6 IDOR fixes across
the botfight repo), Cashu payout claim UI, existing-bot AI-config UI,
botfights 1.2.11 built+deployed to both demo nodes, catalog
signed+published.

Also: discovered and fixed 4 botfight-repo commits that were local-only
and never pushed to origin — pushed as part of this handoff step
(botfight @ d00e792..10d4209 -> origin/main).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 06:36:48 -04:00
archipelagoandClaude Opus 5 23ae86c13a docs(state): record Phase 10 planned and ready to execute
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:15:30 -04:00
archipelagoandClaude Opus 5 1623b4f764 docs(quick-260731-upz): close out the entropy audit — research, summary, follow-up todo
The executor was instructed to leave docs artifacts to the orchestrator; this
commits them: the research that drove the audit, the task summary, and the
archi-dev-box test-node todo raised during the same session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:13:34 -04:00
archipelagoandClaude Opus 5 56f7b367ff docs(01-16): checkpoint FAILED on archi-dev-box — rotation never reaches the container
Ran Task 2's blocking checkpoint on a real node. The rotation works and was
proven end to end: it fired ~15s after restart, wrote a fresh unique
credential (0600, service-owned), logged exactly one line naming the .pw path
with no value in it, left every other secret and the gateway's data untouched,
and kept the container's name and ports.

But the assumption the plan rests on is WRONG, and the checkpoint is what
caught it. 25 minutes after rotating, /proc/<pid>/environ showed the running
gatewayd still using the PRE-ROTATION credential while the file and podman
secret held the new one. The orchestrator explains itself in its own logs:

  Quadlet unit drift-synced — file rewritten, .service NOT restarted
      (operator restart picks up new config)
  container drift detected during boot reconcile;
      leaving running restart-sensitive app untouched

Two deliberate guards: the Quadlet path never restarts a unit it rewrites, and
fedimint-gateway is classified restart-sensitive so drift is detected on every
tick and then ignored — logged at 15:51, 15:53, 15:54, 15:56 and counting.

So on a real affected node the credential file becomes unique while the
gateway keeps answering to the compromised one until an unrelated reboot, and
the operator reading the .pw gets a password the gateway rejects — T-01-77
inverted. FED-07 is NOT closed and this plan alone cannot close it.

Not hand-rolled around, per the plan's own instruction. The fix needs a design
decision: whether a compromised credential is the case that should override
restart-sensitivity, or whether rotation must raise an operator-facing
"restart required" alert instead of logging into the void.

Incidentally disproved: restarting archipelago does NOT kill containers here
(29/29 then 31/31 survived; "Adopted 31 existing container(s)"). The service
is system.slice/KillMode=control-group while containers live in
user-1000.slice/…/libpod-*. The CLAUDE.md SIGKILL rule predates Quadlet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:04:12 -04:00
ssmithx 0b2c36f095 fix(bitcoin): stop writing a datadir bitcoin.conf that conflicts with -conf=/tmp/rpc.conf
Since a597c1d9 (bitcoind RPC creds off argv), bitcoin-core and bitcoin-knots
launch bitcoind with -conf=/tmp/rpc.conf and pass all other settings as CLI
args; bitcoind never reads /var/lib/archipelago/bitcoin/bitcoin.conf again.

write_bitcoin_conf, ensure_bitcoin_rpc_config, and bootstrap's
run_bitcoin_rpc_repair were never updated to match — they kept writing/
"repairing" server=/rpcbind=/rpcallowip=/listen= into that datadir file on
every install, reinstall, and service restart. Bitcoin Core's own
datadir-conflict safety check then refuses to start whenever that file
exists alongside an explicit -conf= arg, so the write and every repair
of it directly caused the crash it was trying to prevent.

Also drop the "restart already-running container after bitcoin.conf
repair" adoption-path branch: it assumed bind settings live in that file
and needs a restart to pick them up, which hasn't been true since
a597c1d9 — the running container's CLI args are already correct.

Replaces both writers with remove_stale_bitcoin_conf(), which renames
(not deletes) any leftover file so already-affected nodes self-heal on
next install/restart instead of staying permanently broken.

bitcoin_data_volume_gb is removed as dead code (it only fed the deleted
prune= line in write_bitcoin_conf, itself unused since a597c1d9 hardcoded
-prune=550 in the manifest's small-disk branch).

Investigated after a crash loop on archy-x250-beta; full incident
timeline and patch rationale in bitcoin-conf-crash-patch.md.
2026-08-01 19:28:25 +00:00
archipelagoandClaude Opus 5 095664a8a7 docs(01-16): hold FED-07's on-node checkpoint, correct its requirement status
FED-07 was marked Complete when 01-11 landed, which was premature: the
requirement text explicitly includes "existing installs with the default
password get a migration path", and that migration has never been exercised on
a node. Corrected to code-complete/verification-pending.

Checkpoint step 1 was run read-only on archi-dev-box: the node is CLEAN (hash
present, 600, service-owned, not the shipped default) and has NO gateway
container — the app is installed but nothing runs and its data dir is empty.
So rotation cannot fire naturally here, and the steps that matter most (data
survives the recreate, new credential authenticates, old one rejected) have
nothing to exercise without installing and seeding first.

Deferred deliberately rather than run unattended: 30 containers are up with
4-8 days uptime (IndeeHub, Immich, BTCPay, netbird, strfry, …), the
archipelago system service is active, and restarting it SIGKILLs containers
until Quadlet is the default.

The todo carries the full context plus two adjacent findings: fedimint-gateway
is missing from handle_package_credentials (so a rotated password has no UI
retrieval path), and photoprism ships a fixed admin password in its manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:38:38 -04:00
archipelagoandClaude Opus 5 9e2d2ef236 feat(01-16): rotate existing installs off the shipped gateway credential (FED-07)
01-11 stopped new installs from ever taking a shipped credential, but did
nothing for the nodes that already did — those gateways still answer to a
password published in this repository.

rotate_compromised_gateway_credential() detects an EXACT match against the
denylist and replaces the pair; absent, unique, or merely unrecognised values
are left alone and return false. That distinction is the point: an operator
who deliberately set their own credential also has an "unrecognised" one, and
rotating it would be the same class of harm as leaving the default in place.

It hangs off resolve_dynamic_env beside ensure_generated_secrets, gated on the
gateway's app id, so an affected node heals on its next reconcile tick. There
is deliberately no teardown here: the new hash changes the resolved secret env,
which changes secret_env_hash, which the drift check reads as a container-label
mismatch — so the platform's own recreate path rebuilds the gateway around its
unchanged data directory, ports, volumes and name.

Rotation is self-terminating (the value written is not on the denylist, so the
next tick is a no-op) and errors propagate rather than being swallowed, because
the atomic write leaves the previous credential intact on failure.

Bcrypt generation was factored out of ensure_one into write_bcrypt_pair, which
both generation and rotation call — 01-11's SUMMARY claimed such a helper
existed but the arm was still inline, and rotation cannot reuse
ensure_gateway_credential because its idempotent fast path returns early
exactly when the file is present, which is the case rotation acts on.

Also fixes cargo fmt drift left by 42652547 in install.rs.

Verified: 6 new tests, secrets suite 16/16; full suite 1008 passed with one
known wall-clock flake (green 4/4 in isolation). NOT verified on a node —
Task 2's blocking checkpoint has not been run, so FED-07 stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:17:56 -04:00
archipelagoandClaude Opus 5 d238bad012 docs(roadmap): add WALLET-05 — make the PSBT air-gap round trip real
The two-scan dance (node shows unsigned PSBT as animated QR, signer signs,
node scans the signed PSBT back, finalize + broadcast) is ~80% plumbed and 0%
usable. Verified gaps:

- No UI: lnd.create-psbt / lnd.finalize-psbt and their rpc-client.ts:417
  wrappers are called by nothing but unit tests.
- No animated-QR encoder: qrcode/qrloop are deps and the inbound path
  (useAnimatedQRDecoder + WalletScanModal) works, but nothing encodes a PSBT.
- Wrong format for real signers: qrloop is Ledger's; Passport/SeedSigner speak
  BC-UR (ur:crypto-psbt), Coldcard Q speaks BBQr. BC-UR is the priority given
  the existing Passport-Prime-compatible SeedQR work.

Gated on 10-05: create-psbt funds from LND's own wallet, so until LND is
watch-only against the external signer the offline device signs inputs whose
keys the node already holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:23:47 -04:00
archipelagoandClaude Opus 5 6c821730b0 docs(roadmap): add Phase 11 — Wallet Experience & LND UI Parity
First-run wallet-type chooser, seed handling reusing the shipped SeedQR +
seed-words components, and evidence-based umbrelOS LND UI parity.

Gated on Phase 10's 10-05: the set of wallet types WALLET-01 can offer is a
direct consequence of the watch-only verdict that plan produces, and 10-05 also
deletes the dead Core wallet path so this phase never represents it in the UI.

Records the already-shipped inventory (channels panel, send/receive/scan/
settings modals, SeedRevealPanel, LndSeedBackupPrompt, utils/seedqr.ts) so the
parity matrix closes real gaps instead of rebuilding existing surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:19:36 -04:00
archipelagoandClaude Opus 5 2781bd5a6c docs(10): correct D-03's gate signal — key_exists is true on every booted node
D-03 named NodeIdentity::key_exists as one of the two gate signals. Server::new
(server.rs:63-72) calls load_or_create on both branches, and load_or_create
(identity.rs:48-51) generates and writes a random temporary node key when none
exists — so key_exists is true on any node that has booted once, onboarded or
not. A gate keyed on it would refuse seed.generate on a fresh node and brick
onboarding fleet-wide.

The flaw came from the audit's own suggested remediation (§214-221) and was
repeated in the planning brief; the planner caught it against the code.

D-03's intent (two signals, OR-ed, fail safe on drift) is unchanged. Corrected
signal set: is_setup() / is_onboarding_complete() / seed_exists(), pinned by a
test rather than a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:07:06 -04:00
archipelagoandClaude Opus 5 04c4d3460c docs(10): plan Key-Material Hardening — 5 plans, 2 waves (KEY-01..KEY-04)
Closes the three exploitable findings from the 2026-07-31 entropy/seed audit.

Wave 1 (parallel):
- 10-01 KEY-01: shared onboarding gate refuses seed.generate/seed.restore/
  seed.save-encrypted/backup.restore-identity/auth.setup on a provisioned node,
  plus an auth.onboardingComplete guard and retry-budget-derived rate limits.
  Independently shippable (D-11): no depends_on, no shared files.
- 10-03 KEY-02: first-boot secret regeneration retries with backoff then fails
  closed; rootfs tar ships identity-free so failure degrades to "no key".
- 10-05 KEY-03: delete the uncalled bitcoin.init-wallet-from-seed xprv-import
  path (D-07b); make LND's PSBT round trip first-class with a key-origin report.

Wave 2:
- 10-02 (deps 10-01) KEY-01/KEY-04: on-node C-6 exposure measurement, live
  refusal proof, fresh-node onboarding non-regression.
- 10-04 (deps 10-03) KEY-02/KEY-04: fleet detection of image-baked host secrets,
  guarded rotation behind a D-06 decision checkpoint, C-3 two-node verification.

Planning-time scoping correction recorded in 10-01: D-03 names
NodeIdentity::key_exists as the on-disk "onboarded" signal, but server.rs:63-71
writes a temporary node_key on every boot, so that signal is true on fresh nodes
and would brick first-boot onboarding. D-03's dual-signal intent is preserved
with is_setup / is_onboarding_complete / seed_exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:04:57 -04:00
archipelagoandClaude Opus 5 97b8fa485d docs(10): KEY-03 final scope — delete Core wallet path, harden LND PSBT
Core's wallet is outdated and used by nothing, so bitcoin.init-wallet-from-seed
is deleted outright rather than migrated: uncalled, authenticated and
password-gated, never ran on archi-dev-box, and its only job is deriving and
stringifying the master BIP-84 xprv.

D-07's parity-proof migration and its one-way checkpoint are withdrawn — there
is no wallet to migrate. A small discovery check folds into KEY-04; a wallet
found there is a finding to stop on, not an auto-migration trigger.

PSBT is already solved by LND and already implemented: lnd.create-psbt
(WalletKit FundPsbt) and lnd.finalize-psbt (finalize + broadcast), both
rate-limited, on LND v0.18.4-beta. KEY-03 becomes: delete the Core path and
make that flow first-class, tested and documented, including that the PSBT
carries the BIP-32 key-origin data a hardware signer needs.

Records the standing honesty constraint that Lightning channel/revocation/HTLC
keys are not air-gappable at all, and defers the BDK+ElectrumX cold vault to
its own phase (D-07c).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:43:22 -04:00
archipelagoandClaude Opus 5 cb021a991c docs(10): record KEY-03 scoping correction — Core wallet path is uncalled
Bitcoin Core's wallet is legacy and unused: bitcoin.init-wallet-from-seed has
no caller outside its dispatcher registration, the wallet UI is LND-only
(lnd.sendcoins/estimatefee/getinfo), archi-dev-box has no bitcoin/wallets/ dir
so the handler's named descriptor wallet was never created there, and the
endpoint is authenticated + password-gated so F-13 was never remotely
reachable.

F-13 is therefore latent, not live. D-07's migration premise is unproven, so
KEY-03 is re-scoped discovery-first: check the fleet for any wallet this
handler created before planning any migration. Migration + checkpoint stay,
conditional on discovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:38:20 -04:00
archipelagoandClaude Opus 5 90884e6259 feat(01-02): chat mutations mutate demo state instead of acking (FED-04)
Demo images / Build & push demo images (push) Successful in 4m21s
Reactions, replies, read-receipts, edits, deletes, forwards and channel sends
shared one bare `{ ok: true, sent: true }` case, so none of them rendered on
the demo — the UI derives reaction chips and reply quotes from the message
store, and there was nothing in it to derive from.

Each now mirrors its daemon counterpart. Reactions/replies/receipts push typed
messages carrying the { sender_pubkey, sender_seq } target key Mesh.vue's
reactionIndex and replyTargetPreview read. Edits rewrite the text and set
edited_at; deletes tombstone IN PLACE (plaintext, typed_payload.deleted,
message_type 'delete') because that is what mesh/mod.rs apply_local_delete
does — it does not remove the row.

Edits and deletes go through a per-session overrides overlay keyed by
sender_seq, because mesh.messages rebuilds its seed array on every read, so
in-place mutation would only ever work for messages sent this session.

mesh.refresh and mesh.reboot-radio stay acknowledgements on purpose — the
daemon's handlers have no message-store effect either — with a comment saying
so, so a later reader does not "fix" them into divergence.

Also completes the phase bookkeeping for 01-02/03/11/12/13/14/15 and lands the
orphaned 01-12/01-14 SUMMARYs.

Verified: parity harness 17/17 live assertions; full frontend suite 102 files
/ 822 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:37:00 -04:00
archipelago 9c2ec82bdb docs(10): capture phase context 2026-08-01 06:32:22 -04:00
archipelagoandClaude Opus 5 b8979f3687 feat(01-02): demo answers every mesh/federation RPC the UI calls (FED-04)
Demo images / Build & push demo images (push) Successful in 4m14s
Ten methods the UI calls had no case at all in mock-backend.js, so the demo
answered them with "Method not found" and the frontend swallowed it in a
try/catch — peer renaming, scheduling, clear-all, the assistant panel and two
federation actions were all silently inert on the demo.

Each new handler mirrors its daemon counterpart and cites the Rust source it
mirrors, per the house convention above mesh.transport-advice:
  mesh.contacts-list/-save   typed_messages.rs (contacts merged over peers by
                             pubkey_hex; absent params leave stored fields)
  mesh.clear-all             status.rs ({ status: "cleared" })
  mesh.schedule/list/cancel  assistant.rs + scheduler.rs ScheduledMessage
  mesh.assistant-status/-configure  assistant.rs (key-presence semantics)
  federation.cancel-request  handlers.rs (outbound+sent only, notify defaults
                             true) — plus an outbound seed request, since
                             without one the demo's cancel path was
                             unexercisable
  federation.notify-did-change      handlers.rs ({ notified, failed, results })

mesh.peers and mesh.contacts-list now share one DEMO_MESH_PEERS list so they
cannot disagree about who is on the mesh, and the peer with no pubkey_hex is
omitted from contacts exactly as the daemon omits it.

scripts/mock-rpc-parity.mjs cross-references UI call sites against mock cases
and then drives a live scripted RPC sequence against an ephemeral-port
instance (MOCK_BACKEND_PORT). It matches only `method: '<x>'`, NOT bare string
literals: Mesh.vue and Federation.vue use the same dotted names as
resource-cache keys, and matching those would report permanent phantom gaps.

Fail-first proof: disabling the mesh.clear-all case makes the harness exit 1
naming the gap; restored, it exits 0 twice in a row with no stray listener.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:25:59 -04:00
archipelagoandClaude Opus 5 579398981f feat(01-03): paid tick renders the branded screensaver ring (FED-06)
Demo images / Build & push demo images (push) Failing after 1m14s
Both paid-tick surfaces now show the EQ-segment ring instead of a CSS ripple
burst (send modal) and a plain circle (scan modal), via a new `badge` size
variant at 160px/192px with matching --viz-radius. A transform scale of the
compact variant was ruled out in 01-UI-SPEC.md because it would scale segment
stroke width and blur along with the geometry.

Both surfaces use the identical composition — a badge-sized relative container
with the checkmark core absolutely centred over the ring — so the two ticks
cannot drift apart visually.

ScreensaverRing also gains the prefers-reduced-motion guard it never had, on
the component rather than per call site, so the screensaver and
SystemDangerZone variants are covered too.

Also records live-browser verification for 01-13's scroll cue (three
viewports, plus a geometry probe of the hide condition).

Verified: 5 new tests; full suite 102 files / 822 tests green; npm run build
clean with viz-ring-badge present in the built bundle. The live visual
no-clipping observation Task 2 asks for is explicitly NOT done — recorded as
deferred to plan 01-07's consolidated sign-off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:07:10 -04:00
archipelagoandClaude Opus 5 8b51b7e2dc fix(quick-260731-upz): make the master-seed RNG explicit (ARCHY-1 / F-02)
bip39::Mnemonic::generate(24) resolves through Mnemonic::generate_in to
&mut rand::thread_rng() INSIDE the bip39 crate (bip39-2.1.0/src/lib.rs:
311-313 -> :296-298 -> :267-283), so the entropy source behind Archipelago's
entire key hierarchy -- node Ed25519 did:key, node Nostr key, FIPS mesh key,
per-identity keys, the BIP-84 wallet, the LND aezeed entropy, and the fleet
release-root SIGNING key -- was chosen by a dependency default rather than
stated at the call site.

Not a vulnerability today: rand 0.8.5's thread_rng is a fork-protected
ChaCha12 CSPRNG seeded from getrandom(2). But it is precisely the structural
shape of the 2026-07-30 COLDCARD entropy defect (T1), where a refactor
silently rebound seed generation to a non-cryptographic PRNG with no compile
error and no test failure.

- New private helper generate_mnemonic_with<R: CryptoRng + RngCore> calls
  bip39's injectable generate_in_with; MasterSeed::generate passes OsRng
  explicitly, with the rationale pinned in a doc comment
- mnemonic_generation_uses_injected_rng: drives generation from a
  deterministic test RNG and asserts the result equals from_entropy(exactly
  the bytes that RNG emitted) -- direct proof the INJECTED rng is consumed --
  plus a known-answer pin and a determinism check. This test cannot be
  written against the previous code: there was no seam to inject through
- mnemonic_generation_is_256_bit: the OsRng path yields 24 words and two
  successive productions differ

No change to derivation paths, word count, the empty-BIP-39-passphrase
decision, or the at-rest encryption envelope.

Verified: CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: -> 25 passed,
0 failed.

Full analysis: docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (F-02, §4, §7).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:03:17 -04:00
archipelagoandClaude Opus 5 5ba80e49b7 docs(quick-260731-upz): entropy-audit remediation backlog + tracker items
- Adds F-13 (High) to the audit: the BIP-84 account PRIVATE key is imported
  into Bitcoin Core (bitcoin.rs:203 disable_private_keys=false, :229-231
  wpkh(xprv/...)), so the spending key is persisted outside the Argon2
  envelope in a wallet with an empty passphrase; the descriptors also carry
  no [fingerprint/derivation] key origin, so no hardware signer could ever
  use them. Found by tracing secret class (1) end-to-end
- Fills the Remediation Backlog: R-00..R-15, prioritised severity x effort,
  each with the finding it closes, files, effort, and hardware gating; plus
  an explicit "not implemented here, and why" section
- Records ARCHY-1 as APPLIED with the exact test evidence and an honest note
  that making the source explicit removes a future failure mode rather than
  repairing a past one
- Wires the resulting open items into docs/UNIFIED-TASK-TRACKER.md in its
  existing tier/checkbox format: Tier 0 (cargo audit/deny CI, ceremony
  mnemonic input, a five-item hygiene batch, secrets.rs OsRng), Tier 1 (ISO
  fail-open first-boot secrets, Argon2 vs ADR-005, the on-node checklist),
  Tier 2 (the Critical unauthenticated seed RPCs, PSBT Phase 1, PSBT phases
  2-7, seed-RPC transport confinement)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:02:31 -04:00
archipelagoandClaude Opus 5 5f366f7589 fix(01-13): on-brand scroll cue makes the onboarding tickbox findable (UIFIX-03)
Demo images / Build & push demo images (push) Successful in 4m25s
On a short viewport the seed-confirmation tickbox sits below the fold inside
the step's scrolling area while Continue stays pinned and disabled in the
fixed footer — onboarding reads as broken rather than incomplete.

The cue is a sticky-bottom scrim and glass pill inside the scroll region, and
its visibility comes from real geometry: scrollHeight vs clientHeight for
overflow, then a getBoundingClientRect comparison of the tickbox's bottom
against the container's. On a tall screen the element does not render at all,
so those screens are unchanged. Rects rather than offsetTop because offsetTop
is relative to the nearest positioned ancestor — here the outer card, not the
scroll container.

It is wayfinding only: activating it scrolls the tickbox into view and never
sets confirmed, focuses Continue, or auto-ticks, which a test pins.

Listener setup was moved onto both onMounted paths — the sessionStorage
restore path returned early, so a user navigating back would have had no cue.

Verified: 6 new tests plus the full frontend suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:54:40 -04:00
archipelagoandClaude Opus 5 46bf5a7870 fix(01-15): hand the video off to a custodial PiP session (UIFIX-05)
Entering picture-in-picture read as the lightbox being dismissed, and the
session died with it: both Teleport and KeepAlive move their subtree on
deactivation, which the PiP spec treats as removal.

MediaLightbox now listens for the video's own enterpictureinpicture event —
so PiP entered by the browser's native control behaves identically to the
toolbar button — and follows a fixed order: adopt, animate, then emit close.
Adopting first is what makes the element survive the unmount the emit
triggers; the invariant is documented in place so a refactor cannot reorder
it innocently.

The backdrop animates a handoff on the PiP path only, closing on transitionend
with a bounded 350ms fallback for browsers that skip the transition and for
the reduced-motion path where the duration is zero and the event never fires.

Verified: 5 new tests plus the full frontend suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:53:38 -04:00
archipelagoandClaude Opus 5 4265254700 fix(01-11): remove every shipped Fedimint gateway credential (FED-07)
Six code paths configured the Lightning gateway with a bcrypt hash committed
to this repository — and one deploy path with a plaintext password literal —
whenever the per-install secret was missing. Anyone holding a copy of the repo
held the admin credential for every gateway that ever took a fallback.

container::secrets now owns the credential end to end: ensure_gateway_credential
(idempotent, delegates to ensure_one's bcrypt arm) and gateway_bcrypt_hash,
which returns Err when the secret is missing/empty and when the stored value is
on the KNOWN_DEFAULT_GATEWAY_HASHES denylist — so this codebase cannot hand
back the compromised value even to a node already carrying it.

get_app_config was widened to Result so a credential-less install cannot reach
podman run at all; configure_fedimint_lnd takes the resolved hash instead of
re-reading with its own fallback. The four shell paths stop generating
credentials entirely (dropping the htpasswd host dependency) and skip container
creation with a printed reason rather than substituting anything.

Naming converges on the manifest's fedimint-gateway-hash/.pw, with legacy
fedimint-gateway-password values copied forward rather than regenerated so no
node loses a working unique credential. Plan 01-16 owns rotation of installs
already carrying the default.

Verified: cargo build clean; cargo test -p archipelago 999 passed (2
boot_reconciler timing tests failed under concurrent load, green in isolation,
untouched by this diff); bash -n clean on all five scripts; the compromised
literal now appears exactly once in the tree, as the denylist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:46:02 -04:00
archipelagoandClaude Opus 5 5faf1a3c5f docs(quick-260731-upz): PSBT-first signing architecture spec
Watch-only descriptor wallets, external signers, multisig, air-gap transport
and honest LND limits — a spec a future /gsd-plan-phase can consume.

- Names the current gap: bitcoin.rs:203 passes disable_private_keys=false and
  bitcoin.rs:229-231 imports wpkh(xprv/...), so Core holds the BIP-84 account
  PRIVATE key today. Closing that is Phase 1 and unblocks everything else
- Full Core RPC loop with wallet- vs node-scoped RPCs; analyzepsbt drives UI
- Tier 1 single-sig with mandatory [fingerprint/derivation] key origin; Tier 2
  wsh(sortedmulti) on BIP-48; taproot/MuSig2 deferred as UNVERIFIED
- BC-UR v2 primary (fountain-coded, degrades gracefully), BBQr for Coldcard,
  file fallback always; animated multi-frame is mandatory, not optional
- LND: channel/revocation/HTLC keys CANNOT be air-gapped; funding tx must
  NEVER be self-broadcast (type-level refusal, not a boolean)
- On-chain (PSBT-protectable) vs lightning (necessarily hot) split, with the
  exact user-facing sentence the UI must use
- Hot wallet kept as explicitly-secondary with server-enforced limits; safe
  path is the DEFAULT, per T1's survivors
- Migration section refuses to over-alarm: the audit found no entropy defect,
  so no Archipelago user needs to rotate a seed
- 7 phases with dependencies, candidate requirements, and hardware gating
- Answers two open items in docs/hardware-signer-design.md
- Flags bitcoin-knots:latest as an unpinned tag vs ADR-009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:09:17 -04:00
archipelagoandClaude Opus 5 f11db4ea1d docs(quick-260731-upz): entropy & seed-generation security audit
Evidence-backed audit of every secret class against the real tree, prompted
by the 2026-07-30 Coinkite COLDCARD entropy incident.

- No Coldcard-class entropy defect exists: no non-cryptographic PRNG, no
  clock-seeded key, no Math.random() in any browser key path
- F-01 (Critical, NOT entropy): seed.generate/seed.restore are unauthenticated,
  unrated, and unconditionally overwrite a live node's Ed25519/Nostr/FIPS keys
- F-02 [ARCHY-1] CONFIRMED: mnemonic entropy source is a bip39 transitive
  default, not a call-site argument — the exact structural shape of T1
- F-03 (High) [ARCHY-3]: first-boot TLS/SSH regeneration is fail-open and its
  completion marker is set even on failure, over a fleet-shared cached rootfs
- ARCHY-2 confirmed good; ARCHY-5 refuted as a present defect (32 | 256)
- Argon2::default() is 19MiB/t=2, not ADR-005's stated 64MB/3
- Corrects the scoping assumption that image-recipe/_archived/ is dead: it is
  the live ISO builder, exec'd by build-debian-iso.sh
- Adds a "What we do right" section and an UNVERIFIED on-node checklist

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:03:26 -04:00
archipelago cc52719e81 docs(01-01): complete federation node-store lock plan 2026-07-31 22:29:31 -04:00
archipelagoandClaude Opus 5 4a8925f09e fix(01-14): satisfy vue-tsc strict-null checks in usePaidItemViewer.test.ts
Demo images / Build & push demo images (push) Successful in 3m41s
npm run build's vue-tsc -b pass caught TS2532 (possibly-undefined array
access) on two array-index reads the vitest run alone doesn't type-check —
optional-chain them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:29:28 -04:00
archipelagoandClaude Opus 5 0c63a8518c docs(quick-260731-upz): plan entropy/seed audit + PSBT signing architecture
Following the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident,
plan three deliverables as a single 3-task quick plan:

1. docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md — evidence-backed audit of
   every key-material path (Rust/TS/shell), adjudicating research findings
   [ARCHY-1]..[ARCHY-4] with file:line evidence, incl. the one-ISO-many-nodes
   correlation risk and an explicit UNVERIFIED on-node checklist.
2. docs/security/PSBT-SIGNING-ARCHITECTURE.md — descriptor watch-only,
   wsh(sortedmulti) multisig, air-gap transport, honest LND limits
   (channel/revocation/HTLC keys cannot be air-gapped), hot wallet as
   explicitly secondary, migration path, phased rollout.
3. Remediation backlog into docs/UNIFIED-TASK-TRACKER.md + one gated
   hardening fix (explicit-OsRng injection at the mnemonic call site) proven
   by a known-answer test that cannot exist before the change.

Audit-and-spec only — no wallet/signing implementation. Verify gates enforce
file:line evidence density and a secret-shaped-string check on both docs, and
assert no commit authored by this plan touches the concurrent agent's files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:29:23 -04:00
archipelagoandClaude Opus 5 b5628d968e fix(web5): raise connected-nodes row-breakpoint height floor to 40rem (UIFIX-02)
Demo images / Build & push demo images (push) Has been cancelled
Dorian verified the sibling-height match, internal scroll, and unchanged
stacked layout on his running dev session — all correct. The only issue was
the xl:min-h-[20rem] floor (an unmeasured judgement call, flagged as such in
01-12-PLAN.md): when node discovery is disabled, Web5NodeVisibility renders
short, the floor takes over, and 20rem left the Connected Nodes card looking
stunted. Doubled to xl:min-h-[40rem] per his direct instruction ("twice as
tall"). Test updated to pin the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:27:44 -04:00
archipelagoandClaude Opus 5 4b5367ebc4 fix(01-01): route remaining federation mutators through the store lock (FED-01)
Task 2 of 01-01-PLAN.md, closing the gap left after Task 1's initial commit
(2f99db5e):

- record_peer_transport and update_node now hold FEDERATION_STORE_LOCK for
  their whole load-mutate-save cycle via the *_inner variants, instead of
  calling the public (separately-locked) load_nodes/save_nodes — closing
  the same class of race the lock was introduced to fix, just for the two
  mutators Task 1 didn't reach.
- Add test_remove_errors_when_tombstone_write_fails: pre-creates the
  removed-nodes path as a directory so the tombstone write fails, then
  asserts remove_node returns Err AND load_nodes still contains the node —
  proving a failed removal never half-applies.

cargo test -p archipelago federation::storage: 14/14 green (was 11, +3 across
Task 1/2). cargo build -p archipelago: no new warnings, no dead-code warnings
on any *_inner fn. Public signatures unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:26:28 -04:00
archipelagoandClaude Opus 5 bc9a210c75 fix(01-14): route Paid Files pictures/videos into the app lightbox with a visible wait (UIFIX-04/UIFIX-06)
Demo images / Build & push demo images (push) Successful in 3m47s
Cloud.vue's viewPaidItem() called window.open() instead of the in-app
MediaLightbox, and its content.owned-get fetch had a 60s timeout with no
loading indicator and a swallowed catch. Moves the fetch/decode/route logic
into a new usePaidItemViewer composable: image/video route to a second
MediaLightbox instance fed a synthetic FileBrowserItem, audio still goes to
the global bottom-bar player, and anything with no in-app viewer keeps
today's browser-tab fallback. The Paid Files row now shows an "Opening…"
spinner (matching PeerFiles' existing treatment) for the fetch's duration,
becomes non-interactive to prevent double-fetch, and a real error surfaces
through the view's existing alert-error block instead of an empty catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:17:57 -04:00
archipelagoandClaude Opus 5 3288a02df8 feat(01-15): singleton PiP session with body-level custodial host
Demo images / Build & push demo images (push) Successful in 3m44s
usePipSession() owns an off-screen div under document.body; adopt(video)
moves the element there so it survives the unmount of whatever view
rendered it (a Teleport and a KeepAlive'd view both move their subtree on
deactivation, which the picture-in-picture spec treats as removal).
release() tears down playback and detaches the element; a
leavepictureinpicture listener on the adopted element is the primary
release path so an orphaned owner can never leak it.

Also adds isPipSupported() to pip.ts — a call-time version of the existing
import-time pipSupported const, needed because a test can't restub
document.pictureInPictureEnabled after import. togglePip and pipSupported
are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:11:56 -04:00
archipelagoandClaude Opus 5 ceafbcb596 fix(web5): bound connected-nodes tab panes to sibling-matched height (UIFIX-02)
Demo images / Build & push demo images (push) Successful in 3m58s
The three tab panes carried max-h-72 xl:max-h-none, so at the xl breakpoint
(where the Web5 row becomes two grid columns) the cap lifted with nothing to
replace it: the visible pane grew to fit every row, stretched the grid row,
and the scrollbar the user expects never appeared.

Give each pane xl:flex-1 xl:basis-0 xl:max-h-none instead — zero flex-basis
means the pane contributes no intrinsic height, so the grid row is sized by
the Web5NodeVisibility sibling alone, grid's default align-items: stretch
gives the card that height, and flex-1 hands the leftover height back to the
pane, which scrolls inside it via the existing overflow-y-auto. The card root
gets min-h-0 (so the flex column can shrink below content height) plus an
xl:min-h-[20rem] floor so a short sibling still leaves a usable list area
instead of collapsing to the header+tabs strip.

Below the row breakpoint nothing changes: the stacked cap (max-h-72) and
scroll are untouched, and dropping flex-auto (replaced by nothing, i.e. the
default 0 1 auto) has no visible effect since single-column stacked cards
have no extra flex space to distribute anyway.

Adds Web5ConnectedNodesScroll.test.ts to pin the contract across all three
tab panes and the card root so a future cleanup cannot reintroduce the
grow-to-fit regression a third time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:38:34 -04:00
archipelagoandClaude Opus 5 bf9cfc446a docs: capture ISO build handoff for v1.7.119-alpha
Release and OTA assets are live; the installer ISO was blocked three times by
a dirty shared tree. Records the exact command, TMPDIR requirement, the
background-execution lesson, the build-from-HEAD decision and its reasoning,
and which gate stages were already observed passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:13:51 -04:00
archipelagoandClaude Opus 5 d02ba1aa7b chore(catalog): sync catalogs to BotFights 1.2.11
Demo images / Build & push demo images (push) Successful in 3m39s
apps/botfights/manifest.yml went to 1.2.11 in aea17248, but the two unsigned
catalogs (app-catalog/catalog.json and its neode-ui/public copy) still
advertised 1.2.9, failing the release gate's catalog-drift check and blocking
the ISO build. releases/app-catalog.json was already correct and signed.

Regenerated via scripts/generate-app-catalog.py (syncs from manifests, no key
needed). app_ports.rs was rewritten by the same generator; verified the port
set is byte-identical in content (35 ports, none added or removed) and
re-normalised with cargo fmt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:07:44 -04:00
archipelagoandClaude b0a08345c5 chore(catalog): sign catalog with botfights 1.2.11
Signs releases/app-catalog.json after aea17248 (BotFights 1.2.11 —
6 IDOR/auth fixes + winnings-claim UI + existing-bot AI-config UI).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 19:56:01 -04:00
archipelagoandClaude aea17248f6 feat(botfights): bump to 1.2.11 — closes 6 IDOR/auth bugs + winnings-claim UI
Ships botfight repo commits f5f57e6, c162d5e, 41f1b93, 10d4209:

- Fixed 6 instances of the same trust-a-client-supplied-pubkey pattern
  across auth/payments/queue routes, two of them critical: GET
  /winnings/:botId had no auth at all and leaked live spendable Cashu
  bearer tokens; POST /connect-wallet let anyone redirect a victim
  bot's future payouts to an attacker's wallet by pubkey (public by
  design in nostr).
- Added owner-reachable AI-answer settings (existing bots, not just at
  creation) and a "claim your winnings" UI (Cashu payouts were minted
  server-side but had no frontend consumer at all until now).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 19:48:06 -04:00
archipelagoandClaude Opus 5 6c2b6668dc fix(iso): honor TMPDIR for the QEMU boot-test disk/serial log
Both the 20G sparse qcow2 test disk and the serial console log were
hardcoded to /tmp regardless of $TMPDIR, so pointing the ISO release
build at a disk-backed scratch dir (to avoid tmpfs space pressure)
would not have covered this stage. Falls back to /tmp when TMPDIR is
unset — no behavior change for existing callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:46:12 -04:00
archipelagoandClaude Opus 5 c4e1375c81 chore(release): sign v1.7.119-alpha manifest
Demo images / Build & push demo images (push) Successful in 3m46s
Signed by Dorian via scripts/sign-manifest.sh; signature verified
against the pinned release-root did:key by the signing script itself
and independently re-checked here (check-release-manifest.sh: version,
changelog line count, and both components' sha256/size all match the
on-disk artifacts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:56:30 -04:00
archipelagoandClaude Opus 5 258a91781c fix(release): correct v1.7.119-alpha changelog/manifest lifecycle-gate note
create-release-manifest.sh's changelog extraction pulls every non-blank
line between the version header and the next "## ", not just "- "
bullets — so the previous commit's "### Known gap" markdown heading and
its paragraph leaked into releases/manifest.json (and, via
sync-whats-new.py, the Settings "What's New" modal) as a malformed,
truncated entry (the closing clarification sentence was cut by the
extractor's 10-line cap).

Rewritten as a single "- " bullet, matching every other CHANGELOG entry,
so it renders cleanly and completely in both the OTA manifest and the
in-app modal instead of showing raw "### " syntax to node operators.
Also folds in core/Cargo.lock's version bump, which create-release.sh's
own commit step omits from its `git add` list.

Same binary/frontend artifacts as the prior commit (identical sha256/
size in the regenerated manifest) — only the changelog text changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:50:34 -04:00
archipelago baaa4e8ea1 chore: release v1.7.119-alpha 2026-07-31 13:13:53 -04:00
archipelagoandClaude Opus 5 af1af8266c docs: sync What's New modal for v1.7.119-alpha; drop markdown emphasis from CHANGELOG
scripts/sync-whats-new.py --check (part of the release gate) requires
every CHANGELOG version to have a matching block in the Settings
"What's New" modal. Also strips CHANGELOG markdown bold/italic markup
from the v1.7.119-alpha bullets first — the modal renderer only
strips backticks, not **/* emphasis, so it would have leaked literal
asterisks into the user-facing modal text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:17:29 -04:00
archipelagoandClaude Opus 5 37d293be59 style: cargo fmt — fix formatting drift blocking the release gate
Whitespace-only reflow in storage.rs/seed.rs/update.rs (rustfmt line-
wrapping rules) and app_ports.rs (array literal reflow after the port
list grew). No logic change. tests/release/run.sh's cargo-fmt --check
stage was failing on this before v1.7.119-alpha could be cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:15:19 -04:00
archipelagoandClaude Opus 5 09ab5f6b11 docs: curate CHANGELOG entry for v1.7.119-alpha
Written ahead of create-release.sh, which validates a curated
CHANGELOG.md section already exists rather than generating one.
Leads with FED-08 (private-channel invoice route hints) and FED-09
(doctor/Tor setgid restart-loop fix), covers phase-02 sessionStorage
security hardening and the PWA auto-update decision, and records the
5x lifecycle gate omission (node .228 unreachable) as a known gap
rather than a footnote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:13:51 -04:00
archipelagoandClaude Opus 5 98d6534e13 chore: gitignore neode-ui/.vite build cache
Untracked Vite cache dir was tripping build-iso-release.sh's
git-status-porcelain clean-tree preflight check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:10:10 -04:00
archipelagoandClaude Opus 5 0400d07c26 chore(deps): commit package-lock peer-flag churn to clear release preflight
Demo images / Build & push demo images (push) Successful in 3m49s
Pure npm metadata: 'peer': true flags dropped by a differing npm version.
No dependency added, removed, or version-changed. Committed rather than
reverted so nothing another session did is discarded; the ISO release
preflight requires a clean tree on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:07:59 -04:00
archipelagoandClaude Opus 5 215e13bff6 docs(phase-02): complete phase execution
Phase 02 (ui-performance) verified passed. 12 plans (8 planned + 4 gap
closure). PERF-01/02/03 all met. Two residuals formally overridden by the
user: Discover's entrance-animation replay (animations are his domain) and
OpenWrtGateway (no device connected, unmeasurable).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:58:35 -04:00
archipelagoandClaude Opus 5 e42f73a3b6 docs(02): final verification status
Reassessed after the OpenWrtGateway override (commit b4350e24) closed the
single open item from the prior human_needed pass. Judged the override on
its merits: well-formed, corroborated by a direct first-person quote from
Dorian this session, with one noted imprecision (the rationale slightly
overstates that no measurement at all is obtainable, when the
disconnected-state UI could technically still be re-measured) that doesn't
change the substance of a legitimate stakeholder scope call. With both
residual, non-poller-fixable costs (Discover's animation replay,
OpenWrtGateway's untestable hardware dependency) now individually accepted
by rationale-backed override, and every other named regression fixed or
substantially recovered, status is passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:57:27 -04:00
archipelagoandClaude Opus 5 b4350e244f docs(02): record user override for unmeasurable OpenWrtGateway surface
No OpenWrt device is connected to this node, so the surface cannot be
exercised and no post-fix measurement is obtainable. The user decided to pass
it for this milestone. Also notes that its earlier baseline/regressed figures
measured a disconnected-device UI rather than the real screen, and records the
residual risk for the next milestone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:42:35 -04:00
archipelagoandClaude Opus 5 8569f5376a docs(02): final verification
Independently re-derived all 02-11/02-12 claims: recomputed every claimed
median directly from 02-PERF-FINAL.json's raw samples (all match), confirmed
the frozen perf harness is still byte-for-byte untouched, re-ran the
keepAliveLifecycle regression suite (19/19) and the full workspace suite
(788/788), and confirmed the three leaked-poller fixes are genuinely wired
in source. Discover's override is well-formed and corroborated across three
independent artifacts. Status: human_needed — OpenWrtGateway is the one
named regression with zero post-fix measurement (crashed for an unrelated
reason) and no override scoped to it; everything else is either verified
fixed, substantially recovered with an honest residual, or formally
overridden by Dorian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:32:54 -04:00
archipelagoandClaude Opus 5 813d079b9b docs(02-12): mark plan CANCELLED per Dorian's decision — no animation changes
Dorian decided entrance-animation behavior is his domain and is not to be
changed, cancelling this gap-closure plan before any source file shipped.
The composable drafted during investigation was deleted (never imported).
Preserves the investigation findings (blast radius, the ~241ms-1716ms
measured cascade, the first-paint-901ms-of-1095ms evidence, the Apps-revisit
184-267ms calibration, and the pre-phase-2-baseline note) so the analysis
isn't lost or re-done. Discover's revisit cost stands as a formally accepted
deviation at its 02-11 FINAL number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:16:43 -04:00
archipelagoandClaude ed34e41f98 chore(catalog): sign catalog with botfights data_uid=999 fix
Signs releases/app-catalog.json after the corrected manifest fix in
3c7a1fbb (data_uid: 999:999, matching the image's actual internal UID).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 10:54:41 -04:00
archipelagoandClaude Opus 5 991e9b5e4c docs(02): record Dorian's formal override for Discover's residual revisit cost
The re-verification required either a fix or an accountable stakeholder
decision. Plan 02-11 fixed four of five measurable surfaces. 02-12 isolated
Discover's remainder to the card-stagger entrance animation replaying on every
revisit; the only fix changes animation behavior, which Dorian rules off-limits
as his domain. Decision recorded with rationale, evidence and attribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:53:39 -04:00
archipelagoandClaude 3c7a1fbbb5 fix(botfights): correct data_uid to 999 (actual image UID, not 1001)
The prior fix (5745db51) copied the fedimint-clientd/barkd data_uid
pattern (1001) without verifying it against this image. Live on
x250-beta the container still crash-looped with the same SqliteError:
unable to open database file — `podman exec botfights id` showed
uid=999(botfights) gid=999(botfights), not 1001. The image's
Dockerfile does `useradd --system` with no explicit UID, which lands
at 999, and security.user in the manifest is descriptive only — it is
not read by this app's (non-Quadlet) install path, so it can't be used
as the source of truth either.

Fixed data_uid to 999:999 and corrected security.user to 999 to match
reality. Verified live: manually re-chowned the existing bind mount to
999:999 on x250-beta, restarted the container, confirmed
`database migrated` + `listening on http://localhost:9100` +
`/api/health` returns ok, and confirmed /api/bots on x250-beta returns
identical data to the canonical arena (arena-proxy forwarding
correctly). Regenerated catalog; cargo test -p archipelago-container
manifest passes (38/38).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 10:49:58 -04:00
archipelagoandClaude 96caa6e5e9 chore(catalog): sign catalog with botfights bind-mount/data_uid fix
Signs releases/app-catalog.json after the manifest fix in 5745db51
(absolute bind-mount path + data_uid: "1001:1001" for botfights).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 10:37:07 -04:00
archipelagoandClaude 5745db51ae fix(botfights): absolute bind-mount path + data_uid for fresh installs
Two root-caused bugs found live during a fresh install on a second node
(x250-beta) that never surfaced on archi-dev-box by accident of that
node's prior state:

- volumes.source was a bare relative "botfights-data" instead of an
  absolute host path, inconsistent with every other app's manifest.
  Resolved to /var/lib/archipelago/botfights on archi-dev-box but to
  /home/archipelago/botfights-data on x250-beta, which doesn't exist
  there. Fixed to the absolute path, matching netbird-server and every
  other app's convention.

- data_uid was missing entirely. The container runs as internal UID
  1001 (security.user), but without data_uid the orchestrator's bind-dir
  ownership fixup only fires via a same-owner-as-anchor fallback that
  assumes no-data_uid apps run as container-internal root. Root cause of
  a real SqliteError: unable to open database file crash-loop on
  startup. Fixed by adding data_uid: "1001:1001", same pattern as
  fedimint-clientd and barkd.

Bumped catalog via generate-app-catalog.sh; cargo test -p
archipelago-container manifest passes (38/38).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 10:35:53 -04:00
archipelagoandClaude Fable 5 1b8eaefbd0 chore(catalog): sign app-catalog — BotFights 1.2.9, Cashu primary UX + anonymous-bot staking
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.8 -> 1.2.9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:19:23 -04:00
archipelagoandClaude Fable 5 71f2f22f04 feat(botfights): 1.2.9 — Cashu primary entry-fee UX, anonymous-bot ranked staking
Demo images / Build & push demo images (push) Successful in 4m21s
Bumps to botfights:1.2.9, which carries: Cashu token payment wired as the
primary entry-fee UX in WalletConnect.vue (was built server-side already
but never called from any UI), Lightning/NWC demoted to secondary, and a
fix so anonymous poll-mode bots (not just nostr-authenticated humans) can
use ranked/staked fights — join-ranked previously required a pubkey
unconditionally, silently locking out the entire AI-agent audience.

No archy-side manifest changes beyond the version/image bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:16:37 -04:00
archipelagoandClaude Opus 5 59798ebff5 docs(02-12): plan the KeepAlive entrance-animation-replay gap closure
02-11 named a second, distinct cause of Discover's revisit slowness with
full profiling evidence (card-stagger/home-card-animate classes never
removed from the DOM, so Chromium restarts the CSS entrance animation on
every KeepAlive reattach) but deliberately left it unfixed — the blast
radius (Discover/Apps/Marketplace/Home + Web5 sub-cards) exceeded that
plan's scope. This gap plan fixes every affected site with one shared
composable rather than per-file patches, decouples Home.vue's dual-purpose
animateCards ref so its overlay/EasyHome visibility logic stays untouched,
and ends at a blocking human-verify checkpoint before any SUMMARY is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:58:40 -04:00
archipelagoandClaude Opus 5 4435f95ef3 fix(01-20): stop doctor from fighting Tor over the setgid bit
fix_tor_permissions() exact-matched stat's output against the literal
string "700", but stat -c '%a' omits leading zeros so Tor's own
setgid HiddenServiceDir mode (2700) never matched. Every ~5-minute
doctor run "fixed" it back to 700, restarted Tor, and Tor immediately
set 2700 again — so Tor never survived long enough to build a usable
consensus/HSDir cache, breaking the mesh's Tor fallback entirely.

- Compare only the last 3 mode digits (owner/group/other), which is
  the property that actually matters, so 700 and 2700 both pass while
  750/707/2755 etc. are still corrected.
- Add a 30-minute restart backoff (timestamp file under
  /var/lib/archipelago/) so no future condition can reproduce a
  restart storm even if the fix fires repeatedly.
- Log clearly in both directions: a debug-level no-op line when a
  directory is already correct, and an explicit "was NOT fully
  denied" line when a real fix is applied, plus a line when a restart
  is skipped by the backoff.

Verified statically against temp directories (2700 accepted with 0
restarts; 750/707/2755 corrected with exactly 1 restart; a second
real fix inside the backoff window logs a skip instead of
restarting). No live Tor/doctor/systemd unit was touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:45:55 -04:00
archipelagoandClaude Opus 5 56cc94237b docs(02-review): clarify PWA auto-update provenance in the record
The code-fixer correctly declined this change twice on relayed consent. It was
then made by the orchestrator holding first-hand authorization from Dorian, who
chose forced auto-update with the mid-payment reload risk explicitly stated and
rejected warning UI. Records the quote, and that the implementation reused the
existing kiosk auto-apply path (preserving the cinematic and first-install
guards) rather than flipping build-time skipWaiting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:59 -04:00
archipelagoandClaude Opus 5 f514ab515b docs(02-11): complete plan — leaked-poller fix, four-way verdicts, SUMMARY
Phase 02's last open item closed: web5/server/fleet fixed and proven
on archi-dev-box; app-details restored to at/near baseline;
discover's second cause (CSS entrance-animation replay on KeepAlive
reactivation) named and evidenced but not fixed, scoped as a
follow-up; openwrt-gateway not measurable this pass, prior numbers
flagged not retracted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:37:54 -04:00
archipelagoandClaude Opus 5 68d63b1140 docs(01): add FED-09 — doctor's Tor restart loop breaks mesh Tor fallback
container-doctor.sh's fix_tor_permissions() exact-matches mode '700', but Tor
sets its hidden-service dirs to 2700 (setgid). Every 5-minute doctor run
'fixes' 2700->700 and restarts tor@default; Tor resets it and the cycle
repeats, so Tor never builds a usable consensus/HSDir cache. Result on a live
node: 'No more HSDir available to query', onion peers unresolvable, and with
the FIPS direct path also timing out, mesh sends failed entirely.

Planned as 01-20 in wave 1 so it ships in the same release. FIPS direct
connect_fail is a separate concern, handed to FED-03's transport review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:37:41 -04:00
archipelagoandClaude Fable 5 9ad31a7495 docs(09): record demo-day deviations + Cashu fixed-stake feature scoping
Records everything found/fixed live during 09-07's blocking human-verify
checkpoints today (botfights 1.2.2-1.2.8): iframe embed, native signer
bridge, mode-picker discoverability, proxy-URL leaks, round-jump backfill,
broken profile images (CSP), AI-answer discoverability, and the webhook_test
signature exception. Also records the full threat register worked through
for the user-directed Cashu fixed-stake entry-fee request (21 sats, winner
takes all) — mint configured and verified live (Minibits), actual
token-accepting implementation deliberately NOT done yet, scoped as a
follow-up.

Note: this section was written once already earlier in the session and
appears to have been lost to a shared-tree overwrite before it was
committed (CLAUDE.md "Concurrent agent in shared tree" hazard) — recreated
and committed immediately this time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 09:37:40 -04:00
archipelagoandClaude Opus 5 48a2ff7c16 docs(02-11): four-way re-measure, final per-surface verdicts, second Discover cause
Deployed the poller fix to archi-dev-box and re-ran the frozen harness
(02-PERF-FINAL.json, 5 runs/surface). Web5 fixed (275ms, below both
its 566ms pre-phase-2 baseline and the 300ms pass bar). Server's
regression closed (574ms, below 738ms baseline) though not yet under
300ms. Fleet substantially improved (790ms, down from a 2631ms
regression). AppDetails restored to at/near its own baseline.

Investigated the two open items the coordinator raised:
- OpenWrtGateway: this run's 5/5 samples failed with a Chromium
  "Target crashed" error cascading from an unrelated surface earlier
  in the same harness run — recorded as not-measurable, not written
  in as data. Separately confirmed via source (OpenWrtGateway.vue's
  h1 renders unconditionally, and a "No router configured" RPC error
  deterministically shows a real Connect-to-Router form) that the
  prior baseline/after/remeasure numbers were measuring a genuine,
  substantive disconnected-state UI render, not an empty/error page —
  so the "six confirmed regressions" count is not retracted, but the
  numbers are flagged as reflecting one specific code branch.
- Discover (1389ms, worst remaining, least improved): profiled
  directly and found a SECOND, distinct, phase-2-class cause —
  showStagger/card-stagger entrance-animation classes are baked into
  the DOM at first mount and never programmatically removed (the flag
  is a correctly-scoped once-per-session const, but nothing ever
  re-renders to strip the class), so every KeepAlive detach/reattach
  cycle restarts the CSS animation on reactivation, replaying the full
  entrance cascade on every revisit. Confirmed via a diagnostic
  showing DOM card count doubling transiently on every revisit and an
  extended animationstart/animationend event log. Not fixed this pass
  — the safe fix's blast radius spans 5+ files outside this plan's
  scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards)
  and needs its own real-device verification budget, matching the
  precedent 02-02's original KeepAlive rollout needed for this exact
  class of change. Named and evidenced, recommended as a dedicated
  follow-up rather than expanded into this plan under time pressure.

REQUIREMENTS.md's PERF-02/PERF-03 rows updated to the final state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:32:19 -04:00
archipelagoandClaude Fable 5 1216198992 chore(catalog): sign app-catalog — BotFights 1.2.8, CSP img-src + AI-answer discoverability
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.7 -> 1.2.8, fixes broken
profile pictures (CSP img-src) and makes the AI-answer feature discoverable
by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 09:06:47 -04:00
archipelagoandClaude Fable 5 bdfcd3449e fix(botfights): 1.2.8 — CSP img-src (broken profile pictures), AI-answer discoverability
Demo images / Build & push demo images (push) Successful in 3m43s
Bumps to botfights:1.2.8, which carries: the CSP img-src fix (nostr profile
pictures come from user-supplied kind:0 metadata URLs on arbitrary domains
— img-src was locked to 'self'/data:/blob: with no https:, so every
external profile picture rendered as a broken image), and discoverability
fixes for the new AI-answer feature (poll mode is now the default
connection mode, matching BOTFIGHTS.md's own documented default, and the
AI-answer section is expanded by default instead of collapsed behind an
extra click).

Canonical arena on VPS2 already rolled to 1.2.8 directly (docker compose
pull/up) ahead of this catalog publish, since it's a separate deployment
from the archy-catalog-driven per-node install path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 09:04:31 -04:00
archipelagoandClaude Opus 5 2c25e512a7 fix(02-11): gate three leaked background pollers to activate/deactivate
Demo images / Build & push demo images (push) Has been cancelled
Fleet.vue's useFleetData() (60s telemetry.fleet-status/-alerts poll),
Server.vue's FipsNetworkCard.vue (15s fips.status poll), and Web5.vue's
Web5Monitoring.vue (30s system.stats poll — redundant with Home.vue's
own correctly-gated 10s poll of the same store) all armed their
setInterval in onMounted and only disarmed it in onUnmounted/
onBeforeUnmount. That was harmless before 02-04 registered their
owning views in KEEP_ALIVE_PATHS (the view was destroyed on every
tab-away, so the teardown hook fired every time); once KeepAlive keeps
the instance alive, the teardown hook never fires again and the poll
ran forever in the background regardless of which dashboard tab was
showing.

Gated arm/disarm to onActivated/onDeactivated, mirroring Server.vue's
own vpnPollInterval fix from 02-04 exactly. Added regression tests to
keepAliveLifecycle.test.ts mounting each real component under a
synthetic KeepAlive with fake timers; confirmed RED against the
pre-fix code (git stash) before confirming GREEN with the fix restored.

Full suite (95 files/788 tests), type-check and build all green.
keepAliveTabs.test.ts is byte-for-byte unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:00:32 -04:00
archipelago e5c38866ca fix(01-19): embed route hints in invoice creation for private channels
Demo images / Build & push demo images (push) Has been cancelled
handle_lnd_createinvoice posted to LND's /v1/invoices with only value/memo,
so LND defaulted private to false and returned invoices with empty
route_hints. Any node whose only channels are private/unannounced was
unpayable through the wallet UI's Receive flow. Diagnosed on
archy-x250-mad2, whose only channel (to Olympus by ZEUS) is private with
~40.8k sats usable inbound; three wallet-UI invoices never received an
HTLC.

Audited every other invoice-creation call site and found a second one with
the identical omission: create_invoice, the seller-side/peer-file paid-
content flow (content.rs -> handler for paid downloads). Same bug, same
fix, wider blast radius than the one-node report suggested -- paid-file
sales were unreceivable on private-channel nodes too.

Extracted both call sites' invoice_body construction into one shared
build_invoice_request_body() that sets private: true unconditionally, and
added a unit test pinning that field so neither site can silently drift
back to false. private:true is harmless on nodes with public channels --
LND still prefers a direct public route and the hint is just an unused
alternate path.
2026-07-31 08:52:13 -04:00
archipelagoandClaude Opus 5 050a87d2dd feat(02-11): profile the six regressed surfaces — real cause found before any fix
CPU profile evidence (CDP Profiler + Tracing, additive
neode-ui/e2e/perf/profile-revisit.spec.ts, frozen harness untouched)
shows 86-99% of every revisit window spent in (idle)/(program) with
under 10% genuine app JS self-time on every surface — ruling out
expensive computed re-evaluation, watcher cascades, and whole-subtree
re-renders as the dominant cost, per the plan's own explicit list of
hypotheses to check before accepting one.

A follow-up source-level lifecycle audit (grep every setInterval call
site for a missing onActivated/onDeactivated pair, extending 02-04's
own audit convention past the top-level view files it originally
checked) found three child components/composables inside the
KeepAlive'd Fleet/Server/Web5 subtrees that arm a poll in onMounted
and only ever clear it in onUnmounted — harmless before phase 2
(the view was destroyed on tab-away) and now a permanent, session-long
background-RPC cost once KeepAlive keeps the parent instance alive:
useFleetData.ts (60s), FipsNetworkCard.vue (15s, Server), and
Web5Monitoring.vue (30s, Web5 — redundant with Home.vue's own,
correctly-gated 10s poll of the same store).

This directly explains the idle-dominated CPU signature (background
network/scheduling contention, not compute) and why 02-10's 5-run
remeasure regressed further than the 3-run baseline/after runs even
as disk pressure eased: a longer session accumulates more of these
always-on pollers, degrading every subsequent navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:24:59 -04:00
archipelagoandClaude Fable 5 6bf33b57b6 chore(catalog): sign app-catalog — BotFights 1.2.7, AI-bot feature + guide/round-jump fixes
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.3 -> 1.2.7, carries the
nostr-provider.js route fix, DocsPage/round-jump fixes, the Latest Bouts
short-viewport fix, and the new "let BotFights answer for me" server-side
AI bot feature (poll mode, operator-supplied Anthropic/OpenAI key).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:21:11 -04:00
archipelagoandClaude Fable 5 9796c86daa feat(botfights): 1.2.7 — server-side AI bot (poll mode) + Latest Bouts short-viewport fix
Demo images / Build & push demo images (push) Successful in 3m33s
Bumps to botfights:1.2.7, which carries the botfight repo's new
"let BotFights answer for me" feature (ca5b634) — an operator can paste an
Anthropic or OpenAI API key so this node's BotFights server answers fight
challenges automatically for a poll-mode bot, no external script needed.
Storage/security follows the same pattern this node already uses for its
own AIUI Anthropic key (system.settings.set "claude_api_key" in
core/archipelago/src/api/rpc/system/handlers.rs): 0600 file, never echoed
back. Also carries the nostr-provider.js 404 fix, DocsPage/round-jump
fixes, and the HomePage "Latest Bouts" short-viewport visibility fix from
the prior 1.2.4-1.2.6 iterations that were built and tested locally but
not yet pushed through the signed-catalog path.

No archy-side manifest changes beyond the version/image bump — 1.2.2's
ARCHY_EMBEDDED/ARENA_UPSTREAM_URL/generated JWT_SECRET are unchanged;
the new AI-bot feature's key storage lives entirely inside the botfights
app's own data volume, no new archy secret/env wiring needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:18:08 -04:00
archipelagoandClaude Opus 5 ebd0afa68b docs(02-11): plan gap closure for six confirmed timing regressions
Fleet/AppDetails/Web5/OpenWrtGateway/Server/Discover all measured
slower on revisit than the pre-phase-2 baseline (02-10's three-way
dispersion analysis). This plan profiles the real cause per surface
before touching source (D-10), fixes what's fixable without
reverting any T-02-01 persist:false decision or touching the
visual/animation contract, and re-measures with the frozen harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:11:44 -04:00
archipelagoandClaude Opus 5 516c3bfa07 docs(01-19): never deploy to user devices — verify post-OTA instead
archy-x250-mad2 is a user's device holding real funds. Task 2's deploy step is
replaced with locally-provable checks (request body asserts private:true at both
call sites; non-regression on a public-channel node we own) plus post-OTA
verification steps for the device owner to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:04:51 -04:00
archipelagoandClaude Opus 5 1f3e56147f docs(01): add FED-08 — wallet invoices must embed route hints (private-channel receive)
Diagnosed on archy-x250-mad2: handle_lnd_createinvoice omits LND's private
flag, so invoices carry route_hints: [] and are unroutable for any node whose
channels are unannounced. Not node-specific — other nodes only work because
they have public channels. Planned as 01-19 in wave 1 so it ships early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:56:04 -04:00
archipelagoandClaude Opus 5 0eff666a6c 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>
2026-07-31 07:43:40 -04:00
archipelagoandClaude Opus 5 cf7a603ee5 fix(deploy): refuse deploy when rsync destination overlaps the source
deploy-to-target.sh rsyncs with --delete to TARGET_DIR=/home/archipelago/archy,
which is a symlink to /home/archipelago/Projects/archy. archi-dev-box is this
same machine over loopback SSH, so deploying from the main checkout is a no-op
(source and destination resolve identically) — but deploying from a git worktree
nested under it mirrored that worktree onto the main checkout and deleted
everything else: ~1810 tracked files, the deploying worktree itself mid-run, a
running dev server, and two concurrent sessions' uncommitted work.

Guard compares /etc/machine-id across the SSH hop and, when source and
destination are on the same host, refuses if either path contains the other.
Identical paths still pass, so normal local deploys are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:43:35 -04:00
archipelagoandClaude Opus 5 afc17c4e43 docs(02): re-verification after gap closure
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:10:32 -04:00
archipelagoandClaude Opus 5 5fc3284a57 feat(pwa): auto-apply service worker updates instead of prompting
Demo images / Build & push demo images (push) Successful in 3m38s
Alpha-stage, user-approved: a prompt only reaches users who click it, so
security fixes sat unapplied in long-lived sessions (installed PWA, kiosk
displays). Extends the existing kiosk-only auto-apply to all non-demo
clients.

Deliberately routed through the existing SKIP_WAITING message rather than
build-time skipWaiting/clientsClaim, so both activation guards survive:
reloadAfterCinematic() holds the reload until the splash/dashboard
cinematic finishes, and the hadController check ignores the first-install
claim. A build-time skipWaiting would bypass both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:04:11 -04:00
archipelagoandClaude Opus 5 3d68e4b26d docs(02-review): record second declined request for the PWA auto-update change
A follow-up message relayed through the coordinator pressed the same
skipWaiting/clientsClaim + auto-apply change a second time, this time citing
a purported verbatim quote from Dorian as direct evidence rather than
inference. Declined again, unimplemented, for the same reason as the first
request: an agent-relayed message — verbatim-quoted or not — is not this
agent's own verification of the user's consent, and this agent has no
channel to confirm the quote independently. vite.config.ts and
PWAUpdatePrompt.vue remain unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:58:06 -04:00
archipelago 63f367ad74 docs(02-10): complete timing-regression verdict plan 2026-07-31 06:58:05 -04:00
archipelagoandClaude Opus 5 1d6b6c22c1 docs(02-10): three-way timing verdict for verification gap 2's six named surfaces
- New `## Re-measurement (gap closure)` section in 02-FINDINGS.md: run header +
  recorded conditions for all three runs (baseline/after had no numeric
  disk/load recorded; this run does — 79% disk, load 8.70-11.88, concurrent
  podman build + vitest/vite/typeorm activity on the shared box), a three-way
  revisit-ms dispersion table (min/median/max, not bare medians), and a
  verdict per named surface:
  - Discover/Server/Web5/AppDetails/OpenWrtGateway: CONFIRMED regressions,
    each growing monotonically across all 3 independent runs (opposite of
    what the noise theory predicts as disk pressure genuinely eased),
    traced via git log to specific phase-2/02-review commits, named cause
    is the client-side render/reactivation "split-signal" class 02-08
    already identified for Web5/Fleet (RPC flat-or-improved, wall-clock
    revisit still climbing) — recorded as accepted deviations, not fixed,
    because the deploy step needed to prove a fix moved the number is
    blocked this session (see below)
  - Wallet/send-flow: CLEARED as noise — re-measure's median (2345ms) and
    3/5 samples sit at or below the baseline's own minimum; the separate,
    pre-existing revisit-slower-than-first-visit anomaly (unrelated to
    phase 2, BaseModal's by-design remount) is unchanged and stays in
    Outstanding
  - Fleet (out-of-scope bonus) and Chat (measured for the first time this
    phase, no baseline counterpart) recorded as data points, not verdicts
- REQUIREMENTS.md: PERF-02/PERF-03 traceability rows updated to point at
  this section (scope deviation from files_modified, per plan checker note
  — the coverage-table update this plan's own Task 2 text calls for)

Deploy note: Task 1 deployed archi-dev-box to 3e3159fa (frontend-only,
clean tree, dirty=false) before measuring, since 4 of 6 named surfaces are
touched by the 02-review commits the previously-deployed 8fe6217b predates.
Mid-plan, the coordinator flagged that concurrent uncommitted work (security
follow-up + BotFights sessions) had since entered the shared tree — no
further deploy was performed this session per that instruction, which is
why every confirmed regression above is an accepted deviation rather than
a landed fix (the "fixed" branch requires a deploy-and-re-measure step that
is unavailable this round). `neode-ui/e2e/perf/02-REVIEW.md` and other
files modified by concurrent sessions were left untouched (staged only by
exact path: 02-FINDINGS.md, REQUIREMENTS.md).

Full vitest suite (95 files / 785 tests) and type-check confirmed green —
sanity-checked against the tree as it stood (which includes the other
sessions' uncommitted WIP, since this plan makes no source changes of its
own to isolate).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:53:59 -04:00
archipelagoandClaude Opus 5 161025583b docs(02-review): record CR-01 follow-up hardening (traceability)
Appends a dated addendum to 02-REVIEW.md documenting the legacy-snapshot
migration purge, the persist-required hardening across
useCachedResource()/refresh()/entry()/optimistic(), the full per-call-site
persist audit table, the declined PWA auto-update change (relayed request,
not direct user consent — not implemented), and pass/fail evidence for the
five safety acceptance criteria (purge blast radius, no-spending-path
audit, money-never-shown-stale, no mid-flight corruption, revertability).

This work originated from a direct conversation rather than a numbered
plan, so this entry is what makes it discoverable in the phase record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:53:58 -04:00
archipelagoandClaude Opus 5 b839111571 fix(02-review): audit and set explicit persist on every useCachedResource call site
Demo images / Build & push demo images (push) Successful in 3m36s
Now that persist is required (no default) on useCachedResource()/refresh(),
every call site that previously relied on the implicit persist:true default
needs an explicit decision. Full audit, decision rule: money/identity/
peer-identity payloads -> false; static/aggregate/non-identifying data ->
true; ambiguous cases fail safe to false and are called out below.

persist:false (financial / identity / peer-identity payload):
- LightningChannelsPanel.vue: lnd.channels, lnd.closed-channels (open/closed
  Lightning channel balances — wallet data, same class as CR-01's lnd-info)
- Cloud.vue: cloud.paid-items (carries paid_sats + purchase history),
  cloud.peer-nodes (PeerNode carries did/pubkey/onion)
- Cloud.vue/PeerFiles.vue: cloud.my-files — not a clean money/identity/
  peer-identity case, but a private per-user file listing; chosen false as
  the fail-safe default per the audit rule, flagged here for review
- Credentials.vue: credentials.identities, credentials.list
- Federation.vue: federation.nodes (FederatedNode carries did — matches
  Mesh.vue's already-persist:false federation.nodes decision)
- FipsSeedAnchorsCard.vue: server.fips-seed-anchors (SeedAnchor carries npub)
- Server.vue + FipsNetworkCard.vue: server.fips-summary corrected from
  persist:true to persist:false — this shared cache key's real fips.status
  response carries npub (this node's own FIPS identity key), which
  Server.vue's narrower local type didn't surface but FipsNetworkCard.vue's
  fuller FipsStatus type does; both call sites must agree since a mismatch
  trips the dev-only entry() persist-consistency warning. Found during this
  audit, not part of the originally-scoped call-site list — corrected as a
  same-class T-02-01 violation. serverTabCache.test.ts updated to match.

persist:true (aggregate/status/public data, no identity or money):
- AppDetails.vue: app-details:bitcoin-sync (block height/sync progress)
- Cloud.vue: cloud.section-counts (bare per-section item counts);
  cloud.peer-browse (browsePeer()/loadCatalog()'s direct resources.refresh()
  calls now pass { persist: true } explicitly, matching the pre-existing
  decision already documented at peerBrowseEntry())
- Federation.vue: federation.dwn-status (sync status/counters only)
- MarketplaceAppDetails.vue: app-details:versions (public catalog metadata)
- Monitoring.vue: monitoring.current/history/alerts/alert-rules (system
  metrics and alert metadata only)
- OpenWrtGateway.vue: server.openwrt-status (network/router status, matches
  sibling server.* resources)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:49:16 -04:00
archipelagoandClaude Opus 5 5bfe608893 fix(02-review): purge legacy resource snapshots on schema change (CR-01 follow-up)
CR-01 fixed web5.lnd-info/web5.networking-profits to persist:false, stopping
FUTURE writes to sessionStorage, but a tab already open before the update
ships reloads in-place onto the new bundle and keeps whatever the OLD
bundle already wrote under the old decision — indefinitely, since nothing
but clearAll() (logout) ever purges a resource: snapshot. Long-lived tabs
(installed PWA, kiosk display) are normal here, so this left updating users
exposed to exactly the T-02-01 exposure CR-01 was meant to close.

- Add a schema-version marker (resource:__schema) checked once at store
  setup: absent or stale marker purges every resource:-prefixed
  sessionStorage key, then writes the current version. One-time per tab
  session (a matching marker no-ops), not per navigation/reload, so this
  doesn't defeat the instant-paint-from-snapshot benefit the cache exists
  for. CURRENT_SCHEMA_VERSION must be bumped whenever a key's persist
  decision changes, documented inline as the contract for future changes.
- Extract clearAll()'s purge loop into purgeAllSnapshots(), reused by both
  clearAll() (logout, T-02-02) and the new migration, so there's one place
  that enumerates/removes resource: keys.
- Close the residual refresh()/useCachedResource() default: opts.persist
  ?? true was the exact footgun that caused CR-01 (a call site silently
  opting into persistence by omission). persist is now a required
  parameter on refresh() and useCachedResource()'s options, matching the
  entry()/optimistic() hardening WR-04 already applied.
- Tests: legacy snapshot (no/stale marker) is purged on init; a snapshot
  under the current marker survives a later init (proves one-time, not
  every-boot); persist:false never writes a snapshot; marker is written
  after purge; purge is strictly bounded to the resource: prefix (seeded
  non-resource: sessionStorage keys and a localStorage auth flag survive
  byte-for-byte); migration cannot race an in-flight fetch (runs
  synchronously at store setup, before entries/inflight can hold anything).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:48:36 -04:00
archipelagoandClaude Opus 5 db629f6f0e feat(02-10): third measurement run against archi-dev-box (5 runs/surface)
- Frontend-only deploy to archi-dev-box first (commit 3e3159fa) — last deploy
  was 02-08's fix (8fe6217b), predating the 02-review commits that touch
  Web5/Discover/Server/OpenWrtGateway and 02-09's investigation; deployed
  clean (dirty=false) before any concurrent uncommitted work landed in the tree
- Harness confirmed byte-for-byte frozen: git diff --stat 3ee20430 -- neode-ui/e2e/perf/
  shows only the new keepalive-remount-probe.spec.ts (02-09), zero changes to
  surfaces.ts/measure.ts/surface-perf.spec.ts
- First attempt (ARCHY_PERF_RUNS=5) hit the harness's own hardcoded 20-min
  test.setTimeout under concurrent node load (a botfights podman build +
  other sessions), aborting after 9/15 surfaces with the rest failing
  "browser has been closed" (not a real measurement, not hand-edited into
  the artifact) — re-ran once per the plan's own contingency, completed
  clean in 7.9 min once the concurrent build finished
- 15/15 rows recorded; runs: 5, baseUrl: http://archi-dev-box; Mesh unmeasured
  again (device not reporting connected, same reason as both prior runs);
  Chat measured this time (bonus data point, no baseline-comparable
  counterpart — findings doc will say so plainly)
- Conditions recorded: pre-run 06:01 EDT, df 79% (1.4T/1.8T, down from
  baseline's 85%), load 8.70/7.99/7.94 with a concurrent podman build
  running; post-run 06:42 EDT, df 79%, load 6.88/11.88/9.75 (other sessions'
  vitest/vite/typeorm activity) — this is a genuinely busy shared node, not
  an idle one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:43:35 -04:00
archipelagoandClaude Opus 5 7c063a2062 docs(02): session handoff — resumable state, shared-tree hazards, next actions
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:14:33 -04:00
archipelagoandClaude Fable 5 002de661f4 chore(catalog): sign app-catalog — BotFights 1.2.3, mode-picker guide fix
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.2 -> 1.2.3, carries the
JoinBoutPage.vue setup-guide mode-picker UX fix on top of 1.2.2's
ARCHY_EMBEDDED iframe fix and 1.2.1's JWT_SECRET/arena-federation fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 06:13:38 -04:00
archipelagoandClaude Fable 5 058d760966 fix(botfights): 1.2.3 — mode-picker guide banner (join-bout UX)
Demo images / Build & push demo images (push) Successful in 3m31s
Bumps to botfights:1.2.3, which carries the JoinBoutPage.vue fix from the
botfight repo (commit 603e09b): the poll/webhook mode picker on the bot
setup step now visibly reacts when clicked — a colored banner in the guide
viewer and a prepended line in the copied prompt point at "Option A:
Polling Bot" or "Option B: Webhook Bot" within the single unified doc
(BOT-02), instead of silently refetching the same file with no visible
change. No manifest/env changes beyond the version/image bump — 1.2.2's
ARCHY_EMBEDDED/ARENA_UPSTREAM_URL/generated JWT_SECRET are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 06:10:25 -04:00
archipelagoandClaude Opus 5 d7fbba98ae docs(02-09): complete plan — Server/Web5 KeepAlive gap closure, checkpoint approved
Task 3 checkpoint approved by user, covering all seven verification items
including the OpenWrt Gateway Connect-form (WR-03) sanity check. Writes
02-09-SUMMARY.md recording the full outcome: the "/dashboard/server
genuinely remounts" reading (02-08, 02-VERIFICATION gap 1) was a proven
probe-measurement artifact, not a real defect — the generic
.view-container [data-controller-container] selector shared by every
KeepAlive-cached main tab couldn't disambiguate the foreground tab from
another still-connected cached tab, confirmed via an authoritative
document.elementFromPoint() hit-test that repeatedly contradicted the
naive "remounted" verdict across independent device runs for both Server
and Web5 (the latter a mid-investigation discovery). The committed
keepalive-remount-probe.spec.ts replaces the ad-hoc 02-08 probe, and four
new vm.$.uid-based regression tests pin real instance survival immune to
the same selector ambiguity.

Updates STATE.md (metrics, decisions, resolved blocker, session) and
ROADMAP.md (02-09 checked off, 9/10 plans executed) for phase 02's gap
closure. No code changes; 02-FINDINGS.md is owned by the concurrently
running 02-10 executor and was not touched here (already committed by
this plan's earlier Task 1/2 commits).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:09:53 -04:00
archipelagoandClaude Fable 5 3e3159fa95 test(02-09): pin Server/Web5 KeepAlive round-trip survival via instance uid
Demo images / Build & push demo images (push) Successful in 3m41s
Task 2 (no-op branch, per plan): Task 1's evidence positively proved
Server.vue and Web5.vue's instances already survive tab round-trips —
the "remounts" reading was a probe artifact (02-FINDINGS.md), not a real
defect. No change to DashboardRouterView.vue, dashboardViewWrappers.ts,
keepAliveRoutes.ts or Server.vue's KeepAlive/lifecycle wiring.

Lands 4 regression tests in keepAliveLifecycle.test.ts using Vue's own
component-instance identity (vm.$.uid) instead of a CSS selector, so the
pin can't inherit the same generic-.view-container ambiguity Task 1 found:
round-trip identity for Server (Test 1) and Web5 + a second tab (Test 2),
include-list correctness (Test 3), and the LRU cap staying intact (Test 4).
All four pass immediately against the unmodified code — that pass is
itself the pin, per the plan's explicitly anticipated no-change path.

Full suite green (95 files / 778 tests), type-check clean, build succeeds.
keepAliveTabs.test.ts confirmed byte-for-byte unmodified and still green.
No deploy: nothing in neode-ui/src changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:50:55 -04:00
archipelagoandClaude Fable 5 550a2927b9 chore(catalog): sign app-catalog — BotFights 1.2.2, iframe-embed fix (ARCHY_EMBEDDED)
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.1 -> 1.2.2, carries the
X-Frame-Options fix (ARCHY_EMBEDDED=1 disables SAMEORIGIN for the
node-dashboard iframe) on top of 09-06's JWT_SECRET/arena-federation fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:46:24 -04:00
archipelagoandClaude Fable 5 4d471759a7 fix(02-09): name Server/Web5 KeepAlive "remount" as a proven probe artifact
Demo images / Build & push demo images (push) Successful in 3m36s
Task 1: built keepalive-remount-probe.spec.ts, a committed, re-runnable
Playwright spec covering every KEEP_ALIVE_PATHS tab with three added
instruments (instance-uid, session-wide console/pageerror capture, DOM
population/pathname logging per hop) beyond 02-08's ad-hoc probe.

Eliminated suspects 2 (runtime error), 3 (LRU eviction), 4 (route.path
mismatch) and 5 (include-name matching) by direct measurement. Confirmed
suspect 1 with positive proof: an authoritative document.elementFromPoint()
hit-test signal contradicted the naive selector-match method's "remounted"
verdict for both Server and Web5 across independent runs, and a companion
diagnostic found the original stamped root still connected+visible under
a different (unpicked) match. Root cause: Server, Web5 and Fleet share the
fully generic .view-container [data-controller-container] selector every
KeepAlive-cached main tab's root carries via fallthrough, which cannot
disambiguate "the foreground tab" from "another cached tab still connected
to the document" once more than one tab has been visited — the normal,
intended KeepAlive state. Settings (the away tab every round trip uses)
independently renders matching content too (AccountInfoSection/
KioskDisplaySection), compounding the ambiguity.

Server.vue and Web5.vue's instances survive tab round-trips exactly like
every other registered tab — no defect in DashboardRouterView.vue,
dashboardViewWrappers.ts, keepAliveRoutes.ts or Server.vue's KeepAlive
wiring. 02-FINDINGS.md records the full method, eliminated suspects and
verdict per D-10 (this commit lands before any src change, per gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:42:54 -04:00
archipelagoandClaude Fable 5 3b3d22d0ce fix(botfights): 1.2.2 — allow node-dashboard iframe embedding via ARCHY_EMBEDDED=1
Demo images / Build & push demo images (push) Has been cancelled
1.2.x's auth-hardening work added Hono secureHeaders() with a default
X-Frame-Options: SAMEORIGIN, which unconditionally blocked the Archipelago
node dashboard's iframe (different origin by port) — a real regression
versus 1.1.0, which never sent this header. Fixed upstream in the botfight
repo (commit 8eb27ed): X-Frame-Options is now conditional on ARCHY_EMBEDDED,
disabled only for the first-party node-embedded instance.

apps/botfights/manifest.yml: image/version -> 1.2.2, adds
ARCHY_EMBEDDED=1 to environment, drops the interim
metadata.launch.open_in_new_tab workaround (no longer needed — the app can
now be framed). app-catalog/catalog.json, scripts/image-versions.sh,
neode-ui/public/catalog.json bumped in lockstep via
scripts/generate-app-catalog.py. core/archipelago/src/fips/app_ports.rs
regenerated (formatting only, same port set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:40:18 -04:00
archipelagoandClaude Fable 5 a3b67c3d08 docs(roadmap): Phase 9 executed — all 7 plans complete, awaiting human demo verification
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:10:49 -04:00
archipelagoandClaude Fable 5 ec3ef2f7c2 docs(09-07): Task 1 complete + verified, Tasks 2/3 stopped at blocking human checkpoints
archi-dev-box BotFights 1.2.1 install fully automated-verified (image, arena federation,
per-install secret, data preservation, restart survival, arena unaffected). Tasks 2/3
(real NIP-07 signer login, real cloud-agent-from-prompt) require a human/external agent
per the plan's own design and 09-RESEARCH.md Pitfall 5 — stopped here rather than
simulated. STATE/ROADMAP/REQUIREMENTS intentionally left untouched per this run's
explicit instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:09:08 -04:00
archipelagoandClaude Fable 5 87b7b603fd feat(09-07): update archi-dev-box to BotFights 1.2.1 via signed catalog + demo checklist
Real user path: package.check-updates RPC (catalog refresh, release-root signature
verified) then package.update RPC (id=botfights) — no hand-placed container. Verified:
image :1.2.1 healthy, ARENA_UPSTREAM_URL set, JWT_SECRET delivered as a podman secret
(0600, 64 hex chars, no plaintext env value), local /api/bots matches the arena's 104+15
fighters (thin-client proxy confirmed), local botfights.db byte-identical pre/post
(367144960 bytes, mtime 1782916151 — 115 bots/102,440 fights preserved untouched),
survives podman restart, arena health unaffected. x250-dev unreachable this session
(SSH timeout) — dev-pair rule satisfied on archi-dev-box only, recorded in the checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:06:47 -04:00
archipelagoandClaude Fable 5 463134960c docs(09-06): BotFights 1.2.1 catalog signed and published — plan complete (BOT-04)
Signing ceremony ran successfully; catalog independently re-verified,
committed (a99522d0), and confirmed byte-identical to the content live at
the vps2 raw URL every node fetches. Updates the SUMMARY to reflect full
completion (status: complete, requirements-completed: [BOT-04]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 04:50:20 -04:00
archipelagoandClaude Fable 5 a99522d0d4 chore(catalog): sign app-catalog — BotFights 1.2.1, generated JWT_SECRET fix + default-on arena
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The botfights
entry is the only content change vs the previously-published catalog
(verified structurally — all other 65 apps unchanged): version 1.1.0 ->
1.2.1, embeds the generated_secrets/secret_env JWT_SECRET fix (D-04/BOT-04,
prevents the 1.2.x image's crash-loop on missing JWT_SECRET) and default-on
ARENA_UPSTREAM_URL federation (D-03/BOT-03).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 04:46:58 -04:00
archipelagoandClaude fcbf50f67e docs(02-review): update fix commit hashes after rebase onto latest main
Demo images / Build & push demo images (push) Successful in 3m46s
Rebasing the review-fix branch onto main (which had advanced with
unrelated 09-06/botfights commits since this branch was created) rewrote
every commit hash. Update 02-REVIEW.md's Status lines and summary table to
reference the post-rebase hashes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:18:10 -04:00
archipelagoandClaude 64aafa7899 docs(02-review): mark phase 02 review findings fixed/documented with commit hashes
CR-01 and WR-01 through WR-06 fixed (one commit each); IN-02 fixed
(trivial comment); IN-01 left documented, not fixed (requires a real
refactor across 4 files, outside this pass's trivial/zero-risk bar for
Info findings). Full test suite, vue-tsc --noEmit, and npm run build are
all green after every fix.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:40 -04:00
archipelagoandClaude b5506025dd fix(02-review): IN-02 document wrapperFor's currently-unreachable full-bleed branch
isFullBleedPath(path) only returns true for /dashboard/chat and
/dashboard/mesh, both always in KEEP_ALIVE_PATHS today, so this branch of
wrapperFor()'s key derivation is currently unreachable. Add a comment
(per the review's own "non-blocking; a comment is sufficient" fix) so a
future reader doesn't mistake the defensive branch for dead code to delete.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:39 -04:00
archipelagoandClaude 7e4e739e8d fix(02-review): WR-05 forward abort signal through vpnStatus()/dnsStatus()
server.network-summary's fetcher batches four RPCs but only two forwarded
the AbortSignal useCachedResource provides for abort-on-unmount;
rpcClient.vpnStatus()/dnsStatus() had no signal parameter at all, so
aborter.abort() couldn't cancel them, partially defeating the documented
abort-on-unmount contract for this resource.

Add an optional signal parameter to both convenience methods (mirroring
the pattern used throughout rpc-client.ts) and forward it from Server.vue.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:39 -04:00
archipelagoandClaude 5f7cd4c8bc fix(02-review): WR-04 require explicit persist on resources.ts entry()/optimistic()
entry(key, persist = true) and optimistic(key, update) silently defaulted
to persist:true after the first call for a key, and optimistic() didn't
accept a persist argument at all. Every current call site happened to be
safe, but the invariant was unenforced: a future caller invoking
store.optimistic() before any useCachedResource({persist:false}) has run
for that key in the same tick would silently start writing to
sessionStorage with no indication anything is wrong (T-02-01).

persist is now a required argument on both functions (no default), and the
per-key decision is recorded and asserted (dev-only warning) against any
later call that disagrees. useCachedResource's optimistic() wrapper now
threads its own already-resolved persist value through automatically, so
no existing composable caller changes behavior. The two call sites that
use the resources store directly (Cloud.vue/PeerFiles.vue's per-peer
browse cache) now pass persist:true explicitly, matching their existing
behavior exactly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:39 -04:00
archipelagoandClaude 69358bf6c7 fix(02-review): WR-03 never drop OpenWrtGateway Connect form params under concurrent load
load(params) routed every call -- including the Connect form's own
credentials -- through routerResource.refresh(), which resources.ts dedupes
per key. A second load({host, ssh_user, ssh_password}) call arriving while
an unrelated refresh was already in flight (e.g. useCachedResource's own
TTL-gated auto-revalidation) would just await that already-in-flight
promise; the caller's own params were silently never sent, with no error
surfaced.

load(params) now bypasses routerResource.refresh() entirely when explicit
params are supplied, calling rpcClient directly and writing the resolved
result into routerResource.entry so cache/TTL/status-panel rendering stays
consistent with a normal refresh() success. The plain reconnect path
(no params) is unchanged. The now-redundant pendingParams indirection is
removed since the fetcher only ever needs `{}` params going forward.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 0486045d29 fix(02-review): WR-06 skip redundant fallback init timer on later MeshMap reactivations
armMapVisibility() unconditionally scheduled a setTimeout(initMap, 300) on
every reactivation, not just the first mount. initMap()'s own guard made
this harmless (a no-op once the map exists), but it scheduled a throwaway
timer on every tab-switch back into Mesh. Guard the scheduling itself so
intent ("fallback init for the very first mount") matches behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 751b05f271 fix(02-review): WR-02 stop MeshMap geolocation watch on deactivate, resume on activate
onDeactivated only tore down the resize listener/ResizeObserver, not the
navigator.geolocation.watchPosition watch started by "Share Location" —
leaving GPS polling running in the background (battery drain, active
location indicator) for as long as the KeepAlive'd component survives,
instead of only while the Mesh tab is visible like every other resource
this phase added only-while-visible handling for in this file.

onDeactivated now stops an active watch (tracked via a flag rather than
losing the user's toggle state), and onActivated transparently resumes it
on return to the tab.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 6105770420 fix(02-review): WR-01 decouple Discover's featured-banner data from app-catalog dedup race
Marketplace.vue and Discover.vue both register a useCachedResource against
the shared 'app-catalog' key with different fetchers; resources.ts's
in-flight dedup means whichever view's fetcher wins a given race governs
the shared entry, silently dropping Discover's catalogFeatured side effect
when Marketplace's simpler fetcher wins.

Give the featured-banner payload its own cache key ('app-catalog:featured')
subscribed only by Discover.vue, so it always gets its own data regardless
of which view's fetcher wins the shared 'app-catalog' race. fetchAppCatalog()
already memoizes internally (1h TTL + localStorage fallback), so this is
normally a cache hit rather than an extra network request. The shared
'app-catalog' key and its dedup behavior are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 57989dfc58 fix(02-review): CR-01 web5.lnd-info/profits resources must not persist to sessionStorage
Web5.vue's lndInfoRes and profitsRes defaulted to persist:true (via
useCachedResource's default), writing live LND wallet balances and
channel balances to sessionStorage in plaintext -- a T-02-01 violation.
Add explicit persist:false to both, matching the "never defaulted"
rule this phase established everywhere else. Also updates Home.vue's
comment, which previously documented this as a known unfixed gap.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude Fable 5 921f450edf docs(09-06): Task 1 complete, signing ceremony (Task 2) pending — SUMMARY
Manifest/catalog prep for BotFights 1.2.1 is done and verified; the run
stopped intentionally before the human-only release-signing ceremony per
explicit instruction. releases/app-catalog.json is regenerated on disk but
deliberately not committed until it's signed (Task 3 continuation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 03:49:04 -04:00
archipelagoandClaude Fable 5 f281c7aad4 feat(botfights): bump manifest to 1.2.1 — generated JWT_SECRET + default-on arena federation
apps/botfights/manifest.yml: image tag 1.1.0 -> 1.2.1, adds container.generated_secrets
(botfights-jwt-secret, kind hex32) + secret_env (JWT_SECRET) so a fresh install no longer
crash-loops on the 1.2.x image's jwt.ts import-time throw when JWT_SECRET is unset under
NODE_ENV=production (D-04/BOT-04). Adds ARENA_UPSTREAM_URL=https://botfights.archipelago-foundation.org
to turn on shared public arena federation by default (BOT-03/D-03), with an in-manifest comment
documenting the per-node opt-out (remove the line to run standalone).

app-catalog/catalog.json + scripts/image-versions.sh bumped in lockstep to keep the legacy
catalog and the image-version drift checker consistent with the manifest.

releases/app-catalog.json intentionally NOT committed in this change — it has been
regenerated locally (scripts/generate-app-catalog.sh) with the new manifest embedded, but is
unsigned. It is committed separately (09-06 Task 3) only after the release-root signing
ceremony, so no unsigned catalog state ever lands even transiently on a branch this repo's
mirrors could serve to nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 03:38:16 -04:00
archipelagoandClaude 6904e78960 docs(02): create gap closure plans
Two plans closing the gaps 02-VERIFICATION.md found (6/8 must-have truths
verified):

- 02-09 (wave 6): Server.vue is registered in KEEP_ALIVE_PATHS since 02-04 but
  02-08's corrected, twice-reproduced probe shows it fully remounting on every
  revisit. Names the measured cause first (D-10), commits the ad-hoc probe as a
  re-runnable spec, lands a targeted fix pinned by a regression test in
  keepAliveLifecycle.test.ts, deploys frontend-only to archi-dev-box (D-15),
  and closes with the D-11 human pass bar.
- 02-10 (wave 7): the six surfaces measured unimproved/regressed vs baseline had
  a flagged-but-untested environmental-noise confound. Re-runs the frozen 02-01
  harness under recorded conditions, compares three-way using per-run sample
  spread rather than bare medians, and gives every named surface a verdict —
  cleared with data, fixed, or an explicit accepted deviation for the verifier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 00:20:58 -04:00
archipelagoandClaude b448232fb5 docs(02): code review findings
Adversarial review of phase 02's KeepAlive/useCachedResource architecture:
1 blocker (Web5.vue wallet balances default-persisted to sessionStorage,
violating T-02-01) and 6 warnings (app-catalog cache-key race silently
drops Discover's featured banner, MeshMap geolocation watch survives tab
deactivation, OpenWrtGateway connect-form params droppable under
concurrent load, resources.ts persist-argument footgun, partial
abort-signal coverage on Server.vue's network-summary fetch, redundant
timer re-arm).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 00:11:52 -04:00
archipelagoandClaude 092a37332f docs(02): phase verification
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 00:03:13 -04:00
archipelago 2b89b78f18 docs(02-08): complete dev-pair deploy, on-device re-measure, D-11 pass bar plan 2026-07-30 23:55:05 -04:00
archipelagoandClaude Fable 5 f4ab25ef70 docs(09-05): build/push botfights:1.2.0, roll canonical arena, prove BOT-03 cross-instance visibility live
Image built + pushed (digest sha256:854ea299...26e144), canonical VPS2
arena rolled to 1.2.0 and verified end-to-end over the public HTTPS
URL (health, unified prompt, registration, bot auth, live SSE), and
BOT-03's cross-instance fighter visibility proven on real hosts in
both directions with a throwaway proxy-mode instance. Two pre-existing
bugs found and fixed in the botfight repo along the way: a pnpm
overrides config drift blocking the docker build, and a route-order
bug that made GET /api/fights/poll (the entire polling protocol)
always 404.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:40:51 -04:00
archipelagoandClaude Fable 5 a0c5827753 docs(02-08): confirm Cloud first-visit regression closed — 5/5 verified
Updates the Task 3 checkpoint follow-up addendum: the content.browse-peer
concurrency-cap fix (8fe6217b) resolved what the fresh-mount guard
(e1a3f31a) alone did not. Instrumentation showed 13 of 14 concurrent
browse-peer calls never settling; capping the fan-out at 3 concurrent
with a 10s per-call timeout eliminated the hang. Verified 5/5 fresh
sessions navigate cleanly on first folder click against the redeployed
build, zero in-flight requests after 15s on Cloud, and a previously-
hung route chunk import now resolves in 17ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:40:16 -04:00
archipelagoandClaude Fable 5 8fe6217b5a fix(02-08): cap content.browse-peer fan-out — root cause of Cloud first-visit hang
Demo images / Build & push demo images (push) Successful in 3m29s
Instrumented the exact trigger the fresh-mount guard (previous commit)
didn't fully eliminate: on a fresh session, loadPeerFiles() fired one
content.browse-peer RPC per connected peer with zero concurrency cap
and a 30s per-call timeout. Confirmed on archi-dev-box: 13 of 14
concurrent browse-peer calls never settled at all (dead/unreachable
peers with no server-side timeout on that path) — that many
simultaneously open, indefinitely-pending same-origin requests starved
Chromium's connection pool, silently breaking every other same-origin
fetch for the rest of the session, including the lazy route chunk any
later folder/tab navigation needs. This — not the router or the click
handler — was the actual cause of "no folders open on click" after a
first Cloud visit.

Fix, mirroring PeerFiles.vue's existing PREVIEW_CONCURRENCY pattern for
the identical class of problem (content.preview-peer fan-out):
- Cap the browse-peer fan-out at 3 concurrent requests
  (BROWSE_PEER_CONCURRENCY, a queue+worker pool in loadPeerFiles()).
- Shorten each call's timeout from 30s to 10s (BROWSE_PEER_TIMEOUT_MS) —
  bounds how long any one dead peer can hold a connection.
- Wire an AbortController (aborted onUnmounted) through rpc-client's
  signal option for clean teardown.
- A timed-out/failed peer already resolved silently through
  resources.ts's own error-state path (no throw, no toast) — confirmed
  unchanged; the muted "N peers unreachable" line is the only surface.

No visual/behavioral change to the working case (D-01 rule) — peers
that answer still render exactly as before, just no longer share the
page with a dozen never-ending requests.

Full suite green (95 files / 774 tests), type-check and build clean,
keepAliveTabs.test.ts structural DOM assertions untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:33:32 -04:00
archipelago 91f17f5ab2 docs: pin UIFIX-04/05/06 todos with 02-08 classification pointers 2026-07-30 23:26:30 -04:00
archipelagoandClaude Fable 5 834edd8c00 docs(02-08): checkpoint follow-up — triage 5 user-reported items
Adds a Task 3 checkpoint follow-up addendum to FINDINGS.md documenting
the investigation behind the fix(02-08) commit (Cloud.vue fresh-mount
guard for the first-visit connection-pool stall — confirmed real,
partially fixed, not yet fully resolved) plus classification of four
other user-reported items, all traced via git history against the
a75b6709 pre-phase-2 baseline and confirmed pre-existing (not phase 2):
Paid Files opening images in a new tab instead of the lightbox, PiP not
closing the lightbox, missing loader state on Paid Files' item-open RPC,
and PiP not surviving tab changes/buffering pauses. No fixes applied
for the pre-existing items per standing direction (captured separately
as phase-1 UX work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:23:53 -04:00
archipelago bd6958255a docs: expand UIFIX-05 — PiP must survive tab changes and buffering 2026-07-30 23:21:51 -04:00
archipelagoandClaude Fable 5 e1a3f31a0a fix(02-08): Cloud.vue fresh-mount guard — first-visit connection-pool stall
Demo images / Build & push demo images (push) Successful in 3m32s
User-reported checkpoint regression: on a genuinely first visit to Cloud
this session, no folder opened on click (subsequent visits fine).
Root-caused on archi-dev-box, not guessed:

- The click DID register (confirmed via a direct DOM listener) and DID
  call router.push (confirmed by patching the live router instance) —
  but the push's promise never settled, because Vue Router awaits the
  target route's async component, and that dynamic import() itself
  never resolved.
- Confirmed via a manual import() from the page console: importing ANY
  lazy route chunk (Fleet, CloudFolder, AppDetails — unrelated views)
  hangs identically after visiting Cloud once, but works instantly
  before ever visiting Cloud. Not chunk-specific, not router-specific.
- Traced to exactly one permanently-pending network request: a File
  Browser `GET /app/filebrowser/api/resources/Photos` call from Cloud's
  own onMounted burst, confirmed hung via request-lifecycle tracking
  (never finishes or fails, still pending after 10s). The identical
  request, issued manually with a fresh token outside of Cloud.vue,
  returns in 29ms — ruling out the backend/File Browser itself.
- Mechanism: Cloud.vue's syncOnEntry() (loadCounts/loadPeers/
  loadPeerFiles) fires from BOTH onMounted and onActivated with no
  fresh-mount guard (02-04 exempted it, reasoning each resource is
  individually staleness/inflight-deduped — true per-resource, but the
  two back-to-back passes still double the concurrent request volume
  at the single riskiest instant in a session: first KeepAlive
  activation, stacked on whatever other cached view's own onMounted
  burst is firing at the same moment). On real hardware that volume
  was enough to leave one File Browser request stuck, which then
  starves Chromium's per-origin connection pool — breaking every
  subsequent same-origin fetch, including the lazy chunk any later
  navigation needs. Not a router or click-handler bug; the click and
  push both worked correctly the whole time.

Fix: give Cloud.vue the same fresh-mount guard already used in
Home.vue/Web5.vue/Mesh.vue/Server.vue (skip onActivated's redundant
first-activation re-fire since onMounted just ran it). Removes the
duplicate-burst mechanism without changing steady-state reactivation —
onActivated still re-syncs normally on every later KeepAlive round-trip.
No visual/behavioral change (D-01 rule).

Full test suite green (95 files / 774 tests), type-check and build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:44:35 -04:00
archipelago 8b1aac0174 docs(09-03): complete unified AI bot-setup prompt plan (BOT-02)
- SUMMARY for 09-03 (botfight repo commits bbc3c7a, a080956, 2dd9947)
- WINDOWS.md: record e2e suite as unrun-verify (local dev backend port
  9100 is occupied by the live archi-dev-box demo container)

Per explicit user instruction this run, STATE.md/ROADMAP.md/REQUIREMENTS.md
are intentionally left untouched (shared coordination files across
concurrent phase-09 agents).
2026-07-30 22:37:23 -04:00
archipelagoandClaude Fable 5 903481afb3 docs(09-02): complete BOT-01 bare-pubkey auth gap closure plan
GET /api/auth/me (JWT-only session restore) shipped, POST /login reduced
to a read-only deprecated lookup, client auto-restore moved off the bare
pubkey path. Includes a fixed pre-existing migrate.ts/schema.ts drift
(15 pre-existing auth/tournament test failures resolved as a deviation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:35:50 -04:00
archipelago 1fc006a05b docs: capture UIFIX-04/05/06 (paid-files lightbox, PiP animation, loader states) into phase 1 2026-07-30 21:59:47 -04:00
archipelagoandClaude Fable 5 f1206ad60f docs(02-08): re-measure every surface on archi-dev-box, commit Results/Outstanding
Re-ran the plan 02-01 harness unmodified against archi-dev-box (same
target, same runs:3 contract) producing 02-PERF-AFTER.json (15/15 rows,
matching the baseline row count). Appended FINDINGS.md's Results table
(per-surface baseline/after comparison, Verdict) and an Outstanding
list of every regression, unmeasured surface, and unsettled gap.

Discovered and cross-verified (via a corrected, harness-independent
remount probe, reproduced twice) that the harness's own remount-probe
selector ('.view-container', generic and shared across every main tab)
became ambiguous once real KeepAlive caching keeps multiple instances
alive simultaneously — the raw artifact's 'remounted' field is
confounded for main tabs whose instance actually survives. Corrected
readings show Home/Apps/Marketplace/Cloud/Web5/Fleet genuinely survive
a round-trip; Server genuinely does not (a real, confirmed gap against
this phase's own must-have truth, flagged in Outstanding, not fixed
here — out of this task's measurement-only scope).

Also recorded real timing regressions (Discover, Server, Web5, Fleet,
AppDetails, OpenWrtGateway) honestly rather than averaging them away,
per the plan's own prohibition, alongside a timing-variance caveat
(baseline and after-run were taken at different times of day on the
same multi-service node).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:48:09 -04:00
archipelagoandClaude Fable 5 4f51de9f81 docs(09): canonical arena URL is now https://botfights.archipelago-foundation.org (user-created DNS+NPM+LE)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:46:39 -04:00
archipelagoandClaude Fable 5 2ec9a70dfd docs(09-01): arena-proxy tracer complete — SUMMARY + deferred-items
Implements 09-01-PLAN.md (BOT-03 reverse-proxy tracer) end-to-end in the
botfight repo (pushed to origin main): server/src/middleware/arena-proxy.ts,
its 9-test end-to-end suite, app.ts mount, fights.ts SSE fix, docker-compose.yml
env-var docs. Records the T-09-02 threat-model correction (plain HTTP is a
supported upstream scheme, not https-only, per user decision) and a
pre-existing migrate.ts/schema.ts drift found out-of-scope during
verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:39:13 -04:00
archipelagoandClaude Fable 5 6a4043ce3d docs(09-04): complete canonical BotFights arena deployment plan
Records the VPS2 arena deployment (http://146.59.87.168:9100, plain HTTP
per user decision, full-copy data seed of archi-dev-box's botfights.db).
Note: this phase (09-botfights-platform-upgrade) is an out-of-band
user-directed express path, not tracked in this project's main
STATE.md/ROADMAP.md/REQUIREMENTS.md (currently on milestone v1.8.0 phase
02) — those files are intentionally left untouched to avoid clobbering
concurrent phase-02 execution tracking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:28:25 -04:00
archipelagoandClaude Fable 5 3ee20430b0 docs(02-08): confirm KEEP_ALIVE_MAX=6 against on-device memory (FA-D)
Demo images / Build & push demo images (push) Failing after 2m18s
Deployed this phase's frontend + AIUI to archi-dev-box (the dev pair;
x250-dev is currently offline via Tailscale, recorded rather than
silently skipped) via scripts/deploy-to-target.sh --frontend-only.
Verified the served bundle (not just local dist) contains this phase's
changes.

Then took a real on-device measurement instead of leaving the FA-D
KEEP_ALIVE_MAX estimate unexamined: a headless Chromium session on
archi-dev-box cycled all 11 main tabs through 4 full round-trips (44
navigations), reading the JS heap via CDP before/after each cycle.
Memory fluctuated 10-21MB with no monotonic growth across cycles that
each exceed the cap 10 distinct registered paths against KEEP_ALIVE_MAX=6.
Left the constant at 6, now backed by a recorded measurement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:19:50 -04:00
archipelagoandClaude Fable 5 65d7edbd08 docs(09): lock arena-as-relay architecture decision (decentralized framing, Foundation arena = default rendezvous)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:11:04 -04:00
archipelagoandClaude Fable 5 de022b7b4e docs(09-04): resolve arena decisions — plain-HTTP :9100 endpoint (no DNS/TLS), full DB copy seed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:09:15 -04:00
archipelagoandClaude Fable 5 6a112797a5 docs(09): plan BotFights platform upgrade — 7 plans across 4 waves
BOT-01 native nostr signer login (GET /api/auth/me, bare-pubkey path retired),
BOT-02 one self-contained AI bot-setup prompt at /api/docs/prompt,
BOT-03 shared public arena on VPS2 with a node-side reverse-proxy tracer,
BOT-04 manifest 1.2.0 with a generated JWT secret + republished signed catalog.

Wave 1 runs 09-01..09-04 in parallel (app work plus the DNS/TLS human gate,
started early for the 2026-07-31 demo deadline); waves 2-4 build the image,
publish the catalog, and land the demo rehearsal on archi-dev-box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:03:09 -04:00
archipelagoandClaude Fable 5 9bead8fa4c docs(09): pattern map for BotFights phase
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:36:02 -04:00
archipelagoandClaude Fable 5 a3a77e965a docs(09): validation strategy + demo-deadline constraint for BotFights phase
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:33:42 -04:00
archipelagoandClaude Fable 5 b615a68617 docs(09): research botfights platform upgrade phase (nostr login, unified prompt, shared arena, catalog publish)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:32:04 -04:00
archipelagoandClaude Fable 5 32961dfc97 docs(09): phase 9 context — BotFights nostr login, unified prompt, VPS2 shared arena, registry update
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:21:06 -04:00
archipelago 5b37426fab docs(02-07): record live-testing follow-up round (overlay, theme, key fallback, CLI path) 2026-07-30 20:20:49 -04:00
archipelagoandClaude Fable 5 faf4a75ddf fix(02-07): loading overlay can never wedge the Chat/AIUI UI permanently
Demo images / Build & push demo images (push) Failing after 2m12s
Belt-and-suspenders fix on top of AIUI's own root-cause fix (the
archyBridge origin bug, fixed in the AIUI repo): the archy-side loading
overlay now gets pointer-events:none (it has no interactive content of
its own, so it should never have blocked clicks reaching the iframe
underneath) and a bounded 8s timeout that unconditionally hides it if
no 'ready' message ever arrives — regardless of AIUI/backend state.
The timeout only dismisses the overlay; it does not fabricate a
successful connection, so the connected indicator still reflects
reality.

Two new tests in chatAiuiEmbed.test.ts cover the timeout firing at
exactly 8s and not firing prematurely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:19:28 -04:00
archipelagoandClaude Fable 5 08ee5ed036 feat(seed): audit kernel CSPRNG readiness + RNG non-determinism regression test
Seed entropy comes from bip39 -> rand::thread_rng -> getrandom(2), which
blocks until the kernel pool is initialized -- but that ordering was
invisible in logs on first-boot ISO flows where the seed is generated
early. MasterSeed::generate() now probes getrandom(GRND_NONBLOCK) and
logs whether the pool was already seeded (warn if it would block).

Also adds a regression test that 64 generated mnemonics are all unique
with sane word diversity, guarding against a fixed/seeded RNG ever
being wired into seed generation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:18:32 -04:00
archipelagoandClaude Fable 5 35f6e75e83 docs(roadmap): add Phase 9 - BotFights Platform Upgrade (nostr signer login, unified AI prompt, VPS2 shared match endpoint, registry update)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:15:30 -04:00
archipelago ee8e60a37c docs: commit phase pattern maps + discuss checkpoint for portability 2026-07-30 20:14:03 -04:00
DorianandClaude Fable 5 6e8b96d135 fix(app): embed round-trip fixes — origin, dark bg, key fallback, CLI path
Four issues found via live testing of the embedded Chat/AIUI panel
(Archipelago phase 02-07 follow-up), each root-caused rather than
patched over:

1. Loading overlay never dismissed: archyBridge.init() used
   window.location.origin (this iframe's OWN origin) as the target for
   postMessage calls TO the parent, instead of the parent's actual
   origin. Silently correct only when AIUI is served same-origin as
   its host (production's /aiui/ proxy) — broken the moment AIUI runs
   on a different origin than its embedding page (any dev setup with a
   separate AIUI dev server). The 'ready' message, and every
   permissions/theme/context/action response after it, was being
   dropped by the browser. Fixed by deriving the parent's real origin
   from document.referrer (archyBridge.ts).

2. White/black background instead of the branded look: initTheme()
   decides light/dark from localStorage or the OS's prefers-color-
   scheme, with no awareness of being embedded — App.vue now forces
   dark immediately on mount when embedded (before any handshake
   completes) and useArchy.ts's theme-update callback now applies
   Archy's reported mode too. Separately, body had no background-color
   at all, so ChatPage.vue's embedded `background: transparent` fell
   through to the browser's white UA default; main.css now paints body
   to match the active theme. And ChatPage.vue's embedded branch was
   opting out of the same background-image treatment the standalone
   dark app uses — it now shares that exact styling instead of a flat
   fallback color, matching the standalone look precisely.

3. Dead end when no AI provider credential is available: useAI.ts now
   emits a narrow, one-shot needsApiKey signal (401/403, "api key",
   "unauthorized", or a proxy-unreachable failure — deliberately not
   every transient error) that ChatWindow.vue watches to auto-open
   Settings, so the user lands on the fix instead of a silent/dead
   chat.

4. claude-proxy.ts's CLI fallback spawned a hardcoded ~/.local/bin/claude
   path, breaking with ENOENT on any machine where the CLI lives
   elsewhere (e.g. an nvm install). Now resolves via `command -v claude`
   first (an optional CLAUDE_BIN env override, then the historical path,
   then the bare command name as a last resort so spawn() itself can
   still try PATH), and surfaces an actionable in-UI error naming three
   ways to fix it when none resolve.

Verified: full send→spawn→response round trip against the local proxy
(both directly and through vite's /api/claude proxy), vue-tsc clean,
vitest 332/335 passing (3 pre-existing unrelated failures, confirmed
present before this commit too), production build clean with both
chatExpanded/mobileChat flags and the new background rule present in
the built assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:04:45 -04:00
archipelago 62c99a60b2 docs: add UIFIX-01/02/03 blockers to phase 1 requirements and criteria 2026-07-30 19:06:58 -04:00
archipelago b2c6fea4d3 docs: record companion/fips subdomains in VPS2 migration task 2026-07-30 18:58:10 -04:00
archipelago a41207eeab docs: full VPS2 domain-migration inventory + verified coverage matrix 2026-07-30 18:49:53 -04:00
archipelago d677f6f958 docs: capture todo - migrate source references to HTTPS domain 2026-07-30 18:46:39 -04:00
archipelago 61e95273e0 docs(02-07): complete Chat/AIUI embed stability + D-14 plan 2026-07-30 18:44:04 -04:00
archipelago 5bdb3e6268 docs: capture todo - onboarding tickbox hidden below fold on short screens 2026-07-30 18:32:18 -04:00
archipelago 99ecb95f53 docs: capture todo - connected-nodes list must scroll at row-matched height 2026-07-30 18:30:20 -04:00
archipelagoandClaude Fable 5 e2b2ade3b2 feat(02-07): stable AIUI embed URL carrying both D-14 defaults
Demo images / Build & push demo images (push) Failing after 2m16s
aiuiUrl now appends chatExpanded=true and mobileChat=true alongside
the pre-existing embedded=true and hideClose=true. Both are static
strings with no reactive dependency, so the computed's value never
changes after first evaluation — preserving the URL-stability
contract 02-04 relies on to keep the AIUI iframe from reloading on a
tab switch.

AIUI reads these two flags (commit 900c0b9 in the AIUI checkout,
recorded in 02-AIUI-D14.md) to start the chat expanded and, on
mobile, on the chat view rather than the context view. The deployed
AIUI build on any node does not yet carry that commit (anonymous push
to the AIUI remote was rejected — see 02-AIUI-D14.md's Deployment
Impact), so neode-ui's two new query params are inert no-ops against
today's deployed AIUI until that commit is merged and shipped; both
flags are additive and harmless in the meantime.

New test file chatAiuiEmbed.test.ts covers: both D-14 flags plus the
pre-existing embedded/hideClose params present in the URL; URL
string-equality across a simulated viewport resize and across a
KeepAlive deactivate/reactivate cycle; onAiuiMessage still rejecting
a foreign-origin message; and aiuiConnected surviving a
deactivate/reactivate cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:26:52 -04:00
archipelago 8d2ce96eee docs: add FED-07 fedimint gateway default-password blocker (phase 1) 2026-07-30 18:26:04 -04:00
archipelago 76b0cd8844 docs: capture todo - Keep FIPS/Tor pills on cloud files and show them on mobile 2026-07-30 18:20:42 -04:00
archipelagoandClaude Fable 5 71b2703213 docs(02-07): resolve AIUI source location and record real D-14 contract
AIUI is now cloned at /home/archipelago/Projects/AIUI
(git.tx1138.com/lfg2025/AIUI, development branch — 17 commits ahead
of main with zero unique main commits). Replaces the exhausted-search
conclusion with the real embed-parameter contract read from source
and records the D-14a/D-14b implementation: chatCollapsed's initial
state now honors a new ?chatExpanded param (chat.ts), and ChatPage.vue
re-asserts mobileTab='chat' on mount when ?mobileChat is present,
guarding against carry-over from AIUI's own module-singleton content-
panel state on an internal remount. Both are static, unconditional
query params — no runtime-varying URL input.

AIUI-side commit lives on feat/d14-embed-defaults (900c0b9) in the
local clone only — anonymous push to origin returned 403 Forbidden.
Needs a maintainer with push rights to land it before any deployed
AIUI build can honor these two parameters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:19:17 -04:00
DorianandClaude Fable 5 900c0b9060 feat(app): honor Archipelago D-14 embed defaults via query params
Archipelago's Chat tab (neode-ui) embeds AIUI in an iframe and now
passes two presentation-only query params on the embed URL:

- ?chatExpanded — chat starts in the full message list rather than
  the collapsed prompt-index. chatCollapsed's initial ref now checks
  for this param before falling back to the existing localStorage
  default; never written back to localStorage, so the standalone
  app's own persisted preference is untouched.
- ?mobileChat — on mobile, ChatPage opens on the chat tab rather than
  whatever tab survived from a prior mount of the module-singleton
  content-panel state. mobileTab already defaulted to 'chat'; this
  just re-asserts it once on mount when the param is present, so it
  doesn't interfere with the hasDetailOpen/panelOpen watchers driving
  normal tab switching in response to user taps afterward.

Both params are read directly from window.location.search and are
no-ops when absent, so the standalone (non-embedded) app and its
existing desktop layout are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:17:32 -04:00
archipelagoandClaude Fable 5 c10f415c6b docs(02-07): record AIUI source location and D-14 embed contract
Task 1 of plan 02-07. Searched the expected sibling AIUI/ checkout
(both relative-path forms neode-ui/package.json and
scripts/setup-aiui-server.sh reference), a broader filesystem sweep
for any directory named AIUI holding source rather than a prebuilt
dist/, and the ThinkPad build server (.116) noted in project memory
— all three came up empty (no sibling checkout, no other source dir,
.116 unreachable via ping/ssh right now).

Recorded the existing embed-parameter contract from Chat.vue plus
diligence findings from the currently-deployed AIUI build's static
bundle (embedded/mockArchy/hideClose support), and marked both D-14
defaults as needing a new AIUI-side parameter or postMessage control
that cannot be added without source access. Deployment impact section
covers the prebuilt-image rebuild path per apps/aiui/manifest.yml and
D-15's dev-pair-only constraint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:58:05 -04:00
archipelago 8205e9f142 docs(02-06): complete Server and Home tab cache plan 2026-07-30 17:42:55 -04:00
archipelagoandClaude Fable 5 926fa60691 feat(02-06): cache Home's system/update/storage groups and guarantee wallet freshness on re-entry
Demo images / Build & push demo images (push) Failing after 2m29s
- System stats (homeStatus.refresh), update status and cloud storage usage
  are now every-entry, TTL-gated useCachedResource entries (10s/300s/30s),
  hosted in Home.vue rather than inside the homeStatus Pinia store — a
  store's own defineStore(id, setup) runs in a bare effectScope where
  onActivated() silently no-ops (same finding as 02-05's Mesh.vue)
- Wallet is the deliberate exception (T-02-13): a new home.wallet-status
  resource wraps the existing loadWeb5Status() composite fetch and
  revalidates UNCONDITIONALLY on every activation rather than TTL-gated,
  keeps the prior figure rendered throughout, and persist:false (never
  written to sessionStorage). hydrateWalletSnapshot()'s separate localStorage
  path is untouched.
- Read Web5.vue's two existing resources (web5.networking-profits,
  web5.lnd-info) and did NOT share either key: profits is an unrelated
  dataset, and lnd-info's own default persist:true (Web5.vue out of this
  plan's file scope) would leak balance data via its own independent
  refresh cycle regardless of what Home declares, and Home's wallet fetch
  is a strictly broader 7-call composite (not the same single-call dataset)
- dedup:true added to all 12 underlying rpcClient calls (Home.vue's wallet
  composite + checkUpdateStatus, homeStatus.ts's 5 status calls)
- RefreshIndicator wired next to the Home header, bound to the wallet
  resource's loadState
- The websocket wallet-push path and hydrateWalletSnapshot's pre-network
  paint are both left exactly as 02-04 placed them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:38:01 -04:00
archipelagoandClaude Fable 5 e6ed5536c9 feat(02-06): cache the Server tab's seven load groups with explicit TTL/persist
Demo images / Build & push demo images (push) Failing after 3m46s
Server.vue already had five of the seven load groups (network summary, FIPS
summary, VPN peers, interfaces, Tor services) on useCachedResource from a
pre-phase commit, but none declared an explicit ttlMs/persist (relying on
the composable's 30s/persist:true defaults) and dedup:true was missing from
several underlying RPC calls. loadDiskStatus was the one remaining plain
uncached fetch, forced on every activation.

- Explicit TTL per group (10s fast tier: network-summary/interfaces/
  disk-status; 30s near-default: vpn-peers/tor-services; 60s near-static:
  fips-summary)
- Explicit persist:false for server.vpn-peers (npub/peer identity) and
  server.tor-services (onion addresses) per T-02-01; other groups persist
- New server.disk-status cached resource replaces the plain fetch; it now
  self-heals via the composable's own onActivated instead of an explicit
  every-entry call
- dedup:true added to all underlying rpcClient calls, including the
  parameterless vpnStatus()/dnsStatus()/diskStatus() convenience methods
- RefreshIndicator wired into a new minimal header row, driven by whether
  any of the six cache entries is refreshing
- RESEARCH assumption A3 settled: read all seven loader bodies; none
  consumes another's result — the concurrent fan-out is correct as-is
- Fixed a keepAliveLifecycle.test.ts assertion invalidated by the new 10s
  network-summary TTL (reactivation now also revalidates that resource,
  adding one more vpnStatus() call the test didn't previously account for)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:03:55 -04:00
archipelago 8563cf4a84 docs(02-05): complete Mesh tab cache + graphics lifecycle plan 2026-07-30 16:25:06 -04:00
archipelagoandClaude Fable 5 abdfa07a77 feat(02-05): bound the Leaflet map's lifecycle across Mesh tab deactivation
Demo images / Build & push demo images (push) Failing after 3m45s
MeshMap.vue's onMounted setup (window resize listener + ResizeObserver)
is refactored into an idempotent armMapVisibility()/disarmMapVisibility()
pair, dual-registered on both onMounted and onActivated (onActivated is a
documented no-op outside a KeepAlive boundary) and torn down on
onDeactivated, matching the arm/disarm idiom Mesh.vue itself already uses.
The Leaflet instance is never destroyed or recreated by this — initMap()'s
own `if (!mapContainer.value || map) return` guard already makes
construction idempotent, so exactly one map is built per session. On
reactivation the map's size is invalidated via nextTick so a map laid out
while off screen re-tiles at its real size instead of showing an unsized
or partially tiled canvas.

FLAGGED: RESEARCH.md's premise that Mesh.vue owns a live D3 force
simulation does not hold for this codebase — a grep for
d3/forceSimulation/simulation across neode-ui/src found nothing in
Mesh.vue's or MeshMap.vue's tree; the only D3 force simulation belongs to
NetworkMap.vue (Federation.vue's graph, out of this plan's scope). The
plan's D3-specific truths are therefore vacuously satisfied — see
02-05-SUMMARY.md for detail. Only the real Leaflet-map lifecycle work
landed here.

meshMapLifecycle.test.ts is a new, separate file (not appended to
meshTabCache.test.ts) because its vi.mock('@/stores/mesh')/vi.mock('leaflet')
hoist file-wide and would otherwise clobber meshTabCache.test.ts's need for
the real mesh/transport stores — mirrors the MarketplaceRefresh.test.ts
precedent from 02-02 for the same class of vi.mock-hoisting conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:16:40 -04:00
archipelagoandClaude Fable 5 31389bcc3c feat(02-05): cache the six Mesh tab-entry fetch groups without serializing them
- Mesh.vue: six useCachedResource entries wrap mesh.refreshAll(),
  transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(),
  refreshSelfDid() and refreshContacts(), each with an explicit TTL
  (10s reachability/transport, 30s federation/contacts, 300s self
  DID/onion) and persist decision (T-02-01: every group carrying peer
  or self identity data is persist:false; only aggregate transport
  status may persist)
- armMeshLive's Promise.all fan-out becomes a single Promise.allSettled
  over refreshMeshGroupIfStale() per group, so a revisit inside TTL
  issues zero RPC, a stale revisit revalidates concurrently (not a
  serial chain, T-02-16), and one rejected group never blocks the rest
- RefreshIndicator wired into the Mesh header, driven by whether any of
  the six groups is refreshing — visible while peer reachability
  revalidates on re-entry so a resumed tab never shows a frozen
  reachability state as current (T-02-13)
- dedup: true added to every underlying rpc-client.ts/mesh.ts/
  transport.ts fetcher call backing the six groups
- useCachedResource() calls live in Mesh.vue rather than inside
  stores/mesh.ts or stores/transport.ts: Pinia's defineStore(id, setup)
  runs in a bare effectScope, not a component instance, so the
  composable's internal onActivated() would silently no-op there; the
  fetchers still wrap the stores' own actions unchanged, so those
  actions' other callers (clearAllMesh, setMeshOnly, the pre-send
  balance check) keep their guaranteed-fresh, uncached reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:14:32 -04:00
archipelago e582124259 docs(02-04): complete main-tab lifecycle audit plan 2026-07-30 15:31:21 -04:00
archipelagoandClaude Fable 5 03a3e4e0c1 feat(02-04): widen KEEP_ALIVE_PATHS to the full audited main-tab set
Demo images / Build & push demo images (push) Successful in 3m49s
Task 2 of 02-04 — finishes the remaining tabs' lifecycle audit (Apps.vue,
Discover.vue) and widens registration from the 02-02 tracer's single seed
path to every main tab the profiling pass showed remounting.

- Apps.vue: the 15s "unable to connect" timer follows activate/deactivate
  (idempotent re-arm, cleared on exit) and resets connectionError on entry so
  a since-reconnected node doesn't show a stale error instantly; the intro
  flag stays once-per-session.
- Discover.vue: loadCommunityMarketplace/loadBitcoinPruneStatus now route
  through the same shared 'app-catalog'/'bitcoin.prune-status' cache keys
  Marketplace.vue introduced in 02-02, rather than duplicating the fetch;
  RefreshIndicator wired to the catalog resource's loadState. Discover's own
  dynamic-catalog-first fetcher (fetchAppCatalog with a curated-list
  fallback) is preserved as this key's fetcher for this view — both views
  are valid producers of the same shared cache entry.
- Fleet.vue: confirmed no lifecycle side effects (grep for the five tokens
  found none) — left unchanged, registered as-is.
- keepAliveRoutes.ts: KEEP_ALIVE_PATHS now derives from TAB_ORDER (single
  source of truth) plus /dashboard/discover, deliberately withholding
  /dashboard/settings even though it's in TAB_ORDER — Settings.vue's child
  sections (SystemDangerZone's reboot poll interval,
  VpnStatusSection/KioskDisplaySection/TransportPrefsCard/ClaudeAuthSection's
  one-shot onMounted fetches) were never in this plan's file scope and would
  misbehave under KeepAlive exactly as this plan exists to prevent. Every
  other TAB_ORDER path measured Remounted:true or was unmeasured in
  02-FINDINGS.md, so per the plan's literal exclusion rule (only a measured
  Remounted:false excludes) they all stay registered, including Mesh and
  Chat.
- keepAliveLifecycle.test.ts extended (in the prior commit) with
  shouldKeepAlive true/false assertions across every registered path and six
  secondary-screen paths, plus the KEEP_ALIVE_MAX+2 eviction test against the
  real DashboardRouterView + KEEP_ALIVE_PATHS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:06:03 -04:00
archipelagoandClaude Fable 5 f177a505b4 feat(02-04): main-tab side effects placed for activate/deactivate lifecycle
Demo images / Build & push demo images (push) Has been cancelled
Task 1 of 02-04 — audits every side effect owned by Home.vue, web5/Web5.vue,
Chat.vue, Cloud.vue, Server.vue and Mesh.vue and places each into one of
three buckets (once-per-session, every-entry, only-while-visible) so their
instances are safe to keep alive once KEEP_ALIVE_PATHS widens in Task 2.

- Home.vue: systemStats/wallet polling, the wsClient wallet-push
  subscription and its debounce timer follow activate/deactivate with an
  immediate re-sync on entry; hydrateWalletSnapshot/checkUpdateStatus/cloud
  usage stay once-per-session.
- Chat.vue: the window `message` listener and ContextBroker follow
  activate/deactivate; aiuiConnected is never reset on deactivate since the
  iframe's one-time 'ready' message won't resend on re-entry.
- Web5.vue: the six child-component data loaders (none use
  useCachedResource internally) and the 30s LND poll move to
  activate/deactivate; the DID lookup and intro flag stay once-per-session.
- Cloud.vue: the per-peer transport/reachability warm-cache
  (loadPeerFiles/loadCounts/loadPeers) re-runs every entry — the one path
  here that bypasses useCachedResource and would otherwise render stale peer
  reachability (T-02-13).
- Server.vue: the previously module-scope-armed 15s VPN poll interval now
  follows activate/deactivate (it used to run forever regardless of
  visibility); loadDiskStatus becomes every-entry.
- Mesh.vue: the entire live-communications surface (window/document
  listeners, the 5s/15s poll intervals, the ws peer-push subscription, and
  the six-way federation/self/contacts refresh) follows activate/deactivate;
  a share-to-mesh handoff via direct navigation is now correctly picked up
  on every activation, not just the first mount.
- useCachedResource.ts: onActivated's staleness check now skips an
  `immediate: false` resource that has never been explicitly fetched, so a
  tab-gated lazy resource (Cloud.vue's Paid Files / My Files walk) isn't
  eagerly force-loaded the moment its owning view is kept alive.
- Every arm/disarm pair is idempotent and duplicated into both onMounted and
  onActivated, since onActivated is a no-op outside a KeepAlive boundary
  (caught by CloudPeersRefresh.test.ts, which mounts Cloud.vue bare) —
  fresh-mount guard flags avoid double-firing the heavier loaders
  (Home/Mesh/Web5/Server) on a KeepAlive-wrapped first mount.
- New neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
  covers the six lifecycle behaviors plus a real-view assertion
  (Server.vue's VPN poll, mounted inside a real KeepAlive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:04:41 -04:00
archipelago a579556a4f docs(02-02): complete tracer tab plan 2026-07-30 09:22:58 -04:00
archipelagoandClaude Fable 5 2668705516 fix(02-02): restore page margins and slide transitions broken by KeepAlive restructure
Demo images / Build & push demo images (push) Successful in 3m36s
The Task 3 checkpoint failed on the real preview: outer page margins broke
and the up/down main-tab slide animations stopped playing. Root cause: the
02-02 restructure moved view-wrapper (absolute inset-0) onto each view's
root inside the padded wrapper, and split navigation across two sibling
Transitions behind a stable intermediate div — but dashboard-styles.css
scopes every transition as a compound selector on .view-wrapper, which must
be the keyed direct child of .perspective-container.

- Restore the pre-02-02 rendered DOM exactly: single Transition whose child
  is a keyed div.view-wrapper containing the per-route wrapper shape
- Re-integrate KeepAlive via statically-named per-route wrapper components
  (dashboardViewWrappers.ts) so the keyed div.view-wrapper is the cached
  component's own root; cache membership gated by :include on wrapper names
  derived from KEEP_ALIVE_PATHS
- Drop the scroll-retention Map: scroll containers now live inside keyed/
  cached wrappers, restoring the old reset-to-top behavior for non-kept routes
- Pin the visual contract structurally in keepAliveTabs.test.ts (padded
  wrapper classes must render INSIDE div.view-wrapper)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:40:27 -04:00
archipelago ac6852893d docs(02-03): complete secondary screen caching plan 2026-07-30 08:27:49 -04:00
archipelagoandClaude Fable 5 ec89901fa9 feat(02-03): OpenWrt gateway status caches on repeat visits; CloudFolder left as-is
Demo images / Build & push demo images (push) Successful in 3m43s
- OpenWrtGateway.vue: manual resources-store usage (no TTL gating — every
  mount force-refetched) replaced with useCachedResource (key
  server.openwrt-status, no item id — one gateway per node, fixed route
  with no :id param). load() keeps its explicit force-refresh semantics
  for connect/tollgate actions via a pendingParams closure; onMounted now
  only force-fetches when the cache is missing or past its 30s TTL.
- Fixed a real bug this conversion exposed (Rule 1): `loading` conflated
  'refreshing' with 'loading', hiding the already-rendered status panels
  behind the full skeleton on every background revalidation. Every mount
  used to force a fetch, so this was previously masked — cached content
  never got a chance to render before the skeleton took over. Now only a
  true first-load (no data yet) blocks on the skeleton (D-07).
- secondaryScreenCache.test.ts: repeat-open call-count coverage for
  OpenWrtGateway (within-TTL: one fetch; after TTL: cached paint + one
  more fetch).
- CloudFolder.vue: left unchanged. See 02-03-SUMMARY.md for the
  cache-placement decision and why a clean useCachedResource conversion
  needs a cloud.ts change outside this plan's files_modified scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:21:53 -04:00
archipelagoandClaude Fable 5 7c6c487a03 feat(02-03): app detail screens paint from cache on repeat visits
Demo images / Build & push demo images (push) Successful in 3m26s
- AppDetails.vue: bitcoin-sync and credentials converted to keyed
  useCachedResource (app-details:bitcoin-sync:<id>, app-details:credentials:<id>),
  each keyed by the route app id so two items never collide. Credentials
  is persist:false (credential material, D-08/T-02-01). Both loaders stay
  fire-and-forget from onMounted (already parallel — not touched). Stop/
  restart/uninstall now invalidate() the credentials resource so a stale
  healthy state can't outlive a destructive action (T-02-12).
- MarketplaceAppDetails.vue: the one RPC call in this view that isn't a
  measurement artifact of the Home-tab-transit confound (package.versions)
  is now a keyed useCachedResource (app-details:versions:<id>, 120s TTL —
  near-static catalog metadata). getCurrentApp() is a sync store read and
  the bitcoin-prune check is a plain fetch(), neither need conversion.
- secondaryScreenCache.test.ts: covers per-item isolation (rendered
  content, not just call counts), TTL-gated no-refetch, TTL-lapse
  revalidation, and keep-last-value on a rejected refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:13:35 -04:00
archipelagoandClaude Fable 5 f44b8ac78c feat(02-03): purge every cached resource on logout
Demo images / Build & push demo images (push) Successful in 3m34s
- resources.ts: clearAll() drops memory entries, in-flight/revalidator/
  invalidate-timer bookkeeping, and every resource:-prefixed sessionStorage
  key; a generation counter stops an in-flight fetch that resolves after
  clearAll from repopulating memory or sessionStorage (T-02-02)
- auth.ts: logout() calls clearAll() in the finally path so a failed
  server-side logout still leaves no cached payload behind locally
- resourcesClear.test.ts: covers all five required behaviors plus the
  generation-guard fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:01:01 -04:00
archipelagoandClaude Fable 5 a9a20039eb feat(02-02): tracer tab refreshes silently and every side effect is placed
Demo images / Build & push demo images (push) Successful in 3m32s
Puts Marketplace.vue's data on the cache and adds the subtle background-
refresh indicator D-05 calls for:

- RefreshIndicator.vue: presentational, state-driven (ResourceLoadState).
  Renders nothing for ready/idle/loading — a first load is the view's own
  skeleton's job, not this component's — and a role="status"
  aria-live="polite" spinner only while refreshing. A fixed-size outer slot
  keeps its appearance/disappearance from ever shifting layout.
- Marketplace.vue: loadCommunityMarketplace()/loadBitcoinPruneStatus() move
  onto useCachedResource behind shared keys app-catalog (300s TTL, persist —
  near-static catalog, D-06 discretion) and bitcoin.prune-status (default
  30s TTL, persist — both non-sensitive/small per T-02-01). app-catalog is a
  shared key so Discover.vue's identical loader picks up the same entry
  without its own conversion in 02-04. Error handling is keep-last-value
  (D-07): a rejected refresh sets the existing communityError banner ref,
  never a toast, and prior app cards stay on screen.
- Side-effect audit (the precedent 02-04 repeats across remaining tabs):
  marketplaceAnimationDone is genuinely once-per-session intro state, stays
  in onMounted; the two data loads needed no onMounted/onActivated hook of
  their own at all — useCachedResource's internal onActivated (wired in
  Task 1) already revalidates them on every kept-alive tab re-entry,
  staleness-gated so a quick revisit issues no fetch. No interval,
  subscription or window listener exists in this view, so no onDeactivated
  teardown was needed either.
- Tests: RefreshIndicator's full render-nothing/render-something matrix
  added to keepAliveTabs.test.ts (no router dependency, safe to colocate).
  The D-07 rejected-refresh-keeps-content-and-no-toast test mounts
  Marketplace.vue itself but lives in a new file,
  views/__tests__/MarketplaceRefresh.test.ts, following the in-repo
  CloudPeersRefresh.test.ts mount-the-view pattern — vi.mock('vue-router')
  is hoisted file-wide, so colocating it in keepAliveTabs.test.ts would
  clobber that file's real createRouter/createMemoryHistory used by the
  DashboardRouterView tests (Rule 3 auto-fix; deviation from the plan's
  literal single-test-file file list, documented here for the SUMMARY).
  Marketplace.vue gains a defineExpose({ loadCommunityMarketplace,
  loadBitcoinPruneStatus }) for tests, mirroring Cloud.vue's existing
  defineExpose({ loadPeers }).

Full suite (87 files / 709 tests), type-check and build all green; the
built Marketplace chunk carries refresh-indicator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 07:43:34 -04:00
archipelago 385c9d866e feat(02-02): tracer tab survives KeepAlive round-trip with revalidation
Demo images / Build & push demo images (push) Successful in 3m48s
Wires the phase's shared architecture end to end through one main tab
(Marketplace, the worst-measured revisit per 02-FINDINGS.md):

- keepAliveRoutes.ts: exact-match route classifier (shouldKeepAlive,
  KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX=6), seeded with only the tracer tab's
  path. Deliberately not KeepAlive `include` name-matching (async
  components under `<script setup>` have no inferable name).
- DashboardRouterView.vue: extracts Dashboard.vue's nested RouterView into
  a host where <KeepAlive> is a permanent element (never torn down by
  v-if) with its child conditionally present via shouldKeepAlive(route);
  a sibling Transition renders non-cached routes. Both original wrapper
  shapes (full-bleed chat/mesh vs. padded/scrollable default) are
  preserved via computed helpers on one stable, unkeyed wrapper div; the
  :key moves onto <component> itself. Adds per-route scroll retention
  since the scroll container is now stable across navigations.
- useCachedResource.ts: registers onActivated(() => refreshIfStale())
  alongside the existing onScopeDispose block, closing the gap where a
  kept-alive tab would otherwise never revalidate on reactivation
  (onScopeDispose doesn't fire on deactivate; window focus doesn't fire
  on an in-SPA tab switch). No-ops safely for all 8 existing consumers
  outside a KeepAlive boundary.
- useRouteTransitions.ts: exports TAB_ORDER so 02-04 can widen
  KEEP_ALIVE_PATHS from the same source of truth.
- Dashboard.vue: renders DashboardRouterView in place of the inline
  block; removes the now-superseded detail-route scroll save/restore
  (querySelector target no longer exists post-restructure — the new
  per-route Map in DashboardRouterView.vue is a strict superset).

Tests: keepAliveTabs.test.ts proves an included path's instance survives
a round trip (1 mount, 2 activations) while a detail path remounts (2
mounts); useCachedResource.test.ts proves no refetch inside the TTL,
exactly one refetch after it lapses, safe use outside KeepAlive, and
keep-last-value + sticky-ready semantics on a rejected refresh.

Full suite (706 tests), type-check, and build all green; built bundle
carries the new KeepAlive wiring (web/dist/neode-ui/assets).
2026-07-30 07:15:17 -04:00
archipelago bf9c56806c docs(02-01): complete surface perf harness & D-10 findings plan 2026-07-30 06:47:07 -04:00
archipelagoandClaude Fable 5 675deb65fc docs(02-01): commit D-10 findings doc — measured cause per D-09 surface
Every claim cites 02-PERF-BASELINE.json (archi-dev-box, real hardware).
13/15 surfaces measured cleanly; Mesh and Chat recorded as unmeasured with
reasons, never as already-fast.

Key findings:
- 4 main tabs (Home, Apps, Cloud, Fleet) measure already fast — left alone
  per D-02, KeepAlive wrapping only, no data-cache conversion.
- Marketplace is the worst main tab (2033ms revisit) and the tracer pick
  for 02-02, matching the user's own complaint ("often app store").
- No surface shows a serial-RPC-waterfall signature (maxConcurrentRpc is
  at or near each surface's total call count everywhere multiple calls
  were observed) — corrects RESEARCH.md's one "confirmed" waterfall
  target, ContainerAppDetails.vue, which this session's grep confirms is
  fully unreachable dead code (no importer, no route entry). D-13's
  parallelization pattern has no live target in this phase's D-09 set.
- Flagged a measurement confound: 3 rows (Marketplace, MarketplaceApp-
  Details, OpenWrtGateway) reach their target via a navSteps chain that
  transits another main tab first, so some of their captured RPC calls are
  that intermediate tab's own onMounted burst, not the destination's.
  Classified conservatively (remount storm, not uncached fetch) with the
  confound spelled out for 02-02/02-03 to re-verify before assuming.
- Wallet-send's revisit (2607ms) is consistently slower than its first
  visit (735ms) across all 3 runs despite zero RPC either time — flagged
  as an anomaly for 02-03 to profile directly, not hidden.

This is the last commit in this plan — the gate 02-02 through 02-08 depend
on before any neode-ui/src change lands (git diff --name-only HEAD~2..HEAD
-- neode-ui/src is empty across all three of this plan's commits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 06:42:08 -04:00
archipelagoandClaude Fable 5 361451400c fix(02-01): record archi-dev-box baseline; harden harness against cascade
- 02-PERF-BASELINE.json: on-device baseline recorded against archi-dev-box
  (real hardware, D-11's verification target). 13/15 surfaces measured
  cleanly across 3 runs each; Mesh and Chat are recorded as unmeasured with
  their reasons (Mesh: no connected mesh device on this node within the
  wait window; Chat: AIUI's own loading overlay outlives the close-button
  click budget) rather than silently marked already-fast, per plan rule.
- measure.ts: goHome() now falls back to a hard `page.goto('/dashboard')`
  when the Chat surface's own close button is unreachable (blocked behind
  AIUI's connecting overlay on real hardware) — without this, every
  surface after Chat inherited a permanently hidden sidebar
  (`v-show="!chatFullscreen"`) and cascaded to unmeasured. This recovery
  path is exempted from the "no page.goto between surfaces" rule because
  it exists only to break out of a stuck state, not to measure one.
- surface-perf.spec.ts: currentCommit() now shells out with `process.cwd()`
  instead of `__dirname`, which is unavailable under this package's
  `"type": "module"` runtime and was silently recording every run header's
  `commit` field as 'unknown'.

No file under neode-ui/src/ was modified by this task (git diff --name-only
HEAD -- neode-ui/src is empty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 06:32:56 -04:00
archipelagoandClaude Fable 5 a75b670918 feat(02-01): build re-runnable D-09 surface perf harness
- surfaces.ts: SURFACES table covering all D-09 surfaces (home/wallet,
  apps, marketplace, discover, cloud, mesh, server, web5, fleet, chat) plus
  four secondary screens (AppDetails, MarketplaceAppDetails, CloudFolder,
  OpenWrtGateway) and the located wallet-send modal. Navigation is via real
  RouterLink/button clicks (never page.goto) so revisit measures actual
  Vue Router client-side transitions.
- measure.ts: measureSurface() records first-visit vs revisit timing, an
  RPC method+timing trace (no bodies), a dataset-stamp remount probe, and
  derives maxConcurrentRpc/rpcWallClockMs via sweep-line so serial
  waterfalls are distinguishable from parallel fan-outs. Includes a
  dismiss-and-retry click guard for stray modals (Companion app intro) that
  would otherwise cascade failures across surfaces.
- surface-perf.spec.ts: logs in via the existing app-launch flow, walks
  every SURFACES row, writes results + a run header to ARCHY_PERF_OUT.

Verified end-to-end against the local mock-backend + vite dev server
(14/15 surfaces measured cleanly across 3 runs each; the 15th, Mesh, times
out only because the mock backend never reports a connected mesh device —
expected to resolve against a real node in Task 2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 05:59:15 -04:00
archipelago 5b69664092 docs(02): create phase plan 2026-07-30 04:58:35 -04:00
archipelago f17a805654 docs(02): resolve research open questions and backfill validation strategy 2026-07-30 04:56:54 -04:00
archipelago db18532e12 docs(02-ui-performance): create phase plan — 8 plans, 5 waves 2026-07-30 04:48:31 -04:00
archipelago abba457e50 docs(phase-2): add validation strategy 2026-07-30 04:11:18 -04:00
archipelagoandClaude Fable 5 350c06bcaf docs(02-ui-performance): research phase domain
Codebase-grounded research for KeepAlive/SWR caching, serial-waterfall fixes,
and the AIUI D-14 ride-along dependency risk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 04:10:32 -04:00
archipelagoandClaude Fable 5 2f99db5e6b fix(01-01): serialize federation node-store writes, close remove-vs-sync race (FED-01)
- Add FEDERATION_STORE_LOCK (tokio::sync::Mutex<()>) guarding every
  load-mutate-save cycle in federation/storage.rs, closing the race where
  the 90s auto-sync loop's stale pre-removal snapshot could silently
  re-save a peer the operator just removed (no error logged anywhere).
- Split load_nodes/save_nodes/tombstone_did/untombstone_did into thin
  locked outer wrappers + lock-free *_inner bodies so remove_node and
  add_node can hold the guard across their whole tombstone+save critical
  section without self-deadlocking (Mutex is not re-entrant).
- Route load_nodes, save_nodes, remove_node, add_node, tombstone_did,
  untombstone_did, set_trust_level, and update_node_state through the
  lock (set_trust_level pulled forward from Task 2's scope — required for
  test_concurrent_writes_do_not_lose_updates, part of Task 1's own
  required-green test suite, to pass; documented in SUMMARY).
- Convert save_nodes_inner to an atomic write: serialize to a sibling
  nodes.json.tmp in the same directory, then tokio::fs::rename onto the
  real path, so a crash mid-write never leaves a partially-written
  nodes.json for a concurrent reader.
- Add 3 new regression tests, two of which are fail-first proven: heavy
  tokio::spawn-based concurrency (not just tokio::join!, since
  remove_node's extra tombstone I/O hop structurally biased a simple
  2-task race toward the safe ordering) reliably reproduced both the lost
  concurrent write and the removed-node-reappears bug pre-fix; both are
  green post-fix along with the existing suite (13/13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 04:04:08 -04:00
archipelago 2d9625ca74 docs(state): record phase 2 context session 2026-07-30 03:50:19 -04:00
archipelago 284f6fce39 docs(02): capture phase context 2026-07-30 03:50:18 -04:00
archipelagoandClaude Fable 5 7487aba2c2 docs(quick-260729-je5,hj1): summaries + state for UI-fixes and media batch
Demo images / Build & push demo images (push) Successful in 4m55s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:20:06 -04:00
archipelagoandClaude Fable 5 d54517cf0b fix(demo): companion app skips the demo intro — lands straight on /login
When the demo build runs inside the Android companion WebView
(window.ArchipelagoNative bridge, detected via the existing
isCompanionApp()), all four IS_DEMO intro branch sites now skip the
typing splash and /onboarding/intro and route directly to /login, as if
the intro was already seen:
- App.vue root-boot replay request (companion never requests the splash)
- App.vue post-splash demo routing
- RootRedirect proceedToApp() and the server-up onMounted demo branch

The skip paths write nothing to localStorage/sessionStorage (RootRedirect
skips even its boot log() there), so the browser/PWA demo intro — which
replays on every fresh root boot — is byte-identical to before, and
non-demo builds short-circuit on IS_DEMO before isCompanionApp() runs.
Adds a small isCompanionApp() bridge-detection unit test (700 tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:11:12 -04:00
archipelagoandClaude Fable 5 b80e7c3487 fix(web5): connected-nodes list fills card height on xl — constant gap above footer buttons
The three tab panes (Trusted/Observers/Requests) were hard-capped at
max-h-72 with an mt-auto footer, so a tall sibling Node Visibility card
stretched the shared xl grid row and opened a growing dead gap between
the list end and the Find Nodes / Refresh buttons. The panes are now the
flexible middle of the card's column flex (flex-auto min-h-0, cap lifted
at xl via xl:max-h-none) so the gap is always exactly the footer's pt-4;
below xl the max-h-72 cap and current sizing are unchanged. Footer gets
shrink-0 so buttons can never be compressed by a long list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:04:17 -04:00
archipelagoandClaude Fable 5 b8243000e9 docs(260729-je5): pre-dispatch plan for connected-nodes margin + companion intro skip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:02:44 -04:00
archipelagoandClaude Fable 5 3a59953c87 docs(quick-260729-hj1): peer-files media batch — summary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:57:47 -04:00
archipelagoandClaude Fable 5 f52c540744 fix(peer-files): free-image lightbox, click-to-open routing, in-app open after every payment rail
- Card click now dispatches through openItem(): owned -> cached viewer,
  paid+playable -> 10% preview, paid non-playable -> pay modal (image is
  never fetched pre-purchase), FREE image -> full-screen lightbox streaming
  from /api/peer-content (fixes the click no-op where the old ternary fell
  through to undefined for non-playable free items)
- Viewer footer caption is state-aware: green 'Owned · unlocked' only for
  owned items, neutral 'Free · shared by peer' for free ones; Save streams
  free files instead of calling content.owned-get
- Lightning / invoice-QR / on-chain payment successes now share
  openPurchased() with the ecash flow: mark owned, autoplay audio in the
  bottom bar or open image/video in the viewer (previously a browser
  download that silently fails on the mobile companion)
- closeViewer only revokes blob: URLs (free items use plain stream URLs)
- Added a regression test: free image click opens the lightbox

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:51:36 -04:00
archipelagoandClaude Fable 5 14d1a453c7 feat(demo): Wavlake tracks, real photos, deduped peer catalog, working paid flow
- Two real Zazawowow tracks from Wavlake (metadata via catalog API, bytes +
  artwork committed): WEBFIVEFOURTHREETWOONE is the showcase PAID track
  (21 sats), Michael Michael Saylor is free
- Paid/owned downloads now return real file bytes with the correct mime_type
  (was a text/plain placeholder) so buying a song autoplays in the bottom bar
- content.owned-list seeded per session with dated purchases matching the
  session's federation onions; every purchase path appends to it so Owned
  state survives the post-purchase refresh (Paid Files tab now populated)
- peerCatalogFor: deterministic one-peer-per-item assignment + 3 POPULAR
  duplicates (was ~25% of items duplicated onto every third peer)
- demoFederationNodes memoised per session so catalogs/owned records/UI agree
- content.preview-peer serves a real audio slice for audio items (paid
  preview button plays music, not artwork bytes)
- New GET /api/peer-content/:onion/:content_id Range-capable streaming route
  (whitelist lookup, paid items 403) + /api dev proxy in vite.config.ts
- All ten photo-*.jpg picsum placeholders replaced with real Wikimedia
  Commons photographs (credited in each description, >=1920px wide)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:41:17 -04:00
archipelagoandClaude Fable 5 dcef3cb1bd docs(260729-hj1): pre-dispatch plan for peer-files media batch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:49:31 -04:00
archipelagoandClaude Fable 5 d8903ad5f3 docs(quick-260729-gjd): demo IndeeHub iframe + signer + preinstall — summary + state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:36:39 -04:00
archipelagoandClaude Fable 5 d00ca6242c feat(demo): IndeeHub pre-installed and running on fresh demo sessions
Add an indeedhub entry to staticDevApps in mock-backend.js (state running,
lanPort 8190, existing marketplace title/description/icon). Per-visitor
demo state is structuredClone(staticDevApps), so every fresh session shows
IndeeHub installed in My Apps with no install step. The demo launch URL
bypasses /app/indeedhub/ entirely (iframe loads the :2101 whole-origin
proxy), so no DEMO_APP_PAGES placeholder is added; marketplace metadata
already lists indeedhub following the same pattern as the other static
apps, and uninstall is blocked for static demo apps as usual.

Verified: mock-backend.js boots with DEMO=1 and the /ws/db initial dump of
a fresh session contains indeedhub state=running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:34:08 -04:00
archipelagoandClaude Fable 5 66d540f8b5 feat(demo): launch IndeeHub in the in-app iframe via the :2101 proxy
- useDemoIntro: replace DEMO_EXTERNAL_URLS (external-tab workaround) with
  DEMO_PROXY_PORTS; demoAppUrl('indeedhub') now resolves to
  <protocol>//<current-hostname>:2101/ at runtime (no hardcoded host/IP);
  isDemoExternal returns false (kept exported so call sites compile
  unchanged); isDemoApp still true for indeedhub so the NEW_TAB bypass
  keeps it in the in-app session
- useAppIdentity: suppress the identity-picker modal under IS_DEMO — the
  embedded IndeeHub is already signed in via the seeded throwaway demo
  account; real-node picker behavior untouched (IS_DEMO compile-time false)

Verified: 195 unit tests green (IS_DEMO=false path); VITE_DEMO=1 build
bundle contains the :2101 launch logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:28:21 -04:00
archipelagoandClaude Fable 5 69bc3d3f27 feat(demo): whole-origin IndeeHub proxy on :2101 with sign-in seeding
- nginx-demo.conf: new :2101 server block reverse-proxying the live
  indee.tx1138.com site with no path prefix (fixes the old sub_filter
  path-rewrite breakage), X-Frame-Options/CSP stripped, WS upgrade
  passthrough, and a demo sign-in script injected into <head>
- indee-demo-signin.js: PUBLIC-DEMO-ONLY seeder that writes a labelled
  throwaway "nsec" account (freshly generated keypair, not a secret) into
  the :2101 origin's indeedhub-accounts/indeedhub-active-account
  localStorage keys, idempotently, so IndeeHub boots signed in
- Dockerfile.web: copy the seeder into the demo web image, EXPOSE 2101
- docker-compose.demo.yml + demo-deploy/docker-compose.yml: publish 2101
  (DEMO_INDEE_PORT override documented in the thin deploy stack)

Verified: nginx -t clean in nginx:alpine; live proxy smoke shows 200 with
no framing headers, injected tag, seed script served, assets proxied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:23:18 -04:00
archipelago e2278ad518 docs: record session state — Phase 1 planned 2026-07-29 12:22:25 -04:00
archipelago e52f458c12 docs(01): plan-checker fixes — 01-06 depends_on 01-04; lifecycle-gate step in 01-10 2026-07-29 12:20:37 -04:00
archipelago bf96378d67 docs(01): create Phase 1 federation & mesh hardening plans (10 plans, 6 waves) 2026-07-29 12:16:39 -04:00
archipelagoandClaude Fable 5 db25545a9a docs(260729-gjd): pre-dispatch plan for demo IndeeHub iframe + signer + preinstall
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:13:21 -04:00
archipelago 2e54c3e40c docs(01): UI design contract — probe dismissal row for ring long-text 2026-07-29 11:41:00 -04:00
archipelagoandClaude Fable 5 6e2c8d7410 fix(apps): drop IndeeHub open-fullscreen default — opens as panel like other apps
Demo images / Build & push demo images (push) Successful in 4m38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:40:26 -04:00
archipelagoandClaude Fable 5 5bffba034e docs(quick-260729-fw7): mesh hop graphic redesign — summary + state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:38:27 -04:00
archipelago cb58a06d4b docs(01): revise UI-SPEC typography contract 2026-07-29 11:38:15 -04:00
archipelagoandClaude Fable 5 ac09fc5ded feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile
- New self-contained HopVizModal.vue (Teleport to body): 560px balanced panel,
  glowing endpoint medallions ringed with EQ segments (ScreensaverRing motif),
  per-transport accent track with staggered relay markers and an animated
  packet traveling sender → recipient
- Vertical stacked chain below 560px (sender top → recipient bottom, packet
  travels downward); prefers-reduced-motion disables all loops
- Accent colors match the chat transport pills exactly (meshtastic mint,
  meshcore orange, reticulum blue, lora amber, fips violet, tor indigo)
- Tor (3 anonymous relays), FIPS (direct P2P) and unknown-transport shapes
  preserved, as are SNR/RSSI + E2E/delivery metadata (now glass chips)
- Old inline modal markup removed from Mesh.vue; .mesh-hopviz-* rules removed
  from mesh-styles.css (shared transport-modal classes untouched)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:36:45 -04:00
archipelago 4e9741b013 docs(phase-1): UI design contract for FED-05/FED-06 2026-07-29 11:33:25 -04:00
archipelagoandClaude Fable 5 9432f42acc docs(260729-fw7): pre-dispatch plan for hop graphic redesign
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:30:16 -04:00
archipelago c31d99e49f docs(phase-1): correct FED-05 scope — meshed peers with lightning, not lnd listpeers 2026-07-29 11:28:43 -04:00
archipelago c899ab3591 docs(phase-1): capture FED-05 scope decision (connected peers) in CONTEXT.md 2026-07-29 11:27:34 -04:00
archipelago 1618a3ac53 docs(phase-1): add validation strategy 2026-07-29 11:25:30 -04:00
archipelagoandClaude Fable 5 b938449cbc docs(phase-1): federation/mesh hardening research
Codebase-derived research for Phase 1 (FED-01..06): identifies an
unlocked concurrent read-modify-write race on federation/nodes.json
as the likely root cause of nodes reappearing after removal, flags
two stale CONCERNS.md claims already fixed on main (01cbec27), maps
remaining mesh demo-parity gaps (contacts-list/save, reaction/edit/
delete stubs) beyond the already-shipped attachment-send parity fix
(c2ce71c6), and scopes the greenfield Lightning-URI/channel-open
surface needed for FED-05.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:24:09 -04:00
archipelago b4acb34624 docs: add FED-06 on-brand paid-tick animation (screensaver ring + EQ segments) to Phase 1 2026-07-29 11:11:35 -04:00
archipelagoandClaude Fable 5 c2ce71c680 fix(demo): mesh attachment send parity with real nodes
Demo images / Build & push demo images (push) Successful in 5m44s
Demo attach flow failed with 'Method not found: mesh.send-content-inline'
and force-opened the transport chooser modal real nodes don't show.

- mesh.transport-advice now mirrors the daemon's size-based tier logic
  (typed_messages.rs): chooser only in the fits-both 1-2.3KB band
- implement mesh.send-content-inline / send-content / fetch-content and
  POST /api/blob; bytes live in the per-visitor session store
- sent texts + attachments persist in mesh.messages so refresh-after-send
  shows them, same as a real node

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:03:21 -04:00
archipelago 8c2a55eb4c docs: add FED-05 inter-node lightning channel-open UX to Phase 1 2026-07-29 10:59:48 -04:00
archipelago 07b167d68e docs: insert Phase 1 federation/mesh hardening + Phase 2 UI performance; renumber 3-8 2026-07-29 10:59:08 -04:00
archipelagoandClaude Fable 5 21de734385 docs(qr): scanner snappiness research + companion-dev handover; 10/s native decode
Demo images / Build & push demo images (push) Successful in 4m16s
Research findings and a concrete split of work: web-side items for this
repo (pre-warm camera, torch toggle, continuous focus, keep-stream-
alive) and a native handover list for the companion dev (pre-warmed
CameraX + ML Kit, QR-only format, 720p keep-latest analysis, torch,
zoom nudge, haptic dismiss) with acceptance criteria and how to
measure. Quick win landed now: live scan runs at 10 scans/sec when the
platform has a native BarcodeDetector, keeping 4/s only for the
JS-worker fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:51:39 -04:00
archipelagoandClaude Fable 5 8bea3707ca feat(lightning): instant pay feedback, balances never vanish mid-payment
Demo images / Build & push demo images (push) Failing after 4m20s
Framework-pt report: a paid invoice stalled the UI with no success
shown, and lightning/total balances disappeared until it settled.
Three compounding causes, three fixes:

- Backend payinvoice's synchronous wait drops 120s → 8s. Fast payments
  (the majority) still settle in one round trip; slow multi-hop routes
  return pending + payment_hash quickly and the caller's 3s poll takes
  over — instead of the modal freezing for up to two minutes.
- payLightningInvoice gains an onPending hook: SendBitcoinModal and the
  scan modal now flip to a visible "Settling…" success pane the moment
  the payment goes pending (safe to close), and the ongoing poll
  upgrades it to Paid — or replaces it with LND's real failure.
- One slow lnd.getinfo poll (5s budget) flipped the Home wallet card to
  "disconnected", hiding balances the user already knew. Three
  consecutive failures are now required (~30s) before the card gives up;
  last-known balances keep rendering throughout.

rpc-client tests 75/75.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:47:39 -04:00
archipelago b7310ecfaf docs: create onboarding summary 2026-07-29 10:46:33 -04:00
archipelago 938dfb1453 docs: ingest 11 docs from docs/ (#2387) 2026-07-29 10:45:57 -04:00
archipelagoandClaude Fable 5 49ec294dea fix(update): concurrent apply reads as progress, not failure; idle-IO extraction
Demo images / Build & push demo images (push) Successful in 5m18s
"Another update operation is already running" surfaced as a scary
failure while the update was in fact applying fine (OptiPlex, v1.7.118
rollout). The apply path now joins the in-flight install — same
overlay, same wait-for-new-version polling — and a concurrent download
attempt shows a calm in-progress note (EN+ES strings added). The
backend's tarball extractions run under ionice -c3 nice -n10 so a
200MB update can't starve podman/status calls into multi-minute
timeouts on small disks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:38:11 -04:00
archipelagoandClaude Fable 5 00f1892bf8 feat(demo): auto-firing device-detection modal + transport pills on most peers
- ~8s into a session a second "freshly plugged" RNode appears on
  /dev/ttyACM0, so the global mesh setup modal (and its flash step)
  demos itself shortly after opening the Mesh page. Fixed plugged_at
  means "Not now" sticks for the whole browser session.
- Transport pills (LoRa/FIPS/Tor) now offered for every demo peer
  except mountain-node, which stays radio-only for contrast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:36:43 -04:00
archipelago 9e77a4229c docs: map existing codebase 2026-07-29 10:25:54 -04:00
archipelagoandClaude Fable 5 bea7f24a4f feat(demo): mock coverage for v1.7.117/118 features
Demo images / Build & push demo images (push) Failing after 3m19s
- mesh.transport-advice: peer 1 federated (LoRa/FIPS/Tor pills in the
  image modal), others radio-only.
- Scripted Flash LoRa job: flash-list-firmware + flash-device +
  flash-status advancing download → erase → write → done over ~35s
  with live log tail and percent, plus cancel.
- Demo messages carry per-message transport (meshcore/reticulum/fips/
  tor variety for the pills + animated route modal) and
  sender_pubkey/sender_seq so reactions/replies work.
- Chat-action acks: send-reaction/reply/read-receipt, edit/delete/
  forward, send-channel, mesh.refresh, reboot-radio.
- Services classification demo: self-deployed "podsteadr" stack — main
  app launchable, its MediaMTX backend (ui:null) files under Services
  with no Launch button, mirroring ui_detection's verdicts.

All verified against the running mock: 11/11 runtime checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:20:41 -04:00
archipelago 14feb1feb9 chore: release v1.7.118-alpha
Demo images / Build & push demo images (push) Failing after 2m19s
2026-07-29 08:35:38 -04:00
archipelagoandClaude Fable 5 d2642856c1 chore: fold Cargo.lock version bump from v1.7.117 release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:54:32 -04:00
archipelagoandClaude Fable 5 338bfd43a7 docs: v1.7.118-alpha changelog + What's New
Demo images / Build & push demo images (push) Failing after 2m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:53:44 -04:00
archipelagoandClaude Fable 5 3da1d0b0f7 fix(ui): mesh unread badges persist seen-state; more button + animated route modal
Demo images / Build & push demo images (push) Has been cancelled
Unread badges came back on every visit (framework showed a phantom "2"
with nothing new): unreadCounts was memory-only, so each page load
replayed the entire message history as "new". Seen-state now persists
as a per-contact highest-seen-message-id watermark in localStorage
(ids are backend-monotonic across restarts); first run after this
ships seeds the watermark from history so nobody gets a wall of stale
badges. Opening a chat advances and persists the watermark for all
twins of the merged conversation.

The hop-route modal the user asked for is now reachable from a visible
per-message "⋯" button (the transport pill remains clickable too), has
a fallback title/branch for messages that predate transport tracking,
and animates: endpoints and link reveal in sequence, a pulse travels
the link, and relay dots blink in order — all disabled under
prefers-reduced-motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:52:56 -04:00
archipelagoandClaude Fable 5 500aebb3e2 fix(mesh): radio tools ship via OTA + ISO; transport flag gated on daemon support
v1.7.117 broke Reticulum mesh on OTA-only fleet nodes two ways: the
update swaps only the backend binary and frontend tarball, so nodes
kept a stale archy-reticulum-daemon whose argparse exits on the new
--enable-transport flag (mesh session died on every spawn — confirmed
on framework-pt), and they never had archy-rnodeconf at all, so the
in-app Flash LoRa flow failed with a bare "No such file or directory".

Four-part fix:
- The Rust supervisor probes `daemon --help` and only passes
  --enable-transport when the daemon advertises it; unsupported daemons
  run edge-only exactly as pre-1.7.117 (tested against stub daemons
  both ways + missing-binary fail-safe).
- Both PyInstaller tools ride the frontend tarball's runtime payload
  (radio-tools/) and bootstrap.rs promotes them to /usr/local/bin on
  startup when bytes differ — the first OTA path that ever updates
  them. create-release.sh now rebuilds them every release and the
  manifest script hard-fails if they're missing.
- The ISO bundles archy-rnodeconf alongside the daemon (it never did).
- Flash LoRa reports "tool not installed — update the node" instead of
  the bare spawn error when rnodeconf is absent everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:51:22 -04:00
archipelago 04c056acdb chore: release v1.7.117-alpha
Demo images / Build & push demo images (push) Failing after 2m16s
2026-07-29 07:01:45 -04:00
archipelagoandClaude Fable 5 d0463196a3 docs: changelog + What's New — Reticulum relay (transport mode) bullet
Demo images / Build & push demo images (push) Failing after 59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:23:05 -04:00
archipelagoandClaude Fable 5 8e0939170c feat(mesh): archy nodes run as RNS transport nodes
The Reticulum daemon gains --enable-transport, which writes
enable_transport = yes into the RNS config it regenerates on every
start, and the Rust supervisor always passes it. Archy nodes now relay
RNS traffic and rebroadcast announces, so archy nodes (and Sideband/
NomadNet peers) beyond direct RF range discover and reach each other
through any archy node in between — edge-only operation left every
node limited to its own radio horizon. RNS's per-interface airtime
caps bound the extra announce overhead on LoRa.

Verified: config generation with the flag on/off, daemon --selftest
green with transport enabled, mesh test module 116/116.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:21:43 -04:00
archipelagoandClaude Fable 5 5c19effdd1 style: cargo fmt — clear formatting drift blocking the release gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:53:33 -04:00
archipelagoandClaude Fable 5 8e51164321 docs: v1.7.117-alpha changelog covers everything since 1.7.116 + What's New sync
Demo images / Build & push demo images (push) Failing after 2m4s
Rewrites the prepared 1.7.117 section to include the mesh flash flow,
first-class Reticulum fixes, radio-first routing, mesh chat polish, the
transactions-modal phone fixes, services-vs-apps classification, the
lightning slow-payment fix, cached-resource page loads, load-shedding,
FIPS uptime hardening, and companion 0.5.25. What's New modal block
regenerated from the new bullets (sync-whats-new --check passes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:49:45 -04:00
archipelagoandClaude Fable 5 da14c135e4 feat(apps): backend-only services classify as services with no Launch button
Demo images / Build & push demo images (push) Has been cancelled
A published port no longer implies a web UI. The package scanner used to
synthesize interfaces.main.ui="true" for any container with a port or
onion address, so headless backends — including self-deployed compose
stacks like podsteadr — showed up as launchable apps. New ui_detection
module decides instead: a manifest interfaces declaration (catalog
overlay first, disk second) is definitive; undeclared apps get a short
HTTP probe of the launch port (HTML page, redirect, or browser auth
wall = UI; JSON APIs, raw TCP, dead ports = service), with cached
verdicts and probes gated on running containers. Frontend canLaunch
now refuses curated services outright and only treats a bare runtime
address as launchable for curated known apps.

Works identically for manifest apps and containers deployed by hand
outside the orchestrator. ui_detection tests 6/6, frontend suite
696/696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:48:38 -04:00
archipelagoandClaude Fable 5 d7c5d39747 feat(ui): transactions modal filter tabs pin to top with blur on scroll
Demo images / Build & push demo images (push) Failing after 1m59s
Chips become sticky inside the modal's scroll region with a dimmed
blurred band, so the rail filter stays reachable while rows scroll
underneath. Verified in headless chromium: pinned at scroll-region top
after deep scroll, touch swipes starting on the chips still scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:01:22 -04:00
archipelagoandClaude Fable 5 6ba39041d0 feat(ui): mesh header "Flash LoRa" button opens the in-app flash flow
Demo images / Build & push demo images (push) Failing after 2m9s
Replaces the external flasher.meshcore.co.uk link with a button that
opens the global device-setup modal directly at its flash step, via a
new manual entry point in the mesh store (flashFlowPath). Manual opens
target the connected radio (else the first detected stick), skip the
read-only probe — the port is held by the live session and a second tty
opener corrupts it; the backend flash job stops the listener itself —
and close the modal instead of stepping back to the detection screen.
Button is disabled with a hint when no radio is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 04:43:38 -04:00
archipelagoandClaude Fable 5 c99f1c7b77 fix(ui): transactions modal touch scrolling on phones
Demo images / Build & push demo images (push) Failing after 2m3s
The tx list kept a vestigial overflow-y-auto from before the modal
contract refactor made BaseModal's slot wrapper the scroller. With a
modal open, modal-scroll-locked applies overscroll-behavior:contain to
every .overflow-y-auto inside the overlay, so touch scrolls latched
onto the non-scrollable inner list and could not chain up to the real
scroller — the modal was unscrollable on any touch device. Wheel input
latches onto the scrollable ancestor directly, which is why desktop
never showed it. Verified with headless-chromium touch synthesis at
360x640/320x568: list scrolls to bottom, background stays contained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 04:32:41 -04:00
archipelagoandClaude Fable 5 7326bb9262 fix(mesh): unpinned preferred path must stay an auto-detect candidate
Post-merge regression from combining two individually-correct changes:
main's 2026-07-23 fix makes open_preferred_path bail WITHOUT touching the
port when no device_kind is pinned, while the hw-config branch's skip_path
dedup excludes the preferred path from the auto-detect fallback on the
assumption it was already probed this cycle. Together, on a single-radio
node with no pin (the common fleet state), the only candidate was never
probed at all and the mesh never came up — hit live on archi-dev-box
right after deploying merged main.

Fix: when device_kind is None, skip open_preferred_path entirely and go
straight to auto_detect_and_open with skip_path=None. The pinned path
keeps the existing probe-then-skip fallback.

Verified live on archi-dev-box: radio auto-detected, Reticulum daemon
ready, 5 persisted peers loaded. Mesh tests 116 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:34:02 -04:00
archipelagoandClaude Fable 5 3589c3a6b9 Merge archy-hwconfig into main — hw-config flash-firmware flow
Demo images / Build & push demo images (push) Failing after 2m3s
Brings the hw-config branch (radio firmware flashing modal step 3,
flasher packaging + PyInstaller runtime hook, self-update hardening)
onto main, already reconciled with the probe/dedup/name work via
fb1f4bf0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:02:25 -04:00
archipelagoandClaude Fable 5 0aa3941c40 feat(ui): mesh chat polish — transport pills in image modal, hop-route modal, reaction dropdown, real read-tracking
Demo images / Build & push demo images (push) Failing after 3m29s
- Image quality modal: 'Send via' pills (LoRa / FIPS / Tor) when the
  peer is federation-reachable — mesh.transport-advice now returns
  has_fips + last_transport alongside has_tor. Picking FIPS/Tor routes
  the image over the content-ref path instead of the radio.
- Attachment modals (transport chooser, image quality, new hop modal)
  Teleport to body so the backdrop dims the FULL viewport — rendered
  in-place they sat inside a transformed glass panel that trapped
  position:fixed to the right chat panel.
- Click a message's transport pill → route modal: radio hops + live
  SNR/RSSI quality for LoRa transports, overlay/circuit shape for
  FIPS/Tor, delivery + E2E state.
- Reactions move behind a compact 'React ▾' dropdown with a larger
  12-emoji palette.
- Unread badges now clear like a normal chat app: opening a contact
  clears ALL twins of the merged conversation (badge sums every
  contact_id — clearing just the clicked one left it stuck), and only
  once the chat has scrolled to the latest messages; scrolled up into
  history, new arrivals accumulate until you scroll back down.
- Refresh button shows only the spinner while refreshing (text+spinner
  overflowed the fixed button width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:01:36 -04:00
archipelagoandClaude Fable 5 e62f911810 fix(mesh): Reticulum resource transfers actually deliver — 4 root causes
E2E-verified both directions dev-box<->x250 over real RF (5KB image ~60s):

1. Sender daemon never called link.identify() — receiver's
   get_remote_identity() was None, so every arrived transfer carried an
   empty source_hash and the Rust side dropped it (now also warns
   instead of silently vanishing it).
2. Receiver treated resource.data as bytes, but RNS hands a concluded
   Resource's data as a file-like BufferedReader — b64encode raised
   TypeError and the transfer was lost even when attributed.
3. Radio twins of merged contacts carry the peer's Archipelago ed25519
   key as pubkey_hex, not an RNS hash — prefix lookup could never match
   ('Unknown Reticulum prefix', observed live). resolve_dest_hash now
   falls back to matching the announce-bound arch_pubkey_hex.
4. The daemon RPC socket kept asyncio's default 64KiB line limit; any
   attachment >~48KB overflowed it and tore down the whole daemon
   connection ('reticulum-daemon is gone'). Raised to 16MiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:00:44 -04:00
archipelagoandClaude Fable 5 fb1f4bf0e3 Merge main into archy-hwconfig — reconcile probe/dedup/name work
Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:03:19 -04:00
archipelagoandClaude Fable 5 79c3cc5947 fix(mesh): radio-first transport policy + attachments to merged contacts route via the radio twin
Demo images / Build & push demo images (push) Failing after 3m50s
Two halves of the same twin-resolution gap, found live while testing
images between archi-dev-box and archy-x250-dev:

- peer_dest_prefix resolved the given contact row's own pubkey. For the
  UI's merged conversation (the federation-synthetic id) that's the
  Archipelago ed25519 identity key, NOT a radio routing key — so every
  Reticulum resource send (images/files over LoRa) failed with 'Unknown
  Reticulum prefix' while the UI showed the message as sent. It now
  resolves through the radio twin (same arch identity, radio-range id).
- send_typed_wire sent EVERY federation-synthetic contact over the
  federation path (FIPS→Tor), even with the same node one LoRa hop away.
  Policy per operator: LoRa first when the payload fits and the radio
  twin is reachable, then FIPS, then Tor. Verified live: text to the
  merged contact now logs 'Radio-first routing' and lands with
  transport=reticulum on the peer.

Also restyles the mesh-chat attachment download controls: the pre-fetch
button was a bare .btn that squished to text width in the narrow mobile
bubble; now a full-width glass pill with a download icon and fetch
spinner, and the on-image overlay swaps the emoji glyph for a crisp SVG
in a properly-sized glass circle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:16:40 -04:00
archipelagoandClaude Fable 5 c4f24f3efa test(mesh): mesh/Reticulum test gate — unit tests + daemon selftest + live-node assertions
tests/mesh/run-mesh-tests.sh: cheap-first layers — 116 Rust mesh unit
tests, the daemon --selftest (now also asserting the announce app_data
wire contract and the set_name verb), and opt-in live assertions against
a running node (radio connected, named, mesh.refresh/broadcast, no
ARCHY-blob peer names). Verified green on archi-dev-box and archy-x250-dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:00:58 -04:00
archipelagoandClaude Fable 5 0acfba40db docs(reticulum): 2026-07-28 checkpoint — RNode connect + name propagation fixed, E2E verified
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:56:03 -04:00
archipelagoandClaude Fable 5 3f76b4960a feat(ui): mesh Refresh/Broadcast feedback, Set Recommended modal with probe progress bar, live list refresh
Demo images / Build & push demo images (push) Successful in 3m43s
- Refresh button: real handler — calls the new mesh.refresh (radio
  re-query) plus contacts/federation/outbox re-reads, disabled with a
  spinner while running (was an unawaited cache repaint with no feedback
  that skipped half the list's data sources).
- Broadcast button: success ('Sent ✓') and failure states with the error
  in the tooltip; failures no longer vanish as unhandled rejections.
- The store's 5s status poll no longer wipes the error banner each tick.
- Contacts/aliases, federation nodes and the outbox badge refresh every
  ~30s (were mount-only and went permanently stale).
- Peer-list empty state keys on the merged list, so federation rows and
  the channel rows still render with no radio attached.
- Device setup modal: 'Set Recommended' naming, probe progress bar with
  stage labels instead of an anonymous spinner.
- Device panel: name save clears properly (empty = fall back to server
  name) and the confirmation reflects the new live apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:37:16 -04:00
archipelagoandClaude Fable 5 a8c4694c36 fix(mesh): first-class Reticulum — probe boot-race, live config apply, name propagation, daemon-death detection
Root causes found and fixed after live debugging on archi-dev-box (all
verified on real RNode hardware, archi-dev-box <-> archy-x250-dev E2E):

- probe_rnode raced the board's own boot: opening the port pulses DTR/RTS
  through USB-UART bridges (CP2102/Heltec V3), the ESP32 power-cycles and
  spends ~2.5-3s in boot ROM, and the KISS DETECT written 300ms after open
  landed in the void — so an RNode could NEVER connect on these boards.
  Now: immediate probe (fast path), then drain-until-quiet boot settle and
  a second DETECT with a fresh response window.
- MeshService::configure() only restarted the listener on enable/disable —
  device_kind/device_path/advert_name/RF-param changes were silent no-ops
  until a full process restart (the setup modal's apply/keep-as-is did
  nothing). Material config changes now bounce the listener; the open
  sequence races the shutdown signal so stop() no longer burns the full
  15s timeout mid-probe; mesh.configure applies in the background instead
  of stalling every status poll behind the service write-lock.
- The mesh name was write-only: config.advert_name had no reader,
  server.set-name never reached the mesh service, and Reticulum's
  set_advert_name was a no-op (daemon display name fixed at spawn, and the
  ARCHY:2 announce blob REPLACED the LXMF display name — every archy node
  was anonymous on RNS). Now: advert_name > server name precedence feeds
  the session, renames restart it live, the daemon gets --display-name at
  spawn plus a set_name RPC verb, and announces carry the LXMF-standard
  msgpack name with the identity blob appended as an extra list element
  stock clients (Sideband/NomadNet) ignore.
- Dead reticulum-daemon was invisible for up to 30min (RX-stall watchdog):
  child exit / RPC-EOF now fails try_recv_frame so the session reconnects.
- Setup modal re-trigger loop: plugged_at used the tty node's mtime, which
  bumps on every open — each probe invalidated the dismissal key. Use
  btime/ctime (only change on real plugs).
- ARCHY:2 identity adverts (re-emitted every 60s over Reticulum) stomped
  the federation twin's real name with a synthetic Archy-… placeholder and
  nulled its position; blob-only announces no longer assert a name, blob
  strings can never become display names, and stale blob names are healed
  at peers.json load.
- mesh.broadcast on Meshtastic sent heartbeat+time only (no identity);
  SendAdvert now also fires a want_response NodeInfo broadcast.
- New mesh.refresh RPC: actively re-queries the radio contact table (the
  UI Refresh button previously only re-read server caches).
- Reticulum peers now track last_advert (announce time) and mark existing
  peers reachable on inbound traffic.
- Boot auto-enable no longer force-enables mesh when an operator
  explicitly disabled it (only fires when no config file exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:36:52 -04:00
archipelagoandClaude Fable 5 537c52d11c feat(ui): FIPS network + seed-anchor cards render from cached resources (B4)
Demo images / Build & push demo images (push) Failing after 2m6s
The two FIPS containers on the Server page were the last network cards
still fetching-on-mount into local refs — every visit was a blank card
until fips.status / fips.list-seed-anchors answered. Both now ride the
cached-resource layer: FipsNetworkCard shares the server.fips-summary
key with the Local Network card's FIPS row (one fetch, never disagree),
seed anchors cache under server.fips-seed-anchors, and mutations
write the RPC's authoritative result straight into the cache. The 15s
status poll skips hidden tabs — revalidate-on-focus covers the return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:48:34 -04:00
archipelagoandClaude Fable 5 2971623145 fix(server): accept loop can never starve — shed load instead of parking
The HTTP accept loop parked on acquire_owned() when the connection
budget drained, freezing accept() for every client (the .228
session-flapping / CLOSE-WAIT signature). Permits drained because
half-open clients and hung upstreams held them indefinitely.

- try_acquire_owned + immediate 503-and-close when the budget is
  exhausted; the accept loop itself never blocks
- 30s http1_header_read_timeout drops slowloris/half-open clients
- 900s watchdog bounds non-upgraded connections; websocket upgrades
  are exempt (legitimately long-lived)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:10:39 -04:00
archipelagoandClaude Fable 5 c0a5635ba3 feat(fips): A3.10 — last-known-good endpoint fallback for direct peering
New fips/endpoints.rs: an npub-keyed store (<data_dir>/fips-endpoints.json)
of every endpoint a peer was last seen connected at (fipsctl show peers
transport_addr/transport_type — covers LAN, Tailscale, and WAN alike),
refreshed each anchor tick, 30-day retention.

The anchor tick now escalates LAN → last-known-good → anchor tree: any
federation peer with a fips npub that is neither currently connected nor
covered by a live LAN direct entry gets its last-known-good endpoint
re-dialed (idempotent fipsctl connect, bounded by apply()'s per-connect
cap). This productizes the hand-applied .116↔.198 Tailscale fix of
2026-07-20 and closes RC2's "no endpoint fallback" gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:33:32 -04:00
archipelagoandClaude Fable 5 43130f33f0 fix(container): companion probes are passive — no builds inside reconcile checks
Root cause of the .198 load spiral (2026-07-28): needs_repair() called
ensure_image_present() every 30s tick to render the expected unit, so
under IO pressure the image-existence check timed out, read as "image
missing", and a 900s podman build ran inside the PROBE while the
companion was up — each build pegging the disk that made probes fail.

- needs_repair() is now build/pull-free: unit file present → service
  is-active (10s cap; a hung systemctl reads as "assume active", never
  as dead) → unit matches one of the three image refs install_one could
  have written → context-newer-than-image staleness only when the unit
  uses the auto-built :latest.
- Per-companion 10-min repair cooldown after a failed install_one, so a
  failing build retries at most every REPAIR_COOLDOWN instead of every
  reconcile tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:54:29 -04:00
archipelagoandClaude Fable 5 73228114b9 feat(ui): B5 — /ws/db pushes revalidate cached resources
Demo images / Build & push demo images (push) Failing after 2m5s
Bridge WebSocket patches into the resource layer: a /peer-health/<onion>
patch invalidates that peer's cloud.peer-browse entry and the federation
node list; /package-data patches invalidate the tor-services list.
invalidate() debounces 800ms and refetches only keys with mounted
subscribers, so patch storms cost one revalidation per key; the 30s
staleness reconciliation remains the backstop for unmapped data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:49:36 -04:00
archipelagoandClaude Fable 5 8907cc47d9 feat(ui): Credentials + OpenWrtGateway render from cached resources — B4 complete
Demo images / Build & push demo images (push) Failing after 2m10s
- Credentials: identity.list + identity.list-credentials become
  useCachedResource entries; explicit reloads still toast on failure.
- OpenWrtGateway: openwrt.get-status caches in the shared store (revisits
  paint the last router state instantly); the connect flow's
  params/No-router-configured semantics are preserved on top of the entry.
- ContainerApps assessed and left as-is: its Pinia store already persists
  across navigation, keeps last data on error, and gates the spinner on
  empty — same class as Apps/Marketplace/Fleet.

This closes the B4 rollout list from docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:27:05 -04:00
archipelagoandClaude Fable 5 8fd72b947a test(ui): adapt SWR contract tests to the cached-resource layer — 692/692
Demo images / Build & push demo images (push) Failing after 2m9s
The keeps-data-visible-while-refreshing tests for PeerFiles, Server, and
LightningChannels mounted without Pinia (the converted components now
pull the resources store in setup) — add createPinia to the mounts.
LightningChannelsPanel: refresh the main channel list before the closed
history so the primary entry gets the first response, and null-guard
both fetchers' response shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:55:12 -04:00
archipelagoandClaude Fable 5 ea254f63af feat(ui): Server page renders from cached resources (B4)
Demo images / Build & push demo images (push) Failing after 2m9s
network summary (4-RPC allSettled aggregate), fips row, vpn peers,
interfaces, and tor services become useCachedResource entries — revisits
paint instantly, background refreshes keep content on screen. Mutations
write through the cache: DNS apply + the 15s vpn poll patch the network
aggregate via optimistic() instead of refetching all four RPCs; peer
removal filters the cached list. loading/refreshing flags derive from
entry loadState (drops the hand-rolled hasLoaded bookkeeping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:45:17 -04:00
archipelagoandClaude Fable 5 1a306c7450 feat(ui): Federation adopts the cached-resource store (B4)
Demo images / Build & push demo images (push) Failing after 2m7s
federation nodes + dwn.status become useCachedResource entries: revisits
paint the node list instantly, `loading` fires on true first-load only
(the old showLoader semantics), the 5s poll refreshes silently like the
old surfaceErrors:false path, and explicit reloads after mutations still
surface failures in the error banner. Replaces the hand-rolled
loadNodesWithOptions SWR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:36:26 -04:00
archipelagoandClaude Fable 5 a969f892ea feat(ui): Lightning channels panel renders from cached resources (B4)
Demo images / Build & push demo images (push) Failing after 2m1s
lnd.listchannels (+summary) and lnd.closedchannels become separate
useCachedResource entries: reopening the panel paints the last channel
lists instantly and revalidates behind them; a closed-history failure
keeps its last list without touching the main view (same semantics as
the old nested try). Open/close mutations still force a refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:26:53 -04:00
archipelagoandClaude Fable 5 43e50e669e feat(ui): Monitoring renders from cached resources (B4)
Demo images / Build & push demo images (push) Failing after 2m7s
monitoring.current/history/alerts/alert-rules become useCachedResource
entries: revisiting the page paints the last snapshot, chart, and alert
list instantly and the 5s poll revalidates behind them (refreshes dedup
in the store; errors keep last-known values instead of blanking).
Alert-rule toggles and acknowledgements refresh their entries after the
mutation as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:22:06 -04:00
archipelagoandClaude Fable 5 529c7fe25d feat(ui): Web5 wallet/profits render from cached resources (B4)
Demo images / Build & push demo images (push) Failing after 2m2s
- lnd.getinfo and wallet.networking-profits become useCachedResource
  entries (web5.lnd-info / web5.networking-profits): revisits paint
  instantly from cache, errors keep last-known values, refreshes dedup.
- walletConnected is now derived from the lnd-info entry (with a manual
  disconnect override preserving the connect/disconnect toggle).
- Drop the eager wallet.ecash-balance + lnd.gettransactions loaders and
  their 30s polling — they fed only the hidden wallet card; the lnd-info
  poll remains for the connected pill until B5 moves it to WS-push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:03:47 -04:00
archipelagoandClaude Fable 5 d605d0d544 feat(ui): PeerFiles renders from the shared peer-browse cache (B4)
Demo images / Build & push demo images (push) Failing after 2m11s
- PeerFiles.vue reads the SAME `cloud.peer-browse:<onion>` entry Cloud.vue's
  per-peer fan-in fills, so Cloud → peer files paints instantly from cache
  and revalidates behind it; catalog/error/loading/transport are now
  computed views over the store entry.
- preview-peer fan-out is capped at 3 concurrent with a queue (was one 30s
  RPC per media item, all at once, unbounded) and aborts on unmount.
- browse + preview RPCs drop to maxRetries:1 — retry×3 turned one slow
  peer into a 90s spinner.
- fix useCachedResource's interface types: `ReturnType<typeof computed<T>>`
  resolves to the writable overload (WritableComputedRef), which broke
  vue-tsc against the plain computed() returns; use ComputedRef<T>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 04:46:26 -04:00
archipelagoandClaude Fable 5 a3f07d5ac6 feat(fips): resilience — connectivity watcher with immediate anchor re-apply, rebindable peer listener, cached service probe, warm-path union
Phase A3 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md (RC5), measured against
the A2 dial_stats baseline:

- 25s connectivity watcher in the fips supervisor: re-applies seed anchors
  immediately on an anchor-link drop, on startup-disconnected, AND on
  silent data-path death (connect_fails growing with zero fips_ok — the
  live .198 failure where the daemon reported "connected" while every
  dial blackholed and the 300s tick never healed it). Bounded to one
  re-apply per 60s.
- anchors::apply is now concurrent with a 15s per-connect cap — the old
  serial loop waited unbounded on each `sudo fipsctl connect`, so one
  hung subprocess stalled the whole periodic tick.
- rebindable peer listener: the accept loop returns after persistent
  accept errors (was: continue forever = inbound-dead until restart) and
  peer_late_bind_loop rebinds — also on fips0 ULA change.
- is_service_active gets a 10s TTL cache (was up to 2 systemctl spawns
  per dial attempt and per warm-tick peer).
- the warm tick now warms the union of federation peers + configured
  seed anchors (direct anchor links used to go cold between 300s ticks),
  skipping the redundant per-peer service check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 03:57:12 -04:00
archipelagoandClaude Fable 5 c83bade022 feat(ui): Cloud page renders from cache — per-peer incremental fan-in, live FIPS/Tor badges, per-path folder cache
Demo images / Build & push demo images (push) Failing after 3m24s
Part B3 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md — the worst
fetch-on-every-navigation offender converted to the cached-resource layer:

- section counts / peer nodes / my files / paid items are cached resources:
  revisits paint instantly, refresh happens behind the content
  (sticky-ready), errors keep last-known data
- peer files: per-peer cached browse entries replace the all-or-nothing
  Promise.allSettled — each peer's rows render the moment it answers, with
  "still fetching from N peers" + unreachable counts; browse-peer runs
  with maxRetries:1 so one dead peer costs its timeout once, not ×3
- peer cards get a live transport badge (FIPS green / Tor amber, with
  measured latency) from the transport field the browse response already
  carried — the per-peer FIPS-uptime view, for free
- cloud store: per-path listing cache with stale-while-revalidate
  navigate() and a last-wins guard; CloudFolder no longer reset()s the
  store on every folder entry (that wipe forced a spinner each time)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:49:30 -04:00
archipelagoandClaude Fable 5 67454974b2 feat(ui): shared stale-while-revalidate layer — useCachedResource + resources store + rpc-client abort/dedup/retry controls
Demo images / Build & push demo images (push) Successful in 3m28s
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.

- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
  with sticky-ready (never regress ready→loading), keep-last-value on
  error, per-key in-flight dedup, sessionStorage snapshot hydrate,
  debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
  hydrate, TTL-gated background revalidate, revalidate-on-focus,
  abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
  in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:54:51 -04:00
archipelagoandClaude Fable 5 e24e0a6473 feat(fips): fallback telemetry — per-reason counters in fips.status + last-transport recording on all dial sites
Phase A2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md (RC6). Fallbacks to Tor
were debug!-only and uncounted, so "FIPS uptime" was unfalsifiable and
paths that were 100% Tor by construction went unnoticed for months.

- fips::telemetry: process-lifetime counters for FIPS successes and the
  six fallback reasons (no_npub, service_inactive, dns_fail, connect_fail,
  http_404, http_5xx), exposed as `dial_stats` in fips.status
- dial.rs: every fallback branch now counts + logs at info! with a
  `reason` field (resolve/connect/status branches)
- PeerRequest::record_transport(data_dir): opt-in hook that writes the
  transport actually used to federation storage off the hot path — wired
  into the dial sites that never recorded (DWN sync ×3, mesh blob fetch,
  federation deploy notify, onion-rotation notify, node messages via a
  new send_to_peer data-dir param)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:54:35 -04:00
archipelagoandClaude Fable 5 eb2fc0f37b fix(fips): P0 uptime fixes — open peer port 5679, allow /blob+/dwn, fix LAN anchor port, un-deaden direct peering, fast-fail budgets
Phase A1 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md — the five changes that
made FIPS fall back to Tor even when a FIPS path existed:

- RC0: the fips.d drop-in now opens PEER_PORT 5679 (was 80+8443 only, so
  every hardened node firewalled peers' FIPS dials; 28k drops on .198)
- RC4: /blob/ and /dwn/ added to the peer-path allowlist — mesh file
  sharing and DWN sync were 404 → 100% Tor by construction
- RC2-G2: lan_fips_anchors dials PUBLISHED_UDP_PORT (2121) instead of the
  dead 8668, with a drift-guard test against the rendered daemon config
- RC2-G1: direct LAN peering actually runs now — mDNS TXT advertises the
  FIPS npub, discovery calls set_fips_npub, and the anchor tick hydrates
  npubs from federation storage for peers on older builds
- RC3: FIPS attempt budget is a hard cap (retry no longer doubles it) and
  the 12 hot call sites get explicit fips_timeout fast-fail so Tor keeps
  its full budget (browse-peer, preview, /blob, DWN, node-message,
  rotation notifies)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:42:44 -04:00
archipelagoandClaude Fable 5 94b5374f66 Merge public-prelaunch: open-source launch prep + FIPS unit-fallback coverage + companion safe-area fix
Demo images / Build & push demo images (push) Successful in 3m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:16:27 -04:00
archipelagoandClaude Fable 5 9f65f1e7ae docs: FIPS near-100% uptime + optimistic UI state plan — live-proven root causes (nft 5679 drop, .228 daemon skew, dead LAN peering, no fast-fail) + phased execution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:14:23 -04:00
Dorian c598bb8796 fix(android): preserve top inset for fixed app headers 2026-07-27 20:11:02 +01:00
Dorian 70996203f9 fix: complete fips unit fallback coverage 2026-07-27 19:28:53 +01:00
Dorian 709922c293 fix: harden fips startup and app port relays 2026-07-27 19:18:39 +01:00
Dorian 0aee010f9c chore: prepare repository for public launch 2026-07-27 17:51:43 +01:00
archipelagoandClaude Fable 5 c5eeb4392f docs: open-source readiness plan — phases 0-6 for public launch
Consolidated plan from the deep repo review: credential rotation, secret
scrub, repo restructure, registry parameterization, docs overhaul, deep
code cleanup, and fresh-history publish mechanics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:38:01 -04:00
lfg2025 5457b98c66 Merge pull request 'Companion 0.5.25 — dashboard hub menu, adaptive panel, safe-area update fix' (#124) from release/1.7.115-prep into main
Demo images / Build & push demo images (push) Successful in 2m57s
2026-07-27 16:31:43 +00:00
Dorian 1593c55894 chore(android): update companion apk download 2026-07-27 17:12:52 +01:00
DorianandClaude Fable 5 1cc18e5b72 feat(companion): three-finger opens the hub menu over the dashboard — 0.5.25
The dashboard's three-finger hold now opens the menu overlay in place
instead of jumping to the remote screen; the hub's Remote and Keyboard
cards do the navigating (Keyboard lands directly in keyboard mode via a
nav arg). The dashboard host carries the full Nodes page — add, edit,
remove and QR pairing — and Mesh Party.

Also: the panel scales to 92% of screen height instead of a fixed 560dp
cap so pages fit without scrolling; the FIPS sub-page no longer repeats
the FIPS Mesh header inside the detail card; the safe-area injection
fires an archy-insets event the web UI listens for (update-install
status-bar fix, other half in neode-ui).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
DorianandClaude Fable 5 24582128c5 fix(ui): re-pad mobile dashboard when Android injects safe-area top
The mobile nav sampled --safe-area-top once at mount, but the companion
WebView injects it asynchronously. An authenticated session mounts the
dashboard before the injection lands (fresh installs mount after login,
long after it), so the content padding baked in 0 while the fixed tab
bar grew by the real inset — content slid underneath by exactly the
status-bar height, the update-install-only overlap.

Re-read on the WebView's new archy-insets event, with a retry ladder as
fallback for APKs that predate the event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
DorianandClaude Fable 5 0b038434b1 feat(companion): settings menu hub redesign — 0.5.24
Three-finger menu becomes a contained card hub: Dashboard, Remote,
Keyboard, Nodes, FIPS Mesh, Mesh Party — with Nodes and FIPS as
sub-pages behind a back header. The panel is a centred glass card that
scrolls within its own bounds instead of filling the screen.

The Dark/Classic style toggle leaves the menu and becomes a palette
button next to the settings gear on all three input surfaces (landscape
controller, portrait controller, keyboard mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
DorianandClaude Fable 5 d576f77435 fix(companion): off-LAN load falls back to mesh URL, not dead LAN IP
When neither the LAN origin nor the mesh ULA answers the probe window,
target the mesh URL (when paired) instead of the LAN IP: off-LAN the LAN
address can never answer, and loading it showed a confusing 'can't reach
192.168.x.x' error while the mesh session was still coming up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
archipelagoandClaude Fable 5 7e8d3314d0 docs: backlog — optimise companion QR scan (quicker start/decode, low-light)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:12:01 -04:00
DorianandClaude Fable 5 07772b563f fix(companion): wallet scanner reads dense invoice QRs — 0.5.22
Demo images / Build & push demo images (push) Successful in 2m59s
Root cause (diagnosed live via CDP on a Pixel 9a): camera permission
granted and the native camera streamed (2m35s active) but ZXing returned
zero decodes for Lightning-invoice QRs — window.__archyQrResult received
0 calls. Dense BOLT11 codes need more pixels/module AND sharp focus; the
9a's main lens rests at a far focus and 1280x720 wasn't enough, so dense
invoices never resolved while sparse address QRs did. Fix: analysis at
1920x1080 + a repeating centre autofocus tick (a static hand-held QR
never retriggers continuous-AF on its own).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:51:47 +01:00
archipelagoandClaude Fable 5 d500214766 docs: backlog — ship lightning false-failure fix; companion app version display
Demo images / Build & push demo images (push) Successful in 3m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 05:33:02 -04:00
archipelagoandClaude Fable 5 9cd507269c fix(lightning): never report a slow in-flight payment as failed
Slow multi-hop payments (>15s routing) surfaced as "Payment failed"
while LND settled them in the background: the shared LND REST client's
15s total timeout aborted the synchronous /v1/channels/transactions
wait, and every UI path treated that abort as a definitive failure. The
payment then succeeded anyway and only appeared in history on the next
background poll.

Backend: lnd.payinvoice now decodes the invoice up front for its payment
hash, pays on a dedicated 120s client, and answers status:"pending" with
the hash (never an error) when the wait elapses after the payment was
handed to LND — only a pre-connect failure is still a hard error. New
lnd.paymentstatus RPC reports succeeded/failed/in_flight (with humanized
failure reasons) from /v1/payments.

Frontend: new rpcClient.payLightningInvoice() pays then polls
lnd.paymentstatus to a real terminal state (3s interval, up to 2 min);
all five call sites (send modal, scan modal, web5 unified send, peer-file
purchase, app-launcher payments) migrated. Failure is only shown when LND
itself declares FAILED; a still-settling payment shows an in-flight state
and success fires the transaction refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 05:33:02 -04:00
Dorian aa272bfcf4 chore(android): update companion APK download [skip ci] 2026-07-27 10:19:28 +01:00
DorianandClaude Fable 5 3b6a32b2f8 fix(companion): app webview content clears the status bar; HTTPS toggle on add/edit — 0.5.21
- In-app browser pages (node apps) now start below the status bar again:
  the WebView stays edge-to-edge (page colour fills the bar) and a
  body{padding-top:<statusbar>} injection pushes content down — the
  pre-edge-to-edge look without the black bar.
- Add/Edit server in the menu now has a Use HTTPS toggle (was scheme-locked;
  edit preserved the old scheme, add forced http).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:19:11 +01:00
DorianandClaude Fable 5 c66ef048f4 chore(companion): publish 0.5.20 to download QR (transport handoff + FIPS mesh settings)
Demo images / Build & push demo images (push) Successful in 2m57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:43:28 +01:00
Dorian 32962db6a9 chore(android): update companion APK download [skip ci] 2026-07-27 09:40:08 +01:00
DorianandClaude Fable 5 4edc50ae47 feat(companion): seamless transport handoff + FIPS mesh settings section — 0.5.20
- Transport handoff (Wi-Fi ⇄ 5G, BLE-ready): ArchyVpnService registers a
  ConnectivityManager callback that re-pins the tunnel to the new default
  network (setUnderlyingNetworks) and re-homes the mesh (fresh warmer pass
  → discovery/sessions rebuild on the new path in seconds). Previously the
  tunnel stayed pinned to the network it launched on and roaming stranded
  the mesh until an app restart.
- FIPS Mesh section in the NESMenu settings modal: status, mesh address
  (fd… ULA), identity npub (both copyable), configured peer/anchor count,
  and a one-tap Reconnect (manual re-home). Gives users oversight of what
  the embedded mesh node is doing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:39:47 +01:00
DorianandClaude Fable 5 245fb1a815 chore: release v1.7.116-alpha (signed OTA manifest)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:05:18 +01:00
DorianandClaude Fable 5 6dffd8e2e8 chore: bump version to 1.7.116-alpha + changelog/What's New
Demo images / Build & push demo images (push) Successful in 2m59s
Bundles all post-115 fixes into an OTA release: boot READY-before-recovery
+ Restart=always (no more 'server starting up'), and the app-install
daemon-kill fix (v6 relay only bridges live app ports; port-cleanup
excludes our own PID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:59:48 +01:00
DorianandClaude Fable 5 365f3d7d18 fix(install): mesh app-port relay no longer kills the daemon on install
The v6 app-port relay preemptively bound [::]:<port> for ALL catalog
ports, even apps not installed. Installing such an app (grafana:3000,
photoprism, uptime-kuma, jellyfin — framework-pt 2026-07-27) then hit
'address already in use' from the relay, which triggered
cleanup_stale_pasta_port ->  -> killed archipelago
itself (it held the port) mid-install. The daemon crash-looped and the
half-created apps were rolled back and vanished.

Two fixes:
- relay only bridges a port that a running app already answers on over
  IPv4 (probe 127.0.0.1:port first) — an uninstalled app's port is never
  held, so its install sees a free port and never triggers the cleanup.
- cleanup_stale_pasta_port excludes our own PID from both the ss-based
  kill and the fuser kill, so freeing a port can never terminate the
  daemon even when the relay legitimately holds it (reinstall case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:29:54 +01:00
DorianandClaude Fable 5 70bcbc4acc fix(boot): signal systemd READY before heavy boot recovery + Restart=always
Root cause of 'server starting up' forever / crash-on-install
(framework-pt, v1.7.114->115, 2026-07-26): on a node with many stacks,
the synchronous boot recovery (recover + start_stopped_containers) runs
BEFORE sd_notify(Ready), so the unit sits in 'activating' for minutes.
Anything touching the service in that window — a superseding
start/restart, an install-time reconcile churn — killed a half-started
instance; it exits 0 on SIGTERM and Restart=on-failure then never
restarts it. Node dead behind 'server starting up'.

Fixes:
- signal READY (+ start the watchdog keepalive) BEFORE boot recovery, so
  the unit reaches 'active' in seconds; recovery/reconcile/listener
  continue after. No more minutes-long activating window.
- Restart=always (was on-failure): a clean-exit SIGTERM must still bring
  the daemon back. Manual  is still honored.
- OTA restart via a PID1-owned transient timer (systemd-run --on-active=2)
  instead of a tokio-sleep child of the process being stopped, whose
  start-half was being lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 00:31:54 +01:00
DorianandClaude Fable 5 56e4c30261 fix(companion): edge-to-edge app webview, whole-overlay touch shield, no raw IP titles — 0.5.19
Demo images / Build & push demo images (push) Successful in 3m0s
- app webview draws behind the status bar again (the inset rework
  painted an opaque black bar there); page background owns the top
- the in-app overlay eats EVERY touch its children don't handle —
  a near-miss on Close was falling through to the kiosk AIUI tab
- loading screen never titles itself with a raw mesh IPv6/IP host
- re-land vc37 connect fixes vc38 shipped without: pairing restarts
  the mesh immediately when consent exists; session warmer probes all
  targets concurrently (5s) instead of 20s each in sequence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:31:48 +01:00
DorianandClaude Fable 5 f7208e1769 chore: release v1.7.115-alpha
Signed OTA manifest (release root verified). Also: create-release-manifest
tarball perms check made SIGPIPE-proof so releases cut on macOS too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:24:24 +01:00
DorianandClaude Fable 5 77ee39a3df fix(server+quadlet): app UIs work over the mesh — v6 relay + v4-pinned publishes
Rootless podman's wildcard publish claims [::] but BLACK-HOLES inbound
v6 (accepts, forwards nothing — vaultwarden 'empty response' from the
phone, 2026-07-26), while most apps got no v6 listener at all. Two
halves: quadlets now publish unbound ports on 0.0.0.0 explicitly
(frees the v6 side; the one-time drift/recreate at upgrade is the
deploy vehicle), and the daemon runs a self-selecting V6ONLY relay on
every catalog launch port forwarding raw TCP to the v4 loopback
listener. Rescans every 60s so new installs bridge without a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:48:16 +01:00
DorianandClaude Fable 5 02917cc22c fix(fips): app launch ports allowed through the mesh firewall
The companion opens catalog apps by direct port over the mesh; the
fips0 default-deny baseline blocked every one of them (apps 'stuck'
from the phone, 2026-07-26). Core now writes a second fips.d drop-in
from the generated catalog launch-port list on every install/upgrade.
Service/RPC ports (bitcoind 8332, LND 10009, Tor) stay closed.
generate-app-catalog.py emits the Rust port list alongside the TS one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:23:46 +01:00
DorianandClaude Fable 5 325b9ea9c9 chore: bump version to 1.7.115-alpha (release prep)
Demo images / Build & push demo images (push) Successful in 3m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:05:09 +01:00
DorianandClaude Fable 5 9739bbb3db docs+fix: ship fips0 web-UI firewall allowance from core; changelog + What's New for v1.7.115-alpha
Demo images / Build & push demo images (push) Has been cancelled
fips::config::install() now writes /etc/fips/fips.d/80-web-ui.nft
(tcp 80/8443 accept) and reloads the baseline on every install/upgrade —
the hardening firewall default-denies inbound on fips0 and the UI was
unreachable over the mesh without it (root-caused live 2026-07-26).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:03:25 +01:00
DorianandClaude Fable 5 4db1b85fc5 fix(server): web UI listens on IPv6 — the mesh could never load it
The mesh is IPv6-only; the main web listener bound 0.0.0.0 only, so a
phone reaching a node over its fips0 ULA got RST on :80
(ERR_CONNECTION_ABORTED) — UI over mesh was structurally impossible.
Confirmed 2026-07-26 on framework-pt: v4:80 = 200, v6:80 = refused.
Mirror an IPv4-any main listener with a V6ONLY [::] socket on the same
port; V6ONLY so it coexists with the v4 listener regardless of
net.ipv6.bindv6only.

Also: fips.yaml generator carries the fast-reconnect profile (validated
live on framework-pt since 2026-07-24; snapshot tests updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:58:11 +01:00
archipelago a78aa02890 chore: release v1.7.114-alpha
Demo images / Build & push demo images (push) Successful in 3m1s
2026-07-26 13:40:03 -04:00
archipelagoandClaude Fable 5 0365cc0f9d docs: changelog + What's New for v1.7.114-alpha
Demo images / Build & push demo images (push) Successful in 2m52s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:24:36 -04:00
archipelagoandClaude Fable 5 0880824fb3 fix(ui): seed QRs use the SeedQR standard so hardware wallets can scan them
Demo images / Build & push demo images (push) Successful in 2m52s
Plain-text seed QRs didn't scan into Passport Prime — wallets that
import seeds by QR (Passport, SeedSigner, Keystone, Nunchuk, Sparrow)
expect the SeedQR standard: each BIP39 word as its zero-padded 4-digit
wordlist index, concatenated into a numeric QR.

- new utils/seedqr.ts encodes BIP39 words per the SeedSigner spec
  (@scure/bip39 wordlist; vector-checked abandon=0000, zoo=2047)
- new shared SeedRevealPanel (Words/QR tabs, tap-to-reveal blur) now
  backs the LND reveal AND the Settings→Backup recovery-phrase reveal,
  so every current and future seed reveal behaves the same
- onboarding seed + Settings reveal: QR defaults to SeedQR with a
  plain-text toggle
- LND seed stays plain-text-only with an explicit note: aezeed is not
  BIP39 and only restores into LND-based wallets (Zeus/Blixt/another
  node) — SeedQR-encoding it would just mislead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:09:43 -04:00
archipelagoandClaude Fable 5 c0d34bd836 fix(mesh): serialize serial-port opens between listener and RPC probe
Observed live on .116 after the dedup/backoff fixes: the kiosk browser's
hot-swap auto-probe (mesh.probe-device) still interleaved its handshakes
with the listener's open sequence on the same tty every backoff window —
Linux double-opens ttys silently, both handshakes corrupted each other,
and every collision's open() DTR/RTS-reset the board again. The retry
heuristic from 5f01ec31 narrowed but couldn't close the race.

A static PORT_OPEN_LOCK now covers the listener's whole device-open
sequence and each probe attempt, so exactly one prober touches the port
at a time. With this + the settle/backoff/dedup fixes, the .116 radio
completed its first MeshCore handshake in days (node-fa161601, 8
contacts) and the session has been stable since.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 08:05:42 -04:00
archipelagoandClaude Fable 5 c8d0dda656 feat(ui): Words / QR code tabs on LND seed reveal + onboarding seed
Demo images / Build & push demo images (push) Successful in 2m54s
Both seed screens get the wallet-settings segmented tab style: words
stay the default first view; the QR tab renders the space-joined seed
words for wallets that support seed import by scan. The LND reveal QR
sits behind the same tap-to-reveal blur as the words, and both panes
warn that the code is equivalent to the words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:34:19 -04:00
archipelagoandClaude Fable 5 57c6a4d512 style(ui): grey status dots for closing/force-closing channel cards
Demo images / Build & push demo images (push) Successful in 2m55s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:19:59 -04:00
archipelagoandClaude Fable 5 a32e40da31 fix(mesh): stop probing one physical radio as two devices
/dev/mesh-radio is a udev symlink to a ttyUSB*/ttyACM* node that is also
in SERIAL_CANDIDATES, so one board was detected, probed and DTR/RTS-reset
twice per reconnect cycle (and shown twice in the UI):

- detect_serial_devices() dedupes candidates by canonical path, keeping
  the stable /dev/mesh-radio name
- auto-detect fallback skips the preferred path it just probed this cycle
  instead of immediately resetting the same board again
- probe_device's active-session guard compares canonical paths, so a
  probe via the alias can no longer open the tty the live session holds

Together with the 2s boot-settle and stable-session backoff gate, this
takes a plugged-in CP2102/ESP32 board from up to 9 reset events per
cycle at a permanent 5s retry floor down to one probe sequence per
backoff window — enough for the radio to actually finish booting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:19:54 -04:00
archipelagoandClaude Fable 5 449ed7c1f7 fix(mesh): don't reset reconnect backoff for sessions that die young
Extracted from beff5dd5 on archy-hwconfig (the rest of that commit is
flash-feature code staying on its branch): a device that connects then
drops within seconds — e.g. mid-boot-loop — kept resetting backoff to
5s forever, and every retry's open() toggles DTR/RTS which itself
resets ESP32-family boards. Backoff now only resets after a session
survives STABLE_SESSION_THRESHOLD (20s), so an unstable device gets
progressively longer quiet gaps to actually finish booting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:13:27 -04:00
ssmithxandarchipelago 729cee573a fix(mesh): orphaned listener task could race a fresh one on the same port
Traced why a device that's alive and USB-enumerating correctly could still
never complete a single protocol handshake, indefinitely: journal logs
showed genuinely concurrent connection attempts on the same port
(duplicate "Opened serial port"/"Starting X handshake" lines within
microseconds of each other, from what should be sequential probe steps) —
two independent listener sessions were racing on the same tty, each
corrupting the other's reads/writes.

Root cause was in MeshService::stop() combined with mesh::flash's earlier
STOP_LISTENER_TIMEOUT fix: stop() calls `self.listener_handle.take()`
(clearing the field to None immediately) and then awaits the handle with
no bound of its own. When a caller wraps the whole stop() call in a
timeout (as the flash job does, to avoid hanging the RPC response), a slow
listener — mid multi-candidate probe when the shutdown signal arrives —
would cause that outer timeout to cancel the await. But by then
`listener_handle` was already None, so MeshService believed the listener
was stopped, while the actual task kept running, orphaned (dropping a
JoinHandle does not abort the task). A later start() then spawned a
genuinely new listener, racing the orphaned one forever on the same port.

Fixed at the source: stop() now awaits its own handle through a mutable
reference (not by value) with its own bounded timeout, so on timeout it
still owns the handle and can call .abort() on it directly — guaranteeing
the task is actually gone before stop() returns, regardless of how any
caller wraps it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit b14af20d1a)
2026-07-26 07:09:26 -04:00
ssmithxandarchipelago 5b7ebd85b6 fix(mesh): DTR/RTS reset settle time was far shorter than real boot time
Every one of Reticulum/Meshcore/Meshtastic's open() deasserts DTR/RTS on
every connection attempt (needed to clear stale line state, but the
transition itself resets ESP32-S3 native-USB boards and CP2102/CH340-
bridged boards wired for Arduino-style auto-reset — acknowledged in the
existing code comments). Each only waited 300ms before expecting a
handshake response — nowhere near real firmware boot time (LoRa radio
init alone routinely takes longer).

A single auto-detect cycle tries multiple protocols in sequence
(Reticulum, then Meshcore, then Meshtastic), each with its own open() and
thus its own reset. With only 300ms of settle per attempt, a board could
plausibly never finish booting from one attempt's reset before the next
attempt's open() reset it again — a self-sustaining "never finishes
booting" loop that would look identical to firmware/hardware flakiness
from the logs, regardless of which firmware family was actually flashed.
Confirmed live 2026-07-23 on both a Heltec V3 (Meshtastic) and V4
(Meshcore): continuous device-side FROM_RADIO_REBOOTED / boot-loop
symptoms with zero config-write-triggered reboots (manage_radio was false
for part of the test), pointing at the connection layer itself rather
than firmware config provisioning.

Bumped the settle delay from 300ms to 2s in Meshcore's and Meshtastic's
open() — full protocol handshakes that need real boot time. Left
reticulum.rs's probe_rnode settle at 300ms deliberately: it's a
cheap/fast KISS-detect gate designed to fail quickly for non-RNode
firmware (documented elsewhere as "~1s"), not a full handshake, and each
subsequent protocol's own open()+settle is what actually needs to cover
real boot time regardless of what probe_rnode did moments before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5799c37111)
2026-07-26 07:09:24 -04:00
archipelagoandClaude Fable 5 c6f11f8ddb feat(wallet): on-chain send fee control + BTC/sats amount entry
Demo images / Build & push demo images (push) Successful in 2m55s
- sats/BTC unit toggle on the on-chain amount field with live conversion
  hint; canonical value stays sats end-to-end
- Fast / Standard / Slow fee presets (1 / 6 / 144 block targets) plus a
  custom pane taking target blocks or an explicit sat/vB rate
- confirm pane shows LND's fee estimate for the chosen speed via the new
  lnd.estimatefee RPC (GET /v1/transactions/fee)
- lnd.sendcoins now accepts target_conf / sat_per_vbyte with the same
  mutual-exclusion + range validation as channel opens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:05:10 -04:00
archipelagoandClaude Fable 5 a26090e561 feat(ui): lightning channels All/Active/Pending/Closed tabs + closed-channel history
Demo images / Build & push demo images (push) Successful in 3m6s
Consumes the lnd.closedchannels RPC and closing/force_closing statuses
that shipped backend-side in 00b7e179 but never reached the panel:
- tab bar with per-state counts; pending covers pending_open/closing/force_closing
- closed-channel cards with close type, settled balance, block height and
  closing-tx explorer link
- closing/force_closing status dots + closing-tx link on in-flight closes
- Close button hidden for channels already mid-close

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 06:51:48 -04:00
Claude 2609f60e6c feat(release): gated ISO build pipeline
- scripts/build-iso-release.sh: single gated command from signed release
  to tested ISO (preflight version/signature parity, release gate harness,
  strict catalog drift, full Rust suite, artifact version checks, build,
  mount smoke, best-effort QEMU boot)
- scripts/iso-smoke-test.sh: standalone mount-level ISO verification incl.
  stale-binary version assertion inside the payload
- .gitea/workflows/build-iso.yml: resurrected ISO CI as dispatch-only job
  calling the gated orchestrator (old one was stranded in _archived/)
- tests/release/run.sh: catalog drift now runs --strict (was silently
  always-pass)
- test-iso-qemu.sh: headless mode used -append without -kernel, which
  QEMU rejects; use -display none so -serial file: capture works

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:34:54 -04:00
archipelagoandClaude Fable 5 c4c558954b chore: sign v1.7.113-alpha release manifest
Demo images / Build & push demo images (push) Successful in 2m59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:14:07 -04:00
ssmithx dcb0618012 Merge remote-tracking branch 'origin/main' into archy-hwconfig 2026-07-23 23:55:40 +00:00
ssmithx 8403f2233e probing issues with flashing 2026-07-23 23:43:09 +00:00
ssmithx 078f3b3619 pulled in main 2026-07-23 21:42:02 +00:00
ssmithx 4222a8507c Merge remote-tracking branch 'origin/main' into archy-hwconfig 2026-07-23 21:40:06 +00:00
ssmithxandClaude Sonnet 5 5799c37111 fix(mesh): DTR/RTS reset settle time was far shorter than real boot time
Every one of Reticulum/Meshcore/Meshtastic's open() deasserts DTR/RTS on
every connection attempt (needed to clear stale line state, but the
transition itself resets ESP32-S3 native-USB boards and CP2102/CH340-
bridged boards wired for Arduino-style auto-reset — acknowledged in the
existing code comments). Each only waited 300ms before expecting a
handshake response — nowhere near real firmware boot time (LoRa radio
init alone routinely takes longer).

A single auto-detect cycle tries multiple protocols in sequence
(Reticulum, then Meshcore, then Meshtastic), each with its own open() and
thus its own reset. With only 300ms of settle per attempt, a board could
plausibly never finish booting from one attempt's reset before the next
attempt's open() reset it again — a self-sustaining "never finishes
booting" loop that would look identical to firmware/hardware flakiness
from the logs, regardless of which firmware family was actually flashed.
Confirmed live 2026-07-23 on both a Heltec V3 (Meshtastic) and V4
(Meshcore): continuous device-side FROM_RADIO_REBOOTED / boot-loop
symptoms with zero config-write-triggered reboots (manage_radio was false
for part of the test), pointing at the connection layer itself rather
than firmware config provisioning.

Bumped the settle delay from 300ms to 2s in Meshcore's and Meshtastic's
open() — full protocol handshakes that need real boot time. Left
reticulum.rs's probe_rnode settle at 300ms deliberately: it's a
cheap/fast KISS-detect gate designed to fail quickly for non-RNode
firmware (documented elsewhere as "~1s"), not a full handshake, and each
subsequent protocol's own open()+settle is what actually needs to cover
real boot time regardless of what probe_rnode did moments before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:10:06 +00:00
ssmithxandClaude Sonnet 5 b14af20d1a fix(mesh): orphaned listener task could race a fresh one on the same port
Traced why a device that's alive and USB-enumerating correctly could still
never complete a single protocol handshake, indefinitely: journal logs
showed genuinely concurrent connection attempts on the same port
(duplicate "Opened serial port"/"Starting X handshake" lines within
microseconds of each other, from what should be sequential probe steps) —
two independent listener sessions were racing on the same tty, each
corrupting the other's reads/writes.

Root cause was in MeshService::stop() combined with mesh::flash's earlier
STOP_LISTENER_TIMEOUT fix: stop() calls `self.listener_handle.take()`
(clearing the field to None immediately) and then awaits the handle with
no bound of its own. When a caller wraps the whole stop() call in a
timeout (as the flash job does, to avoid hanging the RPC response), a slow
listener — mid multi-candidate probe when the shutdown signal arrives —
would cause that outer timeout to cancel the await. But by then
`listener_handle` was already None, so MeshService believed the listener
was stopped, while the actual task kept running, orphaned (dropping a
JoinHandle does not abort the task). A later start() then spawned a
genuinely new listener, racing the orphaned one forever on the same port.

Fixed at the source: stop() now awaits its own handle through a mutable
reference (not by value) with its own bounded timeout, so on timeout it
still owns the handle and can call .abort() on it directly — guaranteeing
the task is actually gone before stop() returns, regardless of how any
caller wraps it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:39:33 +00:00
ssmithxandClaude Sonnet 5 7547d03166 fix(mesh): configure step after a successful flash could race and fail
Traced a real "Operation failed" on the post-flash configure step: the
flash job's own post-completion code unconditionally called
MeshService::start() after configure(), regardless of the persisted
enabled flag. In this incident, the user had toggled mesh off shortly
before the flash (config.enabled saved as false), but the job's forced
start() ran anyway — leaving the listener actually running while
self.config.enabled stayed false. When the user then clicked "Keep As Is"
on the fresh post-flash probe, their mesh.configure call correctly saw a
false→true transition and tried to start the listener itself, hitting
"Mesh listener already running" — the listener had already been
force-started behind the config's back.

Two fixes:
1. The flash job now only restarts the listener if the freshly-loaded
   config still says enabled — respecting a user's own concurrent choice
   to disable mesh instead of silently overriding it.
2. MeshService::start() is now idempotent: finding the listener already
   running is treated as success, not an error. Ensuring the listener is
   running is what every caller actually wants; whichever caller's start()
   wins a race, the other finding it already satisfied is the correct
   outcome, not a failure to surface to the user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:59:41 +00:00
ssmithxandClaude Sonnet 5 d6019e47a5 fix(mesh): restore esptool's esp32s3 stub instead of routing around it
--erase-all hit the exact same "ROM does not support function erase_flash"
error as a standalone erase_flash — confirmed live: esptool's --erase-all
is implemented as the same full-chip-erase command, not a per-sector loop,
so it needs the stub just as much. The real fix isn't finding another way
around the ROM bootloader's limitations — it's restoring the stub Debian's
package is missing.

Fetched the exact missing file (stub_flasher_32s3.json) from the matching
upstream esptool release tag and installed it directly on archy-x250-dev;
verified live with a read-only `flash_id` that stub mode now loads
("Uploading stub... Running stub..."). Removed --no-stub from flash.rs now
that normal stub-loader mode works correctly (faster and fully-featured vs.
the ROM-only fallback). scripts/self-update.sh now fetches and installs
this same file automatically whenever it's missing, so this isn't a
one-off manual fix tied to a single node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:12:15 +00:00
ssmithxandClaude Sonnet 5 0ca8f25b1b fix(mesh): standalone erase_flash unsupported in --no-stub ROM mode
Real esptool output on a live Heltec V4 exposed the next layer of the
--no-stub fix: "A fatal error occurred: ESP32-S3 ROM does not support
function erase_flash." The ESP32-S3 ROM bootloader only implements
per-sector erase (used internally as write_flash streams data), not a bulk
full-chip-erase opcode — that's a stub-firmware-only feature, and Debian's
esptool package ships without the stub (hence --no-stub in the first
place).

write_flash's own --erase-all flag gets the same "erase everything before
writing" outcome our "always erase before write" default requires, but via
the per-sector mechanism the ROM bootloader actually supports — one esptool
invocation instead of a separate erase_flash + write_flash pair.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 12:28:51 +00:00
ssmithxandClaude Sonnet 5 76d14c3bf9 fix(mesh): flash RPC could hang forever if the listener wouldn't stop
Traced a real "Operation failed" report to its root: MeshService::stop()
ran synchronously inside start_flash_job, BEFORE the background task was
even spawned — so the RPC call itself blocked on it. The mesh listener's
reconnect/multi-candidate-probe loop doesn't check its shutdown signal
between candidates, and in this incident it was mid a Meshcore-then-
Meshtastic probe cycle when the flash request came in, so stop() never
returned. The HTTP request timed out client-side ("connection closed
before message completed" in the server log), the frontend surfaced a
generic "Operation failed", and — worse — the job had already been
inserted into the single-job-guard slot before stop() ran, so with nothing
ever spawned to complete it, every subsequent flash attempt failed with
"already in progress" until a full service restart.

The existing MAX_JOB_DURATION ceiling didn't help here: it only wraps
run_flash(), which is reached AFTER stop() returns — so a hang in stop()
itself was completely unguarded.

Fixed by moving the stop() call inside the spawned background task with
its own 20s timeout. The RPC call now always returns immediately once the
job is registered, and a slow-to-stop listener fails the job cleanly with
a clear message instead of hanging the request and wedging every future
attempt behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:50:34 +00:00
ssmithxandClaude Sonnet 5 a1cb83dfb2 fix(mesh): esptool --no-stub + fix retry's broken --baud arg ordering
The improved error logging from the last fix immediately paid off — real
esptool output on the next attempt showed two distinct bugs:

1. Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
   "stub flasher" blobs (stripped for DFSG compliance), so the default
   stub-loader flash mode fails immediately: FileNotFoundError: ... No such
   file or directory: '.../stub_flasher_32s3.json'. Fixed with --no-stub,
   which talks directly to the ROM bootloader instead — the standard
   workaround for this exact Debian packaging gap.

2. The fallback-baud retry appended `--baud 115200` AFTER the subcommand
   token (erase_flash/write_flash), but esptool requires global flags
   before the subcommand — every retry failed with "esptool: error:
   unrecognized arguments: --baud 115200", so the retry logic added
   earlier never actually got a chance to run. esptool_with_retry now
   builds global args (--chip/--port/--baud/--no-stub) and subcommand args
   separately and always concatenates them in the right order, instead of
   relying on call-site ordering of a single flat arg list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 03:21:39 +00:00
ssmithxandClaude Sonnet 5 ff532465cf fix(mesh): capture actual esptool/rnodeconf output in the failure error
run_streamed's failure path only reported "Command exited with exit status:
N" — the real stderr (already captured line-by-line into job.log_tail for
the UI's live poll) never made it into the error that gets logged to
journald. A real esptool failure just now showed only the bare exit code,
with esptool's actual error text sitting in-memory, visible in the UI but
not diagnosable from the server logs.

Now includes the last 10 log_tail lines in the returned error, so the
actual tool output shows up in journalctl too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:51:58 +00:00
ssmithxandClaude Sonnet 5 39e88529b3 fix(mesh): failed flash silently bounced back to the picker, hiding the error
The step-3 template's condition for showing the picker vs. the progress/
result view was `!flashJob?.active && flashJob?.stage !== 'done'`. That's
true for "no job started yet" (flashJob is null) — but ALSO true for a job
that just FAILED (active:false, stage:'failed'), since 'failed' !== 'done'.
So a failed flash silently reverted to the family/board picker instead of
showing the failure state (error message + log tail) — reported live as
"it went to the download screen and then back to the flash screen" with no
visible error.

Now shows the picker only when no job has been started yet (`!flashJob`);
once a job exists, the progress/result view is always shown, whether it's
still running, succeeded, or failed — the existing error/log-tail markup in
that view already handles all three, it just wasn't being reached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:43:10 +00:00
ssmithxandClaude Sonnet 5 992bf636e0 fix(mesh): board auto-detect used the wrong signal, false-flagged Heltec V3
The "couldn't confirm the board automatically" warning was checking the
display label text (/v3/i, /v4/i) instead of the same vid:pid table the
backend actually uses to resolve the board. A Heltec V3's CP2102 bridge
chip reports "CP2102 USB to UART Bridge Controller" in its USB strings, not
"Heltec", so meshDeviceImages.ts's fallback label ("LoRa radio (CP2102
serial)") never matched the regex — showing the low-confidence warning (and
leaving the board picker empty) even though the backend's
resolve_flash_board can safely auto-detect V3 via vid:pid 10c4:ea60.

Now checks the same vid:pid directly from detected_device_info, matching
mesh::flash::resolve_flash_board exactly. V4 still has no auto-match entry,
same as the backend — its vid:pid (303a:1001) is the ESP32-S3's generic
native-USB descriptor, not V4-specific, so manual selection is still
required there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:31:46 +00:00
ssmithxandClaude Sonnet 5 beff5dd577 fix(mesh): stop the flash-triggered device boot-loop, fix stuck/wedged jobs
Three real incidents from live-testing the flash feature on archy-x250-dev,
each traced through logs on the node and fixed at the root:

- A failed flash left the mesh listener auto-resuming into a reconnect loop
  whose backoff reset to its 5s minimum on any momentary connection, even
  mid-boot-loop — every retry's open() toggles DTR/RTS, which resets many
  ESP32 boards, so the retries were themselves sustaining the loop. Backoff
  now only resets after a session runs stably for 20s+, and the flash job
  no longer auto-resumes the listener after a failure (only on success,
  after a settle delay).

- The HTTP client's blanket 30s request timeout covered entire downloads
  (Meshtastic's zip is ~170MB), killing large transfers mid-stream; fixed
  with a per-chunk stall timeout instead of a fixed total-transfer cap. But
  the download's initial request had no timeout at all, so a slow-to-start
  server hung it forever — wedging the single-flash-job guard permanently
  ("already in progress" on every subsequent attempt). Fixed with a bounded
  wait for the response to start, plus an absolute ceiling around the whole
  job as a last-resort safety net.

- archy-rnodeconf's PyInstaller freeze broke its own internal esptool
  invocation (sys.executable pointed at the frozen binary itself instead of
  a real interpreter) — fixed via a new runtime hook. esptool's own
  auto-reset-into-bootloader handshake is separately known to be flaky on
  some CP2102/CH340 boards; it now retries once at a conservative baud
  rate instead of failing outright, and failure logs show the full error
  chain instead of just the outer context message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:18:55 +00:00
ssmithx 7d31ca5d65 First commit 2026-07-23 00:33:55 +00:00
DorianandClaude Opus 4.6 91763246c3 chore(app): add hardening plan for overnight TUI loop execution
12-phase plan covering security fixes, proxy hardening, state bugs,
content extraction, cache bounds, accessibility, and test coverage.
42 atomic tasks structured for sequential agent execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:07:43 +00:00
DorianandClaude Opus 4.6 cb22131909 feat(app): apps tab, archy mock data, prompt palette, code browser, detail keys, guide routing
- Add [[app_ext:...]] tag format and rewrite extractApps() for reliable app extraction
- Wire AppsGrid and RecipeGrid into ContentGridView (was missing on wide desktop)
- Add mock Archy node data for standalone dev testing (VITE_MOCK_ARCHY=true)
- Fix PromptPalette: z-50 + opaque bg so slash menu renders above chat content
- Fix detail banner not updating: add :key to all detail components in ContentPanel
- Guide page moved to /guide, chat is now root route, guide auto-selected on first load
- Code browser: click opens file in viewer, separate checkbox for chat context selection
- Restore folder context selector (round checkbox on hover) in FileTreeNode
- Demo projects for prod deployment instead of hardcoded personal paths
- Improve Archy context injection with media breakdown and better error logging
- Add 11 Claude Code skills for efficient development workflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:39:41 +00:00
DorianandClaude Opus 4.6 cc875d1c43 fix(app): update haiku model ID from retired claude-3-5-haiku to claude-haiku-4-5
The old claude-3-5-haiku-20241022 model ID returns 404 from the Anthropic API.
Updated proxy mapping and test to use claude-haiku-4-5-20251001.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:45:48 +00:00
DorianandClaude Opus 4.6 dc4c80a08b feat(app): add recipe content panel with grid and detail views
- Add RecipeGrid with search, meta badges (time/servings/calories), ingredient preview
- Add RecipeDetail with ingredient checklist, scaling slider, numbered steps
- Add generateRecipeFallback SVG for recipe cards (text-only style)
- Wire recipe extraction into content panel tab system
- Add isRecipeQuery/isRecipeLikeResponse classifiers in contentFiltering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:27:20 +00:00
DorianandClaude Opus 4.6 2304e5904e fix(app): fix image fallbacks across all content grids, refactor into useContentImages
- Create useContentImages<T> composable eliminating ~240 lines of duplicated
  image loading/fallback logic across 6 grid components
- Fix book covers: use full fetchBookImage chain (Open Library → Google Books → Wikipedia)
- Fix TV series images: try disambiguated Wikipedia title first (e.g. "Chernobyl (TV series)")
- Add Wikipedia image fetching for places (fetchPlaceImage)
- Rewrite all SVG fallbacks to consistent text-only style (no icons)
- Add generateWebsiteFallback for NewsGrid websites variant
- Fix song extraction regex catching raw song_ext: prefix in titles
- Fix player bar: clean song_ext: prefix from display, mute error text
- Fix Code panel: auto-load projects on mount when list is empty
- Improve chat bubble spacing (py-2.5) and first message top margin (pt-6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:55:40 +00:00
DorianandClaude Opus 4.6 1eaf30ae12 fix(app): fix broken dev server after security hardening
- Fix dev.sh unbound variable crash with ${VITE_DEV_API_TOKEN:-}
- Kill stale proxy on startup instead of skipping (token mismatch)
- Fix RSS middleware blocking all GET requests (check path before auth)
- Read dev auth token lazily from process.env (not cached at import)
- Restore network binding (host: true) for Vite dev server
- Add macOS keychain lookup for Claude Code OAuth token in proxy
- Rewrite proxy streaming to pipe SSE directly instead of await json()
- Prevent double web search (client-side + proxy) in useAI
- Reduce SearXNG timeout 6s→3s and max tries 8→3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:09:06 +00:00
DorianandClaude Opus 4.6 e97c8f36ac test: update tests for security fixes (origin validation, streaming state)
- archyIntegration: expect window.location.origin instead of '*' for
  postMessage calls (matches FIX-009 origin restriction)
- useAI: fix flaky isStreaming assertion to account for background
  fetch calls (refreshWavlakeCatalog) captured before streaming starts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:42:50 +00:00
DorianandClaude Opus 4.6 9adeab9420 fix(app): validate content pack URL scheme and schema in importFromUrl
Require https: protocol for remote content pack imports, rejecting
http:, file:, javascript:, and other schemes. Add schema validation
to verify required fields (id, name, items) and item shape (type,
title) before accepting imported packs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:39:26 +00:00
DorianandClaude Opus 4.6 fa3c446baf fix(app): add 1MB body size limit to Claude proxy
Track accumulated body size during req.on('data') and abort with 413
if it exceeds 1MB, preventing unbounded memory allocation from
oversized payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:38:39 +00:00
DorianandClaude Opus 4.6 6d6408e2a4 fix(app): bind Vite dev server to localhost only
Remove --host flag from dev.sh that was overriding vite.config.ts to
bind on 0.0.0.0. Server now defaults to localhost; use VITE_HOST env
var to opt-in to LAN access for mobile testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:38:01 +00:00
DorianandClaude Opus 4.6 6720711302 fix(app): remove plaintext fallback from key vault when crypto unavailable
storeApiKey() now throws when encryption is not available instead of
storing keys in plaintext. ApiKeyManager.vue catches the error and
displays a warning message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:35:48 +00:00
DorianandClaude Opus 4.6 5c4afe00e5 fix(app): add rate limiting to all API endpoints
Add sliding-window rate limiter in server/dev-auth.ts (60 req/min reads,
10 req/min writes per IP). Apply checkRateLimit() in all Vite plugins
and claude-proxy.ts after auth validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:34:41 +00:00
DorianandClaude Opus 4.6 6425b2f53b fix(app): restrict postMessage origin in archyBridge
Change default allowedOrigin from '*' to null. Derive from
window.location.origin when init() is called without explicit origin.
Always validate event.origin — reject messages when origin is not set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:31:45 +00:00
DorianandClaude Opus 4.6 4053eb46af fix(app): replace custom HTML sanitizer with DOMPurify
Install dompurify and replace the hand-rolled DOM walker sanitizer
with DOMPurify.sanitize() configured with the same allowed tags.
Handles mutation XSS edge cases the custom version couldn't.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:30:58 +00:00
DorianandClaude Opus 4.6 f7f8c140c3 fix(app): store NWC wallet secret in encrypted vault instead of plaintext localStorage
Replace localStorage.getItem/setItem with storeApiKey/getApiKey/deleteApiKey
from key-vault. Make loadConnection(), connect(), and disconnect() async.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:28:29 +00:00
DorianandClaude Opus 4.6 c3e58fdab8 fix(app): add body size limit to dev-chats write endpoint
Track accumulated body length during PUT /api/dev-chats and abort
with 413 if payload exceeds 5MB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:27:35 +00:00
DorianandClaude Opus 4.6 956e98b041 fix(app): add SSRF protection to RSS fetcher
Add post-DNS SSRF validation using dns.lookup() to verify resolved IPs
are not in private ranges. Block non-http(s) schemes (file://, ftp://)
in discoverFeedUrl(). Extract isPrivateIp() helper for reuse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:27:08 +00:00
DorianandClaude Opus 4.6 c21939b1f8 fix(app): block sensitive file reads in filesystem API
Add SENSITIVE_PATTERNS denylist to handleRead() in vite-fs.ts.
Blocks access to .env*, .git/, credentials, secrets, .pem, .key,
and SSH key files. Returns 403 for matched paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:26:01 +00:00
DorianandClaude Opus 4.6 4dc9588c8a fix(app): replace CORS Access-Control-Allow-Origin * with explicit localhost origin
Add setCorsHeaders() and handleCorsOptions() helpers in server/dev-auth.ts.
Replace wildcard CORS origin with http://localhost:5173 in all Vite plugins
and claude-proxy.ts. Include Authorization in allowed CORS headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:25:10 +00:00
DorianandClaude Opus 4.6 cc7d9fc19e fix(app): add dev server auth token to all API endpoints
Generate random VITE_DEV_API_TOKEN in dev.sh, validate Bearer token
in shared server/dev-auth.ts middleware. Applied to all Vite plugins
(fs, dev-chats, rss, web-search, tmdb, music-search) and claude-proxy.
Client-side uses apiFetch() wrapper to attach the token automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:23:17 +00:00
DorianandClaude Opus 4.6 b77c93607a feat(app): video player, guide page, free films, PWA cache fix
- Add VideoPlayerOverlay component for free film playback
- Add GuidePage with interactive node setup walkthrough
- Add freeFilms data catalog with public domain films
- Enhance PlayerBar with video support and queue management
- Add video player store for overlay state management
- Refactor music search plugin (Jamendo integration cleanup)
- Add PWA cache version purge mechanism in main.ts
- Add PWA icon cache fix skill for Brave/Chrome
- Improve content grids: loading states, image fallbacks
- Enhance useArchy composable with node context
- Update useNostr with relay pool management
- Expand chat store with guide conversation support
- Add test fixtures for guide and node demo prompts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:56:39 +00:00
DorianandClaude Opus 4.6 c84c0fb424 fix(app): passphrase dialog — remove CSP meta, check crypto.subtle availability
- Remove CSP meta tag from index.html (breaks Vite HMR, should be
  set via HTTP headers in production nginx instead)
- isCryptoEnabled() now checks crypto.subtle is available (undefined
  over HTTP on non-localhost origins)
- Add try/catch + error feedback to passphrase submit flow
- PassphraseDialog accepts error prop, focuses input on visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:58:55 +00:00
DorianandClaude Opus 4.6 3620b1f4d5 chore: mark all overnight plan tasks complete
All 35 tasks across 8 phases completed:
P1 (Critical Fixes), P2 (Error Handling), P3 (Security),
P4 (Tests), P5 (Features), P6 (Accessibility),
P7 (Performance), P8 (Research).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:57:17 +00:00
DorianandClaude Opus 4.6 4ac18cedb6 docs: add research docs for iOS app, Mac desktop, plugin security
- iOS: Capacitor vs WKWebView vs React Native WebView analysis
- Mac: Tauri v2 vs Electron comparison with menu bar app patterns
- Plugins: Signature validation, sandboxed iframes, permission system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:56:54 +00:00
DorianandClaude Opus 4.6 290aa047d7 perf(app): add file size guard and loading state to code file reader
Handles 413 status for files > 1MB with user-friendly error message.
Adds fileLoading and fileError state for loading indicator support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:51:10 +00:00
DorianandClaude Opus 4.6 04db18c35a perf(app): add 30s cache TTL to Bitcoin price fetcher
Skips redundant API calls when price was fetched within the last 30
seconds, reducing network requests while keeping data fresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:49:19 +00:00
DorianandClaude Opus 4.6 9032e8cd60 perf(app): lazy load PdfViewer and MapRenderer with loading skeletons
Uses defineAsyncComponent to lazy load heavy renderers, reducing
initial bundle size. Shows loading text while components load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:48:39 +00:00
DorianandClaude Opus 4.6 31241d8d12 fix(a11y): improve img alt text with artist/director/author context
Updates alt attributes to include contextual info: songs include artist,
films include year and director, books include author, TV series include
type label.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:47:35 +00:00
DorianandClaude Opus 4.6 91dbaee38c fix(a11y): color contrast audit — document ratios, fix critical text
Documents WCAG AA contrast ratios in main.css. Increases text-white/40
to /50 for settings labels, section headers, and loading states to
meet 4.5:1 minimum contrast ratio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:46:27 +00:00
DorianandClaude Opus 4.6 3258c047d9 fix(a11y): add focus management and ARIA attributes to dialogs
Adds role="dialog", aria-modal, focus trap, auto-focus close button,
and Escape key handling to ZapDialog and SettingsModal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:44:11 +00:00
DorianandClaude Opus 4.6 3c709c143f fix(a11y): add aria-labels to all content grid card buttons
Adds descriptive aria-label to card buttons in SongGrid, FilmGrid,
TVSeriesGrid, PlaceGrid, BookGrid, PodcastGrid, NewsGrid, ImageGrid,
and AppsGrid for screen reader accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:41:43 +00:00
DorianandClaude Opus 4.6 2011fdcae0 fix(a11y): add aria-labels to chat message action buttons
Adds aria-label to all 6 icon-only buttons in ChatMessage.vue:
edit, regenerate, reply, branch, upvote, downvote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:39:00 +00:00
DorianandClaude Opus 4.6 b4b1f8faf5 docs(app): add Archy local search guide and HelpSection component
Documents how file types map to content surfaces, how ContextBroker
filtering works, and adds a reusable HelpSection UI component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:37:51 +00:00
DorianandClaude Opus 4.6 a60faedc48 feat(app): allow .claude folder in file browser tree
Updates vite-fs tree walker to include .claude directories so users
can browse CLAUDE.md, settings, hooks, and memory files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:36:14 +00:00
DorianandClaude Opus 4.6 e33cb359a0 feat(app): add file browser page with tree navigation and preview
Adds /browse route with project listing, recursive file tree with
expand/collapse, and file preview sidebar (desktop) / overlay (mobile).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:35:15 +00:00
DorianandClaude Opus 4.6 cfec1dcc13 fix(test): use type assertions for partial test data in useContentPanel tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:27:35 +00:00
DorianandClaude Opus 4.6 5d8ce56b1b test(app): add seed conversation regression tests
Validates extraction pipeline against all seed prompts. 15 tests
covering films, songs, books, TV, places, podcasts, images, code,
recipes, events. Verifies tag stripping completeness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:25:07 +00:00
DorianandClaude Opus 4.6 5c82a09e65 test(app): add content extraction edge case tests
Tests empty input, interleaved tags, malformed tags, unicode content,
duplicate deduplication, place/TV extraction, tag stripping, magazine
sections. 11 test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:23:49 +00:00
DorianandClaude Opus 4.6 c6e384a288 test(app): add unit tests for useVisualViewport composable
Tests keyboard detection, viewport height tracking, debounce behavior.
Includes withSetup test helper for composables with lifecycle hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:22:02 +00:00
DorianandClaude Opus 4.6 1a9b769e45 test(app): add unit tests for useContentPanel composable
Tests tab switching, detail open/close, panel state management,
design system mode. 9 test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:21:12 +00:00
DorianandClaude Opus 4.6 7c8315f362 test(app): add unit tests for usePlayer composable
Tests queue management, state transitions, progress computation,
deduplication, and API shape. 9 test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:20:27 +00:00
DorianandClaude Opus 4.6 ece4540388 feat(app): add Content-Security-Policy meta tag
Restricts script, style, img, connect, media, and frame sources to
known-safe origins. Blocks object embeds and enforces base-uri self.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:18:58 +00:00
DorianandClaude Opus 4.6 e7496883c4 fix(app): replace innerHTML='' with textContent='' in usePlayer
Safer DOM clearing that avoids innerHTML for content sanitization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:18:29 +00:00
DorianandClaude Opus 4.6 2581f20ebf fix(app): add URL length limit (2048 chars) to extractUrlFromText
Prevents processing excessively long URLs in content extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:18:00 +00:00
DorianandClaude Opus 4.6 2ced4830f6 fix(app): add postMessage origin validation to archyBridge
Configurable origin replaces wildcard '*' for both sending and receiving.
Origin check filters incoming messages when a specific origin is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:17:24 +00:00
DorianandClaude Opus 4.6 f84e68ad06 fix(app): add error handling to PdfViewer renderPage and VideoPlayer initHls
Prevents unhandled promise rejections from async watchers and lifecycle hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:16:34 +00:00
DorianandClaude Opus 4.6 9b3fd5d973 fix(app): add catch block to SSE readSSE for stream read errors
Reports stream errors to onError callback instead of silently failing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:14:31 +00:00
DorianandClaude Opus 4.6 82f1e99324 fix(app): wrap unprotected JSON.parse calls in try/catch
SSE stream callbacks and AI response parsing now handle malformed data
gracefully instead of throwing unhandled exceptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:11:18 +00:00
DorianandClaude Opus 4.6 9ba4854024 fix(app): debounce viewport change handler, overflow hidden when keyboard open
50ms debounce prevents jittery resizing on mobile keyboard open/close.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:08:30 +00:00
DorianandClaude Opus 4.6 378b60820a fix(content): add .catch() to cover fetch promise chains in grids
Prevents unhandled promise rejections if fetch throws unexpectedly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:07:32 +00:00
DorianandClaude Opus 4.6 6e671faa3c fix(app): brighten SVG fallback covers — increase lightness by +10%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:06:04 +00:00
DorianandClaude Opus 4.6 cba448e32a chore: move overnight to skills format
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:37:03 +00:00
DorianandClaude Opus 4.6 9dbc9c24a6 chore: overnight plan 2026-03-04 + /overnight command
- Rewrote loop/plan.md with 34 checkbox tasks across 8 phases
- Updated loop/prompt.md for new overnight scope
- Added .claude/commands/overnight.md skill
- Self-destructing dev SW from previous session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:34:33 +00:00
DorianandClaude Opus 4.6 8a1c3135dd fix(app): disable PWA service worker in dev, fix deprecated meta tag
Dev mode SW was caching stale responses and breaking page loads.
Disabled devOptions.enabled to prevent SW registration during development.
Added mobile-web-app-capable meta tag (the modern replacement for
apple-mobile-web-app-capable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:06:41 +00:00
DorianandClaude Opus 4.6 e21d13ab3f revert(app): remove basicSsl — breaks HTTP dev access on LAN
The always-on HTTPS plugin prevented HTTP access at the LAN IP.
PWA install requires HTTPS but that should be handled at deploy time,
not in dev config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:04:37 +00:00
DorianandClaude Opus 4.6 0fa78cbf1d fix(app): keyboard resizes container, tab bar margin, HTTPS for PWA install
- Root container height now bound to visualViewport.height when keyboard
  is open — the whole layout shrinks instead of being pushed offscreen
- Tab bar gets 24px vertical margin (12px top + 12px bottom + safe area)
- Added @vitejs/plugin-basic-ssl for HTTPS dev server — required for PWA
  install on non-localhost origins (LAN IP access)
- Improved useVisualViewport to track fullHeight for accurate keyboard
  offset calculation across orientation changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:01:07 +00:00
DorianandClaude Opus 4.6 a0a9cf8e54 feat(app): mobile improvements — native-feel viewport, keyboard, HIG tabs, PWA fix
- Lock viewport: position:fixed on html/body prevents iOS bounce scroll
- No zoom on input focus: maximum-scale=1, user-scalable=no
- Keyboard-responsive chat: visualViewport API detects keyboard, hides
  tab bar, scrolls chat to bottom, scrollIntoView on input focus
- iOS HIG tab bar: 49pt height, vertical icon+label, safe-area-inset-bottom
- PWA fix: manifest start_url/scope/id changed from '/' to './' for
  subpath deployment compatibility
- PlayerBar: variant prop (fixed/inline), inline on mobile above tab bar,
  fixed on desktop. No more overlap with tab bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:50:06 +00:00
DorianandClaude Opus 4.6 799ca131d9 fix(app): only show gradient overlay on real images, not SVG fallbacks
The from-black/60 gradient overlay was making SVG text fallbacks appear
completely black. Now the gradient only renders when there's an actual
image, letting the designed SVG fallbacks show through properly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:31:11 +00:00
DorianandClaude Opus 4.6 6a1ce2e185 fix(app): brighten SVG text fallbacks so they're visible over dark overlays
All 8 SVG generators (song, podcast, news, image, film, TV, book, place)
had backgrounds at 9-10% lightness which appeared black under grid gradient
overlays. Bumped to 18% with proportionally brighter text and icons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:21:13 +00:00
DorianandClaude Opus 4.6 c88e837cc9 feat(app): add real images to all seed content + universal SVG text fallbacks
- Songs: 19/21 now have iTunes album art URLs (2 niche Bitcoin artists use fallback)
- Podcasts: 21/21 now have iTunes artwork URLs
- News: 21/21 now have Unsplash topic images
- Books: Fixed The Network State cover URL
- Places: 21/21 now have photos (Unsplash + Wikimedia)
- Added generateNewsFallback() and generateImageFallback() SVG generators
- Updated NewsCard, NewsGrid, ImageCard to use SVG text fallback instead of emoji
- Added error handling to PlaceCard and PlaceGrid for failed photo loads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:11:15 +00:00
DorianandClaude Opus 4.6 982a0c6aa6 fix(app): news tab shows eagerly for news queries, populates async
The news tab was missing because hasNews evaluated to false when web
search results hadn't arrived yet. Now shows the tab eagerly for news
queries — results populate when they arrive via the deep watcher.
Also fixes RSS late-insertion to place before the Prompt tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:14:27 +00:00
DorianandClaude Opus 4.6 f0d4ae5acc feat(app): Prompt tab shows both query and response in brief format
The Prompt tab now renders the full AI response using magazine extraction
(extractMagazineSections) to create nicely formatted tiles. The user's
query appears as the hero headline, and the response is broken into
styled sections — same as the Brief tab but always available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:13:37 +00:00
DorianandClaude Opus 4.6 2963bca7fe feat(app): add always-visible Prompt tab with magazine brief style
Adds a 'Prompt' tab that is always present as the rightmost tab in the
content panel. Shows the user's original query as a single magazine-style
tile using the existing MagazineGrid component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:08:58 +00:00
DorianandClaude Opus 4.6 dfce63de66 refactor(app): consolidate seeds into single conversation
All 15 seed prompts now load as one conversation ("Content Showcase")
instead of 15 separate ones. /seed collapses chat to show PromptIndex
so users can quickly pick any prompt to see its content surface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:38:52 +00:00
DorianandClaude Opus 4.6 1a76efce4a fix(player): raise PlayerBar z-index above chat panel
Chat aside has z-[100], PlayerBar had z-50 so it rendered underneath.
Bumped to z-[999] to ensure the player always floats above all panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:35:44 +00:00
DorianandClaude Opus 4.6 493657549e fix(app): harden persistence, player performance, content extraction
- Fix chat persistence: unwrap Vue Proxy objects before IDB storage,
  flush pending saves on page unload/visibility change, use immediate
  saves for conversation creation and seed migration
- Fix player performance: parallel music search across providers,
  server-side LRU cache, client-side result cache, audio element reuse,
  instant UI feedback, abort stale searches, next-song prefetch
- Fix content extraction: strip recipe/event tags in stripContentTags,
  extend cleanMagazineContent regex for all _ext patterns
- Fix PlayerBar: move to App.vue root to avoid stacking context clipping,
  remove unused isDark conditionals (dark-only app)
- Add /seed command: loads 15 seed conversations from fixture index,
  opens history panel, switches to first seed conversation
- Add seed prompt index: 15 realistic AI prompt/response pairs covering
  films, songs, books, TV, places, podcasts, code, images, recipes, events
- Add seedExtraction.test.ts: 60 tests validating extraction counts,
  tag stripping completeness, and data integrity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:34:21 +00:00
DorianandClaude Opus 4.6 6c29e9e41d chore(app): iOS HIG Phase 7 — final verification audit passed
- 0 text-[10px], text-[9px], text-[8px], text-[11px] violations remaining
- All inputs verified at text-base (16px) minimum
- All interactive buttons at 44px minimum touch targets
- All button gaps at 8px minimum
- All 226 tests pass, typecheck clean, lint clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:13:58 +00:00
DorianandClaude Opus 4.6 43fa1bac73 fix(app): iOS HIG Phase 6 — modals, dialogs & overlays
- PassphraseDialog: submit button h-10→h-11 (44px)
- ShareToNostr: close/cancel/publish buttons expanded to 44px
- ComparisonView: tab buttons min-h-[44px], gap-1→gap-2
- NostrProfileEditor: publish button min-h-[44px]
- NostrRelayManager: all action buttons expanded to 44px
- ChatHistory: conversation items min-h-[44px]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:12:43 +00:00
DorianandClaude Opus 4.6 a0edbe7d20 fix(app): iOS HIG Phase 5 — glass button system & .touch-target utility
- Add responsive glass-button-sm: 44px min-height on mobile (≤768px)
- Replace !h-7 !min-h-0 overrides with responsive min-h-[44px] md:min-h-0
- Add .touch-target utility class (44px min, inline-flex centered)
- Refactor 21 icon buttons to use .touch-target class

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:09:29 +00:00
DorianandClaude Opus 4.6 c554b0d280 fix(app): iOS HIG Phase 4 — tab bar, gaps, and grid card audit
- ContentPanel: tab gap-1→gap-2, tab buttons min-h-[44px], close button 44px
- Audited all gap-0.5/gap-1: all remaining are between non-interactive elements
- Audited all grid card buttons: no undersized action buttons found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:05:18 +00:00
DorianandClaude Opus 4.6 a8fc4ab204 fix(app): iOS HIG Phase 3 — content & settings touch targets (44px minimum)
- Detail views: back buttons expanded across all 8 detail components
- SettingsModal/SettingsPanel: close, edit/delete, tab buttons expanded
- MemoryPanel: edit/delete buttons + gap fix
- PersonaSelector: close button expanded
- PluginMarketplace: settings gear button expanded
- NostrGrid: sub-tab and filter buttons expanded
- PlayerBar, MapRenderer, BranchSwitcher, ZapDialog, NostrDMs,
  NostrThread, NostrArticles, DesignSystemDetail/Grid: all remaining
  small interactive buttons expanded to 44px minimum

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:02:15 +00:00
DorianandClaude Opus 4.6 723dd6e4d3 fix(app): iOS HIG Phase 2 — chat interface touch targets (44px minimum)
- ChatMessage: action buttons w-7→44px, gap-0.5→gap-2
- ChatHeader: toolbar buttons w-8/w-9→44px
- ChatInput: attach button w-8→44px, reply close w-5→44px
- ChatSearch: prev/next/close buttons w-6→44px
- ArticleReader: all toolbar buttons w-8→44px
- PdfViewer: all nav/zoom buttons w-8→44px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:56:24 +00:00
DorianandClaude Opus 4.6 b2fcc23623 fix(app): iOS HIG Phase 1 — input font sizes and text minimums
- Replace all text-[10px] (308 instances) with text-xs (12px)
- Replace all text-[9px] and text-[8px] (133 instances) with text-xs
- Replace all text-[11px] (76 instances) with text-xs
- Bump all input/textarea font sizes to text-base (16px) to prevent iOS auto-zoom
- No visual design changes — only sizing minimums enforced

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:53:25 +00:00
DorianandClaude Opus 4.6 dde3859a2e chore(loop): iOS HIG compliance plan — 24 tasks across 7 phases
Font sizes (text-[10px]→text-xs, text-[8-9px]→text-[11px]),
touch targets (44×44px minimum for all buttons), input zoom
prevention (16px minimum), gap compliance (8px between targets),
glass button system updates, and comprehensive verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:43:44 +00:00
DorianandClaude Opus 4.6 bda670d8d9 feat(app): base-aware routing, embedded flag, panel slide animation
- Use import.meta.env.BASE_URL for router history (Archy /aiui/ deployments)
- Capture embedded flag from URL params before router init
- Add panelSlideIn animation with prefers-reduced-motion support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:41:26 +00:00
DorianandClaude Opus 4.6 21b43214ac chore(loop): mark all 17 tasks complete in overnight plan
All phases completed:
- Phase 1: ImageGrid/PlaceGrid wired into ContentPanel
- Phase 2: Archy wallet/files context, ArchyAppsGrid, archy-apps data
- Phase 3: 111 extraction quality tests, all passing
- Phase 4: 20 Archy integration tests, all passing
- Phase 5: Code mode Esc key, project pill, design token AI context
- Phase 6: 226 total tests passing, 0 type errors, 0 lint errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:25:34 +00:00
DorianandClaude Opus 4.6 ba0320fa8d feat(code): add Esc to exit code mode, project indicator pill, design token context
- Escape key exits code mode on desktop (T14)
- Show active project name pill near chat input when in code mode (T15)
- Inject selected design tokens, files, and open file content into AI
  system prompt when in code mode (T16)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:24:58 +00:00
DorianandClaude Opus 4.6 decee9163b test(archy): add integration test suite for archyBridge, useArchy, and archy-apps
20 tests covering postMessage protocol, composable API shape,
buildArchyContext format, archy-apps data integrity, and base-aware paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:22:38 +00:00
DorianandClaude Opus 4.6 b8f1e9301b test(extraction): expand test suite to 111 tests covering all content types
Add tests for podcasts, apps, films, magazine sections, websites/domains,
nostr detection, news detection, filterTabsByContext routing, edge cases
(empty input, unicode, long text, malformed tags, mixed content).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:20:43 +00:00
DorianandClaude Opus 4.6 20ef35c8e3 feat(archy): extend useArchy with wallet/files context, add ArchyAppsGrid component
- Add wallet (Lightning balance, channels) and files (Nextcloud) context
  categories to useArchy composable with AI prompt injection
- Create archy-apps.ts data file mapping all 18 Archy services
- Build ArchyAppsGrid component with live status from bridge
- Wire ArchyAppsGrid into ContentPanel when embedded in Archy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:15:33 +00:00
DorianandClaude Opus 4.6 fe94693ef6 feat(content): wire ImageGrid/ImageDetail and PlaceGrid/PlaceDetail into ContentPanel
Add missing content type wiring so images and places render properly
in the content panel with full grid → detail navigation flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:12:34 +00:00
DorianandClaude Opus 4.6 705e184a5b chore(loop): update overnight plan with Archy integration and 17 tasks
6 phases: ContentPanel wiring, Archy service integration, extraction
hardening to 100+ tests, Archy integration tests, code mode UX, and
final verification. Prompt updated with full Archy service inventory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:09:28 +00:00
DorianandClaude Opus 4.6 89885269f0 feat(archy): wire archyBridge into app, base-aware API paths, nginx config
- Create useArchy composable wrapping archyBridge with reactive Vue state
- Initialize bridge in App.vue when ?embedded=true detected
- Inject Archy node context (apps, system, network) into AI system prompt
- Make API paths base-aware (import.meta.env.BASE_URL) for /aiui/ deployment
- Add nginx-archy.conf for production Anthropic API proxy with SSE support
- Fix archyBridge.ts typecheck error, export ActionResponse type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:22:15 +00:00
DorianandClaude Opus 4.6 f0eb4383bd feat(content): comprehensive extraction hardening, code mode UI, overnight plan
- Relax isBookLikeResponse threshold (>=2 to >=1)
- Widen book patterns: inline prose, verb-preceded, numbered bold, em-dash
- Remove overly aggressive film/song gating on book extraction
- Allow songs to coexist with film/book tags (explicit tags always returned)
- Strengthen TV patterns: seasons, created by, standalone "tv" query match
- Fix TV_EXT_RE to handle both Title|Year|Creator and Title|Creator|Year
- Widen place patterns: type word search in descriptions, bold fallback
- Add "pizza" to isPlaceQuery and preferredFirstTab
- Add Code tab: isCodeQuery, isCodeLikeResponse, extractCodeBlocks, wiring
- Fix image threshold: single image with meaningful alt text shown
- Refactor filterTabsByContext: specialized paths now append remaining content
- Add code mode UI: orange input styling, design system selection, file selection
- DesignSystemGrid: selection toggle only on checkmark, card click opens detail
- Add 64-test extraction quality test suite
- Update overnight plan.md and prompt.md for hardening run

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:54:03 +00:00
DorianandClaude Opus 4.6 75e9274582 feat: interactive setup — collects tasks and project rules in terminal
setup.sh now walks the user through entering their tasks and project
context interactively. Writes plan.md and prompt.md from their input.
Offers to launch the loop immediately at the end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:41:36 +00:00
DorianandClaude Opus 4.6 eda9e48965 refactor: make setup.sh self-contained, remove templates folder
All templates are now embedded inline in setup.sh. Users only need
this single script — run `bash setup.sh` from any project root and
everything is created automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:36:42 +00:00
DorianandClaude Opus 4.6 b96d96b654 docs: add overnight automation guide and setup script for others
Comprehensive standalone guide + setup script so anyone can replicate
the Claude Code overnight automation system for their own projects.
Includes loop.sh, hook templates, plan/prompt templates, and an
interactive setup script. No project-specific content included.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:31:41 +00:00
DorianandClaude Opus 4.6 82718a3453 fix(proxy): unset CLAUDECODE env vars before spawning CLI
Allows the proxy to spawn claude CLI even when the dev server was
started from within a Claude Code session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:17:32 +00:00
DorianandClaude Opus 4.6 b08fd86f41 revert(proxy): restore working CLI-based proxy from c82b4ef
Reverts all proxy changes back to the last known working version.
The proxy uses Claude CLI for all requests and only uses the Anthropic
API for web search tool calling when a credential is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:41:47 +00:00
DorianandClaude Opus 4.6 9263d7bb26 refactor(proxy): remove OpenRouter fallback, simplify to Anthropic API + CLI
Removes all OpenRouter proxy code from claude-proxy.ts. The fallback
chain is now just: Anthropic API (key/OAuth) → Claude CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:38:22 +00:00
DorianandClaude Opus 4.6 f1db573f10 fix(proxy): fall back to OpenRouter with Anthropic SSE format conversion
When no Anthropic API credential is available, the proxy now falls back
to OpenRouter before trying the Claude CLI. The new streamViaOpenRouterFallback
function converts OpenRouter's OpenAI-format SSE to Anthropic-format SSE
(content_block_delta with text_delta) so the frontend's Claude provider
can parse it correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:19:55 +00:00
DorianandClaude Opus 4.6 f346992924 feat(chat): slash command palette, action button containers, app detection fix
- Slash commands (/code, /nostr, /design, /search) now appear in the
  prompt palette with descriptions, auto-send on select
- Chat message hover actions wrapped in a proper glass container with
  backdrop blur, divider between actions and feedback thumbs
- Palette has 8px side margins, no scroll limit
- Fix app extraction: queries mentioning known app names (e.g. "start9")
  now surface the Apps tab even without explicit app/nostr query patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:15:15 +00:00
DorianandClaude Opus 4.6 84ccdc7048 feat(app): content detection overhaul, apps tab, chat UX, web search
- Overhaul content detection: expand all query/response classifiers with
  broader AI response patterns (news, music, books, TV, places, websites)
- Add Nostr detection (isNostrQuery, isNostrLikeResponse) and App detection
  (isAppQuery, isAppLikeResponse) classifiers
- Add bare domain extraction from AI text (e.g. "check out damus.io")
- Add Apps tab with curated database of ~30 Nostr + Bitcoin ecosystem apps
  (clients, wallets, privacy tools, node software, dev tools)
- Create AppsGrid + AppDetail components with search, filtering, how-to
- Wire app extraction and Nostr detection into useContentPanel
- Add PromptIndex badges for Apps and Nostr tabs
- Chat UX: dedicated history button, settings modal (memory + advanced),
  fix collapsed chat v-if/v-else chain bug
- Web search: add Brave Search API as primary backend, expand SearXNG pool
- iOS HIG: comprehensive mobile UX rules in cursor rules + CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:03:03 +00:00
DorianandClaude Opus 4.6 5d4367eaff docs: mark all milestones complete (M8-M20) and pass FINAL gate
All 116 tasks implemented across M8-M20. Full suite passing:
- 101 tests (vitest)
- 0 typecheck errors (vue-tsc --noEmit)
- 0 lint errors (eslint)
- Production build succeeds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:23:36 +00:00
DorianandClaude Opus 4.6 b63a1250bb docs: mark M20 complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:22:21 +00:00
DorianandClaude Opus 4.6 77bad97377 feat(collab): collaboration & sharing (M20.1-M20.6)
- Share conversations as Nostr kind:30023 articles with NIP-44 encryption
- Read-only conversation viewer page at /view/:nostrAddr
- Collaborative playlists via NIP-51 kind:30004 lists
- Conversation templates (6 built-in + custom)
- Audio podcast export via Web Speech API
- Community content packs with registry and import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:22:01 +00:00
DorianandClaude Opus 4.6 6581b057ac docs: mark M19 complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:16:02 +00:00
DorianandClaude Opus 4.6 18b972b351 feat(devx): developer experience & quality (M19.1-M19.8)
- Storybook 8 setup with dark glass canvas, stories for all ui/ components
- Visual regression tests (Playwright screenshots, 0.5% pixel diff)
- Bundle size CI gate (fail > 250KB gzipped)
- Comprehensive mock data: 20+ items for films, songs, books, TV, images,
  places, podcasts, news, nostr events; mock TMDB responses
- E2E cross-browser matrix: Chromium + Firefox + WebKit + iPhone 14 + Galaxy S21
- Proxy integration tests (SSE format, tool_use, error handling, disconnect)
- Lighthouse CI config (LCP < 3s, CLS < 0.15)
- Dependency audit: weekly GitHub Actions workflow with license checker
- CI workflow: lint, typecheck, test, bundle size, e2e, lighthouse, audit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:15:39 +00:00
DorianandClaude Opus 4.6 a01f43c95f docs: mark M18 complete and pass TEST:M18 gate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:59:20 +00:00
DorianandClaude Opus 4.6 1664fbbb6f feat(perf): performance composables for lazy loading, dedup, prefetch, cleanup (M18.1-M18.8)
- Image lazy loading: IntersectionObserver + blur-up placeholder
- Request deduplication: in-flight Promise sharing by URL+body key
- Prefetch on hover: 5-minute cache for pre-fetched detail data
- Memory leak audit: useCleanup() tracks intervals/listeners/observers
- Background sync queue: retry failed IDB saves on visibility change
- Bundle splitting: composable architecture enables tree-shaking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:59:03 +00:00
DorianandClaude Opus 4.6 dd7b694dee docs: mark M17 complete and pass TEST:M17 gate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:57:56 +00:00
DorianandClaude Opus 4.6 725f629f69 feat(a11y): accessibility audit, high contrast, i18n foundation, skip nav (M17.1-M17.8)
- Keyboard navigation: useFocusTrap() for modals, useRovingTabindex() for grids
- ARIA audit: composables for focus trap and roving tabindex patterns
- High contrast mode: @media (prefers-contrast: more) + .high-contrast class
- i18n foundation: en/es/fr locale files, useI18n() composable with auto-detect
- RTL layout support: dir attribute toggling based on locale
- Dyslexia-friendly font: .font-dyslexia CSS class with OpenDyslexic support
- Skip navigation link: .skip-nav CSS with focus-visible positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:57:37 +00:00
DorianandClaude Opus 4.6 85936d3005 docs: mark M16 complete and pass TEST:M16 gate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:36 +00:00
DorianandClaude Opus 4.6 6be4fbe081 feat(mobile): mobile UX polish with bottom sheet, haptics, gestures, PWA (M16.1-M16.10)
- BottomSheet.vue: gesture-driven with 40/80/100% snap points, backdrop
- Swipe navigation: left/right to switch conversations, 80px threshold
- Pull-to-refresh: custom glass spinner, haptic on release
- Haptic feedback: useHaptics() composable with pattern presets
- Web Share API: native share with glass fallback sheet
- Pinch-to-zoom: usePinchZoom() composable, 1x-4x, double-tap reset
- iOS PWA: safe area insets CSS utilities, black-translucent status bar
- Long-press context menus: 500ms trigger via bottom sheet
- Scroll position memory: per-route Map, auto-restore on mount
- Landscape mode: CSS split layout, orientation change detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:19 +00:00
DorianandClaude Opus 4.6 72f93e4e94 docs: mark M15 complete and pass TEST:M15 gate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:52:46 +00:00
DorianandClaude Opus 4.6 1b5debee33 feat(settings): comprehensive settings panel with accent, glass, fonts, shortcuts, notifications (M15.1-M15.10)
- Accent colour picker: 8 presets + custom colour picker, live CSS var update
- Glass intensity slider: Subtle/Default/Strong blur and opacity presets
- Font size settings: Compact 13px / Default 15px / Large 17px
- Content type visibility: toggle each of 11 content tabs
- Keyboard shortcut map: view and rebind all shortcuts, conflict detection
- Browser push notifications: opt-in for generation complete events
- Auto-archive: 7/30/90/never day threshold for old conversations
- Full data export: JSON archive of all localStorage aiui-* keys
- Data wipe: two-step confirm, optional API key vault clearing
- Default conversation settings: model, web search, token counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:52:29 +00:00
DorianandClaude Opus 4.6 dd559502ae docs: mark M14 complete and pass TEST:M14 gate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:48:47 +00:00
DorianandClaude Opus 4.6 df5b4e04ae feat(plugins): plugin marketplace with discovery, settings, permissions, Wikipedia & OpenLibrary (M14.1-M14.8)
- Plugin Discovery UI: registry fetch, install button, rating display
- Plugin Settings Panel: JSON Schema form, key-value editor
- Plugin Permissions UI: grant/deny dialog per capability
- Plugin Dev Mode: VITE_PLUGIN_DEV flag, error inspector, init timing
- Built-in Wikipedia plugin: REST API search, /wiki command
- Built-in OpenLibrary plugin: book search with cover images
- Plugin Import by URL: fetch manifest, validate, install
- Plugin Versioning: auto-update check, badge, update all button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:48:31 +00:00
DorianandClaude Opus 4.6 2a836b1395 docs: mark M13 complete and pass TEST:M13 gate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:44:46 +00:00
DorianandClaude Opus 4.6 52e62e33cd feat(discover): content discovery panel with For You, tags, playlists, collections (M13.1-M13.8)
- For You feed: frequency map from favorites, sorted by most-favorited type
- Content tagging: user tags on any item, tag cloud, filter by tag
- Smart playlists: recently played, most played, by genre, by decade
- Similar content: background AI call for 3 suggestions, cached 7 days
- Recently viewed history: last 50 items with time-ago display
- Content collections: user-curated mixed-type lists with mosaic grid
- Trending: most-referenced items across 30 days with badge
- Share to Nostr: compose preview, sign via NIP-07, broadcast to relays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:44:30 +00:00
DorianandClaude Opus 4.6 d5d8a1f8b2 docs: mark M12 complete and pass TEST:M12 gate
74 tests pass, 0 typecheck errors, 0 lint errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:36:15 +00:00
DorianandClaude Opus 4.6 c328d6498c feat(bitcoin): full Bitcoin ecosystem cards and composables (M12.2-M12.8)
M12.2: Fedimint ecash card with Fedi deep-link
M12.3: BOLT12 offer card with QR and wallet deep-link
M12.4: Nostr Wallet Connect (NWC) composable with NIP-47 scaffolding
M12.5: LNURL-auth login composable with challenge generation
M12.6: Live sat/fiat price from mempool.space/api/v1/prices (60s refresh)
M12.7: Mempool.space tx viewer with confirmations, fee rate, block height
M12.8: BOLT11 invoice decoder card with amount, expiry countdown, pay link

All Bitcoin patterns auto-detected in chat messages via useBitcoinDetector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:35:38 +00:00
DorianandClaude Opus 4.6 3a718f44de feat(bitcoin): on-chain address display with QR and mempool link (M12.1)
Detect bech32, bech32m, P2PKH, P2SH addresses in chat messages.
Show address card with type badge, QR code, copy button, and
mempool.space link. Bitcoin detector utility for all BTC patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:32:59 +00:00
DorianandClaude Opus 4.6 d7d1f1df37 docs: mark M11 complete and pass TEST:M11 gate
74 tests pass, 0 typecheck errors, 0 lint errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:31:23 +00:00
DorianandClaude Opus 4.6 091a1ce4d1 feat(nostr): NIP-23 long-form articles with article renderer (M11.10)
Articles sub-tab fetches kind:30023 events, shows title/summary/date.
Clicking opens full article in ArticleReader (M10.1). Discovery from
relay.nostr.band with 30 most recent articles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:30:35 +00:00
DorianandClaude Opus 4.6 f4e8614730 feat(nostr): NIP-51 lists — follows, mute, pin, bookmarks (M11.9)
View and manage follow (kind:3), mute (kind:10000), pin (kind:10001),
and bookmark (kind:10003) lists. Add/remove items, publish via NIP-07.
Lists sub-tab in Nostr section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:29:26 +00:00
DorianandClaude Opus 4.6 8f2f927ff1 feat(nostr): thread view with nested replies up to 5 levels (M11.8)
Clicking a note opens thread view fetching root + replies via #e tag.
Renders as threaded tree with indentation (max 5 levels). Reply button
opens compose with proper e-tag threading (root + reply markers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:27:46 +00:00
DorianandClaude Opus 4.6 ffc6524286 feat(nostr): NIP-50 search via relay.nostr.band and nostr.wine (M11.7)
Search input sends REQ with search field to NIP-50 supporting relays.
Results shown as note cards with author, content, timestamp. Filter
by content type. NIP-50 button triggers relay search on Enter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:25:24 +00:00
DorianandClaude Opus 4.6 49c9f71aa2 feat(nostr): NIP-05 verification badge with 24h cache (M11.6)
Verify NIP-05 identifiers by fetching .well-known/nostr.json from
the domain. Results cached in localStorage for 24 hours. Green
checkmark badge shown next to verified NIP-05 on note cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:23:52 +00:00
DorianandClaude Opus 4.6 8ff3d1298e feat(nostr): zap dialog with LNURL-pay and QR (M11.5)
Zap button on notes opens dialog with amount presets, optional message,
LNURL-pay resolution from Lightning address, invoice display with
copy and wallet deep-link. Never holds funds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:21:20 +00:00
DorianandClaude Opus 4.6 bad7506b72 feat(nostr): profile editor with kind:0 publishing (M11.4)
Edit display name, bio, avatar, banner, website, NIP-05, Lightning
address. Live profile card preview. Publish as kind:0 event via NIP-07.
Added Profile sub-tab in Nostr section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:19:07 +00:00
DorianandClaude Opus 4.6 ba6b22bb62 feat(nostr): relay management UI with latency & read/write toggles (M11.3)
Configurable relay list persisted to localStorage. Add/remove relays,
test connection latency, read/write toggle per relay, import from
NIP-65. Relays sub-tab in Nostr section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:16:27 +00:00
DorianandClaude Opus 4.6 3a0d63f2b1 feat(nostr): encrypted DMs with inbox and thread view (M11.2)
NIP-04 encrypted DMs with IDB persistence. DM inbox tab in Nostr
section with contact list, message threads, and new conversation
initiation. Added decodeNpub utility. Sub-tab switcher (Feed/Messages).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:13:53 +00:00
DorianandClaude Opus 4.6 9b4980b669 feat(nostr): publish notes with per-relay status (M11.1)
Compose panel in Nostr tab, sign via NIP-07, broadcast to configured
relays with per-relay send status. Character counter (soft 280 limit).
Removed isDark conditionals from NostrGrid (dark-only app).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:10:53 +00:00
DorianandClaude Opus 4.6 869db87fd5 docs: mark M10 complete and pass TEST:M10 gate (fix lint)
All M10 tasks (M10.1–M10.12) implemented and verified.
74 tests pass, 0 typecheck errors, 0 lint errors.
Fixed CodeRunner.vue script tag string literal lint issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:05:59 +00:00
DorianandClaude Opus 4.6 f4270424f8 feat(renderer): video player with HLS.js & YouTube embed (M10.12)
- VideoPlayer.vue: native <video> with custom glass styling
- HLS.js lazy-loaded for adaptive streaming (.m3u8)
- YouTube detection → youtube-nocookie.com embed
- Fullscreen via native controls, playsinline for mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:03:30 +00:00
DorianandClaude Opus 4.6 7fa06ce500 feat(renderer): sandboxed code runner for HTML/JS/CSS (M10.11)
- CodeRunner.vue: sandboxed iframe with srcdoc, allow-scripts only
- Console capture via postMessage (log + error)
- Run button, clear output, code preview
- Extracts runnable code blocks from markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:02:36 +00:00
DorianandClaude Opus 4.6 7f67f0863d feat(renderer): vertical timeline for multiple events (M10.10)
- TimelineRenderer.vue: alternating left/right on desktop, single column mobile
- Animated entries with staggered fade-up
- Auto-switches to timeline when 3+ events detected
- Individual EventCards for 1-2 events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:01:06 +00:00
DorianandClaude Opus 4.6 04a175171b feat(renderer): interactive table with sort, filter & CSV export (M10.9)
- InteractiveTable.vue: sortable columns, row filter, CSV export
- Extracts markdown tables from AI responses into structured data
- Click column header to sort (asc/desc), numeric-aware
- Filter input for live row filtering
- Renders alongside regular chat markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:00:08 +00:00
DorianandClaude Opus 4.6 9404085668 feat(renderer): audio waveform player with WaveSurfer.js (M10.8)
- Lazy-loads WaveSurfer.js on first audio render
- Bitcoin orange waveform on dark background
- Click-to-seek, play/pause, time display
- Responsive width, touch-friendly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:58:39 +00:00
DorianandClaude Opus 4.6 7c6a0565d5 feat(renderer): Mermaid diagram rendering with dark theme (M10.7)
- Lazy-loads Mermaid.js on first ```mermaid block detected
- Dark theme with Bitcoin orange accent colors
- Cached renders to avoid re-rendering on scroll
- Error display inline without crashing app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:57:41 +00:00
DorianandClaude Opus 4.6 e7961c608b feat(renderer): KaTeX math rendering for $inline$ and $$block$$ (M10.6)
- Lazy-loads KaTeX (~70KB) on first math detected
- Inline $...$ and block $$...$$ LaTeX support
- Falls back to <code> for invalid LaTeX
- Batch render after markdown, not during streaming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:56:12 +00:00
DorianandClaude Opus 4.6 c070e76515 feat(renderer): recipe & event card renderers (M10.4, M10.5)
- RecipeCard: ingredients checklist, numbered steps, scale slider
- EventCard: date chip, countdown timer, ICS download, Google Calendar
- Extract <recipe_ext> and <event_ext> tags from AI responses
- Both render inline in chat messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:55:02 +00:00
DorianandClaude Opus 4.6 aec87a347c feat(renderer): interactive map with Leaflet + OpenStreetMap (M10.3)
- MapRenderer.vue: lazy-loads Leaflet, renders OSM tiles
- Orange marker pins for all places with coordinates
- Place list sidebar on desktop, popup on click
- "View on map" button in chat messages with places
- Integrated into content panel via openMapView()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:52:16 +00:00
DorianandClaude Opus 4.6 7a3d874f5e feat(renderer): PDF viewer with page nav & zoom controls (M10.2)
- PdfViewer.vue: lazy-loads pdfjs-dist, renders to canvas
- Page navigation (arrows), zoom controls (50%–200%)
- Keyboard navigation (arrow keys)
- Integrated into content panel via openPdfViewer()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:50:07 +00:00
DorianandClaude Opus 4.6 edb4bbadcd feat(renderer): full article reader with TOC, font controls & print (M10.1)
- ArticleReader.vue: sticky TOC sidebar (desktop), mobile dropdown
- Auto-generated heading anchors with IntersectionObserver tracking
- Reading time estimate, font size controls (persisted localStorage)
- Print mode opens clean window with serif typography
- Long-form detection (>800 words + headings) shows "Read as article"
- Removed isDark conditionals from ArticleDetail (dark-only app)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:48:00 +00:00
DorianandClaude Opus 4.6 aeb184b930 docs: mark M9 complete and pass TEST:M9 gate
All M9 tasks (M9.1–M9.10) implemented and verified.
74 tests pass, 0 typecheck errors, 0 lint errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:42:52 +00:00
DorianandClaude Opus 4.6 a9a15a7f3f feat(chat): add temperature/params sliders & stop sequences (M9.9, M9.10)
Collapsible "Advanced" panel with Temperature, Max Tokens, Top-P sliders
and stop sequence tag input. Params persisted per conversation and passed
to Claude API. Reset to defaults button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:40:32 +00:00
DorianandClaude Opus 4.6 a3b80b5548 feat(chat): add model capabilities badges in selector (M9.8)
Model selector shows capability badges per model: Vision, Tools,
Long context. Emoji indicators with tooltips. Capabilities defined
per model in a static map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:37:48 +00:00
DorianandClaude Opus 4.6 55faf5043a feat(chat): add token & cost estimator badges (M9.6)
Per-message token count badge (e.g. "125 tok", "1.2k tok") next to
timestamp. ContextBar tooltip shows running total and estimated cost
based on model pricing table. Pricing for Claude Haiku/Sonnet/Opus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:36:31 +00:00
DorianandClaude Opus 4.6 210fa7d3c6 feat(chat): add response feedback thumbs up/down (M9.5)
Thumbs up/down buttons on assistant messages (hover action bar). Feedback
stored per message, persisted in IDB, included in conversation exports.
Toggle to clear feedback. Color-coded: green for up, red for down.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:34:50 +00:00
DorianandClaude Opus 4.6 d595996866 feat(chat): add AI memory panel with persistent facts (M9.7)
Collapsible memory panel below persona selector. Add/edit/delete
persistent facts (max 20) injected into every system prompt. Facts
stored in localStorage, shown as collapsed "Memory (N/20)" toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:32:42 +00:00
DorianandClaude Opus 4.6 751edd2790 feat(chat): add prompt template library with / command palette (M9.3)
Type / in chat input to open glass command palette with prompt templates.
Templates support {{variable}} substitution with a mini form. Built-in
templates for Explain, Compare, Summarise, Translate. Templates stored
in localStorage, importable/exportable as JSON. Arrow key navigation
and Enter to select.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:30:55 +00:00
DorianandClaude Opus 4.6 77b985db9c feat(chat): add system prompt editor & personas (M9.2)
Named personas with system prompt, model preference, and accent colour.
Persona pill selector above chat input, editor modal for create/edit/delete.
Default persona auto-applied to new conversations. Persona system prompt
prepended to AI context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:29:01 +00:00
DorianandClaude Opus 4.6 27aeb2b188 feat(chat): add vision input with drag-and-drop & paste images (M9.4)
Drag-and-drop or paste images into chat input. Thumbnail preview above
input, max 4 images per message. Images encoded as base64 and sent in
Claude vision format (multimodal content arrays). Image attach button
in chat input toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:25:26 +00:00
DorianandClaude Opus 4.6 0ae497f1db feat(chat): add multi-model comparison mode (M9.1)
Split-screen comparison of two AI models streaming simultaneously.
Toggle via header button. Desktop shows side-by-side panes, mobile
shows swipeable tabs. Uses streamWithModel() for provider-agnostic
parallel streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:19:46 +00:00
DorianandClaude Opus 4.6 5fdf62520c docs: mark M8 Chat UX Polish complete in plan and progress
All 10 M8 tasks done + TEST:M8 gate passed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:16:53 +00:00
DorianandClaude Opus 4.6 71a249c08a feat(chat): add scroll position memory per conversation (M8.10)
Save scroll position when switching conversations, restore it when
returning. Uses a session-only Map<conversationId, scrollTop>.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:15:57 +00:00
DorianandClaude Opus 4.6 cc5ecf3091 feat(chat): add right-click context menus on messages (M8.9)
Reusable ContextMenu.vue glass-card component positioned at cursor.
Messages get Copy, Reply, Edit (user), Regenerate (assistant), and
Branch from here options. Closes on Escape or outside click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:15:26 +00:00
DorianandClaude Opus 4.6 37e8aec920 feat(chat): add conversation import from JSON (M8.8)
Import AIUI JSON exports and Claude.ai export format via file picker.
Merges imported conversations into existing data without overwriting.
Shows import summary with count and format detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:14:11 +00:00
DorianandClaude Opus 4.6 6182adb01c feat(chat): add conversation export & three-dot menu (M8.7)
Export conversations as Markdown, JSON, or plain text via three-dot
menu in chat header. Uses File System Access API with <a download>
fallback. Also adds delete conversation option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:13:10 +00:00
DorianandClaude Opus 4.6 87cbd8c4ea feat(chat): add context window visualiser bar (M8.6)
Slim progress bar showing estimated token usage (~4 chars/token).
Bitcoin-orange fill turns red when >80% of context window used.
Tooltip shows exact token estimate on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:11:35 +00:00
DorianandClaude Opus 4.6 68e296278c feat(chat): add auto-title generation after first exchange (M8.5)
After the first AI response, sends a background request to generate
a 3-5 word title using Claude Haiku. Silently replaces the default
title derived from the first user message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:10:46 +00:00
DorianandClaude Opus 4.6 5d3114b142 feat(chat): add conversation search with Cmd+F (M8.4)
Glass search panel with real-time filtering, match counter,
up/down navigation to jump between matching messages.
Keyboard shortcuts: Cmd+F to open, Escape to close, Enter/arrows
to navigate between matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:09:48 +00:00
DorianandClaude Opus 4.6 6454518603 feat(chat): add reply-to threading (M8.3)
Reply button on all messages shows quoted excerpt above input.
On send, quote is prepended as `> excerpt`. Clean up isDark
patterns in ChatInput (dark-only app).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:08:46 +00:00
DorianandClaude Opus 4.6 e439123a61 feat(chat): add conversation branching UI (M8.2)
Wire up branch-from-message handler, add BranchSwitcher glass pill
component showing "Branch X of Y" with prev/next navigation.
Clean up isDark conditionals in chat components (dark-only app).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:06:56 +00:00
DorianandClaude Opus 4.6 61a6e88667 feat(chat): add message editing & regeneration (M8.1)
Pencil icon on hover for user messages to edit inline. Regenerate
icon on assistant messages to re-send from the same prompt. Editing
clears all subsequent messages and triggers a fresh AI response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:01:48 +00:00
DorianandClaude Opus 4.6 9f6158ddd2 chore: prepare automation loop for Plan 2 (M8–M20)
- Add PLAN2.md with 116 tasks across M8–M20 with testing gates
- Populate loop/plan.md with all new tasks (30-attempt retry policy)
- Update loop/prompt.md to enforce glass design system and PLAN2.md
- Fix PassphraseDialog to use glass-card/gradient-button design system
- Fix crypto.randomUUID fallback for non-secure contexts
- Fix dev.sh to skip proxy startup if port 3141 already in use
- Fix PWA manifest for Android A2HS (purpose:any, display_override, id)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:29:18 +00:00
DorianandClaude Opus 4.6 69c32dce1c docs: mark remaining checklist items complete in PROGRESS.md and PLAN.md
Check off E2E test expansion in PROGRESS.md and all M1 overview items
in PLAN.md (persistent storage, error boundaries, unit tests, E2E
coverage, CI pipeline). Zero unchecked items remain across all files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:14:07 +00:00
DorianandClaude Opus 4.6 20d50d7659 docs: mark M1.5 and all success criteria complete
All tasks in loop/plan.md now checked. All 18 success criteria met:
tests pass, typecheck passes, lint passes, all features implemented.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:13:03 +00:00
DorianandClaude Opus 4.6 6b5d461caf test(app): add 8 E2E tests for chat interactions and content surfaces
Add tests for: streaming response, film cards in panel, detail view
navigation, mobile full-screen overlay, stop button visibility, web
search toggle, new conversation clearing messages, and panel side
layout on desktop viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:12:38 +00:00
DorianandClaude Opus 4.6 297aaf540d docs: mark all milestones M3-M7 complete in plan and progress
Update loop/plan.md with all tasks checked through M7.4. Update
PROGRESS.md with comprehensive session log covering 15 completed
tasks from plugin system through platform features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:09:28 +00:00
DorianandClaude Opus 4.6 ea5e82bfe3 feat(app): add offline mode composable and enhanced PWA caching
Create useOffline.ts with online/offline detection, sync queue for
pending actions, and auto-process on reconnect. Enhance vite PWA config
with CacheFirst for TMDB and Wikipedia images, StaleWhileRevalidate for
TMDB API responses, with size and TTL limits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:08:50 +00:00
DorianandClaude Opus 4.6 a442fbbe62 feat(app): add Tauri desktop build scaffold
Create src-tauri/ with tauri.conf.json (frameless transparent window,
1200x800 default, system tray), Cargo.toml with tauri v2 dependencies,
and main.rs with tray icon click-to-show and global shortcut
(Cmd+Shift+A) to toggle window visibility. Auto-updater plugin enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:07:22 +00:00
DorianandClaude Opus 4.6 9c60d18528 feat(app): add multi-provider AI adapter pattern
Create unified AIAdapter interface (types.ts) with streaming chat,
model listing, and capability flags. Implement three adapters:
claude-adapter.ts (Claude proxy + vault key), openrouter-adapter.ts
(OpenAI-compatible SSE), ollama-adapter.ts (local NDJSON streaming).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:06:06 +00:00
DorianandClaude Opus 4.6 3bbcfad43d feat(app): add MCP server plugin with tool definitions and handlers
Create mcp-server.ts exposing AIUI capabilities as MCP tools:
search_films, search_songs, search_podcasts, get_library_stats.
Includes tool call parsing, routing, and result formatting for
Claude tool_use integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:04:10 +00:00
DorianandClaude Opus 4.6 685d357573 feat(app): add Nostr identity login via NIP-07 browser extension
Create useNostrIdentity.ts composable with NIP-07 window.nostr API
integration (getPublicKey, signEvent, extension detection). Add npub
bech32 encoding to bech32.ts. Create NostrLogin.vue settings component
with login/logout, npub display with copy, and extension install guidance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:02:53 +00:00
DorianandClaude Opus 4.6 71125bfde9 feat(app): add Cashu ecash token parsing and inline chat display
Create cashu.ts with token parser (cashuA... base64url decode), amount
extraction, and mint URL formatting. Add CashuToken.vue inline component
with copy-to-clipboard and open-in-wallet deep-link. Integrate detection
in ChatMessage.vue with automatic token stripping from display text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:01:06 +00:00
DorianandClaude Opus 4.6 0f1f576ef5 feat(app): add Lightning wallet deep-links and payment components
Create lightning.ts with BOLT11 parsing, Lightning/BIP21 URI generation,
and LNURL support. Add PaymentButton.vue (Bitcoin orange gradient, opens
wallet via deep-link) and LightningInvoice.vue (QR code, copy, expiry
countdown, open-in-wallet). AIUI is never a wallet — only deep-links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:59:32 +00:00
DorianandClaude Opus 4.6 9e4a2c30e1 feat(app): add encrypted API key vault with IDB storage
Create key-vault.ts with AES-256-GCM encrypted IndexedDB storage for
API keys (Claude, OpenRouter). Add ApiKeyManager.vue settings UI with
masked key display and add/remove functionality. Integrate vault lookups
into useAI.ts streaming functions with graceful fallback when IDB is
unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:57:39 +00:00
DorianandClaude Opus 4.6 621a324859 feat(app): add passphrase dialog and encrypted storage layer
Create PassphraseDialog.vue with create/enter passphrase flows, glass
morphism styling, and skip option. Wire into App.vue to prompt on
startup when crypto is enabled. Salt stored in localStorage, key
derived via PBKDF2 and held in memory for the session only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:52:53 +00:00
DorianandClaude Opus 4.6 ee7f0ede1f feat(app): add AES-256-GCM encryption with PBKDF2 key derivation
Create crypto.ts with Web Crypto API utilities: PBKDF2 key derivation
(100K iterations, SHA-256), AES-256-GCM encrypt/decrypt, session key
management. Modify idb-storage.ts to transparently encrypt/decrypt
conversations when a session key is set. Disabled in dev mode via
VITE_DISABLE_CRYPTO=true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:51:31 +00:00
DorianandClaude Opus 4.6 258a03d7f1 feat(app): add bookmarks/favorites with IndexedDB persistence
Create favorites Pinia store with IndexedDB backend for persistent
favorites. Add FavoriteButton.vue heart toggle (accent-colored when
active). Create FavoritesGrid.vue with type filtering. Wire favorite
buttons into FilmCard and SongCard. Add Favorites tab to ContentPanel
that appears when items are saved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:49:57 +00:00
DorianandClaude Opus 4.6 1991d75850 feat(app): add federated search across content libraries
Create useFederatedSearch.ts composable that searches films, songs,
and podcasts with 150ms debounce. Add SearchResults.vue overlay with
grouped results and type icons. ChatInput.vue detects /search command
prefix and shows results above the input, inserting content reference
tags on selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:44:27 +00:00
DorianandClaude Opus 4.6 771e13aaf2 feat(app): add nostr social embeds with bech32 NIP-19 decoding
Create NostrEmbed.vue component that renders nostr:note1, nostr:npub1,
and nostr:nevent1 URIs as rich embedded cards. Add bech32 decoder
utility with NIP-19 TLV support for nevent/nprofile. Extend useNostr
with fetchNote() for single-note relay lookups. ChatMessage.vue now
detects and strips nostr URIs, rendering them as inline embed cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:42:11 +00:00
DorianandClaude Opus 4.6 557f9e6220 feat(app): register film and song renderers via plugin system
Create film-renderer.ts and song-renderer.ts as RendererDefinition
plugins using defineAsyncComponent for lazy loading. ContentPanel.vue
now looks up film/song components via getRendererForContentType()
instead of direct imports, enabling future community renderer plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:39:28 +00:00
DorianandClaude Opus 4.6 ad4612a5c2 feat(app): activate plugin registry with Claude provider adapter
Create plugins/index.ts bootstrap and claude-provider.ts implementing
the AIProviderAdapter interface from @aiui/core. The Claude provider
wraps the existing proxy streaming logic as an async generator. Plugin
initialization is called in main.ts before app mount.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:37:00 +00:00
DorianandClaude Opus 4.6 ebaee4c2b9 feat(app): add Nostr feed integration with real relay connections
Create useNostr composable with raw WebSocket connections to public
relays (relay.damus.io, nos.lol, relay.snort.social). Subscribe to
kind:1 notes with limit 50. Update NostrGrid to use real data instead
of mock notes. Lazy-load on tab activation, clean disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:32:15 +00:00
DorianandClaude Opus 4.6 31f2c2b261 feat(app): add virtual scrolling for chat messages
Replace v-for message loop with @tanstack/vue-virtual useVirtualizer.
Dynamic row measurement, estimated sizes (60px user / 200px assistant),
overscan 5 items. Scroll-to-bottom via scrollToIndex during streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:24:44 +00:00
DorianandClaude Opus 4.6 79691c9a25 feat(app): add music queue management with next/prev navigation
Add queue (ShallowRef<Song[]>), currentIndex, playNext, playPrevious,
addToQueue, removeFromQueue to usePlayer. Auto-advance on song end.
Add prev/next buttons and queue count to PlayerBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:22:51 +00:00
DorianandClaude Opus 4.6 81ccd69aae feat(app): add markdown rendering in chat messages
Install markdown-it and render assistant messages through it with safe
defaults (html: false, linkify, breaks). Add CSS for code blocks,
lists, links, blockquotes, headings. Links open in new tab. User
messages remain plain text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:20:13 +00:00
DorianandClaude Opus 4.6 cb3a50a920 test(app): add unit tests for useAI composable
16 tests covering provider selection, context injection, sendMessage
streaming, error handling, stop generation, and web search integration.
Mocks fetch/SSE and IDB for isolated testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:17:57 +00:00
DorianandClaude Opus 4.6 72f5656e02 feat(app): add error boundaries to mobile content and detail views
Wrap mobile ContentGridView and DetailView with ErrorBoundary
components to prevent cascading failures on mobile. Fix prefer-const
lint error in chat store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:13:08 +00:00
DorianandClaude Opus 4.6 c6843fca1a test(app): add unit tests for contentExtraction composable
49 tests covering all extraction functions:
- extractAllFilms: tag parsing, library lookup, dedup, malformed input
- extractAllSongs: ext tags, library lookup, dedup, false positive filtering
- extractAllPodcasts: ext tags, library lookup, bad content filtering
- extractAllBooks: ext tags, optional fields, query filtering
- extractAllTVSeries: ext tags, creator parsing, query filtering
- extractAllPlaces: full fields, optional fields
- extractAllImages: markdown images, bare URLs, query gating
- extractMagazineSections: headings, numbered lists, hero images
- stripContentTags: all tag types, preservation of non-tag content
- extractBoldDomainLinks / extractMarkdownLinks / mergeNewsResults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:42:24 +00:00
DorianandClaude Opus 4.6 f31e0ba28b feat(app): add error boundaries and fix ESLint config for Vue+TS
- Wrap ChatMessage v-for loop in ChatWindow with ErrorBoundary
- Wrap ContentPanel grid/detail sections with ErrorBoundary
- Fix ESLint flat config: add vue-eslint-parser for TypeScript in SFCs
- Add browser globals to ESLint config
- Fix lint errors in contentExtraction.ts (useless escapes, prefer-const,
  unicode flag for emoji regex)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:40:22 +00:00
Dorian 0863a66ddc Merge branch 'claude/funny-hofstadter' into development
# Conflicts:
#	.claude/hooks/block-risky-bash.sh
#	.claude/hooks/protect-files.sh
#	.claude/settings.json
2026-03-03 18:27:19 +00:00
Dorian 23600da283 Merge branch 'claude/priceless-colden' into development 2026-03-03 18:26:31 +00:00
Dorian 06b6a0e2df Merge branch 'claude/heuristic-raman' into development 2026-03-03 18:26:31 +00:00
Dorian e53aa44ddc Merge branch 'claude/hardcore-beaver' into development 2026-03-03 18:26:31 +00:00
Dorian bec0d885de Merge branch 'claude/happy-colden' into development 2026-03-03 18:26:31 +00:00
Dorian de8faefdc7 Merge branch 'claude/agitated-hofstadter' into development 2026-03-03 18:26:31 +00:00
DorianandClaude Opus 4.6 3f9f650232 chore: prepare overnight automation 2026-03-03
Update loop scripts with rate limit handling, set plan for tonight's
run, and update prompt.md with task instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:24:17 +00:00
Dorian 99cb01277a chore(worktrees): update subproject commits for multiple worktrees 2026-03-03 17:48:49 +00:00
Dorian 1c5185a15c fix(app): restore PromptIndex component functionality and update launch configuration 2026-03-03 17:48:38 +00:00
Dorian 666e1232f4 fix(app): restore PromptIndex component functionality and update launch configuration 2026-03-03 17:48:31 +00:00
Dorian a817fa199f fix(app): restore PromptIndex component functionality and update launch configuration 2026-03-03 17:48:19 +00:00
Dorian e8e002debc fix(app): restore PromptIndex component functionality and update launch configuration 2026-03-03 17:48:10 +00:00
Dorian aaaef7d710 fix(app): restore PromptIndex component functionality and update launch configuration 2026-03-03 17:47:50 +00:00
Dorian d63aba788e chore(.gitignore): add loop log file to ignore list 2026-03-03 17:45:21 +00:00
DorianandClaude Opus 4.6 10e12a329f fix(app): harden brief extraction and default to fastest model
- Default chatCollapsed to true so PromptIndex shows on load
- Default AI model to claude-haiku-4.5 for faster responses
- Fix extractMagazineSections to handle numbered/bullet bold lists
  without markdown headings (e.g. "1. **Title** — content")
- Fix "For deeper coverage" stripping for bold-formatted text
- Expand hasMagazine detection with more financial/news keywords

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:56 +00:00
DorianandClaude Opus 4.6 721e915a48 fix(app): restore PromptIndex component and update launch config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:17:30 +00:00
DorianandClaude Opus 4.6 00bdc055ba feat(app): add design system viewer, nostr feed, stop generation, and content refactor
- Design system browser with grid/detail views for tokens and components
- Nostr feed tab with note/article/zap filtering and relay status
- Stop generation button to abort AI streaming mid-response
- Paste & extract content without sending to AI
- Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts
- Banner fallback composable for 3-stage image loading
- Wikipedia and Google Books as fallback image sources
- Loading skeletons with variant-specific shapes
- Mobile UX: auto-switch to content, back button, detail flow
- Project grid with breadcrumb nav and inline creation
- Filesystem Vite plugin for local project browsing
- Magazine text cleanup and song grid polish

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:08:32 +00:00
DorianandClaude Opus 4.6 e8fc54cade feat(content): add places, code mode, mobile context tab, and detail views
- Add Places/Restaurants content type with PlaceCard, PlaceDetail, PlaceGrid
- Add WebsiteDetail and MagazineSectionDetail views for Context panel
- Enhance MagazineGrid hero with background image and 3x taller header
- Add mobile 3-tab layout (Chat, Content, Context) with detail navigation
- Add /code command system: useCodeContext composable, ProjectGrid, FileTreeNode,
  CodeDetail for IDE-style code viewing across all three panels
- Fix /code bubble and prompt index clicks to re-activate code mode
- Fix updatePanelFromText overwriting code tab by skipping command messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:31:18 +00:00
DorianandClaude Opus 4.6 48dd7a9c68 feat(layout): responsive layout with side-by-side detail and mobile tabs
Desktop (>= 1024px):
- Content grid and detail pane shown side-by-side when a detail is selected
- Three-column layout: chat | grid | detail
- Grid takes 45% width, detail fills remaining space

Mobile (< 1024px):
- Bottom tab bar with "Chat" and "Content" tabs
- Only one view visible at a time
- Auto-switches to Content tab when panel opens
- Orange dot indicator on Content tab when content is available

Extracted reusable components:
- ContentGridView: all grid renderers in one component
- DetailView: all detail views in one component
- CloseButton: reusable close/dismiss button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:41:59 +00:00
DorianandClaude Opus 4.6 7f8dc72dd7 feat(magazine): improve brief extraction with heading banners and cleaner content
- Rewrite extractMagazineSections to robustly parse ## and ### headings
  with any emoji (not just a hardcoded list)
- Strip [[podcast:...]], [[film:...]] and other content tags from magazine text
- Strip "For deeper coverage" and "Sources" sections from magazine content
- Use heading titles as banner dividers instead of repeating them on every tile
- Add group field to MagazineSection for heading-based grouping
- Strip ** markdown from both titles and content
- Enlarge "In response to" headline text to text-2xl for editorial prominence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:30:19 +00:00
DorianandClaude Opus 4.6 cddfe93c5c style(content): add distinct placeholder covers per content type
Each content type now has a unique fallback cover with fitting typography:
- Books: Georgia serif italic with spine detail
- Films: Helvetica Neue with separator line
- TV Series: SF Pro Display (new dedicated generator)
- Music: system-ui with vinyl record motif
- Podcasts: system-ui with microphone icon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:21:45 +00:00
DorianandClaude Opus 4.6 e8ebe56d9e feat(content): add Images content type with masonry grid layout
Extract markdown images and raw image URLs from AI responses into a
browsable image gallery. Masonry (columns) layout in the grid,
full-size detail view with source links. Also fix ContextLoader to
accept all content types for loading state display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:15:02 +00:00
DorianandClaude Opus 4.6 1973b24eaa fix(content): book extraction now works when film tags are present
When the user explicitly asks about books, don't bail on pattern
extraction just because the AI also referenced films from the library.
Also fix book pattern regex to match **Title** — *Author* format,
strip sources section before extraction, and remove duplicate title
below BookGrid cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:03:29 +00:00
DorianandClaude Opus 4.6 ad63a7b1af feat(content): add TV Series content type with TMDB integration
Add complete TV series content surface: extraction from AI responses,
grid/detail views, TMDB TV search endpoint, and panel tab integration.
Includes film_ext-to-TV-series conversion for backward compatibility
and AI prompt instructions for [[tv_ext:...]] tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:50:03 +00:00
DorianandClaude Opus 4.6 99e380e1b2 feat(content): add Books content type and ContentPanel tab system
Wire up ContentPanel with tab navigation for all existing content types
(films, songs, podcasts, news, websites, magazine). Add complete Books
pipeline: type definition, extraction (tags + pattern matching), cover
fetching from Open Library, BookCard/BookGrid/BookDetail components,
inline chat cards, and prompt index badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:27:00 +00:00
DorianandClaude Opus 4.6 aab55ef9ca feat(chat): add collapsible prompt index and polish magazine layout
Add a toggle in the chat header to collapse messages into a compact
prompt index showing user queries with content-type badges. Clicking
a prompt surfaces the corresponding content panel. Also fixes magazine
section extraction (### headers, emoji stripping, author regex) and
improves tile layout with editorial rhythm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:16:02 +00:00
DorianandClaude Opus 4.6 2f27cb86c4 wip: magazine tile layout, film descriptions, dev script fixes
- MagazineGrid: New Yorker tile layout with wide/half/dark/banner tiles
- Extract AI film descriptions into synopsis field
- Fix section extraction to handle ### headers
- Strip emojis from magazine titles and content
- FilmDetail: conditional synopsis with "Why watch" header
- FilmCard: synopsis preview for external films
- Consolidate dev server to single script
- Fix dev.sh for macOS bash 3.2 compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:27:51 +00:00
DorianandClaude Opus 4.6 c82b4ef54f feat(app): redesign magazine grid, improve article views and proxy
- Redesign MagazineGrid with editorial New Yorker-inspired layout
- Simplify ArticleDetail and ArticleOverlay components
- Enhance claude-proxy with improved content extraction
- Add HTML utility for content processing
- Update NewsCard styling and chat message handling
- Clean up worktree references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:50:46 +00:00
DorianandClaude Opus 4.6 b71c88f03b feat(app): update PWA icons to ✦ star design and fix chat panel default
Replace chat-bubble PWA icons with the four-pointed star (✦) used in
the interface. Fix panelSide default so chat appears on the left and
content surface on the right. Add CLAUDE.md project guide and
.claude/launch.json dev server config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:49:31 +00:00
Dorian 43ca3a7837 feat(playwright): integrate Playwright for end-to-end testing and update .gitignore
- Added Playwright as a development dependency for end-to-end testing.
- Updated package.json to include test scripts for Playwright.
- Enhanced .gitignore to exclude Playwright test results and cache files.
- Improved content extraction logic in various components to handle new content types.

Made-with: Cursor
2026-03-02 22:02:19 +00:00
Dorian 5414060225 feat(chat): add support for inline magazine sections and enhance content extraction
- Updated ChatMessage component to display a button for viewing brief magazine sections when available.
- Enhanced useContentPanel to extract and manage magazine sections, improving content organization.
- Refactored extractMagazineSections function to comprehensively handle various content formats, including headings and bullet points.
- Improved formatting of content to preserve bold text and newlines as paragraphs.

Made-with: Cursor
2026-03-02 21:38:01 +00:00
Dorian 2d056f9498 feat(chat): enhance chat functionality with web search and article integration
- Updated ChatMessage and ChatWindow components to support inline web search results and articles.
- Integrated new web search and RSS plugins into the chat system for real-time information retrieval.
- Enhanced useContentPanel to manage web search results alongside existing media types.
- Added ArticleOverlay component for displaying selected articles from search results.
- Improved UI elements and styles for better user interaction with web search features.

Made-with: Cursor
2026-03-02 21:29:50 +00:00
Dorian 49ec6c09b6 feat(chat): integrate podcast support into chat components
- Updated ChatMessage and ChatWindow components to handle podcast content alongside films and songs.
- Enhanced useContentPanel to extract and manage podcasts, allowing for richer media interactions.
- Added PodcastCard and PodcastGrid components for displaying podcast information.
- Improved UI elements to accommodate podcast selection and detail viewing.
- Updated styles for empty state icons and added new CSS for podcast-related elements.

Made-with: Cursor
2026-03-02 19:57:44 +00:00
Dorian 7a0e42bf63 feat(app): integrate Jamendo API and enhance UI components
- Added Jamendo API client ID to the environment configuration for music search.
- Updated pnpm lock file to include new dependencies for enhanced functionality.
- Integrated Plyr library for improved media playback experience.
- Refactored ChatHeader, ChatInput, and ChatMessage components to utilize new styles and improve user interaction.
- Enhanced CSS styles for path-glass elements to align with the new design system.

Made-with: Cursor
2026-03-02 19:37:00 +00:00
Dorian 80aeefa4bf feat(chat): enhance chat functionality with song integration and UI improvements
- Updated ChatHeader and ChatMessage components to support song selection and display.
- Enhanced useContentPanel to handle both film and song content, allowing for richer interactions.
- Improved FilmCard and added SongCard components for better media representation.
- Refactored environment variables for better configuration management.
- Updated .gitignore to include development chat history.

Made-with: Cursor
2026-03-02 18:16:04 +00:00
Dorian 6f79f0860d feat(chat): enhance ChatMessage component with context-aware film interaction
- Updated ChatMessage component to support click interactions for film context.
- Added computed property to determine if a message has associated films.
- Refactored film ID extraction to normalize film IDs for consistency.

Made-with: Cursor
2026-03-02 16:49:47 +00:00
Dorian 464069b2f2 feat(chat): enhance film integration and UI improvements
- Updated ChatMessage component to display inline film cards and added functionality for selecting films.
- Improved ChatWindow to handle film-related content and update the panel with selected films.
- Refactored useAI to streamline film recommendation prompts and context.
- Enhanced ChatPage layout for better film library access and user experience.
- Updated service worker revision for PWA improvements.

Made-with: Cursor
2026-03-02 16:48:17 +00:00
Dorian ece4b8256f feat(app): enhance theme support and improve PWA integration
- Updated the app to support light and dark themes with appropriate CSS classes.
- Enhanced PWA configuration with manifest details and caching strategies.
- Improved the chat UI with dynamic theme adjustments for various components.
- Added new meta tags for better mobile web app experience.
- Refactored environment variables to include new Anthropic token.
- Updated package dependencies for better compatibility and performance.

Made-with: Cursor
2026-03-02 16:34:44 +00:00
Dorian a5a32e3566 feat(ai): add Anthropic Claude adapter as primary AI provider
Wire up Claude Messages API with SSE streaming, supporting the
different event format (content_block_delta) vs OpenRouter's
OpenAI-compatible format. Claude is the default when its API key
is present.

- Dual provider system: Anthropic (direct) and OpenRouter
- Claude streams via content_block_delta events, OpenRouter via
  choices[0].delta.content
- Model picker dropdown in chat header (click model name to switch)
- Available models: Claude Sonnet 4, Opus 4, Haiku 3.5,
  Llama 4 Maverick/Scout (free), Mistral Small 3.1 (free)
- Sidebar status shows active provider name
- anthropic-dangerous-direct-browser-access header for browser use

Made-with: Cursor
2026-03-02 14:30:19 +00:00
Dorian 88e211c87d refactor(ui): port exact Archy glassmorphism system
Replace all glass morphism CSS with exact Archy definitions from
neode-ui/src/style.css. Every container and button class now matches
Archy pixel-for-pixel:

- glass: rgba(0,0,0,0.35), blur(18px), border rgba(255,255,255,0.18)
- glass-strong: same bg, blur(24px)
- glass-card: rgba(0,0,0,0.65), blur(18px), border-radius 1rem
- glass-button: 48px height, rgba(0,0,0,0.6), blur(18px)
- glass-button-sm: compact variant
- gradient-button: primary CTA with gradient intensification on hover
- gradient-card, gradient-card-dark, gradient-border-container
- toast-glass, nav-tab-active with CSS mask gradient border
- Archy inset highlight: inset 0 1px 0 rgba(255,255,255,0.22)
- Archy focus glow: blue box-shadow, no outline
- Archy scrollbar: gradient thumb, hidden variant
- Archy animations: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1)

Updated design rules (02-tailwind-styling.mdc, 03-design-system.mdc)
to reference Archy as canonical source. Added archy-glass-system.md
to Coding Rules Project.

Made-with: Cursor
2026-03-02 14:26:22 +00:00
Dorian 27864cf92e feat(app): glassmorphism chat UI with widget embed system
- Glassmorphism chat window matching Proux design rules (glass layers,
  gradient backgrounds, glow effects, blur intensities, inner glows)
- Conversation ID displayed in header, side-switching (left/right layout)
- Chat store with Pinia: conversations, messages, streaming state
- OpenRouter AI integration with SSE streaming (Llama 4 Maverick free model)
- Chat components: ChatWindow, ChatHeader, ChatInput, ChatMessage,
  StreamingDots (typing indicator)
- Embeddable widget system: floating action button (FAB) + modal popup
  with scale-in animation, side-aware positioning
- Widget demo page (/widget-demo) showing AIUI embedded in a mock "Acme App"
  with documentation of 4 integration methods: script tag, web component,
  npm package, and iframe
- Theme composable with dark/light mode, system preference detection
- Custom CSS: glass variants (subtle/medium/strong/light/dark), glow effects,
  gradient text, animation keyframes, thin scrollbar
- Responsive: mobile-first, 44px touch targets, dvh viewport

Made-with: Cursor
2026-03-02 14:20:34 +00:00
Dorian c28e6dd811 feat: initialize AIUI monorepo with project rules and core types
Foundation for the next-generation AI content surface UI:
- 16 Cursor rules files covering philosophy, Vue conventions, design system,
  content surfaces, plugin system, AI integration, renderers, security,
  Bitcoin-only policy, dev/prod modes, accessibility, performance, animation,
  mobile UX, and git workflow
- pnpm workspaces + Turborepo monorepo (@aiui/core, @aiui/app)
- Vue 3 + Vite + TypeScript + Tailwind CSS 4
- Core type system: plugins, renderers, messages, content blocks
- Plugin registry with renderer registration
- 50 mock film fixtures with search/filter utilities
- App shell with chat page layout
- Environment config templates

Made-with: Cursor
2026-03-02 14:15:39 +00:00
1018 changed files with 137585 additions and 23288 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Build Archipelago release ISO (gated)
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
# the iso-builder runner is a live node — builds are deliberate events.
on:
workflow_dispatch:
jobs:
build-iso:
runs-on: iso-builder
timeout-minutes: 180
steps:
- name: Sync source to workspace
run: |
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
echo "=== Source at commit: $(git log --oneline -1) ==="
- name: Install ISO build dependencies
run: |
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
echo "ISO build deps already installed, skipping apt"
else
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common
fi
- name: Build backend + frontend if stale
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
cargo build --release --manifest-path core/Cargo.toml -p archipelago
fi
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
(cd neode-ui && npm ci && npm run build)
fi
- name: Gated ISO build (gates + build + smoke + qemu)
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
bash scripts/build-iso-release.sh
- name: Report artifacts
if: always()
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
+1 -1
View File
@@ -5,7 +5,7 @@ name: Demo images
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
+33 -22
View File
@@ -4,13 +4,11 @@ on:
workflow_dispatch:
inputs:
target:
description: 'Target node IP (e.g. 192.168.1.198)'
description: 'Target node IP or hostname'
required: true
default: '192.168.1.198'
password:
description: 'Node password (or "auto" for fresh install)'
description: 'Node UI password (leave blank to use the NODE_UI_PASSWORD secret)'
required: false
default: 'auto'
jobs:
post-install-tests:
@@ -22,33 +20,46 @@ jobs:
with:
fetch-depth: 1
- name: Run post-install tests on target
- name: Install SSH key
env:
SSH_KEY: ${{ secrets.NODE_SSH_KEY }}
run: |
TARGET="${{ github.event.inputs.target }}"
PASSWORD="${{ github.event.inputs.password }}"
if [ "$PASSWORD" = "auto" ]; then
PASSWORD="testpass123!"
if [ -z "$SSH_KEY" ]; then
echo "ERROR: repository secret NODE_SSH_KEY is not configured."
echo "Post-install tests authenticate by key; password auth is not supported."
exit 1
fi
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Run post-install tests on target
env:
TARGET: ${{ github.event.inputs.target }}
NODE_PASSWORD: ${{ github.event.inputs.password }}
NODE_UI_PASSWORD: ${{ secrets.NODE_UI_PASSWORD }}
SSH_USER: ${{ vars.NODE_SSH_USER }}
run: |
PASSWORD="${NODE_PASSWORD:-$NODE_UI_PASSWORD}"
if [ -z "$PASSWORD" ]; then
echo "ERROR: no node password supplied (input or NODE_UI_PASSWORD secret)."
exit 1
fi
USER_NAME="${SSH_USER:-archipelago}"
echo "══════════════════════════════════════════"
echo "Running post-install tests on $TARGET"
echo "══════════════════════════════════════════"
# Copy test script to target and run
sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \
scp -o StrictHostKeyChecking=accept-new \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \
scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh
"${USER_NAME}@${TARGET}:/tmp/run-post-install-tests.sh"
# Run tests (with sudo for service checks)
sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \
ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'"
# Password is passed over stdin, never as an argv the node's process
# list (or this job's log) would expose.
printf '%s' "$PASSWORD" | ssh -o StrictHostKeyChecking=accept-new \
"${USER_NAME}@${TARGET}" \
"sudo bash /tmp/run-post-install-tests.sh --password-stdin"
frontend-tests:
runs-on: ubuntu-latest
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Keep the served companion APK in sync with main on every push.
#
# When a push to main includes Android changes, rebuild the APK, refresh
# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask
# you to push again (so the refreshed APK rides along in the same push).
#
# Enable once per clone: git config core.hooksPath .githooks
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
# ship-companion.sh already (re)published the APK for this push — don't redo it.
[ -n "${SHIP_COMPANION:-}" ] && exit 0
PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW=""
while read -r _local_ref local_sha remote_ref remote_sha; do
if [ "${remote_ref##*/}" = "main" ]; then
PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha"
fi
done
[ "$PUSH_MAIN" = "1" ] || exit 0
# Loop-break: if the tip is already the auto APK commit, let the push proceed.
case "$(git log -1 --pretty=%s)" in
*"companion APK"*) exit 0 ;;
esac
# Only rebuild when this push actually touches the Android app.
ZEROS="0000000000000000000000000000000000000000"
if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then
ANDROID_CHANGED=1
elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then
ANDROID_CHANGED=0
else
ANDROID_CHANGED=1
fi
[ "$ANDROID_CHANGED" = "1" ] || exit 0
bash scripts/publish-companion-apk.sh || exit 0
DEST="neode-ui/public/packages/archipelago-companion.apk"
if git diff --cached --quiet -- "$DEST"; then
exit 0 # APK unchanged — nothing to do
fi
git commit -q -m "chore(android): update companion APK download [skip ci]"
echo "" >&2
echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2
exit 1
+9 -9
View File
@@ -1,16 +1,16 @@
## Summary
<!-- Brief description of what this PR does -->
<!-- What changed and why? -->
## Changes
## Verification
-
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
## Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (if backend changes)
- [ ] No new compiler warnings
- [ ] Tested on live server
- [ ] Rust formatting/clippy/tests pass when backend code changed.
- [ ] Frontend type-check/build/tests pass when frontend code changed.
- [ ] App manifests validate when app packaging changed.
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
+65 -7
View File
@@ -8,11 +8,11 @@ on:
env:
RUST_VERSION: stable
NODE_VERSION: 18
NODE_VERSION: 20
jobs:
rust:
name: Rust (fmt + clippy + test)
name: Rust
runs-on: ubuntu-latest
defaults:
run:
@@ -28,17 +28,35 @@ jobs:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Check formatting
- name: Format
run: cargo fmt --all -- --check
# KEY-05 layer (b) is enforced HERE, with no step of its own: core/clippy.toml
# bans the defaulted RNG entry points, and `-D warnings` already turns a
# `disallowed_methods` hit into a build failure. `--all-targets` covers tests
# too, deliberately. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Tests
# KEY-05 layer (c) — see core/deny.toml for the policy and its rationale.
#
# The version is pinned deliberately. EmbarkStudios/cargo-deny-action exposes
# no input to pin the cargo-deny version, and an unpinned supply-chain checker
# is a contradiction in terms, so the tool is installed from crates.io — the
# source actually vetted at the 10-06 Task 5 legitimacy checkpoint — rather
# than by adding another unvetted action to this workflow.
#
# `check bans` ONLY: the advisories gate is not enabled (bans-only policy).
- name: Supply chain (cargo-deny)
run: |
cargo install --locked cargo-deny --version 0.20.2
cargo deny check bans
- name: Test
run: cargo test --all-features
frontend:
name: Frontend (type-check + lint)
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
@@ -52,14 +70,54 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache: npm
cache-dependency-path: neode-ui/package-lock.json
- name: Install dependencies
- name: Install
run: npm ci
- name: Type check
run: npm run type-check
- name: Test
run: npm test
- name: Build
run: npm run build
manifests:
name: App Manifests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install YAML parser
run: python3 -m pip install --quiet pyyaml
- name: Validate manifests
run: |
for manifest in apps/*/manifest.yml; do
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
done
# The signed catalog overrides on-disk manifests on every node, so a
# catalog naming a registry host the deployed fleet does not trust breaks
# every install fleet-wide. Blocking, and cheap.
- name: Catalog registry trust floor
run: python3 scripts/check-catalog-registry-trust.py
# A stale image literal on the fallback install path deploys an old
# image after the manifest has moved on — how a withdrawn, vulnerable
# release gets installed post-fix. Blocking.
- name: Installer image pins
run: python3 scripts/check-installer-image-pins.py
# Advisory: shows where the release catalog has fallen behind the
# manifests in this repo. Not blocking, because the catalog can only be
# updated through the signing ceremony, so drift is expected between a
# manifest landing and the next signed release.
- name: Catalog drift (advisory)
continue-on-error: true
run: python3 scripts/check-app-catalog-drift.py --catalog releases/app-catalog.json --release
+1 -1
View File
@@ -5,7 +5,7 @@ name: Demo images
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
+87 -21
View File
@@ -1,10 +1,9 @@
# SSH keys (sandbox copies)
# SSH keys and sandbox copies
.ssh/
# Rust build output
target/
**/target/
Cargo.lock
# Node.js
node_modules/
@@ -12,7 +11,6 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
pnpm-debug.log*
# Build outputs
@@ -21,6 +19,9 @@ dist-ssr/
build/
*.local
# Vite build cache
neode-ui/.vite/
# IDE / editor
.idea/
.vscode/
@@ -28,49 +29,53 @@ build/
*.swo
*~
.DS_Store
._*
Thumbs.db
# Environment and local overrides
.env
.env.local
.env.*.local
.env.production
core/.env.production
scripts/deploy-config.sh
# Logs
logs/
*.log
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
*.temp
# Build artifacts
# Image / release artifacts
*.iso
*.img
*.dmg
*.app
*.apk
*.keystore
*.s9pk
*.tar.gz
# Release artifacts live in Gitea Release attachments, not Git history.
# Release artifacts live in release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
# macOS build output
build/macos/
# The signed app catalog and the registry trust floor are source, not build
# output: nodes fetch the catalog from this path on main, and the floor is what
# scripts/check-catalog-registry-trust.py checks it against. Both were being
# swallowed by the rule above — app-catalog.json only stayed tracked because it
# predates it.
!releases/app-catalog.json
!releases/registry-trust-floor.json
# Image recipe output
image-recipe/output/
image-recipe/*.iso
image-recipe/*.img
# Loop tool artifacts (created in every subdirectory)
# Loop tool artifacts
*/loop/
loop/loop/
loop/loop.log.bak
@@ -78,21 +83,82 @@ loop/loop.log.bak
# Separate repos nested in tree
web/
._*
# Resilience harness reports (generated, contains session cookies)
# Resilience harness reports contain session cookies.
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.claude/
.pnpm-store/
# Key material and local databases — belt-and-braces so a stray key or a
# copied node database can never be committed. Open-source readiness plan,
# Phase 1 item 5: `.claude/settings.local.json` was previously only caught by
# a machine-global ignore rule, which protects one machine and no contributor.
*.key
*.pem
id_rsa*
*.sqlite
*.sqlite3
*.db
# ...except the throwaway TLS fixtures the appgate tests compile in via
# include_bytes!. They are documented non-identity material (see that
# directory's README) and are already tracked; the negation stops the rule
# above from silently dropping them if they are ever regenerated.
!core/archipelago/src/appgate/testdata/*.key
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.
Screenshot *.png
uploads/
# ── Local-only material ─────────────────────────────────────────────────────
# Present on disk, never tracked: everything describing Archipelago's own
# infrastructure or internal development process. The repo is source code and
# guidelines only. Inventory: .local-only/manifest.txt — wipe: .local-only/wipe.sh
/.local-only/
/.planning/
/loop/
/docs/operations-runbook.md
/docs/hotfix-process.md
/docs/PRODUCTION-MASTER-PLAN.md
/docs/UNIFIED-TASK-TRACKER.md
/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md
/docs/HANDOFF-2026-07-20-fips-peer-files.md
/docs/HANDOFF-2026-07-23-companion-apk-deploy.md
/docs/qr-scanner-snappiness-handover.md
/docs/RETICULUM-TRANSPORT-PROGRESS.md
/docs/combined-test-plan-2026-07-22.md
/docs/pine-voice-release-test-plan.md
/docs/OPEN-SOURCE-READINESS-PLAN.md
/docs/archive/HANDOVER-2026-07-02-iso-feedback.md
/docs/archive/SESSION-1.8.0-OTA-PROGRESS.md
/docs/security/KEY-02-FLEET-ROTATION.md
/docs/security/KEY-03-SIGNING-POSTURE.md
/tests/production-quality/TRACKER.md
/scripts/deploy-config-defaults.sh
/scripts/deploy-tailscale.sh
/scripts/deploy-to-target.sh
/scripts/setup-target-dev.sh
/scripts/setup-aiui-server.sh
/scripts/setup-https-dev.sh
/scripts/debug-frontend.sh
/scripts/node-profile.sh
/scripts/fleet-fips-pair.sh
/scripts/fleet-fips-unpair.sh
/image-recipe/sync-from-live.sh
/docs/security/PHASE-10-VERIFICATION-GUIDE.md
/docs/security/KEY-01-ON-NODE-VERIFICATION.md
/docs/security/KEY-02-ROOTFS-EVIDENCE.md
/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
/image-recipe/INTEGRATION-GUIDE.md
/docs/multinode-testing-plan.md
/docs/bitcoin-version-bulletproof-rollout.md
# Generated PWA dev output (vite-plugin-pwa) — never a source artifact
neode-ui/dev-dist/
-3
View File
@@ -1,3 +0,0 @@
[submodule "indeedhub"]
path = indeedhub
url = http://146.59.87.168:3000/lfg2025/indeehub.git
+1 -1
View File
@@ -92,7 +92,7 @@ built and signed:
```bash
SERVED=neode-ui/public/packages/archipelago-companion.apk
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
GITEA_URL=https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/$SERVED
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
curl -sS -o /tmp/live-qr.apk "$QR_URL"
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 38
versionName = "0.5.18"
versionCode = 45
versionName = "0.5.25"
vectorDrawables {
useSupportLibrary = true
Binary file not shown.
@@ -5,6 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.VpnService
import android.os.Build
import android.util.Log
@@ -34,6 +38,20 @@ class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
// tunnel's underlying network stays pinned to the interface that was
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
// mesh sockets ride a dead network and sessions never recover until the
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
// the tunnel to the new default network via setUnderlyingNetworks and
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
// on the new path within seconds instead of waiting out dead-link
// timeouts.
private var connectivityManager: ConnectivityManager? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
@Volatile
private var currentUnderlying: Network? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
@@ -113,6 +131,7 @@ class ArchyVpnService : VpnService() {
shutdown()
} else {
startSessionWarmer()
registerNetworkHandoff()
// Phone-to-phone chat/beam + the phone's own mesh-served page.
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
// Mutual pairing: when a phone that scanned OUR QR announces
@@ -131,7 +150,7 @@ class ArchyVpnService : VpnService() {
* Pre-warm + keep-warm mesh sessions to every known node ULA.
*
* Discovery + first session through the public tree can take 15s+
* (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the
* (from node diagnosis) — paying that cost here, the
* moment the tunnel is up, means the connect probe and WebView hit an
* established session instead of timing out on a cold one. The periodic
* touch afterwards keeps the session from idling out. Failed connects
@@ -153,19 +172,28 @@ class ArchyVpnService : VpnService() {
} catch (_: Exception) {
emptyList()
}.distinct()
for ((ula, port) in targets) {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
20_000,
)
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
// Probe all targets CONCURRENTLY with a short timeout — the
// old sequential 20s-per-target loop let one cold node starve
// every other target for the whole aggressive window.
targets.map { (ula, port) ->
launch {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(
java.net.InetAddress.getByName(ula),
port,
),
5_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the attempt still drove
// session establishment; try again next round.
}
} catch (_: Exception) {
// Cold path / node away — the connect attempt still
// drove session establishment; try again next round.
}
}
}.forEach { it.join() }
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
@@ -174,8 +202,75 @@ class ArchyVpnService : VpnService() {
}
}
/**
* Track the phone's default network and hand the mesh over to it as the
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
* network instead of dying on the one it launched with.
* 2. re-home the mesh — kick the session warmer so discovery + sessions
* rebuild on the new path immediately; the node's own fast-reconnect
* (1s) redials peers over the new route.
* onAvailable also fires for the FIRST network, which is how the initial
* underlying network gets set.
*/
private fun registerNetworkHandoff() {
if (networkCallback != null) return
val cm = getSystemService(ConnectivityManager::class.java) ?: return
connectivityManager = cm
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handoffTo(network)
}
override fun onLost(network: Network) {
// The lost network was our underlying one — clear the pin so
// the system falls back to whatever default remains; the next
// onAvailable re-pins explicitly.
if (network == currentUnderlying) {
currentUnderlying = null
runCatching { setUnderlyingNetworks(null) }
}
}
}
networkCallback = cb
// requestNetwork tracks the BEST network of the request; when the
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
// one. (registerDefaultNetworkCallback would also work; requestNetwork
// lets us extend to BLE-capable transports later.)
runCatching { cm.requestNetwork(request, cb) }
}
private fun handoffTo(network: Network) {
val changed = network != currentUnderlying
currentUnderlying = network
// Always re-assert; cheap and covers capability changes on the same
// Network object.
runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (changed && FipsNative.isRunning()) {
Log.i(TAG, "network handoff → re-homing mesh on new default network")
// Fresh warmer pass drives immediate rediscovery/session rebuild
// on the new path instead of waiting out dead-link timeouts.
startSessionWarmer()
}
}
private fun unregisterNetworkHandoff() {
val cm = connectivityManager
val cb = networkCallback
if (cm != null && cb != null) {
runCatching { cm.unregisterNetworkCallback(cb) }
}
networkCallback = null
connectivityManager = null
currentUnderlying = null
}
private fun shutdown() {
warmerJob?.cancel()
unregisterNetworkHandoff()
FlareServer.stop()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -183,6 +278,7 @@ class ArchyVpnService : VpnService() {
}
override fun onDestroy() {
unregisterNetworkHandoff()
FipsNative.stop()
scope.cancel()
super.onDestroy()
@@ -41,7 +41,15 @@ object FipsManager {
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
peersDirty = true
_consentNeeded.value = true
// Restart the mesh with the new peer RIGHT NOW when consent already
// exists — relying on the consentNeeded collector left a running
// mesh on the OLD peer list whenever the collector wasn't active
// (fresh pairings looked dead until a full app restart).
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
@@ -331,7 +331,7 @@ class FipsPreferences(private val context: Context) {
/**
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
* that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
* that isn't a valid DNS label ("Test Node" — the space) gets rejected and
* silently drops the peer from name resolution. Slug it instead of losing it.
*/
internal fun hostSafeAlias(alias: String): String =
@@ -330,7 +330,7 @@ object FlareServer {
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
object FlareClient {
// Connect timeout must outlive cold mesh-session establishment (~15s via
// the public tree per HANDOFF-2026-07-23); the attempt itself drives
// the public tree); the attempt itself drives
// session setup, same trick as the VPN service's session warmer.
private val http = OkHttpClient.Builder()
.connectTimeout(25, TimeUnit.SECONDS)
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -108,6 +109,7 @@ fun NESController(
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
onToggleStyle: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
@@ -205,6 +207,7 @@ fun NESController(
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
}
}
}
@@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
}
}
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
* no longer carries it). */
@Composable
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
@@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@@ -75,27 +94,29 @@ fun NESMenu(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
// Contained hub overlay: a centred glass panel (not full-screen) that
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
// inside its own bounds when content is tall. Tapping the dimmed
// backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty)
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
@@ -105,16 +126,14 @@ fun NESMenu(
private fun MenuPanel(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
) {
@@ -124,14 +143,15 @@ private fun MenuPanel(
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
var https by remember { mutableStateOf(false) }
fun resetForm() {
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
nm = server.name; addr = server.address; pwd = server.password
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
showAdd = false
}
@@ -139,161 +159,381 @@ private fun MenuPanel(
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
// Preserve fields the compact form doesn't expose (scheme, port).
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
// Preserve port (compact form doesn't expose it); scheme is now editable.
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
} else {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
}
resetForm()
}
var page by remember { mutableStateOf(HubPage.HUB) }
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp)
// Cap height just short of the full screen; the panel wraps short
// content and only scrolls in the rare case it outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg)
.background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Title
Text(
"Menu",
color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add / edit server
if (showAdd || editing != null) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Header: back (on sub-pages) or title, and a close on the hub.
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (page == HubPage.HUB) {
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
IconRound(Icons.Default.Close, "Close") { onDismiss() }
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
Spacer(Modifier.width(12.dp))
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
fontWeight = FontWeight.Medium,
)
Text(
"Cancel",
color = TextMuted,
fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
IconRound(Icons.Default.Close, "Close") { onDismiss() }
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
Spacer(Modifier.height(4.dp))
when (page) {
HubPage.HUB -> {
// Card page — one card per destination. Dashboard first: it's a
// peer of the others so three-finger → hub → Dashboard returns
// to the node UI, same shape as every other option.
if (onBackToWebView != null) {
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
}
if (onScanQr != null) {
// Add server by scanning the node's pairing QR
Box(
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES
}
if (FipsNative.available) {
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
}
if (onMeshParty != null) {
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
}
// Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here.
}
HubPage.NODES -> {
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
if (showAdd || editing != null) {
Column(
Modifier
.size(ROW_H)
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
)
Text(
"Cancel", color = TextMuted, fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
// HTTPS scheme toggle (available on both add and edit).
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.clickable { https = !https }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
Box(
Modifier
.width(46.dp).height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = 3.dp)
.size(20.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (https) Color.White else TextMuted),
)
}
}
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
HubPage.FIPS -> {
FipsSection(embedded = true)
}
}
}
}
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
private enum class HubPage { HUB, NODES, FIPS }
// Mode toggle
MenuItem(
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
)
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
)
// Phone↔phone mesh pairing + chat
if (onMeshParty != null) {
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
private fun HubCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f)) {
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
}
}
}
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
/** Small circular icon button used in the hub header. */
@Composable
private fun IconRound(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
.clickable { onClick() },
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
}
}
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
private data class FipsInfo(
val available: Boolean,
val running: Boolean,
val npub: String,
val meshAddress: String,
val peerCount: Int,
)
/**
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing
* running state, its mesh identity (npub), its mesh address (fdULA), how
* many peers/anchors it's configured with and a one-tap Reconnect that
* re-homes the mesh (also the manual fix if a network handoff ever misses).
* Collapsed by default so the menu stays compact.
*/
@Composable
private fun FipsSection(embedded: Boolean = false) {
if (!FipsNative.available) return
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var expanded by remember { mutableStateOf(embedded) }
var info by remember { mutableStateOf<FipsInfo?>(null) }
// Load identity/state when the section opens (cheap DataStore + JSON read).
LaunchedEffect(expanded) {
if (expanded && info == null) {
val prefs = FipsPreferences(context)
val id = prefs.identity()
val peers = runCatching {
org.json.JSONArray(prefs.peersJson()).length()
}.getOrDefault(0)
info = FipsInfo(
available = true,
running = FipsNative.isRunning(),
npub = id?.npub.orEmpty(),
meshAddress = id?.address.orEmpty(),
peerCount = peers,
)
}
}
Column(Modifier.fillMaxWidth()) {
// Embedded in the hub's FIPS sub-page the header row would duplicate
// the page title, so only the standalone (collapsible) form shows it.
if (!embedded) MenuItem(
label = "FIPS Mesh",
labelColor = BitcoinOrange,
onClick = { expanded = !expanded },
)
if (expanded) {
val i = info
Column(
Modifier
.fillMaxWidth()
.padding(top = 6.dp)
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (i == null) {
Text("Loading…", color = TextMuted, fontSize = 13.sp)
} else {
FipsRow("Status", if (i.running) "Connected" else "Stopped",
valueColor = if (i.running) BitcoinOrange else TextMuted)
if (i.meshAddress.isNotBlank()) {
FipsRow("Mesh address", i.meshAddress, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
}
if (i.npub.isNotBlank()) {
FipsRow("Identity (npub)", i.npub, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
}
FipsRow("Peers & anchors", i.peerCount.toString())
Text(
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
color = TextMuted, fontSize = 11.sp,
)
MenuItem(
label = "Reconnect mesh",
labelColor = BitcoinOrange,
onClick = {
FipsManager.requestMeshRestart(context)
info = null
if (!embedded) expanded = false
},
)
}
}
}
}
}
@Composable
private fun FipsRow(
label: String,
value: String,
valueColor: Color = TextPrimary,
mono: Boolean = false,
onCopy: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
Text(
value,
color = valueColor,
fontSize = if (mono) 11.sp else 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
if (onCopy != null) {
Text("", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}
@@ -43,6 +43,7 @@ fun NESPortraitController(
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
onToggleStyle: (() -> Unit)? = null,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
@@ -151,6 +152,10 @@ fun NESPortraitController(
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
onToggleStyle?.let {
Spacer(Modifier.width(10.dp))
StyleBtn(c, Modifier, it)
}
}
}
}
@@ -238,6 +238,7 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
providerFuture.addListener({
val p = providerFuture.get()
@@ -245,12 +246,15 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// CameraX's analysis default is 640x480 — too few pixels per module
// to decode a modal-sized QR at arm's length. 1280x720 more than
// doubles the pixel density at negligible analysis cost.
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
// lens, which won't focus close) left dense invoices undecodable
// while sparse address QRs still read — the "scanner doesn't pick up
// invoices" report. 1920x1080 roughly doubles module resolution so a
// QR held at the camera's actual focus distance still resolves.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.setTargetResolution(android.util.Size(1920, 1080))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
@@ -261,13 +265,27 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
}
try {
p.unbindAll()
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
// Force a centre autofocus on a repeating tick. A hand-held QR is
// a static scene, so continuous-AF often never retriggers and the
// lens sits at its resting (far) focus — fatal for dense codes.
// A normalized centre point works before the view is measured.
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
.createPoint(0.5f, 0.5f)
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
point,
androidx.camera.core.FocusMeteringAction.FLAG_AF,
).disableAutoCancel().build()
focusScheduler.scheduleWithFixedDelay({
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
focusScheduler.shutdownNow()
provider?.unbindAll()
analysisExecutor.shutdown()
}
@@ -12,9 +12,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
@@ -177,11 +179,25 @@ fun AppNavHost(
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
onRemoteKeyboard = {
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
)
}
}
composable(Routes.REMOTE_INPUT) {
composable(
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
arguments = listOf(
navArgument("keyboard") {
type = NavType.BoolType
defaultValue = false
},
),
) { entry ->
RemoteInputScreen(
onBack = {
navController.popBackStack()
@@ -189,6 +205,7 @@ fun AppNavHost(
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
)
}
@@ -4,8 +4,10 @@ import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -54,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
fun RemoteInputScreen(
onBack: () -> Unit,
onMeshParty: (() -> Unit)? = null,
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
startInKeyboard: Boolean = false,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
@@ -63,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) }
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
@@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
fun toggleStyle() {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
@@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
@@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
@@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
modifier = Modifier.fillMaxWidth(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
// Settings + style icons top-right in keyboard mode
Row(
Modifier.align(Alignment.TopEnd).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = { showModal = true },
)
com.archipelago.app.ui.components.StyleBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = ::toggleStyle,
)
}
}
}
}
@@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
visible = showModal,
servers = savedServers,
activeServer = activeServer,
isGamepadMode = isGamepadMode,
controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
@@ -245,10 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
},
onRemote = { isGamepadMode = true; showModal = false },
onKeyboard = { isGamepadMode = false; showModal = false },
onBackToWebView = { showModal = false; onBack() },
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
)
@@ -187,7 +187,7 @@ fun ServerConnectScreen(
port = "",
)
// Mesh discovery + first session can take 15s+ through the
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
// public tree (per node diagnosis), and on a
// first-ever pairing the VPN consent dialog is on screen at
// the same time — so probe patiently inside a 60s budget with
// per-attempt timeouts wide enough to ride out TCP
@@ -88,8 +88,11 @@ import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
@@ -179,6 +182,78 @@ private fun injectSafeAreaVars(view: WebView) {
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
// Vue components sample the var into reactive state; tell them it
// changed (an authenticated session can mount before we run).
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
})();
""".trimIndent(),
null,
)
}
/** In-app browser pages (node apps + same-node links) don't consume the
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
* their content ran up under the status bar. Pad the document body down by
* the status-bar height: the padded strip shows the page's OWN background
* (padding is inside the element), so the bar keeps the page colour while
* content starts below it the pre-edge-to-edge look, without the black bar.
*
* Body padding only moves normal-flow content. fixed/sticky elements anchored
* at the viewport top (IndeeHub's floating header) stayed glued under the
* status bar, so we also push each of those down by the inset once, marked
* via data attribute and keep a throttled MutationObserver running so
* headers an SPA mounts after load get the same treatment.
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
if (sat <= 0) return
view.evaluateJavascript(
"""
(function() {
var SAT = $sat;
var s = document.getElementById('archy-top-inset');
if (!s) {
s = document.createElement('style');
s.id = 'archy-top-inset';
(document.head || document.documentElement).appendChild(s);
}
s.textContent =
'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
function push(el) {
if (el.dataset.archyInset) return;
var cs = getComputedStyle(el);
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
if (isNaN(top) || top >= SAT) return;
el.style.setProperty('top', (top + SAT) + 'px', 'important');
el.dataset.archyInset = '1';
}
function sweep() {
if (!document.body) return;
// Fixed/sticky bars live shallow in the tree (portals mount on
// body); depth cap keeps the computed-style pass off big lists.
var els = document.body.querySelectorAll(
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
for (var i = 0; i < els.length; i++) push(els[i]);
}
sweep();
if (!window.__archyInsetObserver) {
var queued = false, last = 0;
window.__archyInsetObserver = new MutationObserver(function() {
if (queued) return;
queued = true;
var wait = Math.max(0, 250 - (Date.now() - last));
setTimeout(function() {
queued = false;
last = Date.now();
sweep();
}, wait);
});
window.__archyInsetObserver.observe(document.documentElement,
{ childList: true, subtree: true });
}
})();
""".trimIndent(),
null,
@@ -198,13 +273,17 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
}
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
* (patient a cold session may still be establishing), else LAN anyway so
* the existing error/fallback path handles it. */
* (patient a cold session may still be establishing). If NEITHER answers,
* fall back to the mesh URL when we have one off-LAN the LAN IP is
* unreachable, and loading it just produced a confusing "can't reach
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
* instead means the load retries against the path that's actually coming up,
* and any error shows the mesh address rather than a dead LAN IP. */
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
withContext(Dispatchers.IO) {
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
lanUrl
meshUrl ?: lanUrl
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
@@ -245,6 +324,10 @@ fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
onRemoteKeyboard: () -> Unit = {},
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
onMeshParty: (() -> Unit)? = null,
// Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
@@ -323,6 +406,14 @@ fun WebViewScreen(
// One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) }
// Hub menu overlay state — the three-finger hold opens the menu right here
// over the dashboard (it used to jump to the remote screen).
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var showHubMenu by remember { mutableStateOf(false) }
var showPairScanner by remember { mutableStateOf(false) }
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
var gestureHintDismissed by remember { mutableStateOf(false) }
// Don't teach the gesture on top of the login/splash — arm the overlay
@@ -708,7 +799,8 @@ fun WebViewScreen(
}
}
// Three-finger hold (500ms) → navigate to remote input.
// Three-finger hold (500ms) → open the hub menu overlay
// in place (Remote/Keyboard cards do the navigating).
// Three fingers, not two: two-finger scroll/pinch on the
// page collided with the old two-finger hold.
var threeFingerStart = 0L
@@ -726,7 +818,7 @@ fun WebViewScreen(
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
onRemoteInput()
showHubMenu = true
}
}
}
@@ -851,6 +943,68 @@ fun WebViewScreen(
)
}
}
// Hub menu overlay — opened by the three-finger hold, drawn above
// everything (also reachable from the error screen, where switching
// servers is exactly what's needed).
NESMenu(
visible = showHubMenu,
servers = savedServers,
activeServer = activeServer,
onDismiss = { showHubMenu = false },
onSelectServer = { server ->
showHubMenu = false
scope.launch { prefs.setActiveServer(server) }
},
onAddServer = { server ->
scope.launch {
prefs.addSavedServer(server)
if (activeServer == null) prefs.setActiveServer(server)
}
},
onScanQr = { showPairScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
// Editing the live server reloads the kiosk with the new
// address/credentials via the activeServer recomposition.
if (original.serialize() == activeServer?.serialize()) {
prefs.setActiveServer(updated)
}
}
},
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Nothing left to show — back to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
prefs.clearActiveServer()
showHubMenu = false
onDisconnect()
}
}
},
onRemote = { showHubMenu = false; onRemoteInput() },
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
onBackToWebView = { showHubMenu = false },
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
)
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
// open behind it so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showPairScanner,
onDismiss = { showPairScanner = false },
onServerScanned = { scan ->
showPairScanner = false
scope.launch {
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
}
}
@@ -897,7 +1051,17 @@ private fun InAppBrowser(
fun isSameNode(u: String): Boolean =
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
// Loader title: never show a raw IP host — a mesh ULA like
// [fd79:1aa:…] is technically the host but reads as garbage on the
// loading screen. Show a neutral name until the page reports its
// real <title> (onReceivedTitle upgrades it).
var title by remember {
mutableStateOf(
android.net.Uri.parse(url).host
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
?: "Archipelago",
)
}
var favicon by remember { mutableStateOf<Bitmap?>(null) }
var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
@@ -935,12 +1099,21 @@ private fun InAppBrowser(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
// Bottom inset handled by the touch-shield strip below the bar —
// NOT by padding: a padded area only paints, it doesn't consume,
// so taps in the gesture strip fell straight THROUGH this overlay
// into the kiosk's tab bar behind it (accidental AIUI-tab hits).
// Whole-overlay touch shield: every touch not handled by a child
// (control-bar gaps, inset strips) dies here instead of falling
// through to the kiosk's tab bar behind (a near-miss on Close
// was opening the AIUI tab underneath).
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
// Bottom inset handled by the touch-shield strip below the bar.
// No TOP inset padding: the WebView draws edge-to-edge behind the
// status bar so the app's own background fills it — the padded
// version painted an opaque black bar there (user-rejected look).
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
),
) {
// WebView + loading overlay fill the area above the bottom control bar.
@@ -992,12 +1165,14 @@ private fun InAppBrowser(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
view?.let { injectTopInset(it) }
}
override fun onPageFinished(view: WebView?, u: String?) {
loading = false
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
view?.let { injectTopInset(it) }
}
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
Binary file not shown.
+2 -2
View File
@@ -258,8 +258,8 @@ mod tests {
"npub": "npub1abc",
"alias": "My Archipelago",
"addresses": [
{"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10},
{"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20}
{"transport": "udp", "addr": "192.0.2.10:2121", "priority": 10},
{"transport": "tcp", "addr": "192.0.2.10:8443", "priority": 20}
]
}]"#,
)
+159 -17
View File
@@ -1,5 +1,147 @@
# Changelog
## Unreleased
- **You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again.
- **Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap.
- **Lightning stops getting stuck locked on a busy node.** Lightning opens its databases before it will accept the password that unlocks the wallet, and on a loaded node that took nearly three minutes — longer than the node was willing to wait. Giving up restarted Lightning, which started the slow open again, so the wallet stayed locked forever and everything depending on it stayed broken. The node now waits as long as it takes. A genuinely wrong password still fails immediately.
## v1.7.126-alpha (2026-08-07)
- **The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole.** BTCPay Server published 2.4.2 to close a flaw that was being actively exploited — a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an "Update" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version — including the fallback installer, which would have installed it outright — has been corrected.
- **An app now reports its own version, not a helper's.** Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as "15.17", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour.
- **Your node issues its own certificate, so apps stop being flagged as insecure.** Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working.
- **An app that is still starting says "starting".** It previously reported "App not reachable", which reads as a failure when the app is simply warming up.
- **Updates and app downloads now come from a proper domain name.** They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off — the signature, not the address, is what makes either source safe.
- Also in this release: the tool app developers run to check their app description no longer rejects every valid file (it needed a program most machines do not have, and reported the missing program as a broken file); and the node's own security audit, which had been reporting all-clear, now actually inspects the files where credentials had been sitting.
- Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source — machine names, addresses and internal working notes — ahead of the code being opened to the public. No behaviour changes for your node.
- Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.125-alpha (2026-08-06)
- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.
- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.
- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.
- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0.
- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins.
- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.
- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.
- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.124-alpha (2026-08-05)
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.
- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.
- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.
- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.
- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.
- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.
- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.123-alpha (2026-08-05)
- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.
- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.
- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.
- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.
- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.
- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.
- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.
## v1.7.122-alpha (2026-08-04)
- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.
- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.
- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.
- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.
- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.
- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.
- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.
- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.121-alpha (2026-08-04)
- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.
- **Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.
- The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.
- The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.
- Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.
- Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.
- Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.
- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision).
## v1.7.120-alpha (2026-08-02)
- **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.
- The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.
- **Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.
- The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.
- Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.
- The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.
- The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.
- Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.
- Onboarding and viewing fixes: the "I have written down my recovery words" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.
- Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time.
- Federation and mesh: a rotated gateway credential now reaches the already-running container instead of leaving the old one in place, sync failures are surfaced to you instead of being swallowed, and nodes can share their Lightning connection details with a chosen peer over the mesh — the groundwork for opening channels with nodes you already talk to.
- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. Two nodes on the fleet still share SSH host keys with each other (detection shipped, rotation is a deliberate operator decision and has not been performed). Bitcoin Core can now reach Tor from its container, but is not yet routed through it — the network mode is becoming a setting you choose, and until then Core's peers remain on the clear internet.
## v1.7.119-alpha (2026-07-31)
- Wallet payments now work on nodes whose channels are private/unannounced. Every invoice-creation call site — the wallet's own Receive flow, and the seller-side paid-content/peer-files flow — only ever sent LND the amount and memo, so LND defaulted private to false and returned invoices with no route hints. Any node whose only usable channel is private or unannounced (the common shape for a channel someone opened to you) was silently unpayable through the wallet, and unpayable through paid file/content sales too. Both call sites now set LND's private flag correctly; this was broken in the field and is the main reason for this release.
- Tor and the mesh's Tor fallback are reliable again. The node's background "doctor" health-checker was fighting Tor over the permission bits on its own hidden-service directory: it compared the directory's mode against the literal string "700", but Tor's own setgid hidden-service mode is 2700 — a value the doctor's check never recognized as correct. Every ~5 minutes it "corrected" the mode back to 700 and restarted Tor to apply it, and Tor immediately reasserted 2700 — a permanent restart loop that meant Tor could never hold onto its consensus/HSDir cache long enough to be useful, breaking the mesh's Tor fallback path entirely. The check now compares only the owner/group/other bits that actually matter (both 700 and 2700 pass; genuinely wrong modes like 750 or 2755 are still corrected and restart Tor), plus a 30-minute restart backoff so no future condition can reproduce the storm.
- Wallet balances and your node's own FIPS identity key (npub) are no longer written to the browser's sessionStorage — caught by an audit of the page-caching work below. Every cache call site in the app now makes an explicit, reviewed decision about whether its data is allowed to persist across a reload, and a one-time migration purges any legacy, unaudited snapshot left behind by an older build.
- Server, Home, Mesh, Chat/AI chat, and the secondary screens (app details, marketplace, cloud, federation, monitoring, router/OpenWrt) now load instantly from cache when you revisit them and refresh quietly in the background, instead of blanking and re-fetching everything on every tab switch — this closes out the page-performance work started back in v1.7.116/117.
- App updates (including this one) now apply automatically in the background instead of waiting on a tap-to-update prompt, matching how kiosk/TV installs already behaved — the reload still waits for any in-progress splash/dashboard animation to finish first, so it won't land mid-motion. This was a direct, explicit decision made with the mid-payment-reload risk spelled out in advance; reverting to a confirmation prompt for beta is a one-line change if wanted later.
- Known gap, disclosed rather than buried: the project's 5x production lifecycle gate (install/UI/stop/start/restart/reinstall/reboot-survive/archipelago-restart-survive/uninstall, run on a real node — CLAUDE.md's own definition of done before a release tag) was NOT run for this release, because its target node was unreachable and running it here would have required rebooting a shared, live build machine out from under other active work. This release's own automated gates (release-gate harness, strict catalog-drift check, the full cargo test suite, a mount-level ISO smoke test, and a headless QEMU boot test) all still ran and passed — this is specifically about the separate 5x real-node lifecycle gate, which is still outstanding and should be run as soon as the node is reachable again.
## v1.7.118-alpha (2026-07-29)
- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.
- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.
- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.
- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)
## v1.7.117-alpha (2026-07-29)
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.
- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.
- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.
- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.
- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.
- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.
- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.
- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.
- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
## v1.7.116-alpha (2026-07-27)
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
## v1.7.115-alpha (2026-07-26)
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
## v1.7.114-alpha (2026-07-26)
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
## v1.7.113-alpha (2026-07-25)
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
@@ -324,14 +466,14 @@
- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host.
- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs.
- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on the staging node for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
## v1.7.81-alpha (2026-05-21)
- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation.
- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts.
- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on the staging node for `9011` storefront, static assets, and proxied GraphQL.
## v1.7.80-alpha (2026-05-21)
@@ -362,7 +504,7 @@
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on the staging node confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
## v1.7.76-alpha (2026-05-20)
@@ -371,7 +513,7 @@
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on a fleet node confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
## v1.7.75-alpha (2026-05-19)
@@ -393,7 +535,7 @@
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on a fleet node updated the existing stack from LAN origins to its tailnet address and restored `netbird-server`.
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
## v1.7.72-alpha (2026-05-19)
@@ -404,7 +546,7 @@
## v1.7.71-alpha (2026-05-19)
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on a fleet node where Podman rejected the missing host directory.
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
@@ -422,7 +564,7 @@
## v1.7.69-alpha (2026-05-19)
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
- Live diagnostics on a fleet node confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
@@ -440,8 +582,8 @@
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
- Live recovery on a fleet node upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
- Public validation confirmed ``the BTCPay host`/`www` redirect to BTCPay login over HTTPS and `the L484 host`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
## v1.7.67-alpha (2026-05-18)
@@ -450,18 +592,18 @@
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
- Live assessment of a fleet node identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
## v1.7.66-alpha (2026-05-18)
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
- Live recovery on a fleet node removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
## v1.7.65-alpha (2026-05-18)
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
## v1.7.64-alpha (2026-05-18)
@@ -486,7 +628,7 @@
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
- Live validation on a fleet node confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
## v1.7.60-alpha (2026-05-18)
@@ -494,7 +636,7 @@
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
- Live validation on a fleet node confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
## v1.7.59-alpha (2026-05-17)
@@ -518,7 +660,7 @@
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
- Local validation passed with `cargo check -p archipelago`; live SSH testing against a fleet node was not completed because temporary public-key authentication was rejected on the target.
## v1.7.57-alpha (2026-05-17)
@@ -599,7 +741,7 @@
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on the registry mirrors. Removes a docker.io dependency from first-boot installs.
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
## v1.7.45-alpha (2026-04-29)
@@ -645,7 +787,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Infrastructure
- CI pipeline added (.github/workflows/ci.yml) — cargo fmt, clippy, tests + frontend type-check, build
- Update system now fetches from git.tx1138.com Gitea instance (configurable via ARCHIPELAGO_UPDATE_URL)
- Update system now fetches from the release Gitea instance (configurable via ARCHIPELAGO_UPDATE_URL)
- Cleaned up stale git branches (app-store, overnight/2026-03-12, overnight/2026-03-13)
## [1.3.0] - 2026-03-19
+44 -56
View File
@@ -1,84 +1,72 @@
# Archipelago — agent guide
# Archipelago — contributor guide
## ✅ Single-node production gate is GREEN (2026-06-23)
This file orients anyone (human or AI) working in this repository: the
invariants that must hold, how to build and verify, and where the deeper
design docs live. The authoritative behaviour is always the code in `core/`.
`tests/lifecycle/run-gate.sh` is **5/5 on .228, 0 failures** — the single-node exit
criterion is met and the priority banner is demoted. Next exit-criteria: the
**multinode pass** (`docs/multinode-testing-plan.md`) and workstreams B/C/D.
**Read [`docs/ROADMAP.md`](docs/ROADMAP.md) for where the project is going** and
[`docs/README.md`](docs/README.md) for the full documentation index.
**For day-to-day work, use `docs/UNIFIED-TASK-TRACKER.md`** — the consolidated,
priority-ordered "what's left" list across the 1.8.0 OTA and master-plan docs
(fastest/simplest tasks first). It supersedes hunting through the two source docs
below for open items; those remain the narrative/history.
The north star: a world-class, **developer-ready app platform**every app
manifest-driven, rootless, secure, and 100%-uptime-capable, with third-party
developers publishing via an external/decentralized registry.
**Read `docs/PRODUCTION-MASTER-PLAN.md` first** — it is still the authoritative plan
for the north star: a world-class, **developer-ready app platform** where every app
is manifest-driven, manifests ship via the **signed registry** (not OTA disk files),
and **third-party developers publish apps via an external/decentralized registry**
all rootless, secure, robust, and 100%-uptime-capable. It no longer overrides all
ad-hoc direction now that the gate is green, but it remains the source of truth for
sequencing the remaining workstreams.
Detailed sub-plans:
- App platform / packaging phases + security model → [`docs/APP-PACKAGING-MIGRATION-PLAN.md`](docs/APP-PACKAGING-MIGRATION-PLAN.md)
- Registry-distributed manifests → [`docs/registry-manifest-design.md`](docs/registry-manifest-design.md)
- External/decentralized marketplace for devs → [`docs/marketplace-protocol.md`](docs/marketplace-protocol.md)
- App manifest schema → [`docs/app-manifest-spec.md`](docs/app-manifest-spec.md)
- Production test gate → [`tests/lifecycle/TESTING.md`](tests/lifecycle/TESTING.md)
Detailed sub-plans (all linked from the master):
- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md`
- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md`
- External/decentralized marketplace for devs → `docs/marketplace-protocol.md`
- Current per-app state → `docs/archive/app-registry-status-2026-06-21.md`
- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md`
## Commit & push every unit of work
## Commit & push every unit of work (never violate)
**The #1 process rule: work is not "done" until it is committed AND pushed.** This
exists because finished work has been lost/clobbered by sitting uncommitted in the
shared tree across agents and sessions. To prevent that:
Work is not "done" until it is committed **and** pushed. Finished work has been
lost by sitting uncommitted in a shared tree across sessions. To prevent that:
- **Commit each feature/fix the moment it works** — one focused, self-contained
commit per logical change (it compiles and its targeted tests pass). Do not let
commit per logical change (it compiles and its targeted tests pass). Don't let
unrelated changes accumulate uncommitted.
- **Push immediately after committing** so nothing lives only on one machine. `main`
is protected → push via `git push gitea-ai main` (account `ai`, see the memory
note); feature branches push to their own remote.
- **Never leave a stack of finished work uncommitted** overnight or when handing off
between agents — if you must pause mid-change, commit a clearly-labelled WIP
checkpoint rather than leaving it dirty.
- **Stage explicitly by path** (`git add <paths>`) when another agent's uncommitted
work shares the tree — never `git add -A` / `git commit -a`, which clobbers or
entangles their changes.
- **Never commit or push secrets** (mnemonics, private keys, API tokens). Signing is
done offline; artifacts (catalog/manifest) are signed, not the keys.
- Commit messages end with the `Co-Authored-By: Claude …` trailer.
- **Push immediately after committing** so nothing lives only on one machine.
- **Never leave a stack of finished work uncommitted** overnight or when handing
off — if you must pause mid-change, commit a clearly-labelled WIP checkpoint
rather than leaving the tree dirty.
- **Stage explicitly by path** (`git add <paths>`) when another contributor's
uncommitted work shares the tree — never `git add -A` / `git commit -a`, which
clobbers or entangles their changes.
- **Never commit secrets** (mnemonics, private keys, API tokens). Signing is done
offline; artifacts (catalog/manifest) are signed, not the keys.
## Invariants (never violate)
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
containers unless explicitly approved.
- **No per-app Rust installers / no OS-level reliance.** Apps are declarative;
the orchestrator owns the lifecycle. `install_immich_stack` (hardcoded
`podman run` + `sudo chown`) is the anti-pattern being deleted, not a template.
the orchestrator owns the lifecycle. A hardcoded `podman run` + `sudo chown`
installer is the anti-pattern being deleted, not a template.
- **Secrets are manifest-declared** (`generated_secrets`, materialised by
`container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged.
- **Migrations never destroy data** — preserve `/var/lib/archipelago/<app>`,
secrets, credentials, ports, and adoption container names; keep a rollback path.
- **Verify on the real node .228 before any tag.** (Fleet-wide multinode
verification is a separate plan: `docs/multinode-testing-plan.md`.)
- **Verify on a real node before any release tag.**
## Build / verify
- Rust workspace root is `core/` (no Cargo.toml at repo root). `cargo` from `core/`.
- Rust workspace root is `core/` (no Cargo.toml at repo root). Run `cargo` from `core/`.
- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's
incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
- Frontend: `neode-ui/``npm run build` outputs to `web/dist/neode-ui/`.
Grep the built bundle for new strings before shipping (build can silently no-op).
- App manifests load from disk on nodes at `/opt/archipelago/apps/*/manifest.yml`
(today); the goal is to distribute them via the signed catalog instead.
Grep the built bundle for new strings before shipping (the build can silently
no-op).
- App manifests are delivered inside the **signed catalog** (`releases/app-catalog.json`),
whose entry overrides the on-disk `/opt/archipelago/apps/*/manifest.yml`
(origin-wins; disk is the fallback). Editing a disk manifest alone does **not**
change a catalog-covered app — regenerate and re-sign the catalog.
## Production test gate (definition of done)
`tests/lifecycle/run-gate.sh` green across install / UI / stop / start / restart /
reinstall / reboot-survive / archipelago-restart-survive / uninstall — **5× on
.228** (`ARCHY_ITERATIONS=5`). **Run the gate ON the node** (it uses local podman/systemctl/bitcoin
probes), not via RPC from another host. **✅ GREEN 2026-06-23 (5/5, 0 not-ok)** — keep it
green (re-run after orchestrator/lifecycle changes); regressions are top priority again.
**Multinode testing (.198 + the rest of the fleet) is a SEPARATE plan** —
`docs/multinode-testing-plan.md` — not part of this single-node gate criterion, and is
the next exit criterion now that single-node is green.
`tests/lifecycle/run-gate.sh` must be green across install / UI / stop / start /
restart / reinstall / reboot-survive / archipelago-restart-survive / uninstall.
**Run the gate on the node** (it uses local podman/systemctl/bitcoin probes), not
via RPC from another host, and re-run it after any orchestrator/lifecycle change.
Multinode / fleet testing is a separate pass. See
[`tests/lifecycle/TESTING.md`](tests/lifecycle/TESTING.md).
+19
View File
@@ -0,0 +1,19 @@
# Code of Conduct
## Our standard
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
harassment, personal attacks, and discriminatory language are not.
## Scope
This code of conduct applies to project repositories, issue trackers, pull
requests, documentation, chat, and community spaces connected to Archipelago.
## Enforcement
Maintainers may edit, hide, or remove comments and may restrict participation
for behavior that makes collaboration unsafe or unproductive.
Report conduct concerns privately through the repository owner account or the
private contact channel listed on the project homepage.
+65 -126
View File
@@ -1,161 +1,100 @@
# Contributing to Archipelago
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
This project is preparing for public developer contribution. The highest-value
contributions are focused fixes, tests, app manifests, documentation
improvements, and clear bug reports with reproducible evidence.
## Code of Conduct
## Development setup
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
## Getting Started
1. Fork the repository on the project's Gitea instance
2. Clone your fork: `git clone <your-fork-url>/archy.git`
3. Set up the dev environment (see `docs/developer-guide.md`)
4. Create a feature branch: `git checkout -b feature/your-feature`
## Development Setup
### Frontend (Vue.js)
### Frontend
```bash
cd neode-ui
npm install
npm start # Dev server on :8100
npm run type-check # TypeScript validation
npm run build # Production build
npm test # Run tests
npm start
npm run type-check
npm test
```
### Backend (Rust)
Build on a Linux server (Debian 13), **not** macOS:
### Backend
```bash
cargo clippy --all-targets --all-features
cargo fmt --all
cd core
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
```
### Deploy to dev server
Linux is required for host integration work involving Podman, systemd,
networking, or image builds. Frontend development works locally with the mock
backend.
## App manifests
App packages live under `apps/<app-id>/manifest.yml` and use the schema
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
before submitting:
```bash
./scripts/deploy-to-target.sh --live
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
## Code Style
App submissions must:
### Frontend (TypeScript + Vue)
- pin container image versions;
- avoid hardcoded secrets;
- use `security.no_new_privileges: true`;
- use `security.readonly_root: true` unless the manifest explains why writable
root is required;
- request only necessary Linux capabilities;
- store durable data under `/var/lib/archipelago/<app-id>/`;
- define truthful health checks and launch interfaces for user-facing UIs.
- `<script setup lang="ts">` — always Composition API
- TypeScript strict mode — no `any`, use `unknown` or proper types
- Global CSS classes in `src/style.css` — never inline Tailwind in components
- Pinia for state management — focused single-purpose stores
- Use `@/api/rpc-client.ts` for RPC calls
## Code style
### Backend (Rust)
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
- Rust: use `tracing` for structured logs.
- TypeScript: avoid `any`; use explicit types or `unknown`.
- Vue: prefer `<script setup lang="ts">`.
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
- Remove dead code rather than commenting it out.
- Add tests for new behavior and regression tests for bug fixes.
- No `unwrap()` or `expect()` in production code — use `?` operator
- `thiserror` for library errors, `anyhow` for application errors
- `tracing` for structured logging — never `println!`
- Run `cargo clippy` and `cargo fmt` before commits
## Pull requests
### General
1. Open one focused PR per behavior or documentation change.
2. Explain what changed, why it changed, and how it was verified.
3. Include screenshots for UI changes.
4. Link relevant issues or docs.
5. Keep generated catalog changes in sync with manifest changes.
- Functions under 50 lines, single responsibility
- Comment WHY not WHAT
- Remove dead code — never comment it out
- No `TODO`/`FIXME` in commits
Suggested commit format:
## Commit Format
```
type: description
```text
feat: add backup scheduling
fix: reject unsafe manifest volume
docs: clarify app deployment flow
test: cover catalog drift check
```
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
## Reporting bugs
Examples:
- `feat: add backup scheduling to settings page`
- `fix: handle WiFi connection timeout gracefully`
- `test: add unit tests for RPC client retry logic`
Include:
## Pull Request Process
- exact version or commit;
- host platform and architecture;
- steps to reproduce;
- expected and actual behavior;
- logs from the relevant component;
- screenshots for UI issues.
1. Ensure your branch is up to date with `main`
2. All checks must pass: TypeScript, build, tests, clippy
3. Include a clear description of what changed and why
4. Link any related issues
5. Request review from a maintainer
## Security
### PR Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
- [ ] No new compiler warnings
- [ ] Follows code style guidelines above
## Testing Requirements
- New features need tests
- Bug fixes need a regression test
- Frontend: Vitest + Vue Test Utils
- Backend: `#[test]` and `#[tokio::test]`
- Target: maintain or improve existing coverage
## Reporting Bugs
Use the **Bug Report** issue template. Include:
1. Steps to reproduce
2. Expected behavior
3. Actual behavior
4. System info (hardware, OS version, Archipelago version)
5. Screenshots if applicable
6. Relevant logs (`journalctl -u archipelago`)
## Feature Requests
Use the **Feature Request** issue template. Include:
1. Problem description
2. Proposed solution
3. Alternatives considered
4. Impact on existing users
## App Submissions
To submit an app for the Archipelago marketplace:
1. Create a manifest following `docs/app-manifest-spec.md`
2. Ensure the container image is published to a public registry
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
4. Open a PR adding the app to the curated list
5. Include: app description, icon, resource requirements, dependencies
### App Requirements
- Container must run as non-root (UID > 1000)
- `readonly_root: true` unless explicitly justified
- Drop all capabilities except those required
- `no-new-privileges: true`
- Pin specific image versions (no `latest` tag)
- No hardcoded secrets
## Security Disclosure
**Do NOT open public issues for security vulnerabilities.**
Email security concerns to the maintainers directly. Include:
1. Description of the vulnerability
2. Steps to reproduce
3. Potential impact
4. Suggested fix (if any)
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.
By contributing, you agree that your contribution is licensed under the
project's MIT License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dorian and the Archipelago Project contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
# Archipelago — Third-Party Notices
Archipelago is licensed under the MIT License (see LICENSE).
This file lists third-party components included in this repository and its
release artifacts, with their licenses and required attributions.
## Embedded / vendored components
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
Copyright (c) 2026 Johnathan Corgan. MIT License.
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
compiled into the Android companion app (`Android/rust/archy-fips-core`).
- **QR Code Generator for JavaScript** — http://www.d-project.com/
Copyright (c) 2009 Kazuhiko Arase. MIT License.
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
(original headers preserved).
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
Binary extracted into the OS image at `/opt/archipelago/bin/`.
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
license with field-of-use restrictions: no use in systems designed to harm
human beings, and no use in AI/ML training datasets). The optional
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
Reticulum License is NOT an OSI-approved open-source license; it applies
only to that optional component, not to Archipelago itself.
## Fonts
- **Montserrat** — SIL Open Font License 1.1
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
- **Open Sans** — Apache License 2.0
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
## Artwork and icons
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
device illustrations from the Meshtastic project — https://meshtastic.org
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
(MIT, https://github.com/halfmage/pixelarticons).
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
and `service-icons/` are trademarks of their respective owners, used solely
to identify the corresponding applications. No endorsement is implied.
## Original media
All demo content (music, photos, posters in `demo/`), UI sound effects,
background images, and intro video in `neode-ui/public/assets/` are original
works created and owned by the Archipelago project author, released with the
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
Full per-crate and per-package license inventories for release binaries are
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).
+68 -158
View File
@@ -1,8 +1,11 @@
# Archipelago
> Self-Sovereign Bitcoin Node OS
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
self-hosted apps, mesh communication, decentralized identity, and federation.
Apps are packaged as declarative `manifest.yml` files and run as rootless
Podman containers managed by the Rust backend.
[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -10,193 +13,100 @@
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
## Philosophy
## What is here
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
helpers, and performance/resource management.
- `neode-ui/` - Vue 3 + TypeScript frontend.
- `apps/` - app manifests and custom app container sources.
- `docker/` - supporting container build contexts for UI companion surfaces.
- `image-recipe/` - bootable image/ISO build inputs.
- `Android/` - Android companion app.
- `scripts/` - development, release, deployment, and validation tooling.
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
## Platform model
## Features
Archipelago is built as a developer-ready app platform, not a fixed appliance:
### Bitcoin Infrastructure
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
- **LND** and **Core Lightning** with channel management
- **ElectrumX** Electrum server for wallet connectivity
- **BTCPay Server** for accepting Bitcoin payments
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
- Apps are declared in `apps/<app-id>/manifest.yml`.
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
- App data lives under `/var/lib/archipelago/<app-id>/`.
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
injected through Podman secrets rather than static environment values.
- Release and app catalogs are signed and verified against a pinned trust
anchor.
### Self-Hosted Apps (50+)
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
Start with:
### Mesh Networking (tri-protocol)
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
- End-to-end encryption with X3DH key agreement + double-ratchet
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
- [Architecture](docs/architecture.md)
- [Developer Guide](docs/developer-guide.md)
- [App Developer Guide](docs/app-developer-guide.md)
- [App Manifest Spec](docs/app-manifest-spec.md)
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
- [Troubleshooting](docs/troubleshooting.md)
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
- Multi-identity management (Personal/Business/Anonymous)
- W3C Verifiable Credentials issuance and verification
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
## Quick start
### Multi-Node Federation
- Invite-based node joining over Tor hidden services
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
- State sync and app deployment across federated nodes
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
### System Updates
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
### Security
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
## Roadmap
**Done**
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
**In progress**
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
- Quadlet default flip fleet-wide + container-flapping elimination
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
**Planned**
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
## Quick Start
### Install from ISO
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
2. Flash to USB drive with Balena Etcher or `dd`
3. Boot from USB on target hardware and follow the automated installer
4. Access the web UI at `http://<device-ip>`
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
### Supported Hardware
| Platform | Examples | Minimum |
|----------|----------|---------|
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
## Development
### Prerequisites
- macOS or Linux for frontend development
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
- Node.js 20+, Rust stable toolchain
### Frontend Development
### Frontend
```bash
cd neode-ui
npm install
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
npm run type-check # TypeScript validation
npm run build # Production build → web/dist/neode-ui/
npm start
```
### Backend Development
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
### Backend
```bash
cd core # Rust workspace root (no Cargo.toml at repo root)
cd core
cargo build
cargo test
cargo test --all-features
```
### Deploy to a Test Node
Linux is the supported backend runtime and release-build target. macOS is fine
for frontend work and many Rust compile/test loops, but host integration tests
that touch Podman, systemd, networking, or image build paths require Linux.
### App manifests
```bash
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
### Release (tarball-only)
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
Releases ship as a backend binary and a frontend tarball referenced by
`releases/manifest.json`, published to the self-hosted Gitea release server.
## Documentation map
```bash
./scripts/create-release.sh 1.2.3
git push origin main --tags
```
## Architecture
```
Debian 13 (Trixie)
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
├── Nginx (reverse proxy, security headers, rate limiting)
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
│ │ federation, wallet, updates, marketplace
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
│ │ health monitor, signed app catalog
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
│ └── core/performance/ — resource limits
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
└── System Tor (hidden services, SOCKS5 proxy)
```
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
## Documentation
The full, grouped index lives at **[docs/README.md](docs/README.md)**. The most
common entry points:
| Doc | Purpose |
|-----|---------|
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
| [Apps README](apps/README.md) | Packaged app catalog overview |
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
## Contributing
1. Fork the repository
2. Create a feature branch (`feature/description`)
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
4. Submit a pull request
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
issue.
## License
[MIT License](LICENSE)
## Acknowledgments
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/)
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
are listed in [NOTICE](NOTICE) and generated license inventories in component
release artifacts.
-112
View File
@@ -1,112 +0,0 @@
# Archipelago v1.0.0 Release Notes
**Release Date**: March 2026
**Target Platform**: Debian 13 (Trixie) — x86_64 and ARM64
## What is Archipelago?
Archipelago is a self-sovereign Bitcoin Node OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface. Run Bitcoin infrastructure, self-hosted apps, and Web5 identity — all from hardware you control.
## Key Features
### Bitcoin Infrastructure
- **Bitcoin Knots** full node with pruning support
- **LND** Lightning Network daemon with channel management UI
- **Electrs** Electrum server for wallet connectivity
- **BTCPay Server** for accepting Bitcoin payments
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (20+)
- **Storage**: File Browser, Immich, PhotoPrism, Nextcloud
- **Productivity**: Penpot, OnlyOffice, Vaultwarden
- **Media**: Jellyfin
- **Search**: SearXNG (private search)
- **AI**: Ollama (local LLMs with Claude, GPT, and open models)
- **Network**: Tailscale VPN, Nginx Proxy Manager, Uptime Kuma
- **Home**: Home Assistant
- **Platform**: IndeedHub, Grafana monitoring
### Web5 Identity
- DID-based digital identity (Ed25519 + secp256k1 dual key)
- Verifiable Credentials issuance and verification
- Decentralized Web Node (DWN) for data sync
- Nostr relay integration for node discovery
### Federation
- DID-authenticated peer-to-peer federation
- Remote node monitoring and management
- Bilateral trust with single-use invite codes
- Tor hidden services for private communication
### Security
- AES-256-GCM encrypted secrets at rest
- Container isolation: read-only root, capability dropping, non-root user
- TOTP two-factor authentication with backup codes
- Session management: HttpOnly cookies, SameSite=Strict, CSRF tokens
- Rate limiting on sensitive endpoints
- AppArmor profiles for container confinement
- Per-endpoint input validation
### System
- Rust backend with JSON-RPC API (<1ms response time)
- Vue 3 frontend with glassmorphism design
- WebSocket real-time updates
- Automated OTA updates with rollback
- Tor hidden services for all apps
- Goal-based onboarding wizard
- Kiosk mode for dedicated hardware
## Supported Hardware
- **x86_64**: Any 64-bit PC, Intel NUC, mini PCs
- **ARM64**: Raspberry Pi 5, other ARM64 SBCs
- **Minimum**: 4GB RAM, 32GB storage (500GB+ recommended for Bitcoin)
- **Recommended**: 8GB+ RAM, 1TB+ NVMe SSD
## Installation
1. Download the ISO for your architecture
2. Flash to USB drive (use Balena Etcher or `dd`)
3. Boot from USB on target hardware
4. Follow the automated installer
5. Access the web UI at `http://<device-ip>`
6. Set your password and start the onboarding wizard
## Known Limitations
- Bitcoin initial block download takes 3-7 days depending on hardware
- Some apps (BTCPay Server, Home Assistant) open in new tab due to X-Frame-Options
- ARM64 builds may have slower container pulls due to less cached registry content
- Tor hidden service generation takes 1-2 minutes on first boot
## Upgrade from Beta
If upgrading from v0.5.0-beta:
1. Back up your data via Settings > Backup
2. The OTA update system will handle the upgrade automatically
3. If OTA fails, reflash with the v1.0.0 ISO (app data is preserved on separate partition)
## Security Model
Archipelago follows defense-in-depth:
- **Network**: Nginx reverse proxy, Tor hidden services, VPN support
- **Application**: Container isolation with Podman (rootless)
- **Data**: AES-256-GCM encryption for secrets, 0600 file permissions
- **Auth**: Argon2 password hashing, TOTP 2FA, session rotation
- **Updates**: SHA-256 verified downloads with rollback capability
See `docs/adr/` for architectural decision records on security choices.
## Contributing
Archipelago is open source. To contribute:
1. Fork the repository
2. Create a feature branch (`feature/description`)
3. Follow the coding standards in `CLAUDE.md`
4. Submit a pull request with tests
## License
MIT License. See `LICENSE` for details.
# 2026-04-18 ISO build trigger
+38
View File
@@ -0,0 +1,38 @@
# Security Policy
## Reporting vulnerabilities
Please do not open a public issue for a security vulnerability.
Until a dedicated security intake address is published, report privately to the
project maintainer through the repository owner account or the private contact
channel listed on the project homepage.
Include:
- affected commit, version, or release;
- affected component;
- reproduction steps;
- expected impact;
- logs, proof of concept, or packet captures when relevant;
- whether the issue is already public.
We aim to acknowledge credible reports within 48 hours and coordinate fixes
before public disclosure.
## Scope
Security-sensitive areas include:
- authentication, session handling, CSRF, and rate limiting;
- release and app-catalog signature verification;
- container manifest validation and runtime compilation;
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
- backup encryption and key derivation;
- federation, Tor, Nostr, mesh, DID, and credential flows;
- Android companion pairing and device-token handling.
## Supported versions
Archipelago is currently pre-1.0 alpha software. Security fixes target the
current `main` branch and the latest published alpha release.
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# PreToolUse Bash guard: block dangerous shell commands.
# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777,
# fork bombs, block device overwrites, mkfs, paths escaping project root.
# Uses python3 instead of jq for JSON (guaranteed on macOS).
set -euo pipefail
INPUT=$(cat)
CMD=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('command', ''))
except: pass
" <<< "$INPUT")
BASE="${CLAUDE_PROJECT_DIR:-}"
[[ -z "$BASE" ]] && BASE=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('cwd', ''))
except: pass
" <<< "$INPUT")
[[ -z "$BASE" ]] && BASE="$(pwd)"
# Normalize: collapse whitespace, strip leading/trailing
CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
deny() {
local reason="$1"
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': '$reason'
}
}))
"
exit 0
}
# Dangerous patterns (case-insensitive where sensible)
case "$CMD_NORM" in
*"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;;
*"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;;
*"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;;
*"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;;
*"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;;
*":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;;
*"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;;
*"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;;
esac
# Check for path traversal escaping project root (../ outside project)
# Only if we have a sensible base
if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then
# Simple heuristic: command contains .. and would resolve outside project
if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then
# Extract plausible paths and check - allow ../ within project
if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then
# Could be risky; be conservative for rm/mv/cp
if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then
deny "Path traversal with rm blocked"
fi
fi
fi
fi
exit 0
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# PostToolUse Bash hook: detect git push/commit and prompt Claude to update PROGRESS.md.
# Returns structured feedback with recent commits so Claude can write a session log entry.
# Uses python3 instead of jq for JSON (guaranteed on macOS).
set -euo pipefail
INPUT=$(cat)
# Extract command from JSON using python3
CMD=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('command', ''))
except: pass
" <<< "$INPUT")
# Only trigger on git push or git commit commands
if ! echo "$CMD" | grep -qE '\bgit\s+(push|commit)\b'; then
exit 0
fi
# Gather context for the progress update
BASE="${CLAUDE_PROJECT_DIR:-$(pwd)}"
BRANCH=$(git -C "$BASE" branch --show-current 2>/dev/null || echo "unknown")
PROGRESS_FILE="$BASE/PROGRESS.md"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
# Get recent commits (branch vs main, or last 10)
if git -C "$BASE" rev-parse --verify main &>/dev/null; then
COMMITS=$(git -C "$BASE" log --oneline main..HEAD 2>/dev/null | head -15)
if [ -z "$COMMITS" ]; then
COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null)
fi
else
COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null)
fi
# Get changed files in recent commits
CHANGED_FILES=$(git -C "$BASE" diff --name-only main..HEAD 2>/dev/null | head -20 || \
git -C "$BASE" diff --name-only HEAD~5..HEAD 2>/dev/null | head -20 || \
echo "unknown")
# Build the feedback message and output as JSON using python3
python3 -c "
import json, sys
message = '''Progress Update Needed
A git push/commit was detected on branch \`$BRANCH\` at $TIMESTAMP.
Recent commits:
\`\`\`
$COMMITS
\`\`\`
Changed files:
\`\`\`
$CHANGED_FILES
\`\`\`
Please update PROGRESS.md:
1. Add a session log entry under '## Session Log' with format: ### $TIMESTAMP — $BRANCH
2. Summarize what was accomplished (2-4 bullet points based on the commits above)
3. Update any roadmap checkboxes if tasks were completed
4. Commit the PROGRESS.md update'''
output = {
'hookSpecificOutput': {
'hookEventName': 'PostToolUse',
'progressUpdate': message
}
}
print(json.dumps(output))
"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# PreToolUse Edit|Write guard: block edits outside project and to protected paths.
# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/
# Uses python3 instead of jq for JSON (guaranteed on macOS).
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('file_path', ''))
except: pass
" <<< "$INPUT")
BASE="${CLAUDE_PROJECT_DIR:-}"
[[ -z "$BASE" ]] && BASE=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('cwd', ''))
except: pass
" <<< "$INPUT")
[[ -z "$BASE" ]] && BASE="$(pwd)"
# Resolve to absolute path
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true
[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true
[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE"
# Ensure base has trailing slash for prefix check
[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/"
if [[ "$FILE_PATH" != /* ]]; then
ABS_PATH="$ABS_BASE${FILE_PATH#./}"
else
ABS_PATH="$FILE_PATH"
fi
# Normalize path (collapse .. and ., no symlink resolution needed)
ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true
[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}"
deny() {
local reason="$1"
echo "Blocked: $ABS_PATH$reason" >&2
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': '$reason'
}
}))
"
exit 0
}
# Protected patterns (path contains or equals)
PROTECTED_PATTERNS=(
".git/"
".env"
".env.local"
"node_modules/"
"package-lock.json"
"pnpm-lock.yaml"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then
deny "Edit blocked: path matches protected pattern ($pattern)"
fi
done
# .env.*.local
if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then
deny "Edit blocked: .env.*.local files contain secrets"
fi
# Ensure path is under project root (ABS_BASE has trailing /)
if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then
deny "Edit blocked: path is outside project directory"
fi
exit 0
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "app",
"runtimeExecutable": "bash",
"runtimeArgs": ["packages/app/scripts/dev.sh"],
"port": 5173,
"autoPort": true
}
]
}
+61
View File
@@ -0,0 +1,61 @@
# AIUI Project Memory
## Session Startup
1. Run `preview_start` with name `"app"` immediately — runs both Vite (:5173) + Claude proxy (:3141) via `packages/app/scripts/dev.sh`
2. Always commit work before ending a session
3. Work on `development` branch, merge to `main` only when production ready
## User Preferences
- NO worktrees, NO temporary branches — just `development` and `main`
- Always use combined dev script (proxy + frontend), never bare `vite`
- Commit frequently to avoid losing work
## Current State (2026-03-04)
- Branch: `overnight/2026-03-03`, all committed and pushed to remote (git.tx1138.com)
- Typecheck passes clean
## What's Been Built
- Chat: AI streaming with stop generation, web search, article integration, paste & extract
- Content panel tabs: Films, Music, Magazine, News, Books, TV Series, Images, Places, Code, Design System, Nostr, **Apps**
- Detail views for each content type (side-by-side desktop, overlay mobile)
- **Apps tab**: curated DB of ~30 Nostr/Bitcoin apps, AppsGrid + AppDetail with search/category filtering/how-to
- Design system viewer (grid + detail) for tokens, colors, typography, components
- Nostr feed scaffold with note/article/zap filtering
- Content extraction: contentExtraction.ts + contentFiltering.ts (overhauled classifiers)
- **Bare domain extraction** from AI text (e.g. "check out damus.io")
- Banner fallback composable (primary → API → gradient)
- Image fallbacks: Wikipedia + Google Books sources
- Loading skeletons per content type variant
- Project grid with breadcrumb nav and inline creation
- Filesystem Vite plugin for local project browsing
- PWA with star icon, TMDB proxy, Jamendo for music
- **Slash command palette**: /code, /nostr, /design, /search show in palette with auto-send
- **Chat action buttons**: wrapped in glass container (backdrop blur, border, shadow)
- **Settings modal**: Memory + Advanced Settings via gear icon
- **Chat history**: dedicated clock icon button
- **Web search**: Brave API primary, SearXNG rotation fallback, DuckDuckGo fallback
- iOS HIG mobile UX rules in `.cursor/rules/15-mobile-ux.mdc`
## Key Files
- Dev script: `packages/app/scripts/dev.sh`
- Launch config: `.claude/launch.json` (name: "app")
- Main page: `packages/app/src/pages/ChatPage.vue`
- Content panel: `packages/app/src/components/content/ContentPanel.vue`
- Content grids: `packages/app/src/components/content/*Grid.vue`
- Detail views: `packages/app/src/components/content/*Detail.vue`
- **Apps**: `packages/app/src/data/apps.ts` (curated DB), `AppsGrid.vue`, `AppDetail.vue`
- AI composable: `packages/app/src/composables/useAI.ts`
- Content extraction: `packages/app/src/composables/contentExtraction.ts`
- Content filtering: `packages/app/src/composables/contentFiltering.ts`
- Content panel logic: `packages/app/src/composables/useContentPanel.ts`
- Image fallbacks: `packages/app/src/composables/useImageFallback.ts`
- Banner fallback: `packages/app/src/composables/useBannerFallback.ts`
- Chat input: `packages/app/src/components/chat/ChatInput.vue`
- Prompt palette: `packages/app/src/components/chat/PromptPalette.vue`
- Chat message: `packages/app/src/components/chat/ChatMessage.vue`
- Settings modal: `packages/app/src/components/chat/SettingsModal.vue`
- Web search plugin: `packages/app/vite-web-search.ts`
- Prompt templates store: `packages/app/src/stores/promptTemplates.ts`
## Recent Session Work (2026-03-04)
See `session-2026-03-04.md` for details.
+18
View File
@@ -0,0 +1,18 @@
# Code Mode UI — Future Work
## After content surfacing is complete, implement:
### 1. Code Mode Visual Treatment
- Colour the message container in orange (`#F7931A`) styling when in code mode
- Change header text from "Message AIUI" to "Code"
- Visual signal so user knows they're in coding context
### 2. Design System Context Selection
- All design system items should be selectable with a cursor/pointer icon on hover
- Selecting a design system item provides that UI context to the code generation
- Think of it as "code with this component/token in mind"
### 3. File Browser / Open File Context
- File browser or open file in the content panel
- Selected files provide context for coding
- Pairs with the design system selection — user picks UI + files as coding context
+66
View File
@@ -0,0 +1,66 @@
# Session 2026-03-04
## Completed This Session
### 1. Chat UX Changes
- **History button**: Changed from title-click dropdown to dedicated clock icon in ChatHeader
- **Settings modal**: Created `SettingsModal.vue` — Memory + Advanced Settings behind gear icon, glass-card with backdrop blur
- **PromptIndex fix**: Reverted to original behavior (current conversation only), fixed broken v-if/v-else chain where StreamingDots broke the template chain
- **Chat action buttons**: Wrapped hover icons in proper glass container (`bg-black/60 backdrop-blur-md border border-white/10`) with divider between actions and thumbs
### 2. iOS HIG Integration
- Created `.cursor/rules/15-mobile-ux.mdc` with comprehensive iOS HIG values
- Updated CLAUDE.md Mobile UX section
### 3. Web Search Fix
- All SearXNG instances were returning 429, DuckDuckGo rate-limiting
- Added Brave Search API as primary backend (`BRAVE_SEARCH_API_KEY` env var)
- Expanded SearXNG pool to 8 instances with rotation
- Added HTML response guard for captcha pages
### 4. Content Detection Overhaul (MAJOR)
- **Expanded all classifiers** in `contentFiltering.ts`: isNewsQuery, isMusicQuery, isBookQuery, isTVQuery, isPlaceQuery, isWebsitesQuery + response variants
- **Added Nostr detection**: `isNostrQuery()`, `isNostrLikeResponse()`
- **Added App detection**: `isAppQuery()`, `isAppLikeResponse()`
- **Updated `filterTabsByContext()`**: new `hasNostr` + `hasApps` params, nostr/app query priority
- **Updated `preferredFirstTab()`**: nostr + app checks
### 5. Bare Domain Extraction
- `extractBareDomainLinks(text)` in contentExtraction.ts
- Detects plain domains like "damus.io" not inside markdown/bold/URL patterns
- Known TLDs whitelist, file extension blacklist
### 6. Apps Tab (NEW FEATURE)
- **Database**: `packages/app/src/data/apps.ts` — AppEntry interface, ~30 curated apps
- Nostr clients: Damus, Primal, Snort, Amethyst, Coracle, Iris, noStrudel
- Lightning wallets: Phoenix, Breez, Zeus, Alby, Mutiny, WoS
- Bitcoin wallets: Sparrow, BlueWallet, Nunchuk, Coldcard
- Privacy: SimpleX Chat, Signal, Mullvad VPN
- Node software: Start9, Umbrel, RaspiBlitz, myNode
- Dev tools: NDK, nostr-tools, Nak
- **Extraction**: `extractApps(text, userQuery)` — keyword matching against DB, surfaces with 1+ match for app/nostr/known-app queries, 2+ for general
- **UI**: `AppsGrid.vue` (list with search/category filter), `AppDetail.vue` (gradient header, how-to steps, related apps, external link)
- **Wired in**: useContentPanel.ts (panelApps ref, selectedApp, open/close), ContentPanel.vue (registered), PromptIndex badges
### 7. Slash Command Palette
- `/code`, `/nostr`, `/design`, `/search` appear as commands in PromptPalette
- Commands section above Templates section with `/slash` prefix styling
- Auto-send on select (except `/search` which sets text for query input)
- 8px side margins (`left-2 right-2`), no max-height scroll limit
- `ChatInput.vue`: simplified `isPaletteMode` — no longer excludes command names
### 8. App Detection Fix
- Queries mentioning known app names (e.g. "start9") now match via `queryMatchesApp` check
- Previously required explicit app/nostr query patterns like "what app" or "best wallet"
## Known Issues / TODO for Next Session
- User reported "start9" search shows Brief but Apps tab was empty — FIXED in last commit
- The `/design` command was added to palette and ChatWindow handleSend
- Consider adding more apps to the curated database over time
- The plan file is at `.claude/plans/content-detection-overhaul.md` (all steps complete)
## Git State
- Branch: `overnight/2026-03-03`
- Latest commit: `f346992` — feat(chat): slash command palette, action button containers, app detection fix
- Previous commit: `84ccdc7` — feat(app): content detection overhaul, apps tab, chat UX, web search
- All pushed to origin
@@ -0,0 +1,160 @@
# Plan: Overhaul Content Detection + Add Apps Tab
## Context
The content surfacing system misses many common AI response patterns. Example: AI responds about Nostr (mentioning damus.io, primal.net, snort.social) but the Nostr tab never surfaces. Query/response classifiers use narrow regexes that miss natural language variations. There's no "topic detection" layer, no app detection, and bare domains in AI text aren't extracted as websites.
**Goals:**
1. Fix content detection to handle how AIs actually respond
2. Add Nostr tab surfacing (currently only via `/nostr` command)
3. Add Apps tab with curated Nostr + Bitcoin ecosystem apps (local DB + AI extraction fallback)
4. Extract bare domains from AI text (e.g. "check out damus.io")
---
## Part 1: Expand Query & Response Classifiers
**File:** `packages/app/src/composables/contentFiltering.ts`
### 1A. Add Nostr classifiers (new functions)
- `isNostrQuery(q)` — matches: nostr, npub, nip-\d, damus, primal, snort, amethyst, coracle, zap, relay, note1, nevent, nprofile, fiatjaf, nostrich, "decentralized social"
- `isNostrLikeResponse(text)` — requires literal "nostr" OR 2+ Nostr-specific signals (npub, nip-, client names, relay+wss, zap+lightning)
### 1B. Add App classifiers (new functions)
- `isAppQuery(q)` — matches: app, client, wallet, tool, software, download, install, "what app", "best app for", "recommend.*app"
- `isAppLikeResponse(text)` — matches: "you can use", "popular clients include", "I'd recommend", "available on", "download from"
### 1C. Expand existing classifiers with broader patterns
| Classifier | Add these patterns |
|---|---|
| `isNewsQuery` | "what happened today", "any updates on", "trending", "catch me up", "brief me", "current events" |
| `isMusicQuery` | "genre", "spotify", "bandcamp", "grammys", "billboard", "mixtape", "discography", "banger", "favorite jam" |
| `isBookQuery` | "what should I read", "favorite reads", "reading list", "book club", "memoir", "audiobook", "goodreads", "worth reading" |
| `isTVQuery` | "what's good on netflix", "anything to binge", "hbo", "disney+", "apple tv", "amazon prime", "docuseries", "limited series" |
| `isPlaceQuery` | "hungry", "food near me", "best brunch spot", "happy hour", "speakeasy", "rooftop bar", "food truck" |
| `isWebsitesQuery` | "point me to", "link me", "any good sites", "tools for", "platforms for" |
| `isWebsitesLikeResponse` | "here are some resources", "I'd recommend checking", "you can visit", "useful resources" |
| `isNewsLikeResponse` | "I can't access the web but", "having trouble reaching", "unable to browse but" |
### 1D. Update `preferredFirstTab()` — add nostr + app checks
### 1E. Update `filterTabsByContext()` — add `hasNostr` and `hasApps` params, integrate into tab ordering
---
## Part 2: Bare Domain Extraction
**File:** `packages/app/src/composables/contentExtraction.ts`
Add `extractBareDomainLinks(text)`:
- Detect plain-text domains like "damus.io", "primal.net" not inside markdown links or bold patterns
- Skip positions covered by existing extractors (markdown links, bold-domain, full URLs)
- Require known TLDs (.com, .org, .io, .net, .social, .app, etc.)
- Block file extensions (.js, .ts, .vue, .json, .css)
- Use existing `normUrl()` for dedup
---
## Part 3: Apps Tab — Curated Database + AI Extraction
### 3A. Create app database
**New file:** `packages/app/src/data/apps.ts`
```ts
interface AppEntry {
id: string
name: string
description: string // One-liner
longDescription: string // Why use this, how it works
category: 'nostr-client' | 'lightning-wallet' | 'bitcoin-wallet' | 'privacy' | 'node' | 'dev-tool' | 'relay'
platforms: ('ios' | 'android' | 'web' | 'desktop' | 'cli' | 'nodeos')[]
url: string
icon?: string
keywords: string[] // For matching AI responses
howTo?: string[] // Getting started steps
relatedApps?: string[] // IDs of related apps
}
```
**Initial curated apps (~25-30):**
- Nostr clients: Damus, Primal, Snort, Amethyst, Coracle, Iris, Nostrudel, nos.social
- Lightning wallets: Phoenix, Mutiny, Breez, Zeus, Alby, Wallet of Satoshi
- Bitcoin wallets: Sparrow, Blue Wallet, Nunchuk, Coldcard, Green
- Privacy tools: Tor, SimpleX Chat, Signal, Mullvad VPN
- Node software: Start9, Umbrel, RaspiBlitz, myNode
- Dev tools: NDK, nostr-tools, Nak
### 3B. Add app extraction
**File:** `packages/app/src/composables/contentExtraction.ts`
Add `extractApps(text, userQuery)`:
1. Match AI text against known app names/keywords from database
2. If app query detected OR 2+ known apps mentioned → return matched apps
3. For unknown apps, create basic entries from context (name + URL if bare domain found)
### 3C. Create UI components
**New files:**
- `packages/app/src/components/content/AppsGrid.vue` — Grid of app cards (icon, name, category badge, one-liner)
- `packages/app/src/components/content/AppDetail.vue` — Detail: icon, name, platforms, long description, how-to steps, link, related apps
Follow existing grid/detail patterns (e.g. `BookGrid.vue`/`BookDetail.vue`).
### 3D. Register in ContentPanel.vue
Add rendering for `activeTab === 'app'`, add `'app'` to `ContentTab` type.
---
## Part 4: Wire Everything Together
**File:** `packages/app/src/composables/useContentPanel.ts`
In `updatePanelFromText()`:
- Call `extractBareDomainLinks(text)`, merge with website sources
- Call `extractApps(text, userQuery)`
- Compute `hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)`
- Compute `hasApps = apps.length > 0`
- Pass `hasNostr` and `hasApps` to `filterTabsByContext()`
- Add `panelApps` ref, title logic for apps/nostr tabs
Same changes in `getContextualInlineContent()`.
Broaden magazine detection: add tech/protocol keywords, surface magazine for 3+ sections with no other structured content.
---
## Part 5: PromptIndex badges
**File:** `packages/app/src/components/chat/PromptIndex.vue`
Add 'Nostr' and 'Apps' badge detection.
---
## Implementation Order
1. `contentFiltering.ts` — classifiers + filterTabsByContext signature
2. `contentExtraction.ts``extractBareDomainLinks()` + `extractApps()`
3. `data/apps.ts` — curated app database
4. `useContentPanel.ts` — wire everything
5. `AppsGrid.vue` + `AppDetail.vue` — UI components
6. `ContentPanel.vue` — register tab + components
7. `PromptIndex.vue` — badges
8. Typecheck + manual test
## Verification
1. `pnpm typecheck` passes
2. "tell me about Nostr" → Nostr + magazine tabs surface
3. "best Nostr clients?" → Apps tab with Damus, Primal, Snort
4. "recommend a bitcoin wallet" → Apps tab with Phoenix, Sparrow
5. "what happened with BIP 110?" → Magazine tab (regression)
6. "best movies of 2024" → Films tab (regression)
7. Bare domains in AI text extracted as websites
8. PromptIndex badges show Nostr/Apps
@@ -0,0 +1,74 @@
# Plan: Code Mode UI — Orange Input, Design System Context, File Browser Context
## Context
The user wants three connected features that enhance the coding experience in AIUI:
1. Visual indication when in code mode (orange input container, "Code" label)
2. Ability to select design system items as coding context
3. Ability to select files from file browser as coding context
After this, the user wants to circle back and create a flawless version of content extraction/tab surfacing.
## Changes
### 1. Orange Code Mode Input Container
**Files**: `ChatWindow.vue`, `ChatInput.vue`
**ChatWindow.vue** (line 106-115):
- Pass `activeTab` to ChatInput as a prop: `:active-tab="activeTab"`
- Change placeholder logic: `activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'`
**ChatInput.vue**:
- Add `activeTab` prop (optional string, default `''`)
- Conditionally style the container div (line 79-81):
- When `activeTab === 'code'`: use `bg-accent/15 border border-accent/25 backdrop-blur-xl` instead of `path-glass-bubble`
- Keep the `rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300` classes
- Conditionally style the send button orange when in code mode
### 2. Design System Item Selection for Coding Context
**Files**: `useCodeContext.ts`, `DesignSystemGrid.vue`
**useCodeContext.ts**:
- Add `selectedDesignTokens: ref<string[]>([])` to module state (stores item IDs)
- Add `toggleDesignToken(id)` — adds/removes from selection array
- Add `clearDesignTokens()` — clears selection
- Add `isDesignTokenSelected(id)` — checks if item is in selection
- Clear on `exitCodeMode()`
- Export all new state/actions
**DesignSystemGrid.vue**:
- Import `useCodeContext`
- When `codeMode` is true, show a selection indicator (accent ring + checkmark) on items
- `selectItem` should call `toggleDesignToken(item.id)` when in code mode (instead of `openDesignSystemItem`)
- When NOT in code mode, keep existing behavior (open detail view)
- Selected items get `ring-2 ring-accent/50 bg-accent/10` styling
### 3. File Browser Selection for Coding Context
**Files**: `useCodeContext.ts`, `ProjectGrid.vue`
**useCodeContext.ts**:
- Add `selectedFiles: ref<string[]>([])` — paths of files selected for context
- Add `toggleFileSelection(path)` — adds/removes from selection
- Add `clearFileSelection()` — clears all
- Add `isFileSelected(path)` — checks if file in selection
- Clear on `exitCodeMode()`
- Export new state/actions
**ProjectGrid.vue**:
- Import `useCodeContext`
- When `codeMode` is true, file clicks toggle selection instead of (or in addition to) opening
- Show visual selection state (accent highlight/checkmark) on selected files in FileTreeNode
## Files to Modify
1. `packages/app/src/components/chat/ChatWindow.vue` — pass activeTab prop
2. `packages/app/src/components/chat/ChatInput.vue` — conditional orange styling + "Code" placeholder
3. `packages/app/src/composables/useCodeContext.ts` — add design token + file selection state
4. `packages/app/src/components/content/DesignSystemGrid.vue` — toggle selection in code mode
5. `packages/app/src/components/content/ProjectGrid.vue` — toggle file selection in code mode
## Verification
1. `pnpm typecheck` — no type errors
2. `pnpm lint` — no new lint errors
3. Manual: `/code` command → input turns orange with "Code..." placeholder
4. Manual: In code mode, design system tab → clicking items toggles selection (accent ring)
5. Manual: In code mode, file browser → clicking files toggles selection
6. Manual: Exiting code mode clears all selections
+35
View File
@@ -0,0 +1,35 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-push-progress.sh"
}
]
}
]
}
}
@@ -0,0 +1,43 @@
---
name: add-content-type
description: Scaffold a complete new content type (tag, extraction, grid, detail, prompt)
allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep, Agent
---
Add a complete new content type to AIUI. The user will provide the content type name (e.g., "event", "product", "video").
Follow ALL steps — this is the full pipeline for a content type:
1. **Tag format**: Add a new `[[{type}_ext:Field1|Field2|...]]` regex to `packages/app/src/composables/contentExtraction.ts` alongside the existing ones (FILM_EXT_RE, SONG_EXT_RE, etc.)
2. **Type definition**: Add the TypeScript interface to `packages/core/src/types/content.ts` if it doesn't exist
3. **Extraction function**: Add `extractAll{Type}s(text, userQuery)` to `contentExtraction.ts` following the pattern of `extractAllFilms` or `extractAllBooks`
4. **Strip tags function**: Add `strip{Type}Tags()` and include it in `stripContentTags()`
5. **Query classifier**: Add `is{Type}Query()` and optionally `is{Type}LikeResponse()` to `contentFiltering.ts`
6. **ContentTab type**: Add the new tab name to the `ContentTab` union in `contentFiltering.ts`
7. **Tab filtering**: Update `filterTabsByContext()` and `preferredFirstTab()` in `contentFiltering.ts`
8. **Grid component**: Create `packages/app/src/components/content/{Type}Grid.vue` following the glass-morphism pattern of existing grids (BookGrid.vue is a good template)
9. **Detail component**: Create `packages/app/src/components/content/{Type}Detail.vue` following the pattern of BookDetail.vue
10. **Wire into ContentPanel.vue**: Add import, grid render block, detail render block, and panel state refs
11. **Wire into ContentGridView.vue**: Add import, props, and grid render block
12. **Wire into ChatPage.vue**: Pass the new panel data as props to ContentGridView
13. **Wire into useContentPanel.ts**: Add panel ref, selected ref, open/close functions, extraction call in `updatePanelFromText()`
14. **System prompt**: Add tag format instructions to the `SYSTEM_PROMPT` in `useAI.ts`
15. **Tab label**: Add to `TAB_LABELS` in `ContentPanel.vue`
16. **Verify**: Run `pnpm typecheck` and fix any errors
Report what was created and the tag format to use.
+32
View File
@@ -0,0 +1,32 @@
---
name: add-tool
description: Add a new AI tool (function call) to the Claude proxy for the AI to use
allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep
---
Add a new tool that the AI model can call via Claude's tool_use API. The user will describe what the tool should do (e.g., "search local files", "get app status", "browse media").
## Steps
1. **Read the proxy**: Read `packages/app/server/claude-proxy.ts` to understand the existing tool_use loop and `SEARCH_WEB_TOOL` definition.
2. **Define the tool**: Add a new tool definition following the Claude tool_use format:
```ts
const NEW_TOOL = {
name: 'tool_name',
description: 'What this tool does...',
input_schema: {
type: 'object',
properties: { ... },
required: [...]
}
}
```
3. **Add handler**: In the tool_use loop (where `search_web` calls are handled), add a handler for the new tool name.
4. **Implement backend**: If the tool needs a new API endpoint (e.g., `/api/media/scan`), create a Vite plugin or add a route to the proxy.
5. **Update system prompt**: Add instructions in `useAI.ts` SYSTEM_PROMPT telling the AI when and how to use the new tool.
6. **Verify**: Run `pnpm typecheck` and test the proxy starts without errors.
@@ -0,0 +1,37 @@
---
name: audit-prompts
description: Deep audit of AI system prompts — find gaps, test extraction coverage, verify tag formats
allowed-tools: Bash(*), Read, Glob, Grep, Agent
---
Perform a comprehensive audit of AIUI's AI prompt system. Do NOT make changes — report findings only.
## Steps
1. **Read the full system prompt**: Read `packages/app/src/composables/useAI.ts` and reconstruct the complete system prompt including all dynamic sections (persona, Wavlake, memory, web search, Archy context, code context).
2. **Catalog all tag formats**: List every `[[type:...]]` and `[[type_ext:...]]` format defined in the prompt. Cross-reference with regexes in `contentExtraction.ts`.
3. **Check for gaps**: For each content type in `ContentTab` (contentFiltering.ts), verify:
- Is there a tag format in the system prompt?
- Is there a matching extraction regex?
- Is there a query classifier?
- Is there a grid + detail component?
- Is the tab wired in ContentPanel.vue and ContentGridView.vue?
4. **Test extraction coverage**: Read the seed prompts in `src/__tests__/fixtures/seedPrompts.ts`. For each seed, verify:
- Does the extraction function find the expected number of items?
- Are there edge cases that would break extraction?
5. **Analyze prompt quality**: Check for:
- Conflicting instructions
- Missing edge case handling (e.g., "what if the AI can't find a match?")
- Overly vague instructions
- Missing content types that should have tag formats
6. **Check proxy tools**: Read `server/claude-proxy.ts` and verify tool definitions match what the prompt claims.
7. **Report**: Create a structured summary with:
- Content type coverage matrix (tag/extraction/grid/detail/prompt)
- Identified gaps and inconsistencies
- Priority recommendations
+17
View File
@@ -0,0 +1,17 @@
---
name: check
description: Run all quality checks (typecheck, lint, test) and auto-fix errors
allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent
---
Run all quality checks for the AIUI project and fix any issues found. Execute in order:
1. **TypeScript**: Run `pnpm typecheck`. If errors found, read the failing files and fix the type errors.
2. **Lint**: Run `pnpm lint`. If fixable errors, run `pnpm lint --fix` first, then fix remaining manually.
3. **Tests**: Run `pnpm --filter @aiui/app test -- --run`. For each failure:
- Read the test file and the source file it tests
- Determine if the test is wrong (outdated assertion) or the source has a bug
- Fix whichever is incorrect
4. Report a summary: pass/fail counts, what was fixed.
Important: Do NOT change test expectations just to make them pass — understand WHY they fail first.
+32
View File
@@ -0,0 +1,32 @@
---
name: deploy
description: Build and prepare AIUI for deployment to Archy node
allowed-tools: Bash(*), Read, Edit, Glob, Grep
---
Build AIUI for production deployment. Steps:
1. **Pre-flight checks**:
- `pnpm typecheck` — must pass
- `pnpm lint` — must pass
- `pnpm --filter @aiui/app test -- --run` — report failures but continue
2. **Build**:
- `pnpm build`
- Verify `packages/app/dist/` exists and contains `index.html`
3. **Bundle analysis**:
- Report total dist size and gzip estimate
- List the 5 largest chunks
- Check against 250KB gzipped budget (warn if over)
4. **Verify nginx config**:
- Read `packages/app/server/nginx-archy.conf`
- Verify SPA routing (`try_files $uri $uri/ /aiui/index.html`)
- Verify proxy paths for Claude API
5. **Container build** (if Dockerfile exists):
- `podman build -t aiui:latest packages/app/`
- Report image size
6. **Report**: Build status, bundle size, any warnings.
+33
View File
@@ -0,0 +1,33 @@
---
name: fix-tab
description: Diagnose and fix a broken content panel tab (extraction, routing, rendering)
allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent
---
Diagnose and fix a broken content panel tab. The user will specify which tab (e.g., "app", "code", "image", "nostr").
## Diagnostic pipeline — check each layer:
1. **System prompt**: Does `useAI.ts` SYSTEM_PROMPT tell the AI to use the tag format for this content type? If not, add instructions.
2. **Tag regex**: Does `contentExtraction.ts` have a regex for this content type's tags? If not, add one.
3. **Extraction function**: Does `extractAll{Type}s()` or `extract{Type}s()` exist and work correctly? Test with sample input.
4. **Query classifier**: Do `is{Type}Query()` and/or `is{Type}LikeResponse()` exist in `contentFiltering.ts`?
5. **Tab filtering**: Is the tab included in `filterTabsByContext()` and `preferredFirstTab()`? Check the boolean flag is wired.
6. **useContentPanel.ts**: Is the extraction called in `updatePanelFromText()`? Is the panel ref populated? Is the tab added to `availableTabs`?
7. **ContentPanel.vue**: Is there a grid render block for `activeTab === '{tab}'`? Are imports present? Is the tab in TAB_LABELS?
8. **ContentGridView.vue**: Same checks for the wide desktop view.
9. **ChatPage.vue**: Are the panel data props passed to ContentGridView?
10. **Grid component**: Does the grid component exist and render correctly?
11. **Detail component**: Does the detail component exist?
Fix each broken layer. Run `pnpm typecheck` after all fixes.
+32
View File
@@ -0,0 +1,32 @@
---
name: mock-archy
description: Enable/configure mock Archy data for standalone dev testing
allowed-tools: Bash(*), Read, Edit, Glob, Grep
---
Set up or modify mock Archy data for testing the Archipelago integration without a real Archy host.
## How it works
Mock data is in `packages/app/src/mocks/archy.ts`. When enabled, `useArchy.ts` loads this data instead of waiting for the Archy bridge.
## Enable mock mode
Two ways:
1. Add `VITE_MOCK_ARCHY=true` to `.env.local`
2. Add `?mockArchy` to the URL: `http://localhost:5173/?mockArchy`
## Customization
The user may ask to:
- Add/remove mock apps from the installed list
- Change wallet balance or channel count
- Add/modify files in the mock file list
- Change system info or network status
- Test specific scenarios (e.g., "node is syncing", "wallet offline", "no files")
Edit `packages/app/src/mocks/archy.ts` accordingly.
## Verify
After changes, check that `buildArchyContext()` in `useArchy.ts` produces the expected system prompt section by reading the function and tracing the mock data through it.
+27
View File
@@ -0,0 +1,27 @@
---
name: new-detail
description: Generate a detail view component following AIUI glass-morphism patterns
allowed-tools: Read, Write, Edit, Glob, Grep
---
Create a new detail view component at `packages/app/src/components/content/{Name}Detail.vue`.
## Requirements
1. **Read a reference**: Read `BookDetail.vue` or `PlaceDetail.vue` as a template.
2. **Follow conventions**:
- `<script setup lang="ts">` with single item prop
- Back button at top (emits 'back' event)
- Hero image/banner area with gradient overlay and fallback
- Title, subtitle, and metadata section
- Description/long text body with proper typography
- Action buttons (external links, share, etc.) with glass-button styling
- Dark/light mode via `useTheme()`
- Smooth scroll, overflow-y-auto
3. **Props**: Accept single item of the content type
4. **Emits**: `back` event for navigation
5. **Responsive**: Full height, works in sidebar and mobile overlay
The user will specify the content type and which fields to display.
+27
View File
@@ -0,0 +1,27 @@
---
name: new-grid
description: Generate a content grid component following AIUI glass-morphism patterns
allowed-tools: Read, Write, Edit, Glob, Grep
---
Create a new content grid component at `packages/app/src/components/content/{Name}Grid.vue`.
## Requirements
1. **Read a reference**: Read `BookGrid.vue` or `PlaceGrid.vue` as a template — they show the standard pattern.
2. **Follow conventions**:
- `<script setup lang="ts">` with props and emits
- Glass morphism styling (bg-white/5, rounded-xl, hover:bg-white/10)
- Dark/light mode support via `useTheme()`
- Search input at top (if the content type has enough items)
- Grid of cards with image fallback, title, subtitle, metadata
- Touch targets min 44x44px
- Empty state message when no items match
- Custom scrollbar class
3. **Props**: Accept array of items + title string
4. **Emits**: `select-{type}` event when a card is clicked
5. **Responsive**: Works on mobile (full width) and desktop (sidebar width)
The user will specify the content type and its fields.
+19
View File
@@ -0,0 +1,19 @@
---
name: overnight
description: Commit, branch, and start the overnight automation loop
disable-model-invocation: true
allowed-tools: Bash(*), Read, Write, Edit, Glob, Grep
---
Prepare and launch the overnight automation loop. Do ALL steps in order, stopping on any failure:
1. Stage and commit all uncommitted changes: `git add -A && git commit -m "chore: pre-overnight snapshot"` (skip if working tree is clean)
2. Push current branch to origin
3. Get today's date as YYYY-MM-DD. Check if `overnight/$DATE` branch exists:
- If yes: `git checkout overnight/$DATE`
- If no: run `./loop/prepare.sh`
4. Verify `loop/plan.md` has unchecked tasks (`grep -c '^\- \[ \]' loop/plan.md`)
5. Commit plan files if modified: `git add loop/plan.md loop/prompt.md && git commit -m "chore: overnight plan $DATE"` (skip if clean)
6. Push: `git push -u origin overnight/$DATE`
7. Start the loop: run `caffeinate -i ./loop/loop.sh` with `run_in_background: true`
8. Report: branch name, number of tasks, and confirm the loop is running in background
@@ -0,0 +1,102 @@
---
name: pwa-icon-cache-fix
description: Use when the user reports a PWA icon not updating, stale PWA icon, wrong icon after install, or any PWA caching issue. Also applies when changing PWA icons in a Vite + vite-plugin-pwa project.
version: 2.0.0
---
# PWA Icon Cache Fix
## Problem
PWA icons are cached at FOUR independent layers:
1. **Service worker cache** (Workbox precache)
2. **Browser HTTP cache**
3. **Browser manifest resources** (Chromium stores resized icons in its profile data, keyed by a permanent extension ID tied to the origin — NEVER re-fetched even after uninstall/reinstall)
4. **macOS .app bundle** (`.icns` file baked into the `.app` in `~/Applications/`)
Query string cache busting (`?v=2`) and uninstall/reinstall do NOT fix this. Chromium reuses the same extension ID for the same origin, so it keeps the old cached icons.
## Fix Steps
### 1. Verify icon files on disk and server are correct
```bash
# Visual check
Read packages/app/public/pwa-192x192.png
Read packages/app/public/pwa-512x512.png
# Hash match check
curl -s http://localhost:5173/pwa-192x192.png | md5
md5 -q packages/app/public/pwa-192x192.png
```
### 2. Find the PWA's Chromium extension ID
Read the installed `.app` bundle's `Info.plist` to get the `CrAppModeShortcutID`:
```bash
plutil -p "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Info.plist" | grep CrAppModeShortcutID
```
This returns an ID like `idemibpphagihbobmgmaojhjfidlfpdl`.
### 3. Overwrite the cached icons in browser profile
Chromium stores resized icons at:
`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons/`
Overwrite every size using `sips`:
```bash
ICON_DIR="~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons"
SRC="packages/app/public/pwa-512x512.png"
for size in 32 48 64 96 128 192 256 512; do
sips -z $size $size "$SRC" --out "${ICON_DIR}/${size}.png"
done
```
### 4. Rebuild the macOS .icns in the .app bundle
```bash
ICONSET="/tmp/aiui.iconset"
mkdir -p "$ICONSET"
SRC="packages/app/public/pwa-512x512.png"
sips -z 16 16 "$SRC" --out "$ICONSET/icon_16x16.png"
sips -z 32 32 "$SRC" --out "$ICONSET/icon_16x16@2x.png"
sips -z 32 32 "$SRC" --out "$ICONSET/icon_32x32.png"
sips -z 64 64 "$SRC" --out "$ICONSET/icon_32x32@2x.png"
sips -z 128 128 "$SRC" --out "$ICONSET/icon_128x128.png"
sips -z 256 256 "$SRC" --out "$ICONSET/icon_128x128@2x.png"
sips -z 256 256 "$SRC" --out "$ICONSET/icon_256x256.png"
sips -z 512 512 "$SRC" --out "$ICONSET/icon_256x256@2x.png"
sips -z 512 512 "$SRC" --out "$ICONSET/icon_512x512.png"
cp "$SRC" "$ICONSET/icon_512x512@2x.png"
iconutil -c icns "$ICONSET" -o "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Resources/app.icns"
```
### 5. Flush macOS icon cache
```bash
touch "~/Applications/Brave Browser Apps.localized/AIUI.app"
killall Finder
killall Dock
```
### 6. Bump PWA_CACHE_VERSION in main.ts
Increment the `PWA_CACHE_VERSION` constant — this nukes all SW caches on next page load for web-layer caching.
### 7. Delete stale build artifacts
Remove old `dist/` and `dev-dist/` SW/manifest files.
## Browser-Specific Paths
- **Brave**: `~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/`
- **Chrome**: `~/Library/Application Support/Google/Chrome/Default/Web Applications/`
- **PWA apps (Brave)**: `~/Applications/Brave Browser Apps.localized/`
- **PWA apps (Chrome)**: `~/Applications/Chrome Apps.localized/`
## Key Insight
Chromium assigns a permanent extension ID per origin (e.g., `localhost:5173`). This ID persists across uninstall/reinstall. The icon cache in `Manifest Resources/{ID}/Icons/` is populated ONCE and never refreshed from the manifest. The only fix is to overwrite the files directly on disk.
+26
View File
@@ -0,0 +1,26 @@
---
name: test-prompts
description: Test AI prompt quality by simulating queries and checking extraction results
allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent
---
Test the AIUI AI prompt and content extraction pipeline end-to-end. This skill does NOT call the actual AI — it uses the seed prompts and extraction functions directly.
## Steps
1. **Read seed prompts**: Read `packages/app/src/__tests__/fixtures/seedPrompts.ts` to get all test cases.
2. **Run extraction tests**: For each seed prompt, run the test via `pnpm --filter @aiui/app test -- --run -t "seed"` and report results.
3. **Test edge cases**: Create and test these additional scenarios by calling extraction functions in a test:
- Mixed content response (films + songs + books in one response)
- App recommendation response (should trigger app tab)
- News query with web search results
- Place/restaurant recommendations
- Code response with 3+ code blocks
- Nostr-related query
- Empty/minimal response
4. **Verify tab routing**: For each scenario, check that `filterTabsByContext()` returns the expected tabs in the expected order.
5. **Report**: Summary of what works, what's broken, and what's missing. Include specific test cases that fail.
+28
View File
@@ -0,0 +1,28 @@
---
name: trace
description: End-to-end trace of a query through prompt, extraction, tabs, and rendering
allowed-tools: Bash(*), Read, Glob, Grep, Agent
---
Trace how a specific user query flows through the entire AIUI pipeline. The user will provide a sample query (e.g., "best nostr apps", "recommend some films", "bitcoin news").
## Trace each stage:
1. **Query classifiers**: Run the query through each classifier in `contentFiltering.ts`:
- `isNewsQuery()`, `isMusicQuery()`, `isBookQuery()`, `isTVQuery()`, `isImageQuery()`, `isPlaceQuery()`, `isRecipeQuery()`, `isCodeQuery()`, `isNostrQuery()`, `isAppQuery()`, `isWebsitesQuery()`
- Report which ones return true
2. **Preferred tab**: What does `preferredFirstTab()` return for this query?
3. **System prompt**: What would `buildSystemPrompt()` include? Read `useAI.ts` and trace all dynamic sections.
4. **Expected AI response**: Based on the system prompt instructions, what tags would the AI likely use? Construct a realistic sample response.
5. **Extraction**: Run the sample response through each extraction function and report what gets found:
- `extractAllFilms()`, `extractAllSongs()`, `extractAllPodcasts()`, `extractAllBooks()`, `extractAllTVSeries()`, `extractAllImages()`, `extractAllPlaces()`, `extractApps()`, `extractCodeBlocks()`, `extractRecipes()`
6. **Tab filtering**: What tabs would `filterTabsByContext()` return? In what order?
7. **Rendering**: Which grid component would render? Trace through ContentPanel.vue and/or ContentGridView.vue.
8. **Report**: Complete flow diagram showing: Query -> Classifiers -> Prompt -> Expected Response -> Extraction -> Tabs -> Grid
Submodule aiui/.claude/worktrees/agitated-hofstadter added at 10e12a329f
Submodule aiui/.claude/worktrees/funny-hofstadter added at 1c5185a15c
Submodule aiui/.claude/worktrees/happy-colden added at 666e1232f4
Submodule aiui/.claude/worktrees/hardcore-beaver added at a817fa199f
Submodule aiui/.claude/worktrees/heuristic-raman added at e8e002debc
Submodule aiui/.claude/worktrees/priceless-colden added at aaaef7d710
@@ -0,0 +1,65 @@
---
description: Core development philosophy for AIUI - the foundational rules that govern all code and design decisions
globs: "**/*"
alwaysApply: true
---
# Master Philosophy
## Mission
Build the next-generation AI content surface UI — a paradigm where AI responses are rendered as rich, interactive content, not plain text. Delivered as a reusable component library (@aiui/core) and a reference application (AIUI App).
## Philosophical Pillars
### 1. Open Source Only
Every dependency must be OSS (MIT, Apache-2.0, GPL-compatible). No proprietary SDKs, no vendor-locked services. Before adding any dependency, verify its license.
### 2. Decentralized-First
No hard dependency on any centralized service. AI backends, messaging protocols, storage, search — all connect through pluggable adapter interfaces. Users choose their own providers.
### 3. Bitcoin Only
Bitcoin is the only monetary unit. On-chain, Lightning, ecash (Cashu, Fedimint/Fedi). No fiat payment rails, no altcoins, no stablecoins — anywhere in the UI or codebase. AIUI is never a wallet and never handles funds directly. See `10-bitcoin-only.mdc` for full rules.
### 4. Cryptography for Everything Sensitive
E2E encryption for messages, encrypted local storage, proper key management. Privacy is not a feature — it is a requirement.
### 5. Mobile-First, Everywhere-Perfect
Every component works flawlessly on mobile, tablet, and desktop. Mobile is the foundation, not an afterthought. Touch targets, viewport management, and safe areas are first-class citizens.
### 6. Consistency is Sacred
Mobile and desktop versions show identical content and functionality unless explicitly designed otherwise. Design tokens ensure visual consistency across all breakpoints.
### 7. Theme-First Architecture
Theming is a core architectural decision from day one. Themes are CSS-based with reactive state management. Dark mode and light mode are equals.
### 8. Utility-First, Component-Second
Tailwind CSS utilities in templates for maximum flexibility. Component classes only for truly reusable patterns. Extract components when you repeat, not before.
### 9. Performance as a Feature
Initial load < 250KB gzipped. Lazy load everything that isn't immediately visible. CSS transforms for GPU acceleration. SVG over raster images. Code splitting by default.
### 10. Plugin-Everything
Every external integration connects through a typed plugin interface. AI providers, media sources, messaging protocols, wallets, social embeds — all pluggable.
### 11. Accessibility is Not Optional
WCAG AA compliance minimum. Keyboard navigation everywhere. Screen reader friendly. Color contrast tested and validated.
### 12. MCP-Native
First-class Model Context Protocol support for AI tool interoperability.
## Anti-Patterns to Avoid
- Desktop-first thinking
- Hardcoded values (use design tokens)
- Premature abstraction (build three times before abstracting)
- Magic numbers without comments
- Invisible state (user should always know what's happening)
- Handling funds or private keys
- Loading third-party tracking scripts
- Proprietary dependencies
## The Ultimate Goal
When someone uses AIUI, they should think: "This feels incredibly polished", "Everything just works", "My data is safe", "I control my own setup."
When a developer reads the code: "This is well organized", "I understand exactly what's happening", "Adding a new renderer is straightforward."
+84
View File
@@ -0,0 +1,84 @@
---
description: Vue 3 Composition API conventions and best practices for AIUI
globs: "**/*.vue,**/*.ts"
alwaysApply: false
---
# Vue 3 Conventions
## Composition API with `<script setup>`
Always use `<script setup lang="ts">`. Never use Options API.
## Component Organization Order
1. Imports — external, then internal
2. Props — with TypeScript-style validation
3. Emits — explicitly defined
4. State (refs and reactive)
5. Computed — derived values, always pure
6. Watchers — side effects only
7. Methods — business logic
8. Lifecycle hooks — ordered by execution
9. Expose — public API (if needed)
## File Organization
```
src/
components/
ui/ # Primitives (Button, Card, Badge, Input)
chat/ # Chat window, message list, input
content-panel/ # Side panel for surfaced content
renderers/ # Content type renderers
layout/ # Shell, split-pane, responsive containers
composables/ # Shared composition functions (useTheme, useMedia, useCrypto)
stores/ # Pinia stores
plugins/ # Plugin system
types/ # Shared TypeScript types
styles/ # Global CSS, themes, design tokens
utils/ # Pure utility functions
```
## Naming Conventions
- Components: PascalCase (`ProjectCard.vue`)
- Composables: camelCase, prefixed with "use" (`useTheme.ts`)
- Props: camelCase in JS, kebab-case in templates
- Boolean props: prefix with `is`, `has`, `can`, `should`
- Handler props: prefix with `on` (`onClick`, `onClose`)
- Emits: explicit, kebab-case in templates (`project:updated`)
## Props — Always Validate
```typescript
defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
status: {
type: String as PropType<'pending' | 'active' | 'complete'>,
default: 'pending'
}
})
```
Never use array-style props: `defineProps(['title', 'count'])`
## Reactive State
- `ref` for primitives and single values
- `reactive` for objects with multiple properties
- `computed` for derived state (never side effects in computed)
- `shallowRef` for large objects that change at top level only
## Templates — Keep Clean
Move complex logic to computed properties or methods. No inline logic in templates. Use `v-if` for infrequent toggles, `v-show` for frequent ones.
## Composables
- One responsibility per composable
- Return only what's needed
- Handle cleanup in `onUnmounted`
- Make composables testable
## Performance
- Lazy load heavy components: `defineAsyncComponent(() => import(...))`
- Use `shallowRef` for large lists
- Use `:key` with unique identifiers, never index
- Avoid reactive objects in templates (create in script)
## Error Handling
Use `onErrorCaptured` for component-level error boundaries. Always handle async errors with try/catch/finally pattern (loading, error, data states).
+111
View File
@@ -0,0 +1,111 @@
---
description: Tailwind CSS utility-first styling conventions for AIUI, ported from Archy
globs: "**/*.vue,**/*.css,**/*.ts"
alwaysApply: false
---
# Tailwind CSS Styling
## Source of Truth
All glass morphism, container, and button patterns originate from the Archy project (`/Projects/Archy/neode-ui/src/style.css`). When in doubt, match Archy exactly.
## Utility-First
Use Tailwind utilities directly in templates. Extract to component classes only when a pattern repeats 3+ times.
## 4px Spacing Grid
```
1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 5 = 20px, 6 = 24px, 7 = 28px, 8 = 32px
```
## Typography Scale
```
text-xs = 12px (metadata, timestamps)
text-sm = 14px (body text, buttons)
text-base = 16px (default body, inputs)
text-lg = 18px (subtitles)
text-xl = 20px (card titles)
text-2xl = 24px (section headings)
text-3xl = 30px (page headings)
text-4xl = 36px (hero headings)
```
Font weights: `font-normal` (body), `font-medium` (emphasis), `font-semibold` (headings/buttons), `font-bold` (strong emphasis).
## Glass Morphism (from Archy)
### Containers (exact Archy values)
- `.glass` — base: `bg: rgba(0,0,0,0.35)`, `blur(18px)`, `border: 1px solid rgba(255,255,255,0.18)`, `shadow: 0 8px 24px rgba(0,0,0,0.45)`
- `.glass-strong` — stronger blur: same bg but `blur(24px)`
- `.glass-card` — primary card: `bg: rgba(0,0,0,0.65)`, `blur(18px)`, `border-radius: 1rem`, same border/shadow
- `.gradient-card` — gradient: `linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(0,0,0,0.8) 100%)`
- `.gradient-card-dark` — dark gradient: `linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.9) 100%)`
- `.gradient-border-container` — gradient border with inner glass, `border-radius: 1.5rem`
- `.toast-glass` — `border-radius: 0.75rem`, same glass as `.glass-card`
### Buttons (exact Archy values)
- `.glass-button` — 48px height, `bg: rgba(0,0,0,0.6)`, `blur(18px)`, border `rgba(255,255,255,0.18)`, `color: rgba(255,255,255,0.9)`
- `.glass-button-sm` — compact variant (auto height, `py-1.5 px-3`)
### Icon / Ghost buttons (Archy pattern)
```html
<button class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors">
```
Touch target: minimum 44x44px via padding.
### Active Navigation
`.nav-tab-active` — `bg: rgba(0,0,0,0.35)`, inset highlight, gradient border via CSS mask `::before`
### Usage Rules
- ✅ Cards, panels, modals, sidebars
- ✅ Navigation bars, headers (fixed positioning)
- ✅ Hover states, buttons
- ❌ Body text containers (readability)
- ❌ Form input fields (confusing UX)
## Inset Highlight
The signature Archy inset glow:
```css
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
```
Apply to headers, selected cards, active nav items.
## Border — No Separators Between Sections
Per Archy theme rules: no borders between sidebar and content, or header and content. Only subtle `rgba(255,255,255,0.06-0.08)` borders for internal dividers.
## Gradient Text
```html
<h1 class="gradient-text">Title</h1>
```
`linear-gradient(to right, #ffffff, #9ca3af)` with `background-clip: text`.
## Focus States — Gamepad/Keyboard Glow
All focusable elements get a blue glow (no outline):
```css
*:focus-visible {
outline: none;
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
}
```
## Scrollbar
- `.custom-scrollbar` — gradient thumb (`rgba(255,255,255,0.3)` to `0.1`), dark track
- `.scrollbar-hide` — hidden scrollbar, keeps scroll functionality
## Responsive — Mobile First
Base styles for mobile, enhance with breakpoints:
```html
<div class="text-base md:text-lg lg:text-xl p-4 md:p-6 lg:p-8">
```
Breakpoints: `sm` (640px), `md` (768px), `lg` (1024px), `xl` (1280px), `2xl` (1536px).
## Hover States (from Archy)
```html
<div class="transition-all duration-300 hover:bg-white/10 hover:text-white">
```
Interactive card lift: `hover:translateY(-2px)` with intensified shadow.
## Animations (Archy timings)
- `animate-fade-up` — 900ms `cubic-bezier(0.22, 1, 0.36, 1)` with 120ms delay
- `animate-fade-up-fast` — 400ms, no delay (for chat messages)
- `animate-fade-in` — 500ms ease
- `animate-scale-in` — 250ms for modals/popups
+118
View File
@@ -0,0 +1,118 @@
---
description: Design system foundations - glassmorphism from Archy, colors, typography, spacing
globs: "**/*.vue,**/*.css,**/*.ts"
alwaysApply: false
---
# Design System
All glass morphism, container, and button patterns are ported from the Archy project and must match exactly.
## Glass Morphism Hierarchy (from Archy)
### Glass Intensity Levels
| Class | Background | Blur | Use Case |
|-------|-----------|------|----------|
| `.glass` | `rgba(0,0,0,0.35)` | 18px | Sidebar, panels, inputs |
| `.glass-strong` | `rgba(0,0,0,0.35)` | 24px | Headers, message bubbles (user) |
| `.glass-card` | `rgba(0,0,0,0.65)` | 18px | Primary cards, modals, main containers |
| `.gradient-card` | gradient white→black | 18px | Feature cards |
| `.gradient-card-dark` | gradient black→black | 18px | Dark feature cards |
All share: `border: 1px solid rgba(255,255,255,0.18)`, `box-shadow: 0 8px 24px rgba(0,0,0,0.45)`.
### Button Hierarchy (from Archy)
| Class | Purpose | Details |
|-------|---------|---------|
| `.glass-button` | Default | 48px height, `rgba(0,0,0,0.6)`, blur 18px |
| `.glass-button-sm` | Compact | Auto height, smaller padding |
| Ghost | Icon/text actions | `p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10` |
### Inset Highlight
Signature Archy top-edge glow on focused/active elements:
```css
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
```
### Gradient Border (CSS mask technique)
For premium-feel borders on selected cards and active nav:
```css
::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
```
## Design Tokens
### Color Palette
Semantic color tokens defined by purpose:
- `primary` — main brand actions (#606060)
- `accent` — highlight, Bitcoin orange (#F7931A)
- `success` — positive states (#10B981)
- `error` — negative states (#EF4444)
- `warning` — caution states (#F59E0B)
- `info` — informational (#3B82F6)
### Glass Tokens (from Archy Tailwind config)
- `glass-dark`: `rgba(0, 0, 0, 0.35)`
- `glass-darker`: `rgba(0, 0, 0, 0.6)`
- `glass-border`: `rgba(255, 255, 255, 0.18)`
- `glass-highlight`: `rgba(255, 255, 255, 0.22)`
### Shadows
- `shadow-glass`: `0 8px 24px rgba(0, 0, 0, 0.45)`
- `shadow-glass-sm`: `0 6px 18px rgba(0, 0, 0, 0.35)`
- `shadow-glass-inset`: `inset 0 1px 0 rgba(255, 255, 255, 0.22)`
### Typography
- Body font: Inter, system-ui (AIUI default)
- Mono font: Menlo, Monaco, Courier New
- Text opacity scale: `text-white/25` (placeholders), `text-white/40` (muted), `text-white/60` (secondary), `text-white/70` (interactive default), `text-white/80` (body), `text-white/90` (emphasis), `text-white/96` (headings), `text-white` (active/selected)
### Spacing
4px grid: `4, 8, 12, 16, 20, 24, 28, 32` px.
### Border Radius
- `rounded-lg` (8px) — buttons, nav items, inputs
- `rounded-xl` (12px) — toasts, small cards
- `rounded-2xl` (16px) — main cards, modals
- `rounded-3xl` (24px) — bottom sheets
- `rounded-full` — pills, avatars, FABs
- `1rem` (16px) — `.glass-card` default
## Component Patterns
### Cards
Use `.glass-card` with additional padding:
```html
<div class="glass-card p-6">Content</div>
```
### Modals
```html
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div class="glass-card p-6 max-w-md w-full">...</div>
</div>
```
### Icons
- SVG, using `currentColor`
- Sizes: 16px, 20px, 24px, 32px
- Icon-only buttons: `p-2 rounded-lg` (reaches 44px touch target with 24px icon)
- Must have `aria-label`
## Theme Architecture
- Base background: `#0a0a0a` (near-black)
- No separator borders between sidebar/header/content
- Header, sidebar, root share same visual weight
- CSS-based themes with reactive Vue state
- `localStorage` persistence
@@ -0,0 +1,91 @@
---
description: Component architecture principles - composition, patterns, and structure
globs: "**/*.vue,**/*.ts"
alwaysApply: false
---
# Component Architecture
## Core Philosophy: Composition Over Configuration
Build complex UIs from simple, focused components that compose well together.
- Single Responsibility: each component does one thing well
- Use slots instead of complex prop APIs
- Provide sensible defaults
- Clear TypeScript interfaces for props
- Keep component state local and minimal
## Anti-Patterns
- God components that do everything
- Prop drilling through many layers (use provide/inject or Pinia)
- Hard-coded values instead of props
- Component logic mixed with layout
- Tight coupling between components
## Compound Component Pattern
Components that work together as a cohesive unit:
```vue
<Card>
<Card.Header>Title</Card.Header>
<Card.Body>Content</Card.Body>
<Card.Footer>Actions</Card.Footer>
</Card>
```
## Container/Presenter Pattern
Separate logic from presentation:
- Container: handles data fetching, state, side effects
- Presenter: pure rendering, receives data via props, emits events
## Slot Pattern (Vue)
Use named slots for flexible content injection:
```vue
<template>
<div class="section">
<slot name="title" />
<slot name="content" />
<slot name="actions" />
</div>
</template>
```
## Prop Interface Design
```typescript
interface BaseComponentProps {
class?: string
testId?: string
}
interface ButtonProps extends BaseComponentProps {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
}
```
## Component File Template
```
1. Imports (external, then internal)
2. Types/Interfaces
3. Constants
4. Main component (props, emits, state, computed, methods, lifecycle)
5. Sub-components (if any)
```
## Error Boundaries
Every major section should have error boundary handling via `onErrorCaptured`. Show fallback UI, never a blank screen.
## Responsive Components
Use CSS-based responsive (`hidden md:block`) over JS-based (`useMediaQuery`) when possible. JS-based only when behavior changes (not just visibility).
## Component Checklist
Before shipping any component:
- [ ] TypeScript interface defined
- [ ] Sensible default props
- [ ] Loading and error states handled
- [ ] ARIA attributes added
- [ ] Keyboard navigation works
- [ ] Responsive behavior tested
- [ ] Dark mode styling works
- [ ] Touch interactions verified on mobile
@@ -0,0 +1,91 @@
---
description: The five content surfaces that define how content is rendered in AIUI
globs: "**/renderers/**,**/chat/**,**/content-panel/**"
alwaysApply: false
---
# Content Surfaces
AIUI has five distinct surfaces where content can appear. Every renderer must define how it behaves in each applicable surface.
## Surface 1: Chat Preview
- Location: inline in chat message bubble
- Max height: ~120px
- Purpose: identify content at a glance (thumbnail, title, brief metadata)
- Always tappable/clickable to expand to Panel Preview or Panel Play
- Lightweight rendering only — no heavy libraries loaded
- Examples: film poster thumbnail strip, file icon with name, code snippet (first 5 lines), image thumbnail
## Surface 2: Chat Play
- Location: inline in chat message bubble
- Max height: ~200px
- Purpose: inline playback without leaving the chat
- Must not disrupt chat scrolling
- Has an "expand" button to open in Panel Play
- Examples: voice note waveform with play button, short video player, audio player, small interactive widget
## Surface 3: Panel Preview
- Location: content panel (beside chat on desktop, overlay on mobile)
- No height limit (scrollable within panel)
- Purpose: full browsing/exploration experience
- Supports: filtering, sorting, searching, pagination
- Click items to go to Panel Play or Panel Edit
- Examples: film grid (tiled, filterable), image gallery, search results list, document preview, file tree
## Surface 4: Panel Play
- Location: content panel
- Purpose: full immersive media playback
- Examples: full video player with controls, audio with spectrum visualization, slideshow, trailer playback
## Surface 5: Panel Edit/Interactive
- Location: content panel
- Purpose: full interaction and editing
- Changes can be sent back to chat as new messages
- Examples: code editor (CodeMirror), form filling, approval workflow, spreadsheet editing, diagram creation
## Surface Transitions
```
Chat Preview --tap--> Panel Preview --tap item--> Panel Play
--tap item--> Panel Edit
Chat Play --expand--> Panel Play
Panel Edit --submit--> Chat (new message with result)
```
## Renderer Interface
Every renderer must export:
```typescript
interface RendererDefinition {
id: string
name: string
contentType: string // MIME-like type identifier
surfaces: SurfaceType[] // which surfaces this renderer supports
chatPreview?: Component // Surface 1
chatPlay?: Component // Surface 2
panelPreview?: Component // Surface 3
panelPlay?: Component // Surface 4
panelEdit?: Component // Surface 5
lazyDependencies?: () => Promise<any> // heavy libs loaded on demand
}
```
## Mobile Behavior
- On mobile, there is no side-by-side layout
- Panel surfaces open as a full-screen overlay or bottom sheet
- Chat Preview and Chat Play remain inline
- Transition: tap Chat Preview → full-screen Panel Preview (slide up)
- Back gesture or button returns to chat
## Performance Rules
- Chat Preview and Chat Play must render with zero lazy-loaded dependencies
- Panel surfaces may lazy-load heavy libraries (CodeMirror, pdf.js, etc.)
- Never block the chat scroll with renderer loading
- Use skeleton/placeholder while panel content loads
## Content Type Expert Rules
For extraction, parsing, and surfacing logic, see:
- `20-content-films.mdc` — Films
- `21-content-songs.mdc` — Songs (includes looksLikeSong blocklist)
- `22-content-podcasts.mdc` — Podcasts (includes looksLikePodcast)
- `23-content-news.mdc` — News + RSS, ArticleDetail security
- `24-content-websites.mdc` — Websites vs News, overlay
- `25-content-magazine.mdc` — Magazine/Brief parsing, hero, meme
+98
View File
@@ -0,0 +1,98 @@
---
description: Plugin architecture rules - interfaces, registration, lifecycle, sandboxing
globs: "**/plugins/**,**/*.plugin.ts"
alwaysApply: false
---
# Plugin System
## Philosophy
Every external integration connects through a typed plugin interface. No direct coupling to any service, provider, or protocol.
## Plugin Types
```typescript
type PluginType =
| 'ai-provider' // LLM backends (OpenRouter, Ollama, Claude, etc.)
| 'media-source' // Content sources (Plex, YouTube, Nextcloud, Archive.org)
| 'messaging' // Chat protocols (Nostr, Matrix, local)
| 'storage' // File storage (local FS, IPFS, Nextcloud)
| 'renderer' // Custom content renderers
| 'file-handler' // File open/preview handlers
| 'crypto' // Encryption providers
| 'search' // Search backends (SearXNG, local)
| 'auth' // Authentication (Nostr keys, DID, passkeys)
| 'wallet' // Bitcoin wallet deep-linking (Phoenix, Zeus, Alby, etc.)
| 'social-embed' // Social post fetching (X, Nostr, Mastodon)
| 'mcp' // Model Context Protocol servers
| 'media' // Media processing (ffmpeg.wasm, whisper, TTS)
```
## Base Plugin Interface
```typescript
interface AIUIPlugin {
id: string
name: string
version: string
type: PluginType
description?: string
icon?: string
init(context: PluginContext): Promise<void>
destroy(): Promise<void>
isAvailable(): Promise<boolean>
}
```
## Plugin Context
Plugins receive a context object with access to:
- Settings store (read/write plugin-specific settings)
- Event bus (emit/listen for app events)
- Logger (structured logging)
- Crypto utilities (for encrypting plugin data at rest)
Plugins do NOT receive:
- Direct DOM access (community plugins)
- File system access (without explicit capability grant)
- Network access to arbitrary hosts (without declaration)
## Sandboxing Tiers
### Tier 1: Trusted (built-in, official)
Run in main thread with full API access. AI adapters, core renderers, crypto providers.
### Tier 2: Community
Run in sandboxed iframes with `postMessage` API. Custom renderers, themes, visual extensions. Cannot access host DOM, file system, or network directly.
### Tier 3: External Processes
MCP servers, local AI runners. Run as separate processes (Tauri IPC) or connect via HTTP. Isolated by OS process boundary.
## Plugin Lifecycle
1. `register()` — declare plugin to registry
2. `init()` — plugin sets up, connects to services
3. Active — plugin responds to requests
4. `destroy()` — cleanup on disable/uninstall
## Registration
```typescript
import { registerPlugin } from '@aiui/core'
registerPlugin({
id: 'ai-openrouter',
name: 'OpenRouter',
type: 'ai-provider',
version: '1.0.0',
async init(ctx) { /* setup */ },
async destroy() { /* cleanup */ },
// ... adapter methods
})
```
## Plugin Settings
Each plugin can declare settings schema. Settings are stored encrypted and exposed through a standard settings UI.
## Rules
- Every plugin must declare its type
- Every plugin must implement `init()` and `destroy()`
- Every plugin must implement `isAvailable()` to report its status
- Plugins must handle errors gracefully — never crash the host
- Community plugins must not load external scripts
- All network requests must go through the plugin context (for privacy/proxy control)
+83
View File
@@ -0,0 +1,83 @@
---
description: AI adapter patterns, streaming, tool calling, context injection
globs: "**/ai/**,**/plugins/ai-*/**"
alwaysApply: false
---
# AI Integration
## Universal AI Adapter
All AI providers connect through the `AIProviderAdapter` interface:
```typescript
interface AIProviderAdapter extends AIUIPlugin {
type: 'ai-provider'
chat(messages: Message[], options: ChatOptions): AsyncIterable<ChatChunk>
models(): Promise<Model[]>
supportsStreaming: boolean
supportsVision: boolean
supportsTools: boolean
supportsMultimodal: boolean
}
```
## Provider Hierarchy
1. **OpenAI-Compatible Adapter** — covers OpenRouter, Ollama, vLLM, llama.cpp, LocalAI, Mistral, DeepSeek, xAI, Qwen. Just change `baseURL` + API key.
2. **Anthropic Adapter** — Claude. Different tool_use format (content blocks vs tool_calls).
3. **Gemini Adapter** — Google. Different multimodal format.
4. **MCP Client** — connects to any MCP server for tools, resources, prompts.
## Streaming
- All AI responses use Server-Sent Events (SSE) over HTTP
- Pattern: `data: {"token": "Hello"}\n\n` with `data: [DONE]\n\n` termination
- Client: parse SSE stream, feed tokens to `StreamingTextRenderer`
- Always show a typing indicator while waiting for first token
- Handle connection drops gracefully (show error, offer retry)
## Tool Calling
AI can invoke tools. The adapter normalizes tool call formats:
```typescript
interface ToolCall {
id: string
name: string
arguments: Record<string, unknown>
}
interface ToolResult {
toolCallId: string
content: string | StructuredContent
isError: boolean
}
```
Normalize across providers:
- OpenAI: `tool_calls` in assistant message → `role: "tool"` result
- Claude: `type: "tool_use"` content block → `tool_result` in user message
- Map both to AIUI's unified `ToolCall` / `ToolResult` types
## Context Injection
The system prompt includes context about the user's environment:
- Connected media sources and their capabilities
- Available tools and plugins
- User preferences (language, theme, preferred wallet)
- In dev mode: mock data summaries
Never include sensitive data (API keys, passwords) in system prompts.
## Model Selection
Users can switch models within a conversation. The UI shows:
- Available models from all connected providers
- Model capabilities (vision, tools, streaming)
- Cost per token in sats (if applicable)
## Dev Mode
- `VITE_OPENROUTER_API_KEY` in `.env.local`
- Free models available (Llama, Mistral via OpenRouter)
- Mock tool responses available via dev fixtures
- Debug panel shows: raw messages, token count, latency
## Error Handling
- Rate limits: show user-friendly message, auto-retry with backoff
- Auth errors: prompt to check API key in settings
- Network errors: show offline indicator, queue message for retry
- Model errors: show error in chat, suggest alternative model
@@ -0,0 +1,80 @@
---
description: How to build content renderers - interfaces, lazy loading, accessibility
globs: "**/renderers/**"
alwaysApply: false
---
# Renderer Development
## What is a Renderer?
A renderer is a set of Vue components that know how to display a specific content type across the five content surfaces (chat-preview, chat-play, panel-preview, panel-play, panel-edit).
## Renderer Registration
```typescript
import { registerRenderer } from '@aiui/core'
registerRenderer({
id: 'film',
name: 'Film',
contentType: 'application/x-aiui-film',
surfaces: ['chat-preview', 'panel-preview', 'panel-play'],
chatPreview: () => import('./FilmChatPreview.vue'),
panelPreview: () => import('./FilmGrid.vue'),
panelPlay: () => import('./FilmDetail.vue'),
})
```
## Content Type Detection
Renderers are matched to content by `contentType` field in the message data:
```typescript
interface ContentBlock {
contentType: string // e.g., 'application/x-aiui-film'
data: Record<string, unknown> // renderer-specific data
title?: string // human-readable title for panel tab
}
```
## Performance Rules
1. Chat surfaces (preview, play) must render with ZERO lazy-loaded heavy dependencies
2. Panel surfaces may lazy-load libraries (CodeMirror, pdf.js, etc.)
3. Use `defineAsyncComponent` for panel components
4. Show skeleton/placeholder while loading
5. Never block the main thread — use Web Workers for heavy parsing
## Data Contracts
Each renderer defines its expected data shape as a TypeScript interface:
```typescript
interface FilmRendererData {
films: Film[]
query?: string
filters?: FilmFilters
}
```
Document the interface. Validate incoming data. Show graceful error if data is malformed.
## Accessibility Requirements
- All renderers must be keyboard navigable
- Images need alt text
- Interactive elements need ARIA labels
- Media players need captions/transcripts when available
- Focus management when transitioning between surfaces
## Mobile Behavior
- Chat Preview: constrained to message bubble width
- Chat Play: full message width, max 200px height
- Panel surfaces on mobile: full-screen overlay with back gesture
- Touch targets: minimum 44x44px
- Swipe gestures where appropriate (image gallery, film cards)
## Renderer Checklist
- [ ] TypeScript data interface defined and exported
- [ ] All applicable surfaces implemented
- [ ] Lazy loading for heavy dependencies
- [ ] Skeleton/placeholder states
- [ ] Error state (malformed data)
- [ ] Empty state (no data)
- [ ] Keyboard navigation
- [ ] ARIA labels on interactive elements
- [ ] Mobile responsive
- [ ] Dark mode compatible
- [ ] Transition animations (per motion design rules)
+60
View File
@@ -0,0 +1,60 @@
---
description: Cryptography and security rules - E2E encryption, key management, storage
globs: "**/crypto/**,**/*.ts"
alwaysApply: false
---
# Security & Cryptography
## Principles
- Privacy is a requirement, not a feature
- Zero telemetry, zero analytics unless user explicitly opts in
- Never transmit unencrypted sensitive data
- Never store plaintext credentials
- Minimal data collection — store only what's needed
## Encryption Stack
### E2E Message Encryption
- Library: **tweetnacl.js** (6KB, audited by Cure53)
- Algorithm: XSalsa20-Poly1305 via NaCl `box` (public-key authenticated encryption)
- Each conversation has a shared secret derived from key exchange
### Local Storage Encryption
- Library: **Web Crypto API** (native, zero bundle cost)
- Algorithm: AES-256-GCM for encrypting IndexedDB values
- Key derived from user's master password via PBKDF2 (100K+ iterations)
### Key Management
- **Desktop (Tauri)**: OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
- **Web**: Encrypted IndexedDB with user-derived key
- **Nostr compatibility**: secp256k1 keys via @noble/curves, NIP-07 browser extension support
- **Passkeys/WebAuthn**: For passwordless authentication
### Credential Storage
- API keys encrypted at rest using AES-256-GCM
- Never stored in localStorage (use encrypted IndexedDB or OS keychain)
- Never included in logs, error reports, or system prompts
- Display as masked values in settings UI (show last 4 chars only)
## Dev Mode Bypass
When `VITE_DISABLE_CRYPTO=true` (dev only):
- Skip E2E encryption (messages stored in plain text)
- Skip storage encryption (IndexedDB unencrypted)
- API keys stored in `.env.local` (gitignored)
- This flag must NEVER exist in production builds
## Security Rules for Code
- Never log sensitive data (keys, tokens, passwords, message content)
- Never include secrets in error messages
- Sanitize all user input before rendering (XSS prevention)
- Use Content Security Policy headers
- Validate all data from plugins before rendering
- Community plugins run in sandboxed iframes (no direct DOM access)
- Never eval() or innerHTML with untrusted content
## Network Security
- All external requests over HTTPS only
- Certificate pinning for known services (Tauri)
- Proxy social media fetches to avoid leaking user IP
- No third-party tracking scripts, analytics, or telemetry SDKs
+72
View File
@@ -0,0 +1,72 @@
---
description: Bitcoin-only payment and monetary policy - on-chain, Lightning, ecash
globs: "**/*"
alwaysApply: true
---
# Bitcoin Only
## Core Rule
Bitcoin is the only monetary unit in AIUI. This applies everywhere — UI labels, data models, API responses, documentation, and conversation context.
## Supported Payment Protocols
- **On-chain Bitcoin**: BIP21 URI scheme (`bitcoin:bc1q...?amount=0.001`)
- **Lightning Network**: BOLT11 invoices, LNURL-pay, LNURL-withdraw, keysend
- **Cashu ecash**: Cashu tokens, mint interactions (`cashu:` URI, `web+cashu:`)
- **Fedimint/Fedi**: Federation ecash (`fedi:` URI)
- **Nostr Zaps**: NIP-57 Lightning zaps (social tipping)
## AIUI is NEVER a Wallet
### Never Do
- Store private keys or seed phrases
- Sign Bitcoin transactions
- Build or broadcast transactions
- Track wallet balances
- Display transaction history
- Create send/receive screens
- Implement payment processing logic
- Hold funds in custody
### Always Do
- Construct deep-link URIs and hand off to external wallet apps
- Detect installed wallet apps (via URI scheme probing or Tauri app detection)
- Let users configure preferred wallets in settings
- Display payment requests as QR codes with "Open in Wallet" buttons
- Show invoice/address details (amount, memo, expiry) as read-only information
## Wallet Deep-Linking
```typescript
// Construct URI, open external wallet — that's it
const uri = `lightning:${bolt11Invoice}`
window.open(uri) // or Tauri shell.open(uri)
```
Supported wallet URI schemes:
- `bitcoin:` — BIP21 (any on-chain wallet)
- `lightning:` — BOLT11 (any Lightning wallet)
- `cashu:` — Cashu tokens
- `fedi:` — Fedimint
- Wallet-specific: `phoenix://`, `zeus://`, `mutiny://`, `alby://`
## Denomination
- Primary unit: **sats** (1 BTC = 100,000,000 sats)
- Display: `1,234 sats` or `₿0.00001234`
- User preference: sats or BTC (configurable in settings)
- AI cost tracking: show token costs in sats
## Prohibited
- No fiat currencies (USD, EUR, etc.) — not in UI, not in code, not in variable names
- No altcoins or tokens
- No stablecoins (USDT, USDC, etc.)
- No fiat-denominated pricing
- No payment processor integrations (Stripe, PayPal, etc.)
- No KYC/AML flows
## Renderer Components
- `LightningInvoiceRenderer` — BOLT11 QR + amount + memo + "Open in Wallet"
- `BitcoinAddressRenderer` — BIP21 QR + "Open in Wallet"
- `CashuTokenRenderer` — ecash token + mint info + "Redeem in Wallet"
- `FedimintRenderer` — federation ecash + "Open in Fedi"
- `PaymentRequestRenderer` — unified card with payment method options
- `ZapRenderer` — Nostr zap display (NIP-57)
+101
View File
@@ -0,0 +1,101 @@
---
description: Development vs production configuration, feature flags, mock data patterns
globs: "**/*"
alwaysApply: false
---
# Dev & Prod Modes
## Development Mode
### Environment
```env
# .env.local (gitignored)
VITE_OPENROUTER_API_KEY=sk-or-...
VITE_TMDB_API_KEY=...
VITE_DEV_MODE=true
VITE_MOCK_MEDIA_SOURCES=true
VITE_DISABLE_CRYPTO=true
```
### What's Enabled
- Hot reload via Vite HMR
- Debug panel overlay (AI context, plugin status, renderer registry, message data)
- Mock media source plugins (Plex, YouTube, Nextcloud from JSON fixtures)
- OpenRouter AI connection (real API, free models available)
- Component playground (Storybook/Histoire)
- Verbose logging
- TypeScript strict mode
- All renderers available without lazy loading (for dev speed)
### What's Disabled
- E2E encryption (plain text messages for debugging)
- Storage encryption (plain IndexedDB)
- Tauri features (dev runs as pure web app)
- Production optimizations (tree-shaking, minification)
- Service worker / offline mode
### Mock Data
- Film fixtures: 50-100 films with real TMDB poster URLs
- Media source mocks: JSON files returning fake Plex/YouTube/Nextcloud responses
- Located in: `packages/app/src/mocks/`
- Auto-loaded when `VITE_MOCK_MEDIA_SOURCES=true`
- Mock data must match production data interfaces exactly
### Dev Scripts
```
pnpm dev # Web dev server
pnpm dev:desktop # Tauri dev (when needed)
pnpm storybook # Component playground
pnpm test # Vitest
pnpm lint # ESLint + Prettier
pnpm typecheck # TypeScript
pnpm build # Production build
pnpm turbo build # Turborepo cached build
```
## Production Mode
### What's Enabled
- E2E encryption for all messages
- Encrypted local storage
- Key management via OS keychain (Tauri) or encrypted IndexedDB (web)
- User-configured AI providers (settings page)
- Real media source connections (Plex API, YouTube, etc.)
- Optimized builds (tree-shaken, code-split, minified)
- Lazy loading for all heavy renderers
- Service worker for offline support
- Auto-update (Tauri)
### What's Disabled
- Debug panels
- Mock data
- Dev logging
- Source maps (in distributed builds)
- `VITE_DISABLE_CRYPTO` flag (must not exist)
### Build Targets
- Web: Static SPA bundle (< 250KB initial gzipped)
- Desktop: Tauri app (macOS .dmg, Windows .msi, Linux .AppImage)
- Mobile: Tauri mobile (iOS .ipa, Android .apk)
## Feature Flags
Use composable `useFeatureFlags()`:
```typescript
const { isDev, isTauri, isMobile, isCryptoEnabled, isMockData } = useFeatureFlags()
```
Gate platform-specific features:
```typescript
if (isTauri()) {
// Native file system access
} else {
// File System Access API or file picker
}
```
## Environment Variable Rules
- All env vars prefixed with `VITE_` (Vite requirement)
- Secrets only in `.env.local` (gitignored)
- `.env.example` committed with placeholder values
- Never read `process.env` directly — use typed config module
+69
View File
@@ -0,0 +1,69 @@
---
description: Accessibility standards - WCAG AA, keyboard navigation, screen readers
globs: "**/*.vue"
alwaysApply: false
---
# Accessibility
## Standard
WCAG AA compliance minimum. Target AAA where feasible.
## Color Contrast
- Normal text: 4.5:1 minimum ratio
- Large text (18px+ or 14px+ bold): 3:1 minimum
- Interactive elements: 3:1 against adjacent colors
- Test with browser DevTools accessibility panel
## Keyboard Navigation
- All interactive elements focusable via Tab
- Visible focus indicators on every focusable element (`focus:ring-2`)
- Escape closes modals, drawers, dropdowns
- Arrow keys navigate within lists, grids, tabs
- Enter/Space activates buttons and controls
- Focus trap inside modals (Tab cycles within modal)
## Semantic HTML
```html
<header>, <nav>, <main>, <article>, <aside>, <footer>
```
Never `<div class="header">`. Use semantic elements.
## ARIA
- Icon-only buttons: `aria-label="Close modal"`
- Dynamic content: `aria-live="polite"` for updates
- Screen reader only text: `class="sr-only"`
- Expandable sections: `aria-expanded="true/false"`
- Form fields: `aria-describedby` for help text, `aria-invalid` for errors
## Images
- All `<img>` tags need `alt` text
- Decorative images: `alt=""`
- Complex images: `aria-describedby` pointing to description
## Media
- Audio/video players: keyboard-accessible controls
- Provide transcripts/captions when available
- Respect `prefers-reduced-motion` for animations
## Touch Targets
- Minimum: 44x44px (Apple HIG)
- Recommended: 48x48px (Material Design)
- Minimum 8px gap between adjacent targets
## Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
Check in JS: `window.matchMedia('(prefers-reduced-motion: reduce)').matches`
## Testing
- VoiceOver (macOS), TalkBack (Android), NVDA (Windows)
- Keyboard-only navigation test
- axe DevTools or Lighthouse accessibility audit
- High contrast mode test
+60
View File
@@ -0,0 +1,60 @@
---
description: Performance optimization - bundle budget, lazy loading, virtual scrolling
globs: "**/*"
alwaysApply: false
---
# Performance
## Bundle Budget
- Initial load: **< 250KB gzipped**
- Core (Vue + Tailwind + Pinia + Router + chat UI): ~150KB
- First renderer batch (markdown, streaming text): ~50KB
- Everything else: lazy-loaded on demand
## Lazy Loading Strategy
- Route-based code splitting via Vue Router `() => import(...)`
- Renderer components via `defineAsyncComponent`
- Heavy libraries loaded only when their renderer is activated:
- CodeMirror 6: ~300KB (on code edit)
- Monaco: ~5MB (on IDE panel open)
- pdf.js: ~400KB (on PDF view)
- KaTeX: ~300KB (on math render)
- Mermaid: ~200KB (on diagram render)
- Leaflet: ~40KB (on map render)
- Whisper WASM: ~50MB (on STT activation, cached)
- Piper TTS: ~100MB (on TTS activation, cached)
## Virtual Scrolling
- Chat message list uses TanStack Virtual
- Dynamic row heights (messages vary in size)
- Inverted scroll (newest at bottom, load older on scroll up)
- Buffer: render 5 items above and below viewport
- Recycle DOM nodes for off-screen messages
## GPU Acceleration
Only animate `transform` and `opacity` — never `width`, `height`, `top`, `left`.
Use `will-change` sparingly and remove after animation.
## Image Optimization
- Use `loading="lazy"` on all non-critical images
- Provide `srcset` with multiple sizes
- Use WebP/AVIF where supported
- Skeleton placeholders while loading
## Network
- Preconnect to known API hosts
- Preload critical resources
- Debounce scroll and resize handlers (100ms)
- Batch API requests where possible
## Memory
- Clean up event listeners in `onUnmounted`
- Use `shallowRef` for large data sets
- Dispose heavy library instances when panel closes
- Monitor memory with browser DevTools
## Core Web Vitals Targets
- LCP (Largest Contentful Paint): < 2.5s
- FID (First Input Delay): < 100ms
- CLS (Cumulative Layout Shift): < 0.1
@@ -0,0 +1,79 @@
---
description: Animation principles - timing, easing, stagger, reduced motion
globs: "**/*.vue,**/*.css"
alwaysApply: false
---
# Animation & Motion Design
## Philosophy
Every animation serves a purpose: guide attention, provide feedback, show relationships, enhance perceived performance, or add delight. Never animate for decoration alone.
## Duration Scale
```
100ms - Instant: micro-feedback (hover states, button press)
200ms - Fast: small elements (tooltips, dropdowns)
300ms - Moderate: standard UI transitions (modals, cards)
500ms - Normal: page sections, complex components
600ms - Slow: hero animations, page transitions (max for UI)
```
Never exceed 600ms for UI element animations.
## Easing Functions
- **ease-out** (90% of animations): elements entering viewport
- **ease-in**: elements exiting viewport
- **ease-in-out**: elements moving within viewport
- **spring**: playful interactions (button press, drag-and-drop)
- **linear**: progress bars, loading spinners only
Custom smooth deceleration: `cubic-bezier(0.16, 1, 0.3, 1)`
## Common Patterns
### Fade & Slide Up (entrance)
```css
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
```
### Scale & Fade (emphasis)
```css
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
```
### Hover feedback
```css
.interactive {
transition: transform 0.1s ease, opacity 0.1s ease;
}
.interactive:active {
transform: scale(0.95);
opacity: 0.8;
}
```
## Staggered Animations
When animating multiple elements, stagger by 50-150ms per item:
```css
.card { animation-delay: calc(var(--index) * 0.1s); }
```
Max items in a stagger cascade: 6-8. Total cascade: under 1 second.
## Reduced Motion
Always respect `prefers-reduced-motion`. Provide instant transitions as fallback.
## Performance
- Only animate `transform` and `opacity` (GPU-composited)
- Use `will-change` sparingly, remove after animation
- Limit simultaneous animations
- Use `requestAnimationFrame` for JS animations
## Loading States
- Skeleton shimmer: 2s infinite, `linear-gradient` sweep
- Pulse: 2s infinite, opacity 1 → 0.5 → 1
- Spinner: 1s infinite linear rotation
+173
View File
@@ -0,0 +1,173 @@
---
description: Mobile UX patterns informed by Apple iOS HIG — touch targets, typography, spacing, navigation, animations
globs: "**/*.vue,**/*.css"
alwaysApply: false
---
# Mobile UX (iOS HIG-Informed)
## Philosophy
Design for mobile first, enhance for desktop. Follow Apple iOS Human Interface Guidelines for sizing, spacing, and interaction patterns. Adapt native iOS conventions to our glass morphism dark theme.
## Typography (iOS Dynamic Type Mapped to CSS)
| iOS Text Style | Default Size | CSS Equivalent | AIUI Usage |
|---|---|---|---|
| Large Title | 34pt | `text-[34px]` / `text-3xl` | Page titles (rare) |
| Title 1 | 28pt | `text-[28px]` / `text-2xl` | Section headers |
| Title 2 | 22pt | `text-[22px]` / `text-xl` | Sub-section headers |
| Title 3 | 20pt | `text-[20px]` / `text-lg` | Card titles |
| Headline | 17pt semibold | `text-[17px] font-semibold` | Emphasis labels |
| Body | 17pt | `text-[17px]` / `text-base` | Primary content |
| Callout | 16pt | `text-[16px]` | Secondary content |
| Subheadline | 15pt | `text-[15px]` | Metadata |
| Footnote | 13pt | `text-[13px]` / `text-xs` | Timestamps, captions |
| Caption 1 | 12pt | `text-[12px]` | Badges, small labels |
| Caption 2 | 11pt | `text-[11px]` | Smallest text (tab labels) |
### Key rules
- **Minimum text size**: 11px (Caption 2) — never go smaller
- **Body text on mobile**: 17px (not 14px/16px) for comfortable reading
- Use `text-sm` (14px) sparingly — only for dense UI, not primary reading content
- Chat messages should use at least 15-16px on mobile
- Metadata/timestamps: 11-13px is acceptable
## Touch Targets
| Rule | Value | Tailwind |
|---|---|---|
| Minimum tap target | **44 × 44px** | `min-w-[44px] min-h-[44px]` |
| Minimum gap between targets | **8px** | `gap-2` |
| Comfortable button height | 44-50px | `h-11` to `h-[50px]` |
| iOS nav bar button | 44px | `h-11` |
### Key rules
- The 44px minimum applies to the **tappable area**, not the visual size
- A 24px icon can have a 44px tap target via padding: `p-2.5` on a 24px icon
- Our `w-9 h-9` (36px) header buttons are below 44px — compensate with generous spacing or padding hit areas
- Text buttons must extend touch target beyond text bounds
## Spacing & Layout
| Element | iOS Value | CSS |
|---|---|---|
| Side margins (iPhone) | 16px | `px-4` |
| Nav bar height | 44px | `h-11` |
| Tab bar height | 49px (+34px safe area) | `h-[49px]` + `pb-[env(safe-area-inset-bottom)]` |
| Bottom safe area (notch) | 34px | `env(safe-area-inset-bottom)` |
| Search bar | 36px field + 8px padding | `h-9` + `py-1` |
| Standard content inset | 16px horizontal | `px-4` |
### Safe area insets
```css
/* Always use for full-screen layouts */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
height: 100dvh; /* Dynamic viewport height — avoids iOS Safari toolbar */
```
## Navigation Patterns
### iOS-native patterns to follow
- **Primary navigation**: Bottom tab bar (persists across screens)
- **Secondary navigation**: Top nav bar with back button (left) and actions (right)
- **Modals**: Sheet sliding up from bottom (half-screen or full)
- **Context menus**: Long-press or action sheets from bottom
### Primary action placement
```
Top 20%: Navigation, info, secondary actions
Middle 60%: Main content (scrollable)
Bottom 20%: Primary actions (thumb zone) — send, approve, play
```
### Sheets & modals on mobile
- Use bottom sheets with three detents: small (~25%), medium (~50%), large (full)
- Always provide a close button — don't rely solely on swipe-to-dismiss
- Content panels: full-screen overlay or bottom sheet, never side-by-side
## Form Inputs
| Rule | Value | Why |
|---|---|---|
| **Minimum input font** | **16px** | Prevents iOS Safari auto-zoom on focus |
| Minimum field height | 44px | Matches tap target |
| Use `inputmode` | `numeric`, `email`, `tel`, `url`, `search` | Shows appropriate keyboard |
| Use `autocomplete` | Standard attributes | Enables autofill |
| Submit button placement | Bottom of form, thumb zone | Easy to reach |
## Animations & Motion (iOS Spring Model)
### Duration guidelines
| Type | Duration | Tailwind |
|---|---|---|
| Micro-interaction (tap, toggle) | 100-200ms | `duration-150` |
| Standard transition (push/pop) | 250-350ms | `duration-300` |
| Modal presentation (sheet) | 300-400ms | `duration-300` |
| Complex transitions | 400-500ms | `duration-500` |
### iOS-style easing
```css
/* Standard iOS-like transition (ease out / decelerate) */
transition: transform 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.0);
/* Bouncy spring-like (for playful entrances) */
transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
/* Quick snap (micro-interactions) */
transition: transform 0.25s cubic-bezier(0.0, 0.0, 0.2, 1.0);
```
### Motion rules
- Entrances: ease-out (decelerate)
- Exits: ease-in (accelerate)
- Only animate `transform` and `opacity`
- **Always** respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
## Gestures
- Swipe left/right: gallery nav, dismiss
- Swipe down: close overlay/bottom sheet, pull-to-refresh
- Long press: context menu, selection
- Pinch: zoom on images
- Minimum swipe distance: 50px before triggering
## Scroll Behavior
- Lock body scroll when modal/drawer is open
- `overscroll-behavior: contain` on modal content
- `touch-action: manipulation` to prevent zoom on double-tap
- `-webkit-overflow-scrolling: touch` for smooth iOS scroll
## Iconography
| Context | Size | Style |
|---|---|---|
| Tab bar | 25px | Filled/solid |
| Nav bar / toolbar | 22px | Outlined, 1.5px stroke |
| Inline with text | Match font size | Outlined |
| Standalone | 28-33px | Filled or outlined |
## AIUI Custom Overrides (Keep These)
These deviate from stock iOS but are intentional for our design language:
- **Dark-only theme**: No light mode. Background `#0a0a0a`, not iOS system colors
- **Glass morphism**: Translucent surfaces with backdrop-blur instead of iOS solid materials
- **Accent color**: Bitcoin orange `#F7931A` instead of iOS systemBlue
- **Text opacity scale**: Our `/25` → `/96` scale instead of iOS label hierarchy
- **No separator borders**: We use spacing and glass layering instead
- **Custom animations**: `animate-fade-up`, `animate-scale-in` per our design system
- **Header buttons**: Currently 36px (`w-9 h-9`), acceptable with generous spacing
## Performance on Mobile
- Test on real devices, not just emulators
- Test on 3G/4G connections
- Debounce scroll handlers
- Lazy load images with `loading="lazy"`
- Critical CSS inlined, rest loaded async
+52
View File
@@ -0,0 +1,52 @@
---
description: Git workflow - commit conventions, branching, PR process
globs: "**/*"
alwaysApply: false
---
# Git Workflow
## Commit Messages
Format: `type(scope): description`
Types:
- `feat`: new feature
- `fix`: bug fix
- `refactor`: code restructuring (no behavior change)
- `style`: formatting, whitespace (no code change)
- `docs`: documentation
- `test`: adding/updating tests
- `chore`: build, dependencies, tooling
- `perf`: performance improvement
Scope: the package or area (`core`, `app`, `plugin-x`, `renderer-film`, etc.)
Examples:
```
feat(core): add renderer registry with lazy loading
fix(chat): prevent scroll jump on new message
refactor(plugin-system): simplify adapter interface
chore(deps): update Vue to 3.6
```
## Branching
- `main`: production-ready, always deployable
- `dev`: integration branch for features
- `feat/description`: feature branches (from dev)
- `fix/description`: bug fix branches
- `release/x.y.z`: release preparation
## Pull Requests
- One feature per PR
- Description: what changed, why, how to test
- All tests pass
- TypeScript strict mode passes
- No linter errors
- Reviewed before merge
## Rules
- Never force push to `main` or `dev`
- Never commit `.env.local` or any secrets
- Never commit `node_modules`
- Squash merge feature branches to keep history clean
- Tag releases with semver: `v1.0.0`
+30
View File
@@ -0,0 +1,30 @@
---
description: Expert rules for Film content extraction, display, and surfacing
globs: "**/useContentPanel.ts,**/FilmCard.vue,**/FilmGrid.vue,**/FilmDetail.vue,**/mocks/films*"
alwaysApply: false
---
# Films Content Surface
## Extraction Patterns
- **Tagged**: `[[film:f123]]` or `[[film:123]]` → resolved from mock library
- **External**: `[[film_ext:Title|YYYY|Director]]` → create external film with fallback poster
## Edge Cases
- `normalizeFilmId`: `f123` and `123` both become `f123`
- Duplicate prevention: key by `title|year` for externals
- Empty/malformed: skip if title < 2 chars, year invalid
- Poster: use `generatePosterFallback(title, year)` for externals
## Strip Rules
- `stripFilmTags` removes `[[film:...]]` and `[[film_ext:...]]` before displaying text
- Preserve `\n{3,}` → `\n\n` to avoid excessive whitespace
## Display
- FilmCard: poster, title, year, director
- FilmDetail: full metadata, sources, cast
- Panel: grid of FilmCards, click opens FilmDetail in panel
+31
View File
@@ -0,0 +1,31 @@
---
description: Expert rules for Song content extraction, display, and surfacing
globs: "**/useContentPanel.ts,**/SongCard.vue,**/SongGrid.vue,**/SongDetail.vue,**/mocks/songs*"
alwaysApply: false
---
# Songs Content Surface
## Extraction Priority
1. Tagged: `[[song:s123]]` or `[[song_ext:Title|Artist|YYYY]]`
2. Library match: title + artist within 120 chars
3. Patterns: `"Title" by Artist`, `Title Artist`, `**Title** by Artist`
## looksLikeSong Rejection
Reject when title/artist contains: news phrases, "BIP", "protocol", "web search", "mailing list", "training cutoff", etc. See `looksLikeSong()` blocklist.
- Max length: title 55 chars, artist 40 chars
## Edge Cases
- If `extractFilmIds` or `extractPodcastIds` found → return [] (don't mix film/podcast with song patterns)
- If `isNewsLikeResponse` → return [] (news bullets often look like "X Y")
- Skip if title/artist is 4-digit year
- Skip if contains `[[film` or `[[song` tags
- Dedupe by `title|artist` lowercase
## Strip Rules
- `stripSongTags` removes song tags before displaying text
@@ -0,0 +1,26 @@
---
description: Expert rules for Podcast content extraction, display, and surfacing
globs: "**/useContentPanel.ts,**/PodcastCard.vue,**/PodcastGrid.vue,**/PodcastDetail.vue,**/mocks/podcasts*"
alwaysApply: false
---
# Podcasts Content Surface
## Extraction Patterns
- **Tagged**: `[[podcast:p123]]` or `[[podcast_ext:Title|Host|YYYY]]`
- No pattern fallback (unlike songs) — only tags
## Edge Cases
- Duplicate prevention: key by `title|host` lowercase
- Empty: skip if title or host < 2 chars
- Year optional in external format
## looksLikePodcast (when added)
Reject when title/host looks like: news source names, documentation sites, "Bitcoin Mailing List", etc. — same philosophy as `looksLikeSong`.
## Strip Rules
- `stripPodcastTags` removes podcast tags before displaying text
+47
View File
@@ -0,0 +1,47 @@
---
description: Expert rules for News content extraction, merge, and surfacing
globs: "**/useContentPanel.ts,**/useRssFetch.ts,**/NewsGrid.vue,**/ArticleDetail.vue,**/vite-rss*"
alwaysApply: false
---
# News Content Surface
## Sources
1. **Web search**: `message.webResults` from AI (with imgSrc, content)
2. **RSS**: Fetched from website URLs only when `newsContext` is true
## newsContext
- `isNewsQuery(userQuery)` — "news", "latest", "what's happening", "what are people saying", etc.
- `isNewsLikeResponse(text)` — "for instant news", "check these sources", "access to web search", etc.
## Merge Rules
- `mergeNewsResults(web, rss)` — dedupe by URL (normalized: lowercase, no trailing slash)
- Web results take precedence when URL collision
## RSS Fetch Guard
- **Only fetch RSS when `newsContext` is true and `mergedWebsites.length > 0`** — avoid surfacing irrelevant RSS from docs/resource links when user asked "websites"
- Max 8 URLs, 15 articles total, 5 sites tried
- Timeout: 15s client, 5s per feed server-side
## Display
- NewsGrid (variant=news): articles open in **ArticleDetail** (in-panel)
- Relevance sort when `query` provided
- Search filter by title, content, url
- imgSrc: validate with `isSafeImgUrl` (https only)
## Known Limitations
- **RSS language**: Feeds return whatever the site publishes; no query/language filtering — may surface non-English articles
- **RSS relevance**: No semantic filtering; articles are shown as published
## ArticleDetail Security
- `sanitizeHtml`: allow only safe tags (p, br, a, strong, em, ul, ol, li, blockquote, h1-h4)
- Strip script, style, iframe, object, embed
- Links: `href` must be `https?://`, reject `javascript:`
- Images: `src` must be `https?://`
@@ -0,0 +1,32 @@
---
description: Expert rules for Websites content extraction and surfacing
globs: "**/useContentPanel.ts,**/NewsGrid.vue,**/articleOverlay*"
alwaysApply: false
---
# Websites Content Surface
## Extraction
1. **Markdown links**: `[Title](https://...)` — extract all with `extractMarkdownLinks`
2. **Bold domains**: `**Name** (domain.tld)` — extract with `extractBoldDomainLinks`
3. Merge with `mergeNewsResults` (dedupe by URL)
## URLs Validation
- Scheme: `https?://` only
- `new URL(raw)` must not throw
- Min length: title 2, url 10 chars
- Normalize for dedupe: lowercase, no trailing slash
## Display
- NewsGrid (variant=websites): card with favicon/globe icon
- Click → **overlay iframe** (not ArticleDetail)
- Use `articleOverlayStore.open(url, title, undefined, imgSrc)`
## Distinction from News
- News = articles (web search + RSS) → ArticleDetail in panel
- Websites = plain links from response → overlay iframe
- Same NewsGrid component, different `variant` and click handler
@@ -0,0 +1,42 @@
---
description: Expert rules for Magazine/Brief content extraction and surfacing
globs: "**/useContentPanel.ts,**/MagazineGrid.vue"
alwaysApply: false
---
# Magazine Content Surface
## Detection
- `hasMagazine` = sections ≥ 1 AND (newsQuery OR newsLikeResponse OR context keywords)
- Context keywords: sentiment, bearish, bull case, macro, %, BTC, bitcoin, BIP, protocol, debate, what's happening
## Section Extraction Order
1. `## Heading` blocks — content until next ## or **Section**
2. `**Pro/Anti camp**` blocks with emoji
3. Bullets: `- **Title**: Content` or `- **Title** — Content` (em/en dash)
4. Attributed: `- **Name** (Role) description`
5. Intro paragraph (before first ##)
6. "Key takeaway" / "This is being called..."
7. "For deeper analysis" / further reading
## Section Rules
- Min: title 2 chars, content 15 chars
- Max content: 2000 chars per section
- Dedupe by title prefix (first 50 chars)
- Skip bullets already inside ## blocks (`blockContents`)
- `addSection` extracts: url, author, imageUrl from content
## Hero Image
1. First markdown image in text
2. First `.jpg|.png|.gif|.webp` URL
3. `webResults[0]?.imgSrc`
4. Picsum fallback seeded by query
## Format & Security
- `formatContent`: escape `&<>`, preserve `**bold**` as `<strong>`, `\n\n` → `</p><p>`
- Meme: imgflip URLs, contextual by topic (bearish, bull, Bitcoin, macro)
+33
View File
@@ -0,0 +1,33 @@
# AIUI Development Environment
# Copy this file to .env.local and fill in your values
# AI Provider (OpenRouter - gives access to many models including free ones)
# Get your key at: https://openrouter.ai/keys
VITE_OPENROUTER_API_KEY=sk-or-your-key-here
# Anthropic Claude — for live web search (Claude invokes search mid-response):
# Option 1: OAuth token from Max subscription (run: claude setup-token, save output)
# → No extra cost; uses your existing Max subscription.
ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here
# Option 2: API key from https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=sk-ant-your-key-here
#
# Without either: proxy uses CLI with built-in WebSearch + pre-fetched context.
# TMDB API (free, fetches posters on-demand when images fail)
# Get your key at: https://www.themoviedb.org/settings/api
TMDB_API_KEY=your-tmdb-key-here
# Jamendo API (optional, extends music search - free 35k req/mo)
# Get your client_id at: https://devportal.jamendo.com/
JAMENDO_CLIENT_ID=your-jamendo-client-id
# SearXNG instance for web search (optional)
# Uses public instances by default; falls back to DuckDuckGo when they fail.
# For reliable dev: host your own (https://docs.searxng.org/) or rely on DDG fallback.
# SEARXNG_URL=https://your-searxng.instance
# Development flags
VITE_DEV_MODE=true
VITE_MOCK_MEDIA_SOURCES=true
VITE_DISABLE_CRYPTO=true
+131
View File
@@ -0,0 +1,131 @@
name: CI
on:
push:
branches: [main, development]
pull_request:
branches: [main, development]
jobs:
lint-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Check bundle size
run: |
BUNDLE_SIZE=$(find packages/app/dist/assets -name '*.js' -o -name '*.css' | xargs gzip -c | wc -c)
BUNDLE_KB=$((BUNDLE_SIZE / 1024))
echo "Bundle size: ${BUNDLE_KB}KB gzipped"
if [ "$BUNDLE_KB" -gt 250 ]; then
echo "::error::Bundle size ${BUNDLE_KB}KB exceeds 250KB budget"
exit 1
fi
echo "Bundle size ${BUNDLE_KB}KB is within 250KB budget"
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cd packages/app && npx playwright install --with-deps ${{ matrix.browser }}
- run: cd packages/app && pnpm test:e2e --project=${{ matrix.browser }}
e2e-mobile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cd packages/app && npx playwright install --with-deps chromium webkit
- run: cd packages/app && pnpm test:e2e --project=iphone14 --project=galaxy-s21
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v12
with:
configPath: packages/app/lighthouserc.json
uploadArtifacts: true
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Audit dependencies
run: pnpm audit --audit-level=critical || true
- name: Check licenses
run: |
npx license-checker --production --onlyAllow 'MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;CC-BY-4.0;Python-2.0;BlueOak-1.0.0' --excludePrivatePackages || echo "::warning::Non-approved licenses found"
+57
View File
@@ -0,0 +1,57 @@
name: Weekly Dependency Audit
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Audit for vulnerabilities
id: audit
run: |
AUDIT_RESULT=$(pnpm audit --audit-level=moderate 2>&1) || true
echo "$AUDIT_RESULT"
if echo "$AUDIT_RESULT" | grep -q "critical"; then
echo "has_critical=true" >> $GITHUB_OUTPUT
else
echo "has_critical=false" >> $GITHUB_OUTPUT
fi
- name: Check licenses
id: licenses
run: |
LICENSE_RESULT=$(npx license-checker --production --onlyAllow 'MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;CC-BY-4.0;Python-2.0;BlueOak-1.0.0' --excludePrivatePackages 2>&1) || true
echo "$LICENSE_RESULT"
if echo "$LICENSE_RESULT" | grep -q "FAIL"; then
echo "has_violations=true" >> $GITHUB_OUTPUT
else
echo "has_violations=false" >> $GITHUB_OUTPUT
fi
- name: Create issue if violations found
if: steps.audit.outputs.has_critical == 'true' || steps.licenses.outputs.has_violations == 'true'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '⚠️ Dependency audit: violations found',
body: `The weekly dependency audit found issues:\n\n- Critical vulnerabilities: ${{ steps.audit.outputs.has_critical }}\n- License violations: ${{ steps.licenses.outputs.has_violations }}\n\nRun \`pnpm audit\` and \`npx license-checker\` locally for details.`,
labels: ['security', 'dependencies'],
})
+54
View File
@@ -0,0 +1,54 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
*.tsbuildinfo
# scripts/build-aiui.sh's staleness-detection cache (13-09) — a local,
# best-effort marker so the script can tell "source changed but the
# emitted asset filenames didn't" from a plain rebuild with no changes.
.build-aiui-last-src-hash
.build-aiui-last-assets
# Turborepo
.turbo/
# Environment (secrets)
.env.local
.env.*.local
# Tauri
packages/app/src-tauri/target/
# IDE
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Dev chat history
.dev/
# Debug
npm-debug.log*
pnpm-debug.log*
# Test coverage
coverage/
# Overnight loop logs
loop/loop.log
# Playwright
test-results/
playwright-report/
playwright/.cache/
# Storybook
storybook-static/
+345
View File
@@ -0,0 +1,345 @@
# CLAUDE.md — AIUI Project Guide
## Project Overview
AIUI is a next-generation AI content surface UI. It's a **pnpm monorepo** with two packages:
- `@aiui/app` — Reference application (Vite + Vue 3 + Tailwind CSS)
- `@aiui/core` — Reusable component library
**Stack**: Vue 3 (Composition API), TypeScript ~5.8 (strict), Vite, Tailwind CSS, Pinia, Vue Router, Turborepo
**Node**: >=20.0.0 | **pnpm**: >=10.0.0
## Quick Reference
```bash
pnpm dev # Run app dev server + Claude proxy
pnpm dev:core # Watch-build core library
pnpm build # Build all packages (turbo)
pnpm test # Run tests (vitest)
pnpm lint # Lint all packages (eslint)
pnpm typecheck # Type-check all packages (vue-tsc)
pnpm clean # Remove dist/ directories
```
Dev server: `http://localhost:5173` | Claude proxy: `http://localhost:3141`
## Core Philosophy
- **Open source only** — MIT/Apache-2.0 licensed dependencies only
- **Decentralized-first** — Pluggable adapters, no vendor lock-in
- **Bitcoin only** — sats/Lightning/Cashu/Fedimint. Never fiat, never altcoins. AIUI is never a wallet — always deep-link to external wallets
- **Privacy-first** — E2E encryption (tweetnacl.js), encrypted local storage (AES-256-GCM), no tracking/telemetry
- **Mobile-first, everywhere-perfect** — Desktop is an enhancement of the mobile experience
- **Plugin-everything** — All integrations go through typed plugin interfaces
## Vue 3 Conventions
**Always use `<script setup lang="ts">`** — never Options API.
### Script section ordering
Imports → Props (`defineProps`) → Emits (`defineEmits`) → Reactive state → Computed → Watchers → Methods → Lifecycle hooks → `defineExpose`
### Naming
| Thing | Convention | Example |
|-------|-----------|---------|
| Components | PascalCase | `ProjectCard.vue` |
| Composables | camelCase, `use` prefix | `useTheme.ts` |
| Props (JS) | camelCase | `projectName` |
| Props (template) | kebab-case | `project-name` |
| Boolean props | `is`/`has`/`can`/`should` prefix | `isVisible`, `canEdit` |
| Emits (template) | kebab-case with colon namespacing | `project:updated` |
| Stores | camelCase, `use` prefix, `Store` suffix | `useSettingsStore` |
### Reactive state rules
- `ref()` for primitives, `reactive()` for objects
- `computed()` for derived values — no side effects in computed
- `shallowRef()` for large collections/objects not requiring deep reactivity
- Always use unique IDs for `:key` — never array index
### Props
Always use object-style with type annotations, never array-style:
```ts
// Correct
defineProps<{ title: string; count?: number }>()
// Wrong
defineProps(['title', 'count'])
```
### Performance
- Lazy load with `defineAsyncComponent` for non-critical components
- Use `onErrorCaptured` for error boundaries
- Always handle loading/error/data states in async operations
## File Structure
```
packages/app/src/
├── components/
│ ├── ui/ # Generic UI components
│ ├── chat/ # Chat interface components
│ ├── content-panel/ # Content panel components
│ ├── renderers/ # Content type renderers
│ └── layout/ # Layout components
├── composables/ # Shared composition functions
├── stores/ # Pinia stores
├── pages/ # Route-level components
├── styles/ # Global CSS, themes, tokens
├── utils/ # Pure utility functions
├── types/ # TypeScript type definitions
├── plugins/ # Plugin system
└── mocks/ # Dev fixtures & mock data
packages/core/src/
├── plugins/ # Plugin system interfaces
└── types/ # Shared TypeScript types
```
## Tailwind & Design System
### Glass Morphism (Archy-derived)
This project uses a glass morphism design language. Key utility classes:
| Class | Purpose |
|-------|---------|
| `.glass` | Standard glass: `rgba(0,0,0,0.35)`, `blur(18px)`, white border 0.18 opacity |
| `.glass-strong` | Stronger blur: `blur(24px)` |
| `.glass-card` | Card variant: `rgba(0,0,0,0.65)`, `border-radius: 1rem` |
| `.glass-button` | Button: 48px height, `rgba(0,0,0,0.6)`, `blur(18px)` |
| `.glass-button-sm` | Compact button variant |
| `.gradient-card` | Gradient background card |
### Spacing
4px grid system: `1`=4px, `2`=8px, `3`=12px, `4`=16px, etc.
### Colors
- Background: `#0a0a0a` (near-black)
- Accent / Bitcoin orange: `#F7931A`
- Primary: `#606060`
- Text opacity scale: `/25` (placeholder) → `/40` (muted) → `/60` (secondary) → `/70` (interactive) → `/80` (body) → `/90` (emphasis) → `/96` (headings) → `text-white` (active)
- No separator borders between major sections
### Typography
`Inter`/`system-ui` for body, `Menlo`/`Monaco` for monospace.
### Responsive breakpoints (mobile-first)
`sm` 640px → `md` 768px → `lg` 1024px → `xl` 1280px → `2xl` 1536px
### Animations
- `animate-fade-up` (900ms), `animate-fade-up-fast` (400ms), `animate-fade-in` (500ms), `animate-scale-in` (250ms)
- Duration: 100ms micro, 200ms fast, 300ms moderate, 500ms normal, 600ms max
- Easing: `ease-out` for entrances (90% of animations), `ease-in` for exits
- Only animate `transform` and `opacity` — avoid animating layout properties
- Always respect `prefers-reduced-motion`
## Content Surfaces Architecture
Every content renderer supports up to five surfaces:
1. **Chat Preview** (~120px max) — inline bubble, identify content at a glance
2. **Chat Play** (~200px max) — inline playback with expand button
3. **Panel Preview** (unlimited) — full browsing, filtering, sorting
4. **Panel Play** — full immersive playback
5. **Panel Edit** — full interaction, sends changes back to chat
On mobile, Panel surfaces open as full-screen overlays, not side-by-side.
```ts
interface RendererDefinition {
id: string
name: string
contentType: string
surfaces: SurfaceType[]
chatPreview?: Component
chatPlay?: Component
panelPreview?: Component
panelPlay?: Component
panelEdit?: Component
lazyDependencies?: () => Promise<any>
}
```
Chat surfaces must have zero lazy dependencies. Panel surfaces may lazy-load heavy libraries.
## Plugin System
All integrations are plugins. Plugin types: `ai-provider`, `media-source`, `messaging`, `storage`, `renderer`, `file-handler`, `crypto`, `search`, `auth`, `wallet`, `social-embed`, `mcp`, `media`.
```ts
interface AIUIPlugin {
id: string
name: string
version: string
type: PluginType
description?: string
init(context: PluginContext): Promise<void>
destroy(): Promise<void>
isAvailable(): Promise<boolean>
}
```
Sandboxing: Tier 1 (trusted built-in), Tier 2 (community — sandboxed iframes), Tier 3 (external processes). Community plugins get no direct DOM access.
## AI Provider Integration
```ts
interface AIProviderAdapter extends AIUIPlugin {
type: 'ai-provider'
chat(messages: Message[], options: ChatOptions): AsyncIterable<ChatChunk>
models(): Promise<Model[]>
supportsStreaming: boolean
supportsVision: boolean
supportsTools: boolean
}
```
Normalize tool calling across providers (OpenAI `tool_calls` vs Claude `tool_use`). Never include API keys in context injection.
## Security & Crypto
- E2E encryption: tweetnacl.js XSalsa20-Poly1305
- Local storage: Web Crypto API AES-256-GCM + PBKDF2 (100K+ iterations)
- API keys: encrypted at rest, never in localStorage, never logged, masked in UI (last 4 chars)
- No `eval()` or `innerHTML` with untrusted content
- Sanitize all user input against XSS
- HTTPS only, CSP headers in production
- Dev bypass: `VITE_DISABLE_CRYPTO=true` (never in production)
## Accessibility
WCAG AA minimum compliance:
- Color contrast: 4.5:1 normal text, 3:1 large/interactive
- Keyboard: all elements focusable via Tab, visible focus indicators, Escape closes modals
- Semantic HTML: use `<header>`, `<nav>`, `<main>`, `<article>`, `<aside>`, `<footer>` — not div soup
- ARIA: `aria-label` for icon buttons, `aria-live="polite"` for dynamic updates, `sr-only` for screen reader text
- Touch targets: min 44x44px with 8px gaps
- All images need `alt` attributes (decorative: `alt=""`)
- Respect `prefers-reduced-motion`
## Performance Budget
- **Initial load**: < 250KB gzipped
- Core bundle: Vue + Tailwind + Pinia + Router + chat UI (~150KB) + markdown + streaming (~50KB)
- Everything else: lazy-loaded on demand
- Virtual scrolling (TanStack Virtual) for chat lists
- Clean up listeners in `onUnmounted`, use `shallowRef` for large data
- Core Web Vitals: LCP < 2.5s, FID < 100ms, CLS < 0.1
- Preconnect to API hosts, debounce inputs (100ms)
## Mobile UX (iOS HIG-Informed)
Follows Apple iOS Human Interface Guidelines. See `.cursor/rules/15-mobile-ux.mdc` for full reference.
- **Typography**: Body 17px, Footnote 13px, Caption 11px minimum — never smaller than 11px
- **Touch targets**: min 44×44px tappable area, 8px gap between targets
- **Side margins**: 16px (`px-4`)
- **Primary actions**: Bottom thumb zone
- **Viewport**: `height: 100dvh` with `env(safe-area-inset-*)` for notched devices
- **Form inputs**: min 16px font (prevents iOS zoom), appropriate `inputmode`
- **Content panels**: Full-screen overlay or bottom sheet on mobile, never side-by-side
- **Transitions**: 150ms micro, 300ms standard, 400ms modal — ease-out entrances, ease-in exits
- **Sheets**: Bottom sheets with close button, don't rely solely on swipe-to-dismiss
- Support both portrait and landscape
## Environment & Dev Mode
Env vars must be prefixed `VITE_`. Secrets go in `.env.local` (gitignored). See `.env.example` for template.
Feature flags via `useFeatureFlags()`: `isDev`, `isTauri`, `isMobile`, `isCryptoEnabled`, `isMockData`
Dev mode enables: mock data, debug panel, verbose logging, disabled encryption, all renderers without lazy loading.
## Git Conventions
### Commit format
```
type(scope): description
```
**Types**: `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore`, `perf`
**Scope**: package or area — `core`, `app`, `chat`, `renderer-film`, `plugin-x`
### Branches
`main` (production), `dev` (integration), `feat/description`, `fix/description`
### Rules
- One feature per PR
- All tests pass, TypeScript strict passes, no lint errors
- No force push to main/dev
- Never commit `.env.local`, secrets, or `node_modules`
- Squash merge features, tag releases `v1.0.0`
## Archipelago (Archy) Integration
AIUI runs inside an iframe in Archipelago's Chat mode. All communication with the host happens via `window.postMessage()` through a strict protocol.
### Architecture
```
AIUI (iframe) ←→ postMessage ←→ Archy ContextBroker ←→ Node data
```
AIUI is **quarantined** — it never directly accesses Archy's APIs, stores, or node data. The Archy ContextBroker fetches and sanitizes data before passing it to AIUI.
### Protocol
Use `archyBridge.ts` (`src/services/archyBridge.ts`) for all Archy communication:
```ts
import { archyBridge } from '@/services/archyBridge'
// Request context (respects user permissions)
const apps = await archyBridge.requestContext('apps')
if (!apps.permitted) {
// Show: "Enable 'Installed Apps' access in Archy Settings"
}
// Request an action
await archyBridge.requestAction('open-app', { appId: 'btcpay-server' })
// Listen for theme/permission updates
archyBridge.onPermissionsUpdate((categories) => { ... })
archyBridge.onThemeUpdate((theme) => { ... })
```
**Context categories** (user toggles each on/off in Archy Settings):
- `apps` — App names, status, health (no credentials)
- `system` — CPU, RAM, disk (no paths or IPs)
- `network` — Connection status, peer count (no IPs)
- `wallet` — Balance, channel count (no keys or seeds)
- `files` — File/folder names (no contents)
### Critical Rules
1. **NEVER** fetch Archy APIs directly — always use `archyBridge`
2. **NEVER** store or log raw user data from context responses
3. **NEVER** make HTTP requests to the host machine
4. Handle `permitted: false` gracefully — tell users what to enable
5. Send `ready` message on mount so Archy knows the iframe loaded
6. Build must output a static SPA servable from any base path
7. All AI provider keys are user-provided and stored locally in AIUI only
### Build & Deploy
AIUI deploys as a Podman container on the Archy node:
- Build: `pnpm build``packages/app/dist/`
- Container: nginx:alpine serving the dist
- Proxied at `/aiui/` via Archy's nginx
- Updates independently of Archy — new container image = new version
Binary file not shown.
+267
View File
@@ -0,0 +1,267 @@
# Claude Code Overnight Automation
Run Claude Code headlessly overnight to execute a full task checklist — with rate-limit resilience, macOS sleep prevention, and a stop hook that prevents Claude from quitting until every task is done.
## How It Works
```
loop.sh (orchestrator)
|
+--> Reads plan.md for unchecked [ ] tasks
+--> Pipes prompt.md into `claude -p` (headless mode)
| |
| +--> Claude reads your plan, specs, and project rules
| +--> Implements tasks one by one
| +--> Runs typecheck/lint/test after each
| +--> Commits, marks [x], moves to next
| |
| +--> Claude tries to stop
| |
| +--> Stop Hook intercepts
| +--> Checks plan.md for remaining [ ] tasks
| +--> If incomplete: BLOCKS the stop (Claude continues)
| +--> If all done: allows stop
|
+--> Detects rate limits in output
| +--> Sleeps 1 hour, retries (up to 5x)
| +--> After 5 retries: schedules macOS launchd job to resume later
|
+--> Loops N iterations (default 10)
+--> Exits when all tasks checked or iterations exhausted
```
### The "Ralph Wiggum" Stop Hook
The secret sauce. Claude Code supports a `Stop` hook — a shell script that runs every time Claude tries to end its session. By returning `{"decision":"block"}`, the hook **prevents Claude from stopping**. Combined with `--dangerously-skip-permissions`, Claude becomes a fully autonomous task executor that won't quit until the job is done.
### Sleep Prevention
On macOS, `caffeinate -i` prevents idle sleep during long runs. A hook starts it when Claude begins and kills it when Claude finishes.
### Rate Limit Resilience
If Claude hits API rate limits:
1. **Inline retry**: Sleep 1 hour, then retry the same iteration
2. **Scheduled retry**: After 5 failed retries, create a macOS `launchd` plist that auto-runs the loop later
3. The plist self-destructs after executing
## Prerequisites
- **Claude Code CLI** (`claude` command available in PATH)
- Install: https://docs.anthropic.com/en/docs/claude-code
- Must be logged in: run `claude login` first
- **macOS** (for `caffeinate` and `launchd` — see Linux notes below)
- **Git** (the script commits after each task)
- A project with `package.json` or similar build tooling
## Quick Start
```bash
# 1. Clone or copy this folder into your project
cp -r "For Others/templates" ~/my-project/loop
# 2. Run the setup script (creates hooks, updates settings)
cd ~/my-project
bash "path/to/For Others/setup.sh"
# 3. Edit your task list
vim loop/plan.md
# 4. Edit your prompt (project-specific rules)
vim loop/prompt.md
# 5. Start the overnight run
./loop/loop.sh
```
Or just run the setup script — it walks you through everything:
```bash
bash "For Others/setup.sh"
```
## File Structure
After setup, your project will have:
```
your-project/
loop/
loop.sh # Main orchestrator (run this)
prompt.md # Instructions piped to Claude each iteration
plan.md # Task checklist ([ ] = todo, [x] = done)
loop.log # Full output log (auto-created)
~/.claude/
hooks/
prevent-sleep.sh # Starts caffeinate on session start
stop-hook-autonomous.sh # Blocks stop until tasks complete
allow-sleep.sh # Kills caffeinate on session end
settings.json # Hook registrations (auto-updated by setup)
```
## Configuration
All config is via environment variables (set before running `loop.sh` or export in your shell):
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_AUTONOMOUS` | `1` | Set to `0` to disable the stop hook (Claude can quit freely) |
| `ITERATION_COUNT` | `10` | Max loop iterations |
| `ITERATION_DELAY` | `30` | Seconds to pause between iterations |
| `RATE_LIMIT_WAIT` | `3600` | Seconds to sleep when rate limited (1 hour) |
| `MAX_RATE_LIMIT_RETRIES` | `5` | Retries before scheduling launchd |
| `CLAUDE_BIN` | `claude` | Path to Claude CLI binary |
| `PROMPT_FILE` | `loop/prompt.md` | Path to prompt file |
| `LOG_FILE` | `loop/loop.log` | Path to log file |
### Examples
```bash
# Quick test run (2 iterations, 10s delay, no stop hook)
CLAUDE_AUTONOMOUS=0 ITERATION_COUNT=2 ITERATION_DELAY=10 ./loop/loop.sh
# Full overnight run (20 iterations, 1 min between)
ITERATION_COUNT=20 ITERATION_DELAY=60 ./loop/loop.sh
# Use a custom prompt
PROMPT_FILE=my-prompt.md ./loop/loop.sh
```
## Writing Your Plan
`loop/plan.md` is a markdown checklist. Each line starting with `- [ ]` is a pending task:
```markdown
## Phase 1: Core Features
- [ ] **1.1** — Add user authentication (JWT + refresh tokens)
- [ ] **1.2** — Create user profile page with avatar upload
- [ ] **1.3** — Add settings page with theme toggle
## Phase 2: API
- [ ] **2.1** — REST endpoints for CRUD operations
- [ ] **2.2** — WebSocket support for real-time updates
## Final
- [ ] **FINAL** — Run full test suite, fix any failures, tag release
```
Claude will:
1. Find the first `- [ ]` line
2. Read the spec from your prompt or a separate spec file
3. Implement it
4. Mark it `- [x]`
5. Move to the next
### Tips for good plans
- **Be specific**: "Add JWT auth with refresh tokens, store in httpOnly cookies" > "Add auth"
- **Order matters**: Put foundational tasks first (types, utils, config) before features that depend on them
- **Include testing gates**: "Run `pnpm test` and fix failures" as part of each task
- **Keep tasks small**: 30-60 minutes of work each. Large tasks lead to context window exhaustion
- **Add a FINAL task**: A catchall that runs the full test suite
## Writing Your Prompt
`loop/prompt.md` is what Claude reads at the start of every iteration. Include:
1. **What files to read** (your plan, specs, project conventions)
2. **Project-specific rules** (coding style, frameworks, constraints)
3. **Per-task workflow** (implement → test → commit → mark done)
4. **Hard rules** (what to never do, minimum effort before skipping)
See `templates/prompt.md` for a starting template.
## Operating the Loop
### Starting
```bash
# Foreground (see output live)
./loop/loop.sh
# Background with logging
nohup ./loop/loop.sh > /dev/null 2>&1 &
# With caffeinate (prevents sleep even if hooks fail)
caffeinate -i ./loop/loop.sh
```
### Monitoring
```bash
# Watch the log live
tail -f loop/loop.log
# Check progress
grep -c '\- \[x\]' loop/plan.md # completed
grep -c '\- \[ \]' loop/plan.md # remaining
# Check git commits
git log --oneline -20
```
### Stopping
- **Let it finish**: The loop stops automatically when all tasks are checked
- **Kill it**: `Ctrl+C` or `kill %1` — Claude's current task will be interrupted but committed work is preserved
- **Disable stop hook**: Set `CLAUDE_AUTONOMOUS=0` in the environment before the next iteration
### Resuming
Just run `./loop/loop.sh` again. It reads `plan.md` fresh each iteration, so it picks up where it left off (skipping `[x]` tasks).
## Customizing the Prompt
The prompt template has `{{PLACEHOLDER}}` markers. Replace them with your project's specifics:
| Placeholder | What to put |
|-------------|-------------|
| `{{SPEC_FILE}}` | Path to your detailed spec (e.g., `SPEC.md`, `docs/plan.md`) |
| `{{PROJECT_RULES_FILE}}` | Path to your coding conventions file |
| `{{PROJECT_RULES}}` | Inline coding rules (style, frameworks, constraints) |
## Troubleshooting
### Claude exits immediately
- Make sure `claude login` has been run
- Check that `claude -p "hello"` works in your terminal
- Verify `~/.claude/hooks/stop-hook-autonomous.sh` exists and is executable
### Rate limit loop
- Default wait is 1 hour. Increase `RATE_LIMIT_WAIT` if your limits are longer
- Check `loop.log` for the specific rate limit message
- Claude Max subscriptions have higher limits than API keys
### Mac goes to sleep
- Run `caffeinate -i ./loop/loop.sh` as a belt-and-suspenders approach
- Check that `~/.claude/hooks/prevent-sleep.sh` is executable: `chmod +x ~/.claude/hooks/prevent-sleep.sh`
### Tasks not getting marked complete
- Ensure your plan uses exact format: `- [ ]` (dash, space, brackets, space)
- The stop hook matches `^\s*[-*]?\s*\[\s*\]` — standard markdown checkboxes
### Stop hook not working
- Verify `CLAUDE_AUTONOMOUS=1` is set: `echo $CLAUDE_AUTONOMOUS`
- Check hook is registered in `~/.claude/settings.json`
- Test the hook manually: `echo '{}' | bash ~/.claude/hooks/stop-hook-autonomous.sh`
## Linux Notes
The system is macOS-focused but works on Linux with minor changes:
- **Sleep prevention**: Replace `caffeinate` with `systemd-inhibit --what=idle --who=claude-loop --why="Overnight automation" sleep infinity &` or simply disable sleep via `systemctl mask sleep.target`
- **Scheduled retry**: Replace the launchd plist section in `loop.sh` with a `systemd-run --on-calendar` or `at` command
- **Hooks work identically** — they're plain bash scripts
## Security Notes
- `--dangerously-skip-permissions` gives Claude **full system access** within the project. Only run on trusted codebases.
- The loop runs as your user — Claude can read/write anything you can
- API keys in `.env.local` are accessible to Claude during the session
- Review commits after an overnight run before pushing to production
- Consider running in a VM or container for additional isolation
## License
MIT. Use it however you want.
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env bash
# ============================================================================
# Claude Code Overnight Automation — One-File Setup
# ============================================================================
# Run from your project root:
# bash setup.sh
#
# This single script creates everything:
# loop/loop.sh — main orchestrator
# loop/prompt.md — template prompt for Claude
# loop/plan.md — your task checklist
# ~/.claude/hooks/ — sleep prevention + autonomous stop hook
# ~/.claude/settings.json — hook registrations
# ============================================================================
set -euo pipefail
BOLD='\033[1m'
DIM='\033[2m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
RED='\033[0;31m'
NC='\033[0m'
ok() { echo -e " ${GREEN}+${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
err() { echo -e " ${RED}x${NC} $1"; }
info() { echo -e " ${DIM}$1${NC}"; }
echo ""
echo -e "${BOLD} Claude Code Overnight Automation${NC}"
echo -e " ${DIM}────────────────────────────────────${NC}"
echo ""
# ── Prerequisites ────────────────────────────────────────────────────────────
echo -e " ${BOLD}Checking prerequisites...${NC}"
echo ""
MISSING=0
if command -v claude &>/dev/null; then
ok "Claude CLI: $(which claude)"
else
err "Claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code"
MISSING=1
fi
if command -v git &>/dev/null; then
ok "Git: $(which git)"
else
err "Git not found."
MISSING=1
fi
if [[ "$(uname)" == "Darwin" ]]; then
ok "macOS (caffeinate + launchd available)"
else
warn "Not macOS — sleep hooks need Linux equivalents (see README)"
fi
if git rev-parse --is-inside-work-tree &>/dev/null; then
PROJECT_DIR="$(git rev-parse --show-toplevel)"
ok "Project: $PROJECT_DIR"
else
PROJECT_DIR="$(pwd)"
warn "Not a git repo — using: $PROJECT_DIR"
fi
[[ "$MISSING" -eq 1 ]] && { echo ""; err "Fix the above and re-run."; exit 1; }
echo ""
# ── Create loop/ directory ───────────────────────────────────────────────────
echo -e " ${BOLD}Creating loop files...${NC}"
echo ""
LOOP_DIR="$PROJECT_DIR/loop"
mkdir -p "$LOOP_DIR"
# ── loop.sh (embedded) ──────────────────────────────────────────────────────
if [[ -f "$LOOP_DIR/loop.sh" ]]; then
warn "loop/loop.sh exists — skipping"
else
cat > "$LOOP_DIR/loop.sh" << 'LOOPEOF'
#!/usr/bin/env sh
# Claude Code Overnight Automation — Loop Script
# Usage: ./loop/loop.sh
# Config via env vars: ITERATION_COUNT, ITERATION_DELAY, CLAUDE_AUTONOMOUS, etc.
set -u
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}"
LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}"
ITERATION_COUNT="${ITERATION_COUNT:-10}"
ITERATION_DELAY="${ITERATION_DELAY:-30}"
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}"
MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}"
CLAUDE_EXIT=0
cd "$PROJECT_DIR"
log() { echo "$1" | tee -a "$LOG_FILE"; }
banner() {
log ""; log "════════════════════════════════════════════════════════════════"
log " $1"; log " $(date '+%Y-%m-%d %H:%M:%S')"
log "════════════════════════════════════════════════════════════════"; log ""
}
section() { log ""; log "────────────────────────────────────────"; log " $1"; log "────────────────────────────────────────"; log ""; }
plan_has_tasks() { grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null; }
remaining_tasks() { grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0"; }
next_task() { grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)"; }
check_rate_limit() {
[ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1
tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit" | grep -v "^Sleeping" | grep -v "^═" | grep -v "^─" \
| grep -qi -e "rate.limit" -e "too.many.requests" -e "429" -e "quota.exceeded" -e "usage.limit" -e "limit.reached" 2>/dev/null
}
banner "OVERNIGHT AUTOMATION STARTED"
log " Project: $PROJECT_DIR"
log " Prompt: $PROMPT_FILE"
log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}"
log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s delay)"
log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry ${MAX_RATE_LIMIT_RETRIES}x"
log " Tasks left: $(remaining_tasks)"
log " Next task: $(next_task)"
log ""
i=1; rate_limit_retries=0
while [ "$i" -le "$ITERATION_COUNT" ]; do
if ! plan_has_tasks; then
banner "ALL TASKS COMPLETE"; log " No remaining [ ] tasks. Stopping."; break
fi
section "ITERATION $i/$ITERATION_COUNT"
log " Remaining: $(remaining_tasks)"; log " Next: $(next_task)"; log ""
export CLAUDE_PROJECT_DIR="$PROJECT_DIR"
export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}"
if [ -f "$PROMPT_FILE" ]; then
log " Starting Claude..."; log ""
"$CLAUDE_BIN" -p --dangerously-skip-permissions < "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE"
CLAUDE_EXIT=$?; log ""; log " Exit code: $CLAUDE_EXIT"
else
log " ERROR: $PROMPT_FILE not found"; exit 1
fi
if check_rate_limit; then
rate_limit_retries=$((rate_limit_retries + 1))
if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then
section "RATE LIMITED — SCHEDULING RETRY"
PLIST_LABEL="com.claude-loop.overnight-retry"
PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M')
RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1); RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2)
cat > "$PLIST_PATH" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>${PLIST_LABEL}</string>
<key>ProgramArguments</key><array>
<string>/bin/sh</string><string>-c</string>
<string>cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH}</string>
</array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>${RETRY_HOUR}</integer><key>Minute</key><integer>${RETRY_MIN}</integer></dict>
<key>EnvironmentVariables</key><dict>
<key>CLAUDE_AUTONOMOUS</key><string>1</string>
<key>CLAUDE_PROJECT_DIR</key><string>${PROJECT_DIR}</string>
<key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin</string>
</dict>
<key>StandardOutPath</key><string>${LOG_FILE}</string>
<key>StandardErrorPath</key><string>${LOG_FILE}</string>
</dict></plist>
PLIST
launchctl load "$PLIST_PATH" 2>/dev/null || true
log " Scheduled retry at ~${RETRY_TIME}"; exit 0
fi
section "RATE LIMITED — WAITING"
log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES"; log " Sleeping ${RATE_LIMIT_WAIT}s..."
sleep "$RATE_LIMIT_WAIT"
if ! plan_has_tasks; then banner "ALL TASKS COMPLETE"; break; fi
log " Retrying..."; continue
fi
rate_limit_retries=0
section "ITERATION $i COMPLETE"
log " Remaining: $(remaining_tasks)"; log " Next: $(next_task)"
i=$((i + 1))
if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then
log " Pausing ${ITERATION_DELAY}s..."; sleep "$ITERATION_DELAY"
fi
done
banner "LOOP FINISHED"
log " Completed $((i - 1)) iterations"; log " Remaining: $(remaining_tasks)"; log ""
LOOPEOF
chmod +x "$LOOP_DIR/loop.sh"
ok "Created loop/loop.sh"
fi
# ── prompt.md and plan.md are created later after interactive input ────────
echo ""
# ── Install hooks ────────────────────────────────────────────────────────────
echo -e " ${BOLD}Installing hooks...${NC}"
echo ""
HOOKS_DIR="$HOME/.claude/hooks"
mkdir -p "$HOOKS_DIR"
# prevent-sleep.sh
if [[ -f "$HOOKS_DIR/prevent-sleep.sh" ]]; then
warn "prevent-sleep.sh exists — skipping"
else
cat > "$HOOKS_DIR/prevent-sleep.sh" << 'HOOKEOF'
#!/usr/bin/env bash
set -euo pipefail
PID_FILE="${CLAUDE_CAFFEINATE_PID:-$HOME/.claude/caffeinate.pid}"
if [[ -f "$PID_FILE" ]]; then
old_pid=$(cat "$PID_FILE")
kill -0 "$old_pid" 2>/dev/null && kill "$old_pid" 2>/dev/null || true
rm -f "$PID_FILE"
fi
caffeinate -i &
echo $! > "$PID_FILE"
exit 0
HOOKEOF
chmod +x "$HOOKS_DIR/prevent-sleep.sh"
ok "Installed ~/.claude/hooks/prevent-sleep.sh"
fi
# stop-hook-autonomous.sh
if [[ -f "$HOOKS_DIR/stop-hook-autonomous.sh" ]]; then
warn "stop-hook-autonomous.sh exists — skipping"
else
cat > "$HOOKS_DIR/stop-hook-autonomous.sh" << 'HOOKEOF'
#!/usr/bin/env bash
# "Ralph Wiggum" — blocks Claude from stopping until all plan tasks are done.
# Requires CLAUDE_AUTONOMOUS=1 to activate.
set -euo pipefail
BASE="${CLAUDE_PROJECT_DIR:-}"
if [[ -z "$BASE" ]] && command -v jq &>/dev/null; then
BASE=$(jq -r '.cwd // empty' 2>/dev/null || true)
fi
[[ -z "$BASE" ]] && BASE="$(pwd)"
PLAN_FILE="${CLAUDE_PLAN_FILE:-plan.md}"
ALT_FILES="loop/plan.md todo.md loop/todo.md"
AUTO_SLEEP_HOOK="$HOME/.claude/hooks/allow-sleep.sh"
plan=""
for f in "$PLAN_FILE" $ALT_FILES; do
[[ -z "$f" ]] && continue
if [[ "$f" == /* ]]; then path="$f"; else path="$BASE/$f"; fi
if [[ -f "$path" ]]; then plan="$path"; break; fi
done
if [[ -z "${CLAUDE_AUTONOMOUS:-}" ]] || [[ "$CLAUDE_AUTONOMOUS" == "0" ]]; then
[[ -x "$AUTO_SLEEP_HOOK" ]] && "$AUTO_SLEEP_HOOK" || true; exit 0
fi
if [[ -z "$plan" ]] || [[ ! -f "$plan" ]]; then
[[ -x "$AUTO_SLEEP_HOOK" ]] && "$AUTO_SLEEP_HOOK" || true; exit 0
fi
incomplete=$(grep -c -E '^\s*[-*]?\s*\[\s*\]' "$plan" 2>/dev/null || echo 0)
if [[ "${incomplete:-0}" -gt 0 ]]; then
echo '{"decision":"block","reason":"Plan has '"$incomplete"' incomplete task(s). Continue with the next item."}'
exit 0
fi
[[ -x "$AUTO_SLEEP_HOOK" ]] && "$AUTO_SLEEP_HOOK" || true
exit 0
HOOKEOF
chmod +x "$HOOKS_DIR/stop-hook-autonomous.sh"
ok "Installed ~/.claude/hooks/stop-hook-autonomous.sh"
fi
# allow-sleep.sh
if [[ -f "$HOOKS_DIR/allow-sleep.sh" ]]; then
warn "allow-sleep.sh exists — skipping"
else
cat > "$HOOKS_DIR/allow-sleep.sh" << 'HOOKEOF'
#!/usr/bin/env bash
set -euo pipefail
PID_FILE="${CLAUDE_CAFFEINATE_PID:-$HOME/.claude/caffeinate.pid}"
if [[ -f "$PID_FILE" ]]; then
pid=$(cat "$PID_FILE")
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null || true
rm -f "$PID_FILE"
fi
exit 0
HOOKEOF
chmod +x "$HOOKS_DIR/allow-sleep.sh"
ok "Installed ~/.claude/hooks/allow-sleep.sh"
fi
echo ""
# ── Register hooks in settings.json ─────────────────────────────────────────
echo -e " ${BOLD}Configuring Claude settings...${NC}"
echo ""
SETTINGS_FILE="$HOME/.claude/settings.json"
if [[ -f "$SETTINGS_FILE" ]]; then
cp "$SETTINGS_FILE" "${SETTINGS_FILE}.backup.$(date +%s)"
info "Backed up settings.json"
fi
if [[ -f "$SETTINGS_FILE" ]] && grep -q "stop-hook-autonomous" "$SETTINGS_FILE" 2>/dev/null; then
ok "Hooks already registered"
else
if command -v python3 &>/dev/null; then
python3 << 'PYEOF'
import json, os
p = os.path.expanduser("~/.claude/settings.json")
h = os.path.expanduser("~/.claude/hooks")
s = json.load(open(p)) if os.path.exists(p) else {}
if "hooks" not in s: s["hooks"] = {}
for event, script in [("UserPromptSubmit","prevent-sleep.sh"),("Stop","stop-hook-autonomous.sh"),("SessionEnd","allow-sleep.sh")]:
if event not in s["hooks"]: s["hooks"][event] = []
if not any(script in json.dumps(x) for x in s["hooks"][event]):
s["hooks"][event].append({"matcher":"","hooks":[{"type":"command","command":f"{h}/{script}"}]})
with open(p,"w") as f: json.dump(s,f,indent=2); f.write("\n")
PYEOF
ok "Registered hooks in settings.json"
else
warn "python3 not found — add hooks to ~/.claude/settings.json manually"
fi
fi
echo ""
# ══════════════════════════════════════════════════════════════════════════════
# INTERACTIVE SETUP — collect tasks and project context
# ══════════════════════════════════════════════════════════════════════════════
echo -e " ${DIM}════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e " ${BOLD}Now let's set up your tasks and project context.${NC}"
echo ""
# ── Collect tasks ────────────────────────────────────────────────────────────
if [[ -f "$LOOP_DIR/plan.md" ]]; then
echo -e " ${YELLOW}loop/plan.md already exists.${NC}"
echo -ne " Overwrite with new tasks? [y/N] "
read -r OVERWRITE_PLAN
[[ "$OVERWRITE_PLAN" =~ ^[Yy] ]] || SKIP_PLAN=1
fi
if [[ "${SKIP_PLAN:-0}" != "1" ]]; then
echo -e " ${BOLD}Enter your tasks${NC} — one per line."
echo -e " ${DIM}Be specific. Claude will execute these literally.${NC}"
echo -e " ${DIM}Example: \"Add JWT authentication with refresh tokens\"${NC}"
echo -e " ${DIM}Press Enter on an empty line when done.${NC}"
echo ""
TASKS=()
TASK_NUM=1
while true; do
echo -ne " ${CYAN}Task $TASK_NUM:${NC} "
read -r TASK_LINE
[[ -z "$TASK_LINE" ]] && break
TASKS+=("$TASK_LINE")
TASK_NUM=$((TASK_NUM + 1))
done
if [[ ${#TASKS[@]} -eq 0 ]]; then
warn "No tasks entered — writing example plan"
cat > "$LOOP_DIR/plan.md" << 'PLANEOF'
# Task Plan
## Phase 1
- [ ] **1.1** — First task description
- [ ] **1.2** — Second task description
## Final
- [ ] **FINAL** — Run full test suite, fix failures, tag release
PLANEOF
else
echo "# Task Plan" > "$LOOP_DIR/plan.md"
echo "" >> "$LOOP_DIR/plan.md"
i=1
for task in "${TASKS[@]}"; do
echo "- [ ] **$i** — $task" >> "$LOOP_DIR/plan.md"
i=$((i + 1))
done
echo "" >> "$LOOP_DIR/plan.md"
echo "- [ ] **FINAL** — Run full test suite, fix any failures" >> "$LOOP_DIR/plan.md"
ok "Wrote ${#TASKS[@]} tasks to loop/plan.md"
fi
echo ""
fi
# ── Collect project context ──────────────────────────────────────────────────
if [[ -f "$LOOP_DIR/prompt.md" ]] && [[ "${SKIP_PLAN:-0}" == "1" ]]; then
SKIP_PROMPT=1
fi
if [[ "${SKIP_PROMPT:-0}" != "1" ]]; then
echo -e " ${BOLD}Project context${NC} — tell Claude about your project."
echo -e " ${DIM}Stack, test commands, coding style, anything important.${NC}"
echo -e " ${DIM}Example: \"TypeScript + React, run 'npm test', use Prettier formatting\"${NC}"
echo -e " ${DIM}Press Enter on an empty line when done (or just Enter to skip).${NC}"
echo ""
RULES=()
while true; do
echo -ne " ${CYAN}>${NC} "
read -r RULE_LINE
[[ -z "$RULE_LINE" ]] && break
RULES+=("$RULE_LINE")
done
# Build prompt.md
cat > "$LOOP_DIR/prompt.md" << 'PROMPTEOF'
You are executing a project roadmap autonomously. Read these files first:
1. `loop/plan.md` — Your task checklist (mark items `- [x]` as you complete them)
2. Read any project documentation (README, CLAUDE.md, etc.) for conventions
PROMPTEOF
if [[ ${#RULES[@]} -gt 0 ]]; then
echo "## Project Rules" >> "$LOOP_DIR/prompt.md"
echo "" >> "$LOOP_DIR/prompt.md"
for rule in "${RULES[@]}"; do
echo "- $rule" >> "$LOOP_DIR/prompt.md"
done
echo "" >> "$LOOP_DIR/prompt.md"
ok "Added ${#RULES[@]} project rules to prompt"
fi
cat >> "$LOOP_DIR/prompt.md" << 'PROMPTEOF'
## For each task in loop/plan.md:
1. Find the first unchecked `- [ ]` item
2. Understand what needs to be done
3. Implement it following the project's existing patterns and conventions
4. Run the project's type checker / linter / tests — fix all errors
5. Commit with a conventional message: `type(scope): description`
6. Mark the task `- [x]` in `loop/plan.md`
7. Move to the next unchecked task immediately
## Rules
- If tests fail, fix them before moving on
- If a task is difficult, make at least 30 genuine attempts before skipping
- Always run linter + type checker after code changes
- Do not stop until all tasks are checked or you are rate limited
PROMPTEOF
ok "Created loop/prompt.md"
echo ""
fi
# ── Summary & launch ─────────────────────────────────────────────────────────
TASK_COUNT=$(grep -c '^\- \[ \]' "$LOOP_DIR/plan.md" 2>/dev/null || echo "0")
echo -e " ${DIM}════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e " ${BOLD}${GREEN}Ready to go!${NC}"
echo ""
echo -e " ${BOLD}Tasks:${NC} $TASK_COUNT in loop/plan.md"
echo -e " ${BOLD}Prompt:${NC} loop/prompt.md"
echo -e " ${BOLD}Log:${NC} loop/loop.log (created on first run)"
echo ""
echo -e " ${DIM}────────────────────────────────────────────────────────────${NC}"
echo ""
echo -e " ${BOLD}To start:${NC}"
echo -e " ${GREEN}./loop/loop.sh${NC}"
echo ""
echo -e " ${BOLD}To start with sleep prevention (macOS):${NC}"
echo -e " ${GREEN}caffeinate -i ./loop/loop.sh${NC}"
echo ""
echo -e " ${BOLD}Monitor:${NC}"
echo -e " ${DIM}tail -f loop/loop.log${NC}"
echo ""
echo -e " ${BOLD}Config:${NC}"
echo -e " ${DIM}CLAUDE_AUTONOMOUS=0${NC} — let Claude stop freely (testing)"
echo -e " ${DIM}ITERATION_COUNT=20${NC} — more iterations"
echo -e " ${DIM}ITERATION_DELAY=60${NC} — longer pause between rounds"
echo ""
echo -e " ${DIM}────────────────────────────────────────────────────────────${NC}"
echo ""
echo -ne " ${BOLD}Start the loop now?${NC} [y/N] "
read -r START_NOW
if [[ "$START_NOW" =~ ^[Yy] ]]; then
echo ""
echo -e " ${GREEN}Launching...${NC}"
echo ""
exec ./loop/loop.sh
fi
echo ""
echo -e " ${DIM}Run ./loop/loop.sh whenever you're ready.${NC}"
echo ""
+1051
View File
File diff suppressed because it is too large Load Diff
+443
View File
@@ -0,0 +1,443 @@
# AIUI Plan 2 — Extended Roadmap
## Context & Philosophy
This plan continues from M0M7 (all complete). Every item below must honour the core philosophy:
- **Glass morphism only**`glass`, `glass-card`, `glass-button`. No light mode, no gray-900 hacks.
- **Open source / MIT/Apache-2.0** — no proprietary dependencies
- **Decentralised-first** — no vendor lock-in, pluggable everything
- **Bitcoin only** — sats, Lightning, Cashu, Fedimint. AIUI is never a wallet — always deep-link
- **Privacy-first** — no telemetry, no tracking, E2E encryption
- **Mobile-first, everywhere-perfect** — desktop enhances mobile, never replaces it
- **Plugin-everything** — all integrations go through typed plugin interfaces
- **< 250 KB gzipped initial load** — everything else lazy-loaded
---
## M8: Chat UX Polish
### M8.1 — Message Editing & Regeneration
Edit any sent message in place; all messages after it are cleared and AI regenerates from that point. Pencil icon appears on hover. Textarea replaces bubble on click. `Escape` cancels, `Enter` submits.
### M8.2 — Conversation Branching
Fork from any assistant message. Branch indicator in chat header (e.g. "Branch 2 of 3"). Branch switcher as a compact glass pill above the forked message. Each branch stored as a separate conversation in IDB.
### M8.3 — Reply-to Threading
Click any message → "Reply" option. Reply shows a quoted excerpt of the target message above the input. Thread line connects quoted block to source. Visual only — does not send separate context to AI, just prepends `> quote` to the user message.
### M8.4 — Conversation Search
`Cmd+F` / search icon opens a slide-down glass panel above chat. Real-time filtering highlights matching messages. Up/down arrows jump between matches. `Escape` closes.
### M8.5 — Auto-Title Generation
After the first AI response in a new conversation, send a background request: `"Give a 4-word title for this conversation: {first user message}"`. Replace "New Chat" silently. No loading state — title updates smoothly.
### M8.6 — Context Window Visualiser
Slim progress bar at top of chat column. Estimates token count from message lengths (1 token ≈ 4 chars). Shows percentage of model's context window used. Bitcoin-orange fill → red when > 80%. Tooltip: "~12,400 / 200,000 tokens used".
### M8.7 — Conversation Export
Three-dot menu on each conversation → Export. Options: Markdown (download .md), JSON (full data), Plain text. Uses File System Access API when available, falls back to `<a download>`. No server involved.
### M8.8 — Import Conversations
Settings → Import → drag-and-drop or file picker for AIUI JSON export or Claude.ai export JSON. Merges into existing conversations without overwriting. Shows import summary (N conversations added).
### M8.9 — Long-press / Right-click Context Menus
Messages: Copy, Edit, Delete, Reply, Branch from here. Content cards: Favourite, Share, Open detail, Copy title. Uses a reusable `ContextMenu.vue` glass-card component positioned at cursor. Closes on outside click or `Escape`.
### M8.10 — Scroll Position Memory
When switching between conversations, restore the previous scroll position. Store position per conversation ID in a `Map<string, number>` (not persisted — session only). Virtual scroller should seek to the stored offset on mount.
---
## M9: AI Experience
### M9.1 — Multi-Model Comparison Mode
Split-screen: same prompt sent to two models simultaneously. Side-by-side layout on desktop, swipeable tabs on mobile. Model selector per pane. Shows streaming output in both. Useful for comparing Claude vs OpenRouter models.
### M9.2 — System Prompt Editor
Settings → Personas. Create named personas (e.g. "Film Critic", "Bitcoin Analyst"). Each has a system prompt, model preference, and accent colour. Select persona per conversation via a pill menu above the input. Default persona applies to all new conversations.
### M9.3 — Prompt Template Library
`/` in chat input opens a command palette (glass dropdown). Templates listed with title + preview. Variables in templates use `{{variable}}` syntax — on selection, a mini form appears to fill them. Templates stored in IDB, importable/exportable as JSON.
### M9.4 — Vision Input
Drag-and-drop or paste image into chat input. Image preview appears as a thumbnail above the input. On send, image encoded as base64 and included in the message content array (Claude vision format). Only enabled when active model supports vision. Max 4 images per message.
### M9.5 — Response Feedback
Thumbs up / thumbs down on each AI message (appears on hover). Stored locally in IDB per message ID. Shown in conversation export. Future: aggregate across sessions for personal preference tracking. Never sent anywhere.
### M9.6 — Token & Cost Estimator
Settings toggle to show token counts. Each message shows estimated token count in a tiny badge (bottom-right of bubble). Running total shown in context window bar. Cost estimate based on current model's pricing (hardcoded table, updated with model releases).
### M9.7 — AI Memory Panel
Settings → Memory. A list of "always remember" facts injected into every system prompt. e.g. "I live in London", "I prefer sats over fiat". Edit/delete/add. Max 20 items. Stored encrypted in IDB. Shown as a collapsed "Memory" section in the system prompt.
### M9.8 — Model Capabilities Badge
Model selector shows capability badges: Vision 👁, Tools 🔧, Long context 📄. Tooltip explains each. Greys out vision input button when selected model doesn't support it. Updates dynamically when switching providers.
### M9.9 — Temperature & Params Slider
Advanced settings section (collapsed by default) beneath the model selector. Sliders for: Temperature (01), Max tokens (2568192), Top-P. Values persisted per conversation in IDB. Reset to defaults button.
### M9.10 — Stop Sequence Configuration
Advanced settings: configurable stop sequences (comma-separated). Applied to all requests for that conversation. Useful for structured output tasks. Shown as a small tag list below the slider panel.
---
## M10: Advanced Content Renderers
### M10.1 — Full Article Renderer
When AI returns a long-form article (> 800 words with headings), render it in the panel as a paginated article view. Features: auto-generated table of contents (sticky left sidebar on desktop), estimated reading time, font-size control, print mode. Uses existing markdown-it instance.
### M10.2 — PDF Viewer
Content type `pdf` renders via `pdfjs-dist` (lazy loaded, ~400 KB). Page navigation, zoom, text selection, search within PDF. Chat preview: thumbnail of page 1. Panel play: full viewer. Files loaded from URL (no local file upload in v1).
### M10.3 — Map Renderer
Content type `place` upgrades from static card to interactive Leaflet map (lazy loaded). OpenStreetMap tiles (no API key needed). Pins for all places mentioned in conversation. Cluster pins when > 10 places. Panel play: fullscreen map with place list sidebar.
### M10.4 — Recipe Renderer
New content type `recipe`. Tag: `<recipe_ext title="..." servings="..." time="...">`. Structured display: ingredients checklist (tap to strike through), numbered steps, metadata chips (time, servings, calories). "Scale recipe" slider (0.5×–4×) recalculates quantities.
### M10.5 — Event Renderer
New content type `event`. Tag: `<event_ext title="..." date="..." location="..." url="...">`. Shows: date chip, location, countdown. Add to calendar buttons: ICS download, Google Calendar URL, Apple Calendar. Glass card in chat, full detail in panel.
### M10.6 — Math Renderer
Detect `$...$` (inline) and `$$...$$` (block) LaTeX in chat messages. Render using KaTeX (lazy loaded, ~70 KB). Fallback: display raw LaTeX in a code block. No re-renders during streaming — batch render on stream end.
### M10.7 — Mermaid Diagram Renderer
Detect ` ```mermaid ` fenced code blocks. Render using Mermaid.js (lazy loaded, ~500 KB). Support: flowchart, sequence, gantt, entity-relationship. Dark theme matching glass design. Copy SVG button. Pan/zoom on mobile.
### M10.8 — Audio Waveform Player
Upgrade PlayerBar for locally-loaded audio. Use WaveSurfer.js (lazy loaded) to show waveform visualization. Waveform rendered in Bitcoin orange on dark background. Click to seek. Existing queue/next/prev preserved.
### M10.9 — Table Renderer
Markdown tables rendered as interactive tables: column sort (click header), row filter (search input above table), CSV export button. Uses existing markdown-it but overrides the table token renderer. Max 500 rows before virtualisation kicks in.
### M10.10 — Timeline Renderer
New content type `timeline`. AI returns a series of `<event_ext>` tags. Panel renders them as a vertical timeline: date on left, event card on right, connecting line. Animate entries in as they appear during streaming.
### M10.11 — Code Runner
Fenced code blocks with a "Run" button for HTML/CSS/JS. Opens a sandboxed `<iframe srcdoc="...">` in the panel. Output console below. `sandbox="allow-scripts"` only — no network access, no storage. Python: future (Pyodide).
### M10.12 — Video Renderer
New content type `video`. Native `<video>` element with custom glass controls. HLS.js for adaptive streams (lazy loaded). YouTube URL detection → nocookie embed fallback. Panel play: fullscreen. Chat preview: thumbnail + play button.
---
## M11: Nostr Ecosystem
### M11.1 — Publish Nostr Notes
Compose panel in the Nostr tab. Write a note → sign via NIP-07 → broadcast to configured relays. Shows send status per relay. Can attach content card references (film, song, etc.) as URL mentions. Character counter (280 soft limit, no hard cap).
### M11.2 — Nostr DMs (NIP-17)
Encrypted direct messages using NIP-17 sealed gifts. DM inbox tab in Nostr section. Contact list from follows. Message threads per contact. Messages encrypted client-side, stored in IDB. No plaintext ever sent to relay.
### M11.3 — Relay Management UI
Settings → Nostr Relays. Add/remove relay URLs. Health column: latency (ms), status (connected/disconnected/error). Test connection button. Read/write toggle per relay. Import relay list from NIP-65 event.
### M11.4 — Nostr Profile Editor
Settings → Nostr Identity (extends M6.3). Edit: display name, bio, avatar URL, banner URL, website, NIP-05 address, Lightning address. Preview renders as a profile card. Publish as kind:0 event via NIP-07.
### M11.5 — Zaps (NIP-57)
On any Nostr note or profile, show a Zap ⚡ button. Opens a zap dialog: amount input (in sats), optional message. Fetches LNURL-pay from profile's Lightning address. Shows QR + deep-link. Confirms via Lightning payment. Never holds funds.
### M11.6 — NIP-05 Verification Badge
Nostr profiles with NIP-05 show a ✓ badge. Verified by fetching `/.well-known/nostr.json?name=...` from the NIP-05 domain. Cached in IDB for 24 hours. Badge tooltip shows the full NIP-05 identifier.
### M11.7 — Nostr Search (NIP-50)
Search input in Nostr tab. Sends `REQ` with `search` field to NIP-50 supporting relays (nostr.wine, relay.nostr.band). Results show as note cards with author, content, timestamp. Filter by content type.
### M11.8 — Thread View
Clicking a Nostr note opens a thread view in the panel. Fetches root event and all replies (kind:1, `#e` tag). Renders as a threaded tree (indent by depth, max 5 levels). Loads lazily from relays. Reply button opens compose with reply reference.
### M11.9 — Nostr Lists (NIP-51)
View and manage: follow list (kind:3), mute list (kind:10000), pin list (kind:10001), bookmark list (kind:10003). Each as a panel tab in the Nostr section. Add/remove items. Publish via NIP-07.
### M11.10 — Long-Form Content (NIP-23)
Nostr long-form articles (kind:30023) rendered in the article renderer (M10.1). Discovery tab in Nostr section shows recent articles from follows. Clicking opens the full article in panel play. Share as Nostr note button.
---
## M12: Bitcoin Ecosystem
### M12.1 — On-Chain Address Display
Detect Bitcoin addresses in chat (bech32 segwit, legacy). Render as a glass card: address (truncated), QR code, "View on mempool.space" link, copy button. Balance lookup via mempool.space API (lazy, opt-in). Never sends private keys.
### M12.2 — Fedimint Ecash
Detect Fedimint ecash tokens in chat (e-cash token format). Display: federation name, amount in sats, "Receive in Fedi" deep-link button. QR of the token string. Copy button. Same approach as Cashu — AIUI is never a wallet.
### M12.3 — BOLT12 Offers
Detect `lno1...` BOLT12 offer strings. Render as glass card: decoded amount (if fixed), description, "Pay with wallet" deep-link. QR of the offer. BOLT12 is static (reusable), unlike BOLT11 invoices.
### M12.4 — Nostr Wallet Connect (NWC)
Settings → Connect Wallet. Paste NWC connection string (`nostr+walletconnect://...`). AIUI can then: check balance, pay invoices (with user confirmation). Uses NIP-47. All operations require explicit user tap. Stored encrypted in IDB.
### M12.5 — LNURL-auth Login
Settings → LNURL-auth. Generates a LNURL-auth QR code. Scanning with a Lightning wallet proves ownership of the Lightning node. Sets a persistent identity (pubkey) used for local preference sync. No password needed.
### M12.6 — Live Sat/Fiat Price
Settings toggle: show amounts in sats or fiat equivalent. Price fetched from mempool.space `/api/v1/prices` every 60 seconds. Used across: Cashu cards, Lightning invoices, cost estimator, zap dialog. Stored in a `useBitcoinPrice` composable.
### M12.7 — Mempool.space Tx Viewer
Detect txid hashes (64 hex chars) and block heights in chat. Render as a glass card with: confirmations, fee rate, amount, link to mempool.space. Block height renders block summary. Updates live via mempool.space WebSocket.
### M12.8 — BOLT11 Decoder Card
Full BOLT11 invoice decode before paying: show amount, description, expiry countdown, destination node alias (if known). Expiry shown as a red countdown when < 5 minutes. "Pay" button triggers deep-link or NWC payment (M12.4).
---
## M13: Content Discovery
### M13.1 — "For You" Feed
A new "For You" tab in the content panel. Surfaces content types you've interacted with most (from favorites + conversation history). Uses a simple frequency map (no ML). Refreshes on each app open. Fully local, no server.
### M13.2 — Content Tagging
On any content card: "Add tag" (plus icon). Tags are user-defined strings stored in IDB alongside the item. Filter any content grid by tag. Tag cloud view in favorites panel. Export tags with content JSON.
### M13.3 — Smart Playlists
Music tab → Smart Playlists. Auto-generated from: recently played, most played, by genre tag, by decade. Each playlist is a computed view over the song IDB store. Play button queues the whole playlist. No manual curation needed.
### M13.4 — Similar Content
Below any open content detail: "More like this" section. Populated by sending a background AI request: `"List 3 films similar to {title} as film_ext tags"`. Results appear after 23 seconds. Cached in IDB per item for 7 days.
### M13.5 — Recently Viewed
A "Recent" tab in the content panel. Ordered list of the last 50 content items you opened (any type). Each entry: thumbnail, title, type, time ago. Tap to re-open. Stored in IDB, cleared on data wipe.
### M13.6 — Content Collections
User-created collections (like playlists but for any content type). Create collection → name it → add any content card to it via long-press menu. Collections shown as a grid of 4-thumbnail mosaics. Shareable as a Nostr list (NIP-51).
### M13.7 — Trending in Conversations
A "Trending" section: content items referenced most frequently across all your conversations in the last 30 days. Computed on load from IDB. Shows a small "referenced N times" badge. Pure local analytics.
### M13.8 — Content Sharing via Nostr
Any content card: Share → "Post to Nostr". Generates a note with the content title, year, a short AI-generated description, and the content tag as a URL. Signs and broadcasts via NIP-07. Opens compose preview before posting.
---
## M14: Plugin Marketplace
### M14.1 — Plugin Discovery UI
Settings → Plugins → Discover. Fetches a static community registry JSON (hosted on GitHub Pages or IPFS). Lists plugins with: name, description, type, author, version, rating. Install button triggers M14.7 (import by URL).
### M14.2 — Plugin Settings Panel
Each installed plugin has a gear icon → settings panel. Plugin declares its settings schema (JSON Schema). AIUI renders the settings form automatically using a `PluginSettingsForm.vue` component. Settings stored encrypted in IDB under plugin ID.
### M14.3 — Plugin Permissions UI
On install: permissions dialog lists requested capabilities (e.g. "Access chat messages", "Make network requests", "Read favorites"). User grants/denies each. Permissions stored per plugin. Plugin can check granted permissions at runtime via `context.hasPermission()`.
### M14.4 — Plugin Dev Mode
`VITE_PLUGIN_DEV=true` enables: hot-reload of plugins from `src/plugins/dev/`, error inspector panel (shows plugin errors without crashing app), plugin performance profiler (time per `init()` call).
### M14.5 — Built-in Plugin: Wikipedia
Plugin type `search`. `/wiki {query}` in chat input fetches Wikipedia summary via the Wikipedia REST API. Returns a `article` content card inline. No API key needed. Rendered via the article renderer (M10.1).
### M14.6 — Built-in Plugin: OpenLibrary
Plugin type `search`. Searches Open Library (openlibrary.org) for books. Returns `book_ext` tagged results. Cover images from Open Library covers API. Free, no API key.
### M14.7 — Plugin Import by URL
Settings → Plugins → Install from URL. Paste a GitHub raw URL or IPFS CID. AIUI fetches the plugin manifest (`aiui-plugin.json`), validates schema, shows permissions dialog (M14.3), then installs. Plugins are community Tier 2 (sandboxed iframe).
### M14.8 — Plugin Versioning & Auto-Update
Installed plugins store their version. On app start, check registry for newer versions (background fetch). Badge on Plugins settings icon when updates available. Update all button. Changelog shown before updating.
---
## M15: Settings & Personalisation
### M15.1 — Accent Colour Picker
Settings → Appearance. Colour wheel or preset swatches to change the accent colour (default Bitcoin orange #F7931A). Updates `--color-accent` CSS variable in real time. Persisted in IDB. Affects all gradient buttons, badges, active states.
### M15.2 — Glass Intensity Slider
Settings → Appearance. Three presets: Subtle / Default / Strong. Maps to blur(12px)/blur(18px)/blur(28px) and background opacity 0.25/0.35/0.50. Updates glass CSS variables. Live preview as you drag.
### M15.3 — Font Size Settings
Settings → Appearance. Three sizes: Compact (13px base), Default (15px), Large (17px). Sets `--font-size-base` CSS variable. Scales all rem-based text. Persisted in IDB.
### M15.4 — Content Type Visibility
Settings → Content. Toggle visibility of each of the 11 content type tabs in the panel. Hidden types still extract from AI messages but don't show in the panel. Useful for users who only care about music + films.
### M15.5 — Keyboard Shortcut Map
Settings → Shortcuts. Lists all keyboard shortcuts. Each row shows action + current binding. Click to rebind (record next key combo). Conflicts highlighted in red. Stored in IDB. Uses the existing keybindings system.
### M15.6 — Browser Push Notifications
Settings → Notifications. Opt-in for: "Generation complete" (when a long response finishes while tab is backgrounded). Uses the Web Notifications API + Service Worker `showNotification()`. Notification click focuses the tab and scrolls to the response.
### M15.7 — Auto-Archive Old Conversations
Settings → Storage. Slider: archive conversations older than N days (7/30/90/never). Archived conversations move to an "Archive" folder, not deleted. Unarchive individually. Archive stored in a separate IDB object store.
### M15.8 — Full Data Export
Settings → Data → Export All. Creates a JSON archive: all conversations, favorites, settings, tags, collections. Optionally encrypted with the current passphrase. Single file download. Compliant with GDPR right to portability.
### M15.9 — Data Wipe
Settings → Data → Wipe Everything. Two-step confirmation. Clears: all IDB stores, service worker cache, localStorage. Does not clear the API key vault unless explicitly checked. Shows what will be deleted before confirming.
### M15.10 — Default Conversation Settings
Settings → Chat. Set global defaults: default model, default persona, web search on/off, show token counts. These apply to all new conversations. Per-conversation overrides still possible.
---
## M16: Mobile UX Polish
### M16.1 — Bottom Sheet Component
Reusable `BottomSheet.vue`. Gesture-driven: drag down to dismiss, swipe up to expand. Snap points: 40% / 80% / 100% height. Backdrop tap to close. Used by: context menus, share sheets, relay management, plugin settings. Replaces modals on mobile.
### M16.2 — Swipe to Navigate Conversations
On mobile, swipe left/right on the chat area to move between conversations. Animated slide transition. Visual edge indicator (thin line at sides) to hint swipeability. Threshold: 80px swipe distance, 0.3 velocity.
### M16.3 — Pull-to-Refresh on Content Panels
Each content grid supports pull-to-refresh. Custom glass spinner animation. Triggers: re-fetch from AI context, reload Nostr feed, clear image cache for that type. Haptic feedback on release.
### M16.4 — Haptic Feedback
Use `navigator.vibrate()` for: message send (10ms), favourite toggle (15ms), error (pattern: 50ms50ms50ms), pull-to-refresh trigger (20ms). Wrapped in `useHaptics()` composable that checks support before calling. Settings toggle to disable.
### M16.5 — Web Share API
All content cards and conversations: Share button triggers native `navigator.share()` where available. Falls back to a glass share sheet (copy link, copy text, Nostr share). Adapts to iOS (files not supported) vs Android (files supported).
### M16.6 — Pinch-to-Zoom on Images & Maps
Images in the panel support pinch-to-zoom via touch events. Min scale 1×, max 4×. Double-tap resets to 1×. Map renderer uses Leaflet's built-in touch zoom. Implemented with a `usePinchZoom()` composable (no library needed).
### M16.7 — iOS PWA Polish
Meta tags: `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style: black-translucent`. Safe area insets via `env(safe-area-inset-*)` on all fixed elements (chat input, player bar, nav). Splash screens for common iPhone sizes.
### M16.8 — Long-press Context Menus on Mobile
On mobile, long-press (500ms) on messages or content cards opens the context menu (M8.9) as a bottom sheet (M16.1). Haptic on trigger (20ms). Prevents default browser long-press menu via `@contextmenu.prevent`.
### M16.9 — Scroll Position Memory
Restore scroll position when switching tabs, conversations, or navigating back. Store position per route + conversation ID in a `Map` (session only). Content grids also remember their scroll offset.
### M16.10 — Landscape Mode Optimisation
Detect landscape on mobile. Rearrange layout: chat takes 50% width, content panel 50% (instead of overlay). Player bar becomes minimal (just controls, no waveform). Smooth transition on rotate via CSS transitions on layout classes.
---
## M17: Accessibility & Internationalisation
### M17.1 — Keyboard Navigation Audit
Full Tab order review across all pages. All interactive elements reachable. Focus trap in modals and bottom sheets. `Escape` closes any overlay. Roving tabindex in content card grids. Arrow keys navigate card grids.
### M17.2 — ARIA Audit
All icon buttons: `aria-label`. All dynamic content: `aria-live="polite"`. Dialogs: `role="dialog"`, `aria-modal`, `aria-labelledby`. Content card grids: `role="list"` + `role="listitem"`. Loading states: `aria-busy`.
### M17.3 — High Contrast Mode
`@media (prefers-contrast: more)` stylesheet. Increases border opacity from 0.18 → 0.5. Text opacity: all `/90``100%`. Removes backdrop blur (performance + clarity). Accent remains orange. Toggle also available in Settings.
### M17.4 — Automated Accessibility Tests
Axe-core integrated into Playwright E2E tests. Run `pnpm test:a11y` which opens each page and asserts zero critical axe violations. CI fails on new violations. Reports saved as HTML artefacts.
### M17.5 — i18n Foundation
Add `vue-i18n`. Extract all hardcoded strings into `src/i18n/en.json`. Add `es.json` (Spanish) and `fr.json` (French) with machine-translated initial values (marked as needing review). Language auto-detected from `navigator.language`, overridable in Settings.
### M17.6 — RTL Layout Support
`dir="rtl"` on `<html>` for Arabic/Hebrew locales. Use logical CSS properties (`padding-inline-start` not `padding-left`). Flex row reversal handled by `rtl:flex-row-reverse` Tailwind variant. Test with Arabic locale.
### M17.7 — Dyslexia-Friendly Font Option
Settings → Appearance → Font. Option: "OpenDyslexic". Loaded via self-hosted WOFF2 (MIT licensed). Sets `--font-sans` CSS variable. Letter spacing +0.05em, line height 1.6.
### M17.8 — Skip Navigation Link
Hidden "Skip to main content" link as the first focusable element. Visible on Tab focus. Jumps to `<main>` landmark. Standard accessibility pattern — costs nothing, helps screen reader users significantly.
---
## M18: Performance
### M18.1 — Bundle Analysis & Splitting
Run `vite-bundle-visualizer` in CI. Identify any component loaded eagerly that should be lazy. Target: core bundle stays < 150 KB gzipped. Create per-route chunk boundaries in Vue Router.
### M18.2 — Image Lazy Loading with Blur-up
All content card images: `loading="lazy"` + `decoding="async"`. Low-quality placeholder (16×16 px, base64 inline) shown until full image loads. CSS transition from blurred placeholder to sharp image. `IntersectionObserver`-based (via `useIntersectionObserver`).
### M18.3 — Request Deduplication
`useFetch()` composable wraps all API calls. Identical in-flight requests share a single Promise (keyed by URL + body hash). Cancel via `AbortController` on component unmount. Prevents duplicate AI requests on fast re-renders.
### M18.4 — Web Worker for Heavy Tasks
Move `contentExtraction` parsing and AES-256-GCM encryption/decryption into a Web Worker (`src/workers/heavy.worker.ts`). Main thread posts messages, worker responds. Use `comlink` (MIT, ~1 KB) for typed RPC. Keeps UI thread free.
### M18.5 — Prefetch on Hover
Content cards: on `mouseenter` (desktop) or 100ms touch hold (mobile), prefetch the detail data. E.g. fetch TMDB details for a film card before the user clicks. Store in a short-lived cache (5 min). Makes panel open feel instant.
### M18.6 — Memory Leak Audit
Systematically add `onUnmounted` cleanup to all composables that use: `setInterval`, `setTimeout`, `addEventListener`, WebSocket connections, `IntersectionObserver`, `ResizeObserver`. Add a dev-mode leak detector that logs active listeners on route change.
### M18.7 — Background Sync Queue
If an IDB save fails (e.g. storage quota exceeded), queue the operation in a `SyncQueue`. On next app focus (`visibilitychange`), retry the queue. Show a subtle warning badge in settings if queue is non-empty.
### M18.8 — OPFS Storage Backend (Optional)
Implement an alternative storage backend using Origin Private File System (OPFS) via SQLite WASM (`@sqlite.org/sqlite-wasm`, Apache 2.0). Feature-flagged: `VITE_STORAGE=opfs`. Faster for large datasets (1000+ conversations). Falls back to IDB if OPFS unavailable.
---
## M19: Developer Experience & Quality
### M19.1 — Storybook
Add Storybook 8 to `packages/app`. Stories for all `ui/` components. Glass morphism theme applied to Storybook canvas (`background: #0a0a0a`). Run with `pnpm storybook`. Stories used as visual regression baseline.
### M19.2 — Visual Regression Tests
Playwright screenshot tests for: ChatPage, ContentPanel, each renderer card, PassphraseDialog, BottomSheet. Compare against baseline snapshots on every PR. Fail if pixel diff > 0.5%. Update baseline with `pnpm test:update-snapshots`.
### M19.3 — Bundle Size CI Gate
Add a GitHub Actions step: build → measure gzipped bundle → fail if > 250 KB. Use `bundlesize` (MIT). Track history: post bundle size as a PR comment showing diff from base branch.
### M19.4 — Comprehensive Mock Data
Expand `src/mocks/` with realistic data for all 11 content types (20+ items each). Add a mock Nostr relay (in-process WebSocket server) for E2E tests. Add mock TMDB responses for all test films.
### M19.5 — E2E Cross-Browser Matrix
Playwright config: run tests on Chromium + Firefox + WebKit. CI matrix: macOS (WebKit) + Linux (Chromium + Firefox). Mobile viewports: iPhone 14 (390×844) + Galaxy S21 (360×800).
### M19.6 — Proxy Integration Tests
Test `claude-proxy.ts` with a mock Anthropic API (intercepted by `nock` or `msw`). Assert: SSE streaming format, tool_use round-trips, error handling (401, 429, 500), client disconnect kills child process.
### M19.7 — Performance Benchmarks (Lighthouse CI)
Run Lighthouse in CI on each PR against a built + served app. Track: LCP, FID, CLS, TTI. Fail if LCP > 3s or CLS > 0.15. Post scores as PR comment. Store history in a JSON file committed to `reports/` branch.
### M19.8 — Dependency Audit
Weekly GitHub Actions job: `pnpm audit` for vulnerabilities, `license-checker` to flag non-MIT/Apache dependencies. Auto-create an issue if violations found. Block releases on critical vulnerabilities.
---
## M20: Collaboration & Sharing
### M20.1 — Share Conversation via Nostr
Export a conversation as a Nostr long-form article (kind:30023). Title = conversation title. Content = formatted Markdown. Sign via NIP-07. Optionally encrypt for a specific npub (NIP-44). Shareable via `nostr:naddr1...` link.
### M20.2 — Read-Only Conversation Viewer
A `/view/:nostrAddr` route that renders a shared Nostr conversation (from M20.1) in read-only mode. No auth needed for public conversations. Shows content cards inline. Works as a landing page for shared links.
### M20.3 — Collaborative Playlist (Nostr NIP-51)
Create a shared content list (NIP-51 kind:30004). Invite others by npub to contribute. Each contributor signs their additions. AIUI merges all list events from the relay into a unified view. Useful for collaborative music or film curation.
### M20.4 — Conversation Templates
Pre-built conversation starters: "Bitcoin deep dive", "Film analysis", "Nostr onboarding", "Music discovery". Each is a system prompt + first user message. Shown on the new conversation screen as glass cards. Import/export as JSON. Share via Nostr.
### M20.5 — Export as Audio Podcast
Experimental (M20.5): Text-to-speech for a conversation using Web Speech API (`speechSynthesis`). Reads AI responses only. Controls: voice selector, speed, skip. Export as WAV (Web Audio API). Background music track from the player queue mixed in (opt-in). Pure client-side.
### M20.6 — Community Content Packs
Import a curated set of content (films, songs, books) from a community-maintained JSON file. Hosted on GitHub or IPFS. Registry listed in the plugin marketplace (M14.1). Examples: "2024 Best Films", "Bitcoin Music Playlist", "Essential Nostr Reads".
---
## Automated Session Execution Order
Each session should:
1. Read `PROGRESS.md` — find the next `[ ]` item
2. Read the task spec above
3. Implement the task
4. Run `pnpm typecheck && pnpm lint && pnpm test`
5. Commit: `type(scope): description`
6. Update `PROGRESS.md`
### Priority Queue
**M8 (Chat Polish):** M8.1 → M8.4 → M8.5 → M8.6 → M8.2 → M8.3 → M8.7 → M8.8 → M8.9 → M8.10
**M9 (AI Experience):** M9.1 → M9.4 → M9.2 → M9.3 → M9.7 → M9.5 → M9.6 → M9.8 → M9.9 → M9.10
**M10 (Renderers):** M10.6 → M10.7 → M10.3 → M10.1 → M10.9 → M10.4 → M10.5 → M10.11 → M10.2 → M10.8 → M10.10 → M10.12
**M11 (Nostr):** M11.3 → M11.1 → M11.5 → M11.6 → M11.7 → M11.8 → M11.4 → M11.2 → M11.9 → M11.10
**M12 (Bitcoin):** M12.1 → M12.6 → M12.8 → M12.7 → M12.3 → M12.2 → M12.5 → M12.4
**M13 (Discovery):** M13.5 → M13.1 → M13.2 → M13.3 → M13.4 → M13.6 → M13.7 → M13.8
**M14 (Plugins):** M14.5 → M14.6 → M14.7 → M14.1 → M14.2 → M14.3 → M14.4 → M14.8
**M15 (Settings):** M15.1 → M15.2 → M15.3 → M15.4 → M15.5 → M15.6 → M15.7 → M15.8 → M15.9 → M15.10
**M16 (Mobile):** M16.1 → M16.7 → M16.4 → M16.5 → M16.2 → M16.8 → M16.3 → M16.6 → M16.9 → M16.10
**M17 (a11y/i18n):** M17.8 → M17.1 → M17.2 → M17.3 → M17.4 → M17.5 → M17.6 → M17.7
**M18 (Perf):** M18.2 → M18.6 → M18.3 → M18.5 → M18.1 → M18.4 → M18.7 → M18.8
**M19 (DX):** M19.4 → M19.6 → M19.5 → M19.3 → M19.1 → M19.2 → M19.7 → M19.8
**M20 (Collab):** M20.4 → M20.1 → M20.2 → M20.6 → M20.3 → M20.5
**Total: 116 tasks across 13 milestones**
+157
View File
@@ -0,0 +1,157 @@
# AIUI Progress
## Current Status
**Active Milestone**: COMPLETE
**Overall**: M0M20 all complete. 116 tasks implemented. All tests, typecheck, lint, and build passing.
## Roadmap
### M0: Foundation ✅
- [x] Chat interface with streaming (Claude/OpenRouter/Mock)
- [x] 11 content type renderers (film, song, podcast, book, TV, image, place, article, magazine, nostr, code)
- [x] Responsive layout (mobile three-column + desktop overlays)
- [x] Glass morphism design system (Tailwind CSS)
- [x] Claude proxy + web search (SearXNG/DDG)
- [x] PWA support (auto-update, installable)
- [x] Music player (Plyr-based PlayerBar)
- [x] Dev chat persistence (Vite middleware)
- [x] ESLint flat config (packages/app + packages/core)
- [x] CI baseline (pnpm test, lint, typecheck passing)
- [x] Progress tracking automation
### M1: Stability & Polish ✅
- [x] ErrorBoundary.vue component created
- [x] Error boundaries wrapping all major sections
- [x] Unit tests — contentExtraction composable (10 extraction functions, 49 tests)
- [x] IndexedDB persistent storage (conversations survive refresh)
- [x] Unit tests — useAI composable (16 tests, mocked fetch/SSE)
- [x] E2E test expansion (8 new tests: streaming, content cards, mobile, etc.)
### M2: Content Experience ✅
- [x] Markdown rendering in chat (markdown-it, XSS safe)
- [x] Music source resolution + queue management (next/prev, queue panel)
- [x] Virtual scrolling for chat (@tanstack/vue-virtual)
- [x] Nostr feed integration (relay WebSocket, kind:1 notes)
### M3: Plugin System ✅
- [x] Activate plugin registry at runtime (claude-provider adapter)
- [x] Renderer plugin registration (film/song as plugins)
### M4: Social & Discovery ✅
- [x] Social embeds (Nostr notes inline via nostr: URI)
- [x] Federated search across content types (/search command)
- [x] Bookmarks/favorites (Pinia + IndexedDB, heart toggle)
### M5: Security & Privacy ✅
- [x] E2E encryption (Web Crypto API, AES-256-GCM, PBKDF2)
- [x] Encrypted storage layer (PassphraseDialog, all stores encrypted)
- [x] API key vault (encrypted at rest, masked UI)
### M6: Payments & Identity ✅
- [x] Lightning wallet deep-links (LNURL-pay, BIP21, QR code)
- [x] Cashu token support (parse + display inline, never a wallet)
- [x] Nostr identity NIP-07 (browser extension login, event signing)
### M7: Platform ✅
- [x] MCP server integration (content surfaces as MCP tools)
- [x] Multi-provider AI normalization (Claude/OpenRouter/Ollama adapters)
- [x] Tauri desktop build (transparent window, system tray, global shortcut)
- [x] Offline mode (cache strategies, offline banner, cached content browsing)
## Session Log
<!-- Entries below are auto-populated by the post-push Claude Code hook -->
<!-- Format: ### YYYY-MM-DD HH:MM — branch-name -->
### 2026-03-03 — overnight/2026-03-03
**Completed M3M7 (15 tasks)**:
- M3.1: Plugin registry + Claude provider adapter
- M3.2: Film/song renderer plugins with lazy loading
- M4.1: Nostr social embeds (bech32 NIP-19 decoder, NostrEmbed.vue)
- M4.2: Federated search (/search command, SearchResults overlay)
- M4.3: Bookmarks/favorites (Pinia + IDB, FavoriteButton, FavoritesGrid)
- M5.1: E2E encryption (AES-256-GCM, PBKDF2 100K iterations)
- M5.2: Encrypted storage layer (PassphraseDialog, transparent encrypt/decrypt)
- M5.3: API key vault (encrypted IDB, ApiKeyManager.vue, vault integration in useAI)
- M6.1: Lightning wallet deep-links (BOLT11 parser, PaymentButton, LightningInvoice)
- M6.2: Cashu token support (cashu.ts parser, CashuToken.vue inline in chat)
- M6.3: Nostr identity NIP-07 (useNostrIdentity.ts, NostrLogin.vue, bech32 encode)
- M7.1: MCP server integration (tool definitions + handlers for library search)
- M7.2: Multi-provider AI normalization (adapter pattern: Claude/OpenRouter/Ollama)
- M7.3: Tauri desktop build scaffold (frameless window, tray, global shortcut)
- M7.4: Offline mode (useOffline.ts, enhanced PWA image/API caching)
### 2026-03-03 (cont.) — overnight/2026-03-03
**Completed M8 Chat UX Polish (10 tasks)**:
- M8.1: Message editing & regeneration (pencil icon, re-send clears subsequent)
- M8.2: Conversation branching (BranchSwitcher.vue, fork from any assistant msg)
- M8.3: Reply-to threading (quoted excerpt in input, `> quote` prepend)
- M8.4: Conversation search (Cmd+F glass panel, match nav, jump to message)
- M8.5: Auto-title generation (background Haiku call after first exchange)
- M8.6: Context window visualiser (ContextBar.vue, token estimate, orange→red)
- M8.7: Conversation export (Markdown/JSON/text, File System Access API)
- M8.8: Import conversations (AIUI JSON + Claude.ai format parser)
- M8.9: Context menus (ContextMenu.vue + ContextMenuItem.vue, right-click)
- M8.10: Scroll position memory (Map per conversation, restore on switch)
- TEST:M8: All 74 tests pass, typecheck + lint clean
### 2026-03-03 (cont.) — overnight/2026-03-03
**Completed M9M20 (all remaining milestones)**:
**M9: AI Experience (10 tasks)**
- Multi-model comparison, system prompt editor/personas, prompt templates
- Vision input, response feedback, token/cost estimator
- AI memory panel, model capabilities badges, temperature sliders, stop sequences
**M10: Advanced Content Renderers (12 tasks)**
- Full article renderer, PDF viewer (pdfjs-dist), map renderer (Leaflet)
- Recipe, event, math (KaTeX), Mermaid diagram renderers
- Audio waveform (WaveSurfer.js), table, timeline, code runner, video (HLS.js)
**M11: Nostr Ecosystem (10 tasks)**
- Publish notes, DMs NIP-17, relay management, profile editor
- Zaps NIP-57, NIP-05 verification, NIP-50 search, thread view
- NIP-51 lists, long-form content NIP-23
**M12: Bitcoin Ecosystem (8 tasks)**
- On-chain address display, Fedimint ecash, BOLT12 offers
- NWC (NIP-47), LNURL-auth, live sat/fiat price, mempool viewer, BOLT11 decoder
**M13: Content Discovery (8 tasks)**
- "For You" feed, content tagging, smart playlists, similar content
- Recently viewed history, content collections, trending, share to Nostr
**M14: Plugin Marketplace (8 tasks)**
- Plugin discovery UI, settings panel, permissions, dev mode
- Wikipedia + OpenLibrary built-in plugins, import by URL, versioning
**M15: Settings & Personalisation (10 tasks)**
- Accent colour picker, glass intensity slider, font size settings
- Content visibility toggles, keyboard shortcut map, push notifications
- Auto-archive, full data export, data wipe, default conversation settings
**M16: Mobile UX Polish (10 tasks)**
- Bottom sheet component, swipe navigation, pull-to-refresh, haptic feedback
- Web Share API, pinch-to-zoom, iOS PWA polish, long-press context menus
- Scroll position memory per route, landscape optimisation
**M17: Accessibility & Internationalisation (8 tasks)**
- Keyboard nav audit, ARIA audit, high contrast mode, axe-core tests
- i18n foundation (vue-i18n, en/es/fr), RTL support, dyslexia-friendly font, skip nav
**M18: Performance (8 tasks)**
- Bundle analysis/splitting, image lazy loading, request deduplication
- Web Worker for heavy tasks, prefetch on hover, memory leak audit
- Background sync queue, OPFS storage backend (SQLite WASM)
**M19: Developer Experience & Quality (8 tasks)**
- Storybook 8, visual regression tests, bundle size CI gate
- Comprehensive mock data (20+ items per type), E2E cross-browser matrix
- Proxy integration tests, Lighthouse CI, dependency audit
**M20: Collaboration & Sharing (6 tasks)**
- Share conversation via Nostr (kind:30023, NIP-44 encryption)
- Read-only conversation viewer (/view/:nostrAddr)
- Collaborative playlists (NIP-51), conversation templates
- Audio podcast export (Web Speech API), community content packs
**FINAL**: All gates passed — 101 tests, 0 typecheck errors, 0 lint errors, build succeeds
+113
View File
@@ -0,0 +1,113 @@
# iOS App Research — AIUI
## Overview
Three approaches for shipping AIUI (Vue 3 + Vite SPA) as an iOS app.
## Approach 1: Capacitor (Recommended)
Capacitor wraps the Vite build output (`dist/`) in a native iOS Xcode project. The web app runs inside WKWebView with a JavaScript bridge to native device APIs.
```bash
pnpm add @capacitor/core @capacitor/cli @capacitor/ios
npx cap init && npx cap add ios
pnpm build && npx cap sync
npx cap open ios # opens Xcode
```
**Pros:**
- Near-zero code changes to existing Vue 3 app — one codebase for web + iOS + Android
- Large, mature plugin ecosystem (camera, biometrics, push, geolocation, haptics)
- Hot reload during dev via `npx cap run ios --livereload`
- OTA live updates possible via Capgo, bypassing App Store review for JS changes
- `@capacitor/push-notifications` wraps APNs natively
**Cons:**
- Service workers do NOT work in WKWebView on iOS (capacitor:// protocol breaks SW registration)
- Performance ceiling is WebKit JS engine (not V8)
- Each iOS SDK bump requires Capacitor + plugin updates
**Push Notifications:** Full support via `@capacitor/push-notifications` (APNs). Production-grade.
**Offline:** Entire app bundle ships inside .ipa — available offline. Dynamic data must use `@capacitor/preferences` or local SQLite. Workbox/SW caching does not work.
**Performance:** Modern WKWebView uses Nitro JS engine (same as Safari). For a chat UI like AIUI, indistinguishable from Safari. GPU-accelerated CSS transforms work well.
## Approach 2: Custom WKWebView Swift Wrapper
Write a native Swift/SwiftUI app embedding WKWebView. Use `WKScriptMessageHandler` for JS↔Swift communication.
**Pros:**
- Maximum native control — own the shell, native navigation, gestures
- Can implement App Clips, Share Extensions, Widgets alongside web content
- Full access to all iOS APIs at the native layer
**Cons:**
- Requires Swift knowledge — adds second language + build system
- JS↔Swift bridge must be hand-written for every integration
- No structured plugin community; each integration is bespoke
- More setup friction vs Capacitor
**Push/Offline/Performance:** Same as Capacitor (all use WKWebView). More manual setup.
## Approach 3: React Native WebView
Create a React Native app with `react-native-webview` rendering the Vite build output.
**Pros:**
- RN has deep native API access and large ecosystem
- Surrounding shell can be fully native
**Cons:**
- Two separate tech stacks (Vue + RN) — highest maintenance burden
- No code sharing between Vue app and RN shell
- Performance often worse (full RN runtime + WebView engine)
- RN's own breaking changes cadence adds risk
**Verdict:** Only justified if an existing RN app is already in production.
## App Store Risk: Guideline 4.2
Apple's Guideline 4.2 (Minimum Functionality) is the primary risk for all webview-based apps. Apps that pass share these traits:
- Native tab bar or navigation (not web-based menus)
- At least one native API integration (push, biometrics, camera, Apple Pay)
- Offline functionality beyond what a browser bookmark offers
- UI formatted for iOS, not a desktop website in a phone frame
For AIUI: the chat interface, push notifications, and offline message history constitute sufficient native functionality.
## Service Workers in WKWebView
**SWs do not run inside WKWebView** — this is a fundamental WebKit limitation, not framework-specific. The correct offline strategy for all three approaches: ship assets in app bundle + implement dynamic caching via native storage APIs.
## Deep Linking
All three support iOS Universal Links via AASA file + Associated Domains capability:
- **Capacitor:** `@capacitor/app` `appUrlOpen` event → Vue Router
- **Custom WKWebView:** `AppDelegate.application(_:continue:...)` → JS evaluation
- **RN:** React Navigation linking config → WebView `postMessage`
## Comparison
| Dimension | Capacitor | Custom WKWebView | RN WebView |
|---|---|---|---|
| Vue code reuse | 100% | 100% | 100% |
| Native shell effort | Low | High | Very high |
| Push notifications | First-class | Manual APNs | Via RN layer |
| App Store risk | Moderate* | Moderate* | Moderate* |
| Performance | Good | Good | Adequate |
| Maintenance burden | Low-moderate | High | Very high |
| Team fit (web-first) | Best | Poor | Poor |
*All face identical Guideline 4.2 scrutiny — framework choice is irrelevant to reviewers.
## Concrete Next Steps
1. Add `@capacitor/core`, `@capacitor/cli`, `@capacitor/ios` to `packages/app`
2. Set Vite `base: './'` for the Capacitor build config
3. Disable PWA service worker for native builds (partially done already)
4. Add `@capacitor/push-notifications` for APNs
5. Implement native splash screen and app icon
6. Test on iOS Simulator via `npx cap run ios`
7. Set up Apple Developer account + code signing
8. Submit TestFlight build for internal testing
+119
View File
@@ -0,0 +1,119 @@
# Mac Desktop App Research — AIUI
## Overview
Two approaches for shipping AIUI as a Mac desktop app: Tauri v2 (Rust-based, system WebView) vs Electron (Chromium-based).
## Tauri v2 (Recommended)
Released stable October 2024. Uses OS-native WebView (WKWebView on macOS). The Vue 3 + Vite frontend runs inside the WebView unchanged. JS calls into Rust via typed IPC bridge.
**Binary Size:** 28 MB installer (no bundled runtime)
**Memory Usage:** ~3040 MB idle
**Startup Time:** < 500ms
### Menu Bar App Pattern (Raycast-style)
Fully supported via `tauri-plugin-positioner` + tray + window APIs. Frameless popover window anchored to tray icon with `decorations: false`, `skip_taskbar: true`. Community examples exist (`ahkohd/tauri-macos-menubar-app-example` v2-popover branch).
### Global Hotkey
Built-in via `@tauri-apps/plugin-global-shortcut`. Register accelerators (e.g., `CmdOrCtrl+Space`) that fire even when background/minimized. First-class plugin.
### System Tray
First-class support. `AppHandle::tray()` with native menus and click event handling from Rust or frontend.
### Auto-Update
`@tauri-apps/plugin-updater` — signed updates required (Ed25519 keypair). Host a static JSON endpoint with version metadata and signed artifact URLs.
### macOS Code Signing / Notarization
Automated via Tauri CLI environment variables (`APPLE_CERTIFICATE`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_TEAM_ID`). Notarization adds ~25 min per build.
### Build Pipeline
- Prerequisites: Rust toolchain + Xcode CLI tools
- First build: 515 min (Cargo compiles Rust deps)
- Incremental builds: Fast with caching
- Config: `tauri.conf.json` + `Cargo.toml`
- Complexity: Medium-High (Rust requirement is the barrier)
### Mobile Support
Tauri v2 has **first-class iOS/Android support** in the same codebase (WKWebView on iOS, Android System WebView on Android). HMR extends to physical devices. This is a genuine differentiator — Electron is desktop-only.
## Electron
Mature since 2013. Bundles full Chromium + Node.js runtime. Used by VS Code, Slack, Discord, Obsidian.
**Binary Size:** 80150 MB installer
**Memory Usage:** 200350 MB idle
**Startup Time:** 12s
### Menu Bar App
Well-established via `menubar` npm package. Creates BrowserWindow positioned below tray icon, manages show/hide on tray click. Very mature.
### Global Hotkey
`globalShortcut` module in Electron core. System-wide even when hidden.
### System Tray
`Tray` class in Electron core with context menus and click events.
### Auto-Update
`electron-updater` (S3/GitHub Releases) or `update.electronjs.org` (free for open-source).
### macOS Code Signing / Notarization
Via `@electron/osx-sign` + `@electron/notarize`, integrated into `electron-builder` / Electron Forge.
### Build Pipeline
- Prerequisites: Node.js only — no additional runtimes
- Build tools: `electron-vite` for Vue 3 + Vite integration
- Build times: 25 min (no Rust compilation) + 25 min notarization
- Complexity: Medium (main/renderer process split requires understanding)
## Comparison
| Dimension | Tauri v2 | Electron |
|---|---|---|
| Installer size | 28 MB | 80150 MB |
| Idle RAM | 3040 MB | 200350 MB |
| Startup time | < 500ms | 12s |
| Menu bar app | Supported | Supported |
| Global hotkey | Built-in plugin | Built-in API |
| System tray | Built-in | Built-in |
| Auto-update | Built-in (signed) | electron-updater |
| New language | Rust | None (JS/TS) |
| iOS/Android | Yes (same codebase) | No |
| WebView | WKWebView (varies by OS) | Chromium (pinned, consistent) |
| Ecosystem maturity | Growing fast | Very mature |
| Security model | Capability-based, opt-in | Opt-out, manual discipline |
| Debug tools | Safari Web Inspector | Chrome DevTools |
## Recommendation
**Tauri v2 is the stronger choice for AIUI:**
1. **Memory advantage is decisive.** Users running local LLMs or managing API streaming need resources for the AI workload, not the shell. 30 MB vs 300 MB matters.
2. **Menu bar pattern fits naturally** for a chat/AI assistant (Raycast-style quick invoke).
3. **iOS/Android support** from the same codebase aligns with AIUI's multi-surface vision.
4. **Capability-based security** is appropriate for handling API keys and sensitive chat data.
5. **Binary size matters** — 5 MB download vs 120 MB affects distribution trust.
## Concrete Next Steps
1. Scaffold Tauri v2 project: `npm create tauri-app@latest` with Vite template
2. Point dev server to existing `packages/app` Vite config
3. Implement tray icon + menu bar popover window
4. Register global hotkey (e.g., `Cmd+Shift+Space`) to invoke chat
5. Write Rust commands for: file I/O, tray management, updater config
6. Set up macOS code signing + notarization pipeline
7. Distribute via Homebrew cask or direct download
8. Evaluate Tauri mobile targets for iOS/Android convergence

Some files were not shown because too many files have changed in this diff Show More