From e5d77916d43503665eecf4d982d30e015d075f7f Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 8 Oct 2026 12:18:45 -0400 Subject: [PATCH] feat: share reviewed website archives and repair companion setup flows --- apps/blossom/README.md | 14 ++++ core/archipelago/src/api/rpc/publishing.rs | 1 + core/archipelago/src/publishing/mod.rs | 42 +++++++++++- core/archipelago/src/publishing/serving.rs | 65 ++++++++++++++++++- docker/blossom/ui/app.ts | 11 +++- docker/blossom/ui/index.html | 2 +- docs/external-access-and-websites.md | 63 ++++++++++++++++++ .../src/components/SearchableAppSelect.vue | 57 ++++++++++++++++ .../src/components/WebsiteArchiveSharing.vue | 38 +++++++++++ .../__tests__/SearchableAppSelect.test.ts | 29 +++++++++ .../__tests__/WebsiteArchiveSharing.test.ts | 26 ++++++++ neode-ui/src/services/publishing.ts | 6 +- neode-ui/src/views/NostrTabSigner.vue | 9 ++- .../__tests__/NostrTabSigner.test.ts | 18 +++++ .../src/views/publishing/PublishingSetup.vue | 22 +++++-- .../__tests__/PublishingSetup.test.ts | 7 +- tests/apps/blossom/ui-smoke.cjs | 4 +- 17 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 neode-ui/src/components/SearchableAppSelect.vue create mode 100644 neode-ui/src/components/WebsiteArchiveSharing.vue create mode 100644 neode-ui/src/components/__tests__/SearchableAppSelect.test.ts create mode 100644 neode-ui/src/components/__tests__/WebsiteArchiveSharing.test.ts diff --git a/apps/blossom/README.md b/apps/blossom/README.md index 8e20c22c..8feabc4a 100644 --- a/apps/blossom/README.md +++ b/apps/blossom/README.md @@ -86,3 +86,17 @@ reboot, integrated website archive acceptance, then reviewed source/mirror parit and signed catalogue gates. Selective public asset routes remain separate work; the authenticated app address must never be advertised as a public Blossom URL. Restore dashboard 2FA with the operator after live testing. + +### Companion follow-up — 2026-10-08 + +Blossom now requests the canonical identity chooser once when opened, identifies +itself explicitly to the tab signer, and disables the provider's unrelated +NIP-98 web-app login. Cancelled selection leaves a retry button; uploads still +require file review and signer approval. A host signer bug sent a Vue reactive +Proxy through postMessage after selection, closing the picker but stranding the +app behind an empty signer. The host now copies only public identity fields. +The reactive-object regression test and a real direct-app mobile-width browser +check pass: automatic chooser, closed signer, visible app, denied upload and +approved local upload. Physical companion confirmation remains pending. +The corrected image is a private rebuild of the existing candidate tag; assign +an updated package/image version before reviewed catalogue publication. diff --git a/core/archipelago/src/api/rpc/publishing.rs b/core/archipelago/src/api/rpc/publishing.rs index 51ef0cad..1d3fe7f3 100644 --- a/core/archipelago/src/api/rpc/publishing.rs +++ b/core/archipelago/src/api/rpc/publishing.rs @@ -349,6 +349,7 @@ impl RpcHandler { "grants": grants, "nostr_relays": self.config.nostr_relays, "publication_enabled": true, + "public_archive_enabled": true, "listeners": publishing::serving::status().await, "onions": publishing::tor::status().await, "notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Nostr publishing requires an explicit identity, Blossom server and relay selection. Automated gateway setup is not enabled yet. Saving choices does not change app access; external verification is separate.", diff --git a/core/archipelago/src/publishing/mod.rs b/core/archipelago/src/publishing/mod.rs index 1a7a9ad7..fe11d173 100644 --- a/core/archipelago/src/publishing/mod.rs +++ b/core/archipelago/src/publishing/mod.rs @@ -64,6 +64,9 @@ pub struct LocalArchive { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Publication { + /// Exact archived bytes explicitly approved for public hash-addressed reads. + #[serde(default)] + pub public_archive: Option, pub port: u16, pub html: String, pub created_at: String, @@ -122,6 +125,15 @@ pub enum Change { domain: Option, html: String, }, + ShareArchive { + id: String, + route: Route, + acknowledge_public: bool, + }, + UnshareArchive { + id: String, + route: Route, + }, PublishFips { id: String, acknowledge_public: bool, @@ -268,6 +280,31 @@ impl State { p.local_archive = Some(receipt); Ok(Some(id)) } + Change::ShareArchive { id, route, acknowledge_public } => { + if !acknowledge_public { bail!("Confirm public access to the exact archived website bytes"); } + let p = self.projects.get_mut(&id).context("Website project not found")?; + let archive = p.local_archive.as_ref().context("Store this website in local Blossom first")?; + let publication = match route { + Route::Fips => p.fips_publication.as_mut(), + Route::Tor => p.tor_publication.as_mut(), + _ => bail!("Choose the FIPS/public-web or Tor publication"), + }.context("Publish this connection before sharing its archived file")?; + if archive.sha256 != nsite::hash(publication.html.as_bytes()) || archive.size != publication.html.len() { + bail!("The archive differs from this published version. Store and publish the same version first"); + } + publication.public_archive = Some(archive.sha256.clone()); + Ok(Some(id)) + } + Change::UnshareArchive { id, route } => { + let p = self.projects.get_mut(&id).context("Website project not found")?; + let publication = match route { + Route::Fips => p.fips_publication.as_mut(), + Route::Tor => p.tor_publication.as_mut(), + _ => bail!("Choose the FIPS/public-web or Tor publication"), + }.context("This connection is not published")?; + publication.public_archive = None; + Ok(Some(id)) + } Change::RecordNsite { id, receipt } => { receipt.validate(&id)?; let p = self @@ -379,6 +416,7 @@ impl State { .context("No website ports available")?, }; p.fips_publication = Some(Publication { + public_archive: None, port, html: p.draft.clone(), created_at: chrono::Utc::now().to_rfc3339(), @@ -411,6 +449,7 @@ impl State { .context("No onion website ports available")?, }; p.tor_publication = Some(Publication { + public_archive: None, port, html: p.draft.clone(), created_at: chrono::Utc::now().to_rfc3339(), @@ -481,7 +520,8 @@ pub async fn load(root: &Path) -> Result { (&project.tor_publication, 32100..32132), ] { if let Some(p) = publication { - if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML { + if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML + || p.public_archive.as_ref().is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes())) { bail!("Invalid stored website publication; existing state has been preserved"); } } diff --git a/core/archipelago/src/publishing/serving.rs b/core/archipelago/src/publishing/serving.rs index f6ab9f50..72bdd640 100644 --- a/core/archipelago/src/publishing/serving.rs +++ b/core/archipelago/src/publishing/serving.rs @@ -53,13 +53,23 @@ pub(super) fn response_for( else { return simple(StatusCode::NOT_FOUND, "Website is not published"); }; + // Only the selected immutable snapshot is exposed, never the Blossom backend. + // No listing, upload, arbitrary hash lookup, filesystem access or credentials. + let asset = publication.public_archive.as_ref().is_some_and(|hash| { + req.uri().path() == format!("/{hash}") && *hash == super::nsite::hash(publication.html.as_bytes()) + }); + if asset && req.method() == Method::OPTIONS { + let mut response = simple(StatusCode::NO_CONTENT, ""); + asset_headers(&mut response); + return response; + } if req.method() != Method::GET && req.method() != Method::HEAD { return simple( StatusCode::METHOD_NOT_ALLOWED, "Only GET and HEAD are supported", ); } - if !matches!(req.uri().path(), "/" | "/index.html") { + if !asset && !matches!(req.uri().path(), "/" | "/index.html") { return simple(StatusCode::NOT_FOUND, "Not found"); } let mut response = simple(StatusCode::OK, ""); @@ -70,11 +80,20 @@ pub(super) fn response_for( "content-length", publication.html.len().to_string().parse().unwrap(), ); + if asset { asset_headers(&mut response); } if req.method() == Method::GET { *response.body_mut() = Body::from(publication.html.clone()); } response } +fn asset_headers(response: &mut Response) { + for (name, value) in [ + ("access-control-allow-origin", "*"), + ("access-control-allow-methods", "GET, HEAD, OPTIONS"), + ("access-control-expose-headers", "Content-Length, Content-Type"), + ("content-disposition", "attachment; filename=\"index.html\""), + ] { response.headers_mut().insert(name, value.parse().unwrap()); } +} fn simple(status: StatusCode, body: &str) -> Response { let mut r = Response::new(Body::from(body.to_owned())); *r.status_mut() = status; @@ -241,6 +260,50 @@ pub(super) async fn listen( #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn public_archive_is_exact_explicit_route_scoped_and_revocable() { + use crate::publishing::{Change, LocalArchive, Route}; + let mut state = State::default(); + let id = state.apply(Change::Create { name: "Archive".into() }).unwrap().unwrap(); + state.apply(Change::Save { id: id.clone(), name: "Archive".into(), routes: [Route::Fips, Route::Tor].into_iter().collect(), domain: None, html: "public snapshot".into() }).unwrap(); + state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap(); + state.apply(Change::PublishTor { id: id.clone(), acknowledge_public: true }).unwrap(); + let port = state.projects[&id].fips_publication.as_ref().unwrap().port; + let tor_port = state.projects[&id].tor_publication.as_ref().unwrap().port; + let hash = crate::publishing::nsite::hash(b"public snapshot"); + let req = Request::builder().uri(format!("/{hash}")).body(Body::empty()).unwrap(); + assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND); + assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).is_err()); + state.apply(Change::RecordLocalArchive { id: id.clone(), receipt: LocalArchive { sha256: hash.clone(), size: 15, pubkey: "a".repeat(64), created_at: "now".into() } }).unwrap(); + assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: false }).is_err()); + state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap(); + assert_eq!(response_for(&state, &id, tor_port, Route::Tor, &req).status(), StatusCode::NOT_FOUND); + let r = response(&state, &id, port, &req); + assert_eq!(r.status(), StatusCode::OK); + assert_eq!(r.headers()["access-control-allow-origin"], "*"); + assert!(r.headers()["content-disposition"].to_str().unwrap().starts_with("attachment")); + assert_eq!(r.headers()["content-security-policy"], CSP); + assert_eq!(hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(), b"public snapshot"); + for path in ["/upload", "/list", "/0000000000000000000000000000000000000000000000000000000000000000", "/../state.json"] { + let r = Request::builder().uri(path).body(Body::empty()).unwrap(); + assert_eq!(response(&state, &id, port, &r).status(), StatusCode::NOT_FOUND); + } + let head = Request::builder().method(Method::HEAD).uri(format!("/{hash}")).body(Body::empty()).unwrap(); + let r = response(&state, &id, port, &head); + assert_eq!(r.headers()["content-length"], "15"); + assert!(hyper::body::to_bytes(r.into_body()).await.unwrap().is_empty()); + let post = Request::builder().method(Method::PUT).uri(format!("/{hash}")).body(Body::empty()).unwrap(); + assert_eq!(response(&state, &id, port, &post).status(), StatusCode::METHOD_NOT_ALLOWED); + state.projects.get_mut(&id).unwrap().draft = "private later edits".into(); + assert_eq!(hyper::body::to_bytes(response(&state, &id, port, &req).into_body()).await.unwrap().as_ref(), b"public snapshot"); + state.apply(Change::UnshareArchive { id: id.clone(), route: Route::Fips }).unwrap(); + assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND); + state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap(); + state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap(); + assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND); + assert!(state.apply(Change::ShareArchive { id, route: Route::Fips, acknowledge_public: true }).is_err()); + } + #[tokio::test] async fn draft_changes_never_leak_and_unpublish_revokes() { use crate::publishing::{Change, Route}; diff --git a/docker/blossom/ui/app.ts b/docker/blossom/ui/app.ts index bc0d387d..5bc93c93 100644 --- a/docker/blossom/ui/app.ts +++ b/docker/blossom/ui/app.ts @@ -34,14 +34,17 @@ async function auth(action: string, hash?: string) { if (signed.pubkey !== pubkey) throw new Error('Signer returned another identity. Nothing was sent.'); return 'Nostr ' + btoa(JSON.stringify(signed)); } -element('identity').onclick = () => perform(async () => { +async function chooseIdentity() { await perform(async () => { + pubkey = ''; approve.checked = false; element('files').replaceChildren(); + element('pubkey').textContent = 'Choose a profile in the Archipelago signer…'; if (!window.nostr) throw new Error('Archipelago signer is unavailable'); await window.archipelagoNostr?.selectIdentity?.(); const key = await window.nostr.getPublicKey(); if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid signer identity'); pubkey = key; approve.checked = false; element('pubkey').textContent = key; element('files').replaceChildren(); -}); +}); } +element('identity').onclick = chooseIdentity; fileInput.onchange = () => { approve.checked = false; const file = fileInput.files?.[0]; @@ -78,3 +81,7 @@ refresh.onclick = () => perform(async () => { item.append(link); list.append(item); } }); + +// Request the canonical chooser once on opening. Cancellation leaves the page +// usable with a retry button; this never signs an upload or publishes an event. +void chooseIdentity(); diff --git a/docker/blossom/ui/index.html b/docker/blossom/ui/index.html index cd718201..6119d5db 100644 --- a/docker/blossom/ui/index.html +++ b/docker/blossom/ui/index.html @@ -1 +1 @@ -Blossom · Archipelago

Blossom on your node

Store files with your Archipelago identity. Files stay on this node. Uploading here does not publish a Nostr event or send a copy to another server.

Your identity

Choose a profile identity to manage its files.

After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.

Store a file

Choose a file up to 16 MiB. Review it before storing.

External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.

Your files

    +Blossom · Archipelago

    Blossom on your node

    Store files with your Archipelago identity. Files stay on this node. Uploading here does not publish a Nostr event or send a copy to another server.

    Your identity

    Choose a profile identity to manage its files.

    After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.

    Store a file

    Choose a file up to 16 MiB. Review it before storing.

    External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.

    Your files

      diff --git a/docs/external-access-and-websites.md b/docs/external-access-and-websites.md index 21d5f5b5..577b9af6 100644 --- a/docs/external-access-and-websites.md +++ b/docs/external-access-and-websites.md @@ -304,3 +304,66 @@ Immich, rejection for dashboard login, and revocation of both bearer and cookie access. One-hour expiry metadata was checked; elapsed expiry remains covered by unit tests, not a one-hour live wait. The temporary grant was removed. No Nostr events were posted and no other app data was changed. + +### Controlled Framework reboot — 2026-10-08 + +The operator confirmed physical recovery access and authorized remaining +qualification. A fresh native LND snapshot and static channel backup were retained +privately on the node before reboot; no pending HTLCs were present. A changed boot +ID confirms the full reboot. Native wallet identity, channel set, on-chain and +channel balances matched exactly afterward, and LND reported chain sync without +manual unlock/restart. Backend and signed-catalogue hashes matched. All app +running/stopped states, exact publishing/project/archive state, FIPS address, onion +address and guest eligibility survived. Public HTTPS and Tor returned the exact +synthetic page. Guest scope, dashboard denial and revocation passed again. + +Physical companion acceptance remains OPEN: the operator found the native +`datalist` app picker invisible in the companion, and Blossom blank after choosing +an identity. These are tracked as current regressions, not successful companion +acceptance. The picker replacement uses an in-page glass menu; the tab signer +must copy public identity fields instead of passing a Vue reactive Proxy through +postMessage. Blossom also requests the canonical chooser once on opening and +disables the unrelated generic NIP-98 web-app login. Deployment and actual-device +retest are required before closing these reports. Operator will restore 2FA after +the remaining installer/signer tests. + +### Selective public archive implementation — 2026-10-08 + +Each FIPS/public-web or Tor publication can separately expose its exact archived +HTML snapshot at `/`, only after an acknowledged action verifies the +local archive receipt matches the published bytes. This is a read-only +hash-addressed snapshot route, not a publicly opened Blossom app or upload API. +GET/HEAD and CORS reads serve only the selected immutable bytes with sandbox and +attachment headers. Unknown hashes, listings and uploads remain unavailable. +Later drafts cannot change the served bytes; publishing an update resets archive +sharing, and removing sharing does not unpublish the page or remove private files. + +The focused harness and isolated platform suite each passed 12 publishing tests. +The candidate backend is deployed on Framework with its preceding executable and +publishing state retained under `~/external-access-uat/`. Live trusted HTTPS +readback matched the exact snapshot; unknown hashes/list/upload returned 404. +Revocation returned the selected hash to 404 while the website still served. +The synthetic archive was unshared after the test. No external replica or Nostr +announcement was made. UI deployment and live UI acceptance are still pending. + +Stored Publication now has an optional `public_archive` field. Before rolling back +to the preceding binary, account for its deny-unknown-fields parser: retain the +latest state and migrate only this field away, or restore the pre-test state only +if no user changes would be lost. Do not blindly restore an older project file. + +### Companion corrections deployed — 2026-10-08 + +The final dashboard build includes the in-page searchable glass app picker and +the tab signer's explicit cloneable identity fields. Sixteen UI tests passed, +including a structuredClone regression test using a reactive picker identity. +Live touch-browser checks at 390px and 1440px opened all six choices, filtered to +Immich, selected it and exposed the grant controls without horizontal overflow. +The normal Blossom lifecycle rebuilt/restarted the private candidate with +`data-app-id="blossom"`, `data-no-nip98` and one automatic chooser request. Its +previous image and build context are retained for rollback. The live direct app +window reproduced the blank frame before the signer correction; after deployment, +automatic selection returned to the visible file page, the signer iframe was +hidden, no generic login request occurred, refusal prevented upload and explicit +approval stored the synthetic file. Actual phone confirmation is still pending. +The archive UI is deployed with backend capability gating; UI tests cover fresh +consent on snapshot changes and independent revocation. No public release made. diff --git a/neode-ui/src/components/SearchableAppSelect.vue b/neode-ui/src/components/SearchableAppSelect.vue new file mode 100644 index 00000000..309f2b9f --- /dev/null +++ b/neode-ui/src/components/SearchableAppSelect.vue @@ -0,0 +1,57 @@ + + + diff --git a/neode-ui/src/components/WebsiteArchiveSharing.vue b/neode-ui/src/components/WebsiteArchiveSharing.vue new file mode 100644 index 00000000..555e0605 --- /dev/null +++ b/neode-ui/src/components/WebsiteArchiveSharing.vue @@ -0,0 +1,38 @@ + + +