feat: integrate local Blossom, reviewed nsites and scoped app access

This commit is contained in:
archipelago
2026-10-08 09:12:40 -04:00
parent 05e999b117
commit 28a92fcc9b
39 changed files with 2060 additions and 50 deletions
+13
View File
@@ -673,6 +673,19 @@
"tier": "optional", "tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png", "icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry" "repoUrl": "https://github.com/hoytech/strfry"
},
{
"id": "blossom",
"author": "hzrd149 / Archipelago",
"requires": [],
"tier": "optional",
"title": "Blossom",
"version": "6.4.1-archy.1",
"description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.",
"dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1",
"category": "data",
"repoUrl": "https://github.com/hzrd149/blossom-server",
"icon": "/assets/img/app-icons/blossom.svg"
} }
] ]
} }
+88
View File
@@ -0,0 +1,88 @@
# Blossom on Archipelago
Candidate package, not a published catalogue release. Follow
[`docs/app-developer-guide.md`](../../docs/app-developer-guide.md) and
[`docs/candidate-catalog-qualification.md`](../../docs/candidate-catalog-qualification.md)
for lifecycle and catalogue acceptance.
## Package contract
- MIT upstream `hzrd149/blossom-server` 6.4.1, source commit
`a492dc61c4a581bbd0992546b2aec6f9aa543f75`. The Dockerfile verifies the source
archive SHA-256 and uses upstream's frozen dependency lock for the server.
- Manifest-owned local build; the runtime payload must include `docker/blossom`.
No unpublished registry image is advertised. Initial installation needs access
to the open-source build dependencies; normal startup uses cached dependencies.
- Rootless container, read-only root, no capabilities, no new privileges,
explicit `slirp4netns`. Host port 8191 binds IPv4 loopback behind AppGate.
Keep that private backend binding: any FIPS/IPv6 ingress belongs at the gate.
- Persistent data and SQLite under `/var/lib/archipelago/blossom/data`.
Preserve this directory on uninstall. No automatic expiry/pruning, automatic
mirroring, media conversion, or upstream administration dashboard.
- Uploads require BUD-11 signatures from the profile identities supplied by
`{{NODE_IDENTITY_PUBKEYS}}`. No profile means startup fails closed. Changes to
that allowlist take effect on restart, including revocation of removed profiles.
The appliance identity is excluded. Listing requires the owner's signature.
- The custom local UI loads the canonical, host-managed `nostr-provider.js`
through the documented lifecycle hook. A missing provider fails verification.
The UI uses the platform identity chooser and ordinary NIP-07 signing; there
is no generated browser key, nsec input, or second consent modal.
- Upload authorization is scoped to the file hash, actual server hostname and
five-minute expiry. Local upload does not send a public Nostr announcement.
- Uploaded files are returned as sandboxed attachments. Untrusted HTML/SVG must
not acquire this app's origin or signer access. Published website rendering
needs the separate website origin, not a relaxation of this policy.
AppGate protects reads as well as the UI. Blossom itself is content-addressed,
not an encrypted per-user vault: other authorized node users who know a hash can
retrieve its bytes. Do not open the whole app gate to publish one website. Public
asset serving must authorize exact selected hashes; external replication requires
its own explicit content/destination review. An inaccessible local URL is not a
working public Blossom endpoint.
## Qualification evidence — 2026-10-08
- Manifest preflight: 16 passed, no warnings. Generated catalogue drift: zero.
- Candidate built and started on Framework with read-only root and the declared
resource/security constraints. All protocol tests use synthetic identities and
files; no public relay or external Blossom server is contacted.
- `tests/apps/blossom/protocol.ts` passed against the candidate: authenticated
upload/readback, exact hash/size/bytes, wrong identity/server/expired/anonymous
upload rejection, owner-only listing, disabled mirror, canonical provider and
health endpoint. HTML response has sandbox CSP and attachment headers.
- Fixture survived container recreation with the same data directory and restart
with explicit slirp4netns. An earlier test using Podman's default pasta hit a
transient port teardown conflict; that is not the package's configured network.
- Canonical Rust parser: all shipped manifests parse in the isolated test runner.
- Setup/source tests: 13 passed, dashboard typecheck passed including the final
receipt-review presentation changes.
- Packaged UI passed a real Chromium test at mobile width: explicit identity
chooser, consent before upload, signer refusal blocks upload, hash/host-scoped
upload, consent reset and no external requests. Signer and upload transport
were mocked for this UI test; live protocol checks above are separate.
- Framework's normal installer succeeded after the operator temporarily disabled
dashboard 2FA. Candidate manifest and build context are staged in the runtime
payload. The app is healthy, with its canonical bridge installed by the hook,
read-only root, slirp4netns and a loopback backend behind AppGate. Anonymous
HTTPS access on port 8191 returns the gate's 401 sign-in page.
- Real HTTPS tab signer acceptance passed: profile chooser, refusal prevents any
upload, and an approved BUD-11 authorization stores a synthetic local file.
No real identity key was exported or public Nostr event sent. Existing native
Bitcoin/LND processes retained their original start times during installation.
- No signed catalogue, source proposal, public Nostr event, OTA or ISO published.
- Real HTTP tab signing also passed. Normal app stop/start, restart with a new
container, uninstall with `preserve_data:true`, reinstall, and management restart
all preserved the uploaded synthetic file, verified by hash. Native Bitcoin/LND
processes retained their original start times.
- Local website archive integration is implemented in source: an explicit action
signs a hash/server-scoped upload, stores the saved draft through the local
manifest-owned Blossom backend, verifies exact readback and records a receipt.
It does not announce or replicate anything. Its backend RPC is not yet deployed.
Still required before release: cross-profile identity switch (only one profile
was available), HTTP/HTTPS iframe and physical companion validation, arranged
reboot, integrated website archive acceptance, then reviewed source/mirror parity
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.
+87
View File
@@ -0,0 +1,87 @@
app:
id: blossom
name: Blossom
version: 6.4.1-archy.1
upstream:
kind: github
repo: hzrd149/blossom-server
description: Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.
category: data
container:
network: slirp4netns
build:
context: /opt/archipelago/docker/blossom
dockerfile: Dockerfile
tag: localhost/archipelago-blossom:6.4.1-archy.1
derived_env:
- key: ARCHY_BLOSSOM_PUBKEYS
template: '{{NODE_IDENTITY_PUBKEYS}}'
dependencies:
- storage: 1Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
seccomp_profile: default
ports:
- host: 8191
container: 3000
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/blossom/data
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/blossom/bridge
target: /bridge
options: [rw]
- type: bind
source: /var/lib/archipelago/blossom/config.json
target: /config/config.json
options: [ro]
- type: tmpfs
target: /tmp
options: [rw, nosuid, nodev, size=64m]
files:
- path: /var/lib/archipelago/blossom/config.json
overwrite: false
content: '{}'
hooks:
post_install:
- copy_from_host:
src: web-ui/nostr-provider.js
dest: /bridge/nostr-provider.js
- exec: [sh, -c, 'test -s /bridge/nostr-provider.js']
health_check:
type: http
endpoint: http://127.0.0.1:3000
path: /healthz
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
interfaces:
main:
name: Local files
type: ui
port: 8191
protocol: http
path: /
metadata:
author: hzrd149 / Archipelago
tier: optional
icon: /assets/img/app-icons/blossom.svg
license: MIT
repo: https://github.com/hzrd149/blossom-server
tags: [nostr, blossom, storage, websites]
launch:
open_in_new_tab: false
requires_host_frame: false
+1
View File
@@ -67,6 +67,7 @@ app:
path: / path: /
metadata: metadata:
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
icon: /assets/img/app-icons/homeassistant.png icon: /assets/img/app-icons/homeassistant.png
category: home category: home
author: Home Assistant author: Home Assistant
+1
View File
@@ -84,5 +84,6 @@ app:
path: / path: /
metadata: metadata:
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
launch: launch:
open_in_new_tab: true open_in_new_tab: true
+1
View File
@@ -63,6 +63,7 @@ app:
path: / path: /
metadata: metadata:
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
icon: /assets/img/app-icons/jellyfin.webp icon: /assets/img/app-icons/jellyfin.webp
category: data category: data
author: Jellyfin author: Jellyfin
+1
View File
@@ -59,6 +59,7 @@ app:
path: / path: /
metadata: metadata:
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
icon: /assets/img/app-icons/nextcloud.webp icon: /assets/img/app-icons/nextcloud.webp
category: data category: data
author: Nextcloud author: Nextcloud
+1
View File
@@ -60,6 +60,7 @@ app:
path: / path: /
metadata: metadata:
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
icon: /assets/img/app-icons/photoprism.svg icon: /assets/img/app-icons/photoprism.svg
category: data category: data
author: PhotoPrism author: PhotoPrism
+3
View File
@@ -219,3 +219,6 @@ app:
nostr_integration: nostr_integration:
relay_type: public relay_type: public
monetization_enabled: true monetization_enabled: true
metadata:
guest_access: true
+1
View File
@@ -237,6 +237,7 @@ dependencies = [
"hyper 0.14.32", "hyper 0.14.32",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"tempfile", "tempfile",
"tokio", "tokio",
"uuid", "uuid",
@@ -12,9 +12,15 @@ impl RpcHandler {
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
match method { match method {
"publishing.status" => self.handle_publishing_status().await, "publishing.status" => self.handle_publishing_status().await,
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
"publishing.update" => self.handle_publishing_update(params).await, "publishing.update" => self.handle_publishing_update(params).await,
"publishing.dns" => self.handle_publishing_dns(params).await, "publishing.dns" => self.handle_publishing_dns(params).await,
"publishing.generate" => self.handle_publishing_generate(params).await, "publishing.generate" => self.handle_publishing_generate(params).await,
"publishing.nsite-prepare" => self.handle_publishing_nsite_prepare(params).await,
"publishing.blossom-prepare" => self.handle_publishing_blossom_prepare(params).await,
"publishing.blossom-store" => self.handle_publishing_blossom_store(params).await,
"publishing.access-create" => self.handle_publishing_access_create(params).await,
"publishing.access-revoke" => self.handle_publishing_access_revoke(params).await,
"echo" => self.handle_echo(params).await, "echo" => self.handle_echo(params).await,
"server.echo" => self.handle_echo(params).await, "server.echo" => self.handle_echo(params).await,
"server.get-state" => self.handle_server_get_state().await, "server.get-state" => self.handle_server_get_state().await,
+358 -2
View File
@@ -5,6 +5,322 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
impl RpcHandler { impl RpcHandler {
pub(super) async fn handle_publishing_verify_https(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
}
let request: Request =
serde_json::from_value(params.context("Missing website to verify")?)?;
let state = publishing::load(&self.config.data_dir).await?;
anyhow::ensure!(
state.version == request.version,
"Settings changed. Reload before checking"
);
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
anyhow::ensure!(
project.routes.contains(&publishing::Route::PublicWeb),
"Select public web and save first"
);
let host = publishing::hostname(
&project
.domain
.as_ref()
.context("Save a domain first")?
.hostname,
)?;
let expected = project
.fips_publication
.as_ref()
.context("Publish the website upstream first")?
.html
.as_bytes();
let addresses: Vec<_> = tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::net::lookup_host((host.as_str(), 443)),
)
.await
.context("DNS lookup timed out")?
.context("Domain DNS lookup failed")?
.collect();
anyhow::ensure!(
!addresses.is_empty() && addresses.iter().all(|a| publishing::public_ip(a.ip())),
"HTTPS checks require DNS resolving exclusively to public addresses"
);
// Pin this validated resolution: do not resolve again, follow redirects,
// inherit proxy settings, accept custom ports or relax TLS verification.
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(&host, &addresses)
.timeout(std::time::Duration::from_secs(20))
.build()?;
let mut response = client
.get(format!("https://{host}/"))
.header("Accept-Encoding", "identity")
.send()
.await
.context("HTTPS connection failed; check DNS, proxy and certificate")?;
anyhow::ensure!(
response.status() == reqwest::StatusCode::OK,
"Expected HTTP 200 from the website; received {}",
response.status()
);
let mut offset = 0;
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
offset + chunk.len() <= expected.len()
&& expected[offset..offset + chunk.len()] == chunk[..],
"The HTTPS address serves different content from this published version"
);
offset += chunk.len();
}
anyhow::ensure!(offset == expected.len(), "Website response was incomplete");
anyhow::ensure!(
publishing::load(&self.config.data_dir).await?.version == request.version,
"Settings changed during verification. Check the current version again"
);
Ok(
json!({"hostname":host,"sha256":publishing::nsite::hash(expected),
"checked_at":chrono::Utc::now().to_rfc3339()}),
)
}
pub(super) async fn handle_publishing_access_create(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
app_id: String,
label: String,
hours: u32,
}
let request: Request =
serde_json::from_value(params.context("Missing app access request")?)?;
let label = request.label.trim();
anyhow::ensure!(
!label.is_empty() && label.len() <= 64 && !label.chars().any(char::is_control),
"Enter a guest label of at most 64 characters"
);
anyhow::ensure!(
(1..=720).contains(&request.hours),
"Choose an expiry between one hour and 30 days"
);
let map = crate::appgate::identity::build_port_map();
let app = map
.gated_ports()
.find(|p| {
p.app_id == request.app_id
&& p.guest_access
&& p.declared
&& p.auth_enabled
&& !p.session_passthrough
})
.context("This app has not opted in to external guest access")?;
let id = format!("external:{}:{label}", uuid::Uuid::new_v4());
let expires = chrono::Utc::now().timestamp() as u64 + u64::from(request.hours) * 3600;
let token = crate::device_tokens::create_scoped_expiring(
&self.config.data_dir,
&id,
Some(vec![app.app_id.clone()]),
Some(expires),
)
.await?;
Ok(json!({"id":id, "token":token, "app_id":app.app_id, "expires_at":expires}))
}
pub(super) async fn handle_publishing_access_revoke(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
}
let request: Request =
serde_json::from_value(params.context("Missing access credential")?)?;
let credentials = crate::device_tokens::list(&self.config.data_dir).await;
anyhow::ensure!(
credentials.iter().any(|c| c.name == request.id
&& c.name.starts_with("external:")
&& c.apps.is_some()),
"External app access credential not found"
);
Ok(
json!({"revoked":crate::device_tokens::remove(&self.config.data_dir, &request.id).await?}),
)
}
pub(super) async fn handle_publishing_blossom_prepare(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
}
let request: Request =
serde_json::from_value(params.context("Missing local archive request")?)?;
let state = publishing::load(&self.config.data_dir).await?;
anyhow::ensure!(
state.version == request.version,
"Publishing settings changed. Reload before storing"
);
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
anyhow::ensure!(
!project.draft.trim().is_empty(),
"Save a website draft first"
);
let digest = publishing::nsite::hash(project.draft.as_bytes());
let now = chrono::Utc::now().timestamp();
Ok(
json!({ "sha256": digest, "size": project.draft.len(), "authorization": {
"kind":24242, "created_at":now, "content":"Store this website draft on my local node only",
"tags":[["t","upload"],["x",digest],["server","127.0.0.1"],["expiration",(now+300).to_string()]]
}}),
)
}
pub(super) async fn handle_publishing_blossom_store(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use base64::Engine;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
authorization: nostr_sdk::Event,
}
let request: Request =
serde_json::from_value(params.context("Missing local archive authorization")?)?;
request
.authorization
.verify()
.context("Invalid local upload signature")?;
let state = publishing::load(&self.config.data_dir).await?;
anyhow::ensure!(
state.version == request.version,
"Publishing settings changed. Reload before storing"
);
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
anyhow::ensure!(
!project.draft.trim().is_empty(),
"Save a website draft first"
);
let digest = publishing::nsite::hash(project.draft.as_bytes());
let event = serde_json::to_value(&request.authorization)?;
let tags = event["tags"]
.as_array()
.context("Missing upload authorization tags")?;
anyhow::ensure!(
event["kind"] == 24242
&& tags.contains(&json!(["t", "upload"]))
&& tags.contains(&json!(["x", digest]))
&& tags.contains(&json!(["server", "127.0.0.1"])),
"Authorization does not match this local draft upload"
);
// This is a protocol adapter, not a general URL proxy. Resolve only the
// manifest-owned Blossom backend and never send node session cookies.
let map = crate::appgate::identity::build_port_map();
let port = map
.gated_ports()
.find(|p| p.app_id == "blossom" && p.declared && p.auth_enabled)
.context("Install local Blossom with its app gate enabled first")?
.port;
let base = format!("http://127.0.0.1:{port}");
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build()?;
let auth = base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&request.authorization)?);
let mut response = client
.put(format!("{base}/upload"))
.header("Authorization", format!("Nostr {auth}"))
.header("Content-Type", "text/html; charset=utf-8")
.body(project.draft.clone())
.send()
.await
.context("Local Blossom is not responding. Start it from Apps")?;
anyhow::ensure!(
response.status().is_success(),
"Local Blossom rejected the upload ({})",
response.status()
);
let mut descriptor = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
descriptor.len() + chunk.len() <= 8192,
"Invalid local Blossom receipt"
);
descriptor.extend_from_slice(&chunk);
}
let descriptor: serde_json::Value = serde_json::from_slice(&descriptor)?;
anyhow::ensure!(
descriptor["sha256"] == digest && descriptor["size"] == project.draft.len(),
"Local Blossom returned another file receipt"
);
let mut response = client
.get(format!("{base}/{digest}"))
.send()
.await?
.error_for_status()?;
let expected = project.draft.as_bytes();
let mut offset = 0;
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
offset + chunk.len() <= expected.len()
&& expected[offset..offset + chunk.len()] == chunk[..],
"Local Blossom readback differs from the saved draft"
);
offset += chunk.len();
}
anyhow::ensure!(
offset == expected.len(),
"Local Blossom readback was incomplete"
);
let receipt = publishing::LocalArchive {
sha256: digest,
size: expected.len(),
pubkey: request.authorization.pubkey.to_hex(),
created_at: chrono::Utc::now().to_rfc3339(),
};
let (state, _) = publishing::update(
&self.config.data_dir,
publishing::Update {
version: request.version,
change: publishing::Change::RecordLocalArchive {
id: request.id,
receipt,
},
},
)
.await
.context("The local file was stored, but its project receipt could not be saved")?;
Ok(json!({"state":state}))
}
pub(super) async fn handle_publishing_status(&self) -> Result<serde_json::Value> { pub(super) async fn handle_publishing_status(&self) -> Result<serde_json::Value> {
let state = publishing::load(&self.config.data_dir).await?; let state = publishing::load(&self.config.data_dir).await?;
let gate = crate::appgate::listener::shared_status(); let gate = crate::appgate::listener::shared_status();
@@ -18,18 +334,24 @@ impl RpcHandler {
"id": p.app_id, "name": p.app_name, "port": p.port, "id": p.app_id, "name": p.app_name, "port": p.port,
"authentication": if p.auth_enabled { "node-session" } else { "application" }, "authentication": if p.auth_enabled { "node-session" } else { "application" },
"listener_claimed": crate::appgate::listener::port_claimed(&gate, p.port), "listener_claimed": crate::appgate::listener::port_claimed(&gate, p.port),
"guest_access": p.guest_access && p.auth_enabled,
}) })
}) })
.collect(); .collect();
apps.sort_by_key(|a| a["id"].as_str().unwrap_or_default().to_owned()); apps.sort_by_key(|a| a["id"].as_str().unwrap_or_default().to_owned());
drop(gate);
let credentials = crate::device_tokens::list(&self.config.data_dir).await;
let grants: Vec<_> = credentials.iter().filter(|c| c.name.starts_with("external:") && c.apps.is_some()).map(|c| json!({"id":c.name,"label":c.name.splitn(3, ':').nth(2).unwrap_or("Guest"),"apps":c.apps,"expires_at":c.expires_at})).collect();
Ok(json!({ Ok(json!({
"state": state, "state": state,
"fips_address": crate::fips::iface::fips0_ula().map(|a| a.to_string()), "fips_address": crate::fips::iface::fips0_ula().map(|a| a.to_string()),
"apps": apps, "apps": apps,
"grants": grants,
"nostr_relays": self.config.nostr_relays,
"publication_enabled": true, "publication_enabled": true,
"listeners": publishing::serving::status().await, "listeners": publishing::serving::status().await,
"onions": publishing::tor::status().await, "onions": publishing::tor::status().await,
"notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Automated gateways and Nostr publishing are not enabled yet. Saving choices does not change app access; external verification is separate.", "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.",
})) }))
} }
@@ -37,11 +359,45 @@ impl RpcHandler {
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let update = serde_json::from_value(params.context("Missing publishing settings")?)?; let update: publishing::Update =
serde_json::from_value(params.context("Missing publishing settings")?)?;
if let publishing::Change::RecordNsite { receipt, .. } = &update.change {
let event: nostr_sdk::Event = serde_json::from_value(receipt.event.clone())?;
event.verify().context("Invalid nsite event signature")?;
}
let (state, project_id) = publishing::update(&self.config.data_dir, update).await?; let (state, project_id) = publishing::update(&self.config.data_dir, update).await?;
Ok(json!({ "state": state, "project_id": project_id })) Ok(json!({ "state": state, "project_id": project_id }))
} }
pub(super) async fn handle_publishing_nsite_prepare(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
server: String,
html: String,
}
let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?;
let state = publishing::load(&self.config.data_dir).await?;
if state.version != request.version {
anyhow::bail!("Publishing settings changed. Reload before preparing the nsite");
}
let project = state
.projects
.get(&request.id)
.context("Website project not found")?;
if request.html.len() > 512 * 1024 || request.html.contains('\0') {
anyhow::bail!("Prepared website exceeds the HTML limit");
}
let mut prepared = project.clone();
prepared.draft = request.html;
publishing::nsite::prepare(&prepared, &request.server)
}
pub(super) async fn handle_publishing_dns( pub(super) async fn handle_publishing_dns(
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
+37
View File
@@ -19,6 +19,8 @@ use std::path::PathBuf;
/// An app port the gate is responsible for. /// An app port the gate is responsible for.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatedPort { pub struct GatedPort {
/// Explicit manifest permission to offer app-only external credentials.
pub guest_access: bool,
pub port: u16, pub port: u16,
pub app_id: String, pub app_id: String,
/// Display name for the login page. Falls back to the id when a manifest /// Display name for the login page. Falls back to the id when a manifest
@@ -215,10 +217,24 @@ pub fn build_port_map() -> PortMap {
map map
} }
#[cfg(test)]
pub(super) fn test_port_map(port: GatedPort) -> PortMap {
let mut map = PortMap::default();
map.gated.insert(port.port, port);
map
}
/// Classify one manifest's ports into the map. Split from [`build_port_map`] /// Classify one manifest's ports into the map. Split from [`build_port_map`]
/// so the catalog-overlay pass and the disk pass cannot diverge. /// so the catalog-overlay pass and the disk pass cannot diverge.
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
let app_id = manifest.app.id.clone(); let app_id = manifest.app.id.clone();
let guest_access = manifest
.app
.extensions
.get("metadata")
.and_then(|m| m.get("guest_access"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let icon = manifest_icon(manifest); let icon = manifest_icon(manifest);
let app_name = if manifest.app.name.trim().is_empty() { let app_name = if manifest.app.name.trim().is_empty() {
app_id.clone() app_id.clone()
@@ -260,6 +276,9 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
map.gated.insert( map.gated.insert(
port.host, port.host,
GatedPort { GatedPort {
guest_access: guest_access
&& !port.session_passthrough
&& port.auth_policy() == PortAuth::Gated,
port: port.host, port: port.host,
app_id: app_id.clone(), app_id: app_id.clone(),
app_name: app_name.clone(), app_name: app_name.clone(),
@@ -309,6 +328,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
map.gated.insert( map.gated.insert(
port.host, port.host,
GatedPort { GatedPort {
guest_access: false,
port: port.host, port: port.host,
app_id: app_id.clone(), app_id: app_id.clone(),
app_name: app_name.clone(), app_name: app_name.clone(),
@@ -372,6 +392,23 @@ app:
image: example.org/testapp:1.0 image: example.org/testapp:1.0
"#; "#;
#[test]
fn guest_access_requires_explicit_gate_and_never_allows_session_passthrough() {
for (auth, passthrough, expected) in [
("gated", false, true),
("gated", true, false),
("session", false, false),
] {
let text = format!("{BASE} metadata:\n guest_access: true\n ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 0.0.0.0\n auth: {auth}\n session_passthrough: {passthrough}\n");
let mut map = PortMap::default();
classify_manifest(&manifest(&text), &mut map);
assert_eq!(map.gated(8090).unwrap().guest_access, expected);
}
let mut map = PortMap::default();
classify_manifest(&manifest(&format!("{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n")), &mut map);
assert!(!map.gated(8090).unwrap().guest_access);
}
/// `auth: gated` is the only classification allowed to redirect traffic — /// `auth: gated` is the only classification allowed to redirect traffic —
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on /// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
/// `declared`. An undeclared Session port is challenged and audited but /// `declared`. An undeclared Session port is challenged and audited but
+11 -5
View File
@@ -406,7 +406,7 @@ async fn serve_connection(
if is_tls { if is_tls {
match gate.tls.acceptor().await { match gate.tls.acceptor().await {
Some(acceptor) => match acceptor.accept(stream).await { Some(acceptor) => match acceptor.accept(stream).await {
Ok(tls_stream) => serve_http(tls_stream, peer, gate, app).await, Ok(tls_stream) => serve_http(tls_stream, peer, gate, app, true).await,
Err(e) => { Err(e) => {
// Routine: a browser probing a cert it does not trust, or a // Routine: a browser probing a cert it does not trust, or a
// scanner. Not operator-actionable, so debug. // scanner. Not operator-actionable, so debug.
@@ -424,18 +424,24 @@ async fn serve_connection(
} }
} }
} else { } else {
serve_http(stream, peer, gate, app).await; serve_http(stream, peer, gate, app, false).await;
} }
} }
/// The HTTP half, generic over the transport so TLS and plain share one path — /// The HTTP half, generic over the transport so TLS and plain share one path —
/// the gate's authentication, proxying and upgrade handling must not differ by /// the gate's authentication, proxying and upgrade handling must not differ by
/// scheme, and generics make that structural rather than a thing to remember. /// scheme, and generics make that structural rather than a thing to remember.
async fn serve_http<S>(stream: S, peer: SocketAddr, gate: Arc<AppGate>, app: GatedPort) async fn serve_http<S>(
where stream: S,
peer: SocketAddr,
gate: Arc<AppGate>,
app: GatedPort,
secure: bool,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{ {
let service = hyper::service::service_fn(move |req| { let service = hyper::service::service_fn(move |mut req: hyper::Request<hyper::Body>| {
req.extensions_mut().insert(super::SecureTransport(secure));
let gate = gate.clone(); let gate = gate.clone();
let app = app.clone(); let app = app.clone();
async move { Ok::<_, std::convert::Infallible>(gate.handle(req, &app, peer.ip()).await) } async move { Ok::<_, std::convert::Infallible>(gate.handle(req, &app, peer.ip()).await) }
+182 -9
View File
@@ -50,6 +50,8 @@ use tokio::sync::RwLock;
/// Paths the gate serves itself rather than proxying. Namespaced so an app /// Paths the gate serves itself rather than proxying. Namespaced so an app
/// that happens to have its own `/login` is unaffected. /// that happens to have its own `/login` is unaffected.
const GATE_PREFIX: &str = "/__archipelago-gate/"; const GATE_PREFIX: &str = "/__archipelago-gate/";
#[derive(Clone, Copy)]
pub(crate) struct SecureTransport(pub bool);
/// Result of examining a request's credentials. /// Result of examining a request's credentials.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@@ -60,6 +62,11 @@ pub enum Authorization {
/// `Authorization: Bearer <device token>` — strip that header before the /// `Authorization: Bearer <device token>` — strip that header before the
/// app sees it, exactly as the session cookie is stripped. /// app sees it, exactly as the session cookie is stripped.
AllowGateToken, AllowGateToken,
/// App-only cookie; never repair or issue a dashboard session for it.
AllowGuest,
/// Expiring external guest credential presented as an API bearer token.
/// It still requires the current port's guest opt-in and is stripped.
AllowGuestToken,
/// Serve the login page. /// Serve the login page.
Challenge, Challenge,
} }
@@ -105,7 +112,7 @@ impl AppGate {
/// Does this request carry a credential good for `app_id`? /// Does this request carry a credential good for `app_id`?
/// ///
/// Two accepted forms, deliberately no others: /// Accepted credentials retain distinct scopes:
/// ///
/// * the node session cookie — and because a session still pending its /// * the node session cookie — and because a session still pending its
/// TOTP step fails `validate()`, **2FA is honoured here for free**. The /// TOTP step fails `validate()`, **2FA is honoured here for free**. The
@@ -113,6 +120,8 @@ impl AppGate {
/// * an app-scoped bearer token, for machine clients that speak HTTP but /// * an app-scoped bearer token, for machine clients that speak HTTP but
/// cannot hold a cookie or complete an interactive login (Home /// cannot hold a cookie or complete an interactive login (Home
/// Assistant reaching an app's API is the motivating case). /// Assistant reaching an app's API is the motivating case).
/// * a separately named app-only cookie, with live scope/expiry/revocation
/// checks and no ability to authenticate to dashboard RPC.
pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization { pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization {
if let Some(token) = crate::session::extract_session_cookie(headers) { if let Some(token) = crate::session::extract_session_cookie(headers) {
if self.sessions.validate(&token).await { if self.sessions.validate(&token).await {
@@ -120,12 +129,29 @@ impl AppGate {
} }
} }
let guest_enabled = self
.port_map
.read()
.await
.gated_ports()
.any(|p| p.app_id == app_id && p.guest_access && p.auth_enabled);
if let Some(token) = bearer_token(headers) { if let Some(token) = bearer_token(headers) {
if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id) if let Some(credential) =
.await crate::device_tokens::verified_app_token(&self.data_dir, &token, app_id).await
.is_some()
{ {
return Authorization::AllowGateToken; if !credential.name.starts_with("external:") || credential.apps.is_none() {
return Authorization::AllowGateToken;
}
if guest_enabled {
return Authorization::AllowGuestToken;
}
}
}
if guest_enabled {
if let Some(token) = cookie_value(headers, &format!("archy_app_access_{app_id}")) {
if crate::device_tokens::verify_guest(&self.data_dir, &token, app_id).await {
return Authorization::AllowGuest;
}
} }
} }
@@ -216,12 +242,20 @@ impl AppGate {
} }
// The credential WAS the Authorization header, and it was ours. // The credential WAS the Authorization header, and it was ours.
Authorization::AllowGateToken => proxy_to_app(req, app, true).await, Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
Authorization::AllowGuest if app.guest_access && !app.session_passthrough => {
proxy_to_app(req, app, false).await
}
Authorization::AllowGuestToken if app.guest_access && !app.session_passthrough => {
proxy_to_app(req, app, true).await
}
// 401 rather than a redirect: a redirect to a login page is // 401 rather than a redirect: a redirect to a login page is
// indistinguishable from the app itself redirecting, and machine // indistinguishable from the app itself redirecting, and machine
// clients would follow it and parse HTML as if it were their API // clients would follow it and parse HTML as if it were their API
// response. The status says "you are not authenticated" in a way // response. The status says "you are not authenticated" in a way
// every client understands, and browsers still render the body. // every client understands, and browsers still render the body.
Authorization::Challenge => { Authorization::Challenge
| Authorization::AllowGuest
| Authorization::AllowGuestToken => {
login_page(app, None, StatusCode::UNAUTHORIZED, &mount_prefix) login_page(app, None, StatusCode::UNAUTHORIZED, &mount_prefix)
} }
} }
@@ -269,6 +303,16 @@ impl AppGate {
// never appears in the HTML, in a `view-source`, or in a screenshot // never appears in the HTML, in a `view-source`, or in a screenshot
// of the second-factor page. // of the second-factor page.
let pending = crate::session::extract_session_cookie(req.headers()); let pending = crate::session::extract_session_cookie(req.headers());
let secure = req
.extensions()
.get::<SecureTransport>()
.map(|s| s.0)
.unwrap_or(false)
|| req
.headers()
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
== Some("https");
// Same limiter instance as the JSON-RPC login path, so an attacker // Same limiter instance as the JSON-RPC login path, so an attacker
// cannot get a fresh budget of guesses simply by moving to an app // cannot get a fresh budget of guesses simply by moving to an app
@@ -295,6 +339,26 @@ impl AppGate {
}; };
match action { match action {
"guest" if app.guest_access && app.auth_enabled => {
let token = field(&form, "access_token").unwrap_or_default();
if !crate::device_tokens::verify_guest(&self.data_dir, &token, &app.app_id).await {
self.limiter.record_failure(client_ip).await;
return login_page(
app,
Some("App access token is invalid, expired or revoked."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
}
let mut response = redirect_to_app(mount_prefix);
// Host-only and app-specific. A token is rechecked on EVERY
// request, so revocation and its expiry apply immediately.
let suffix = if secure { "; Secure" } else { "" };
if let Ok(cookie) = header::HeaderValue::from_str(&format!("archy_app_access_{}={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600{suffix}", app.app_id)) {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
response
}
"login" => self.do_login(app, &form, client_ip, mount_prefix).await, "login" => self.do_login(app, &form, client_ip, mount_prefix).await,
"totp" => { "totp" => {
self.do_totp(app, &form, pending, client_ip, mount_prefix) self.do_totp(app, &form, pending, client_ip, mount_prefix)
@@ -535,6 +599,9 @@ async fn proxy_to_app(
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
parts.uri = uri; parts.uri = uri;
strip_matching_cookies(&mut parts.headers, |name| {
name.starts_with("archy_app_access_")
});
// Strip the gate's own credential before it reaches the app — the app // Strip the gate's own credential before it reaches the app — the app
// should never be in a position to log, echo, or forward the node // should never be in a position to log, echo, or forward the node
// session. But ONLY the gate's cookies: apps run their own cookie logins // session. But ONLY the gate's cookies: apps run their own cookie logins
@@ -662,12 +729,18 @@ fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) {
} }
/// Cookie names owned by the gate/daemon, never the app's to see. /// Cookie names owned by the gate/daemon, never the app's to see.
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"]; const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token", "remember"];
/// Remove the gate's own cookie pairs from the Cookie header, preserving the /// Remove the gate's own cookie pairs from the Cookie header, preserving the
/// app's cookies (its login/session/prefs) untouched. Drops the header /// app's cookies (its login/session/prefs) untouched. Drops the header
/// entirely when nothing remains. /// entirely when nothing remains.
fn strip_gate_cookies(headers: &mut hyper::HeaderMap) { fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
strip_matching_cookies(headers, |name| {
GATE_COOKIE_NAMES.contains(&name) || name.starts_with("archy_app_access_")
});
}
fn strip_matching_cookies(headers: &mut hyper::HeaderMap, remove: fn(&str) -> bool) {
let Some(cookie) = headers.get(header::COOKIE) else { let Some(cookie) = headers.get(header::COOKIE) else {
return; return;
}; };
@@ -681,7 +754,7 @@ fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
.map(str::trim) .map(str::trim)
.filter(|pair| { .filter(|pair| {
let name = pair.split('=').next().unwrap_or("").trim(); let name = pair.split('=').next().unwrap_or("").trim();
!GATE_COOKIE_NAMES.contains(&name) !remove(name)
}) })
.filter(|pair| !pair.is_empty()) .filter(|pair| !pair.is_empty())
.collect(); .collect();
@@ -1254,7 +1327,8 @@ fn login_page(
<form method="post" action="{prefix}login"> <form method="post" action="{prefix}login">
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required> <input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
<button type="submit"><span class="idle">Sign in</span><span class="busy">{spinner}Signing in…</span></button> <button type="submit"><span class="idle">Sign in</span><span class="busy">{spinner}Signing in…</span></button>
</form>"#, </form>
{guest_form}"#,
logo = logo_markup(), logo = logo_markup(),
spinner = SPINNER_SVG, spinner = SPINNER_SVG,
icon = icon_markup(app), icon = icon_markup(app),
@@ -1263,6 +1337,14 @@ fn login_page(
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e))) .map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(), .unwrap_or_default(),
prefix = gate_url(mount_prefix, ""), prefix = gate_url(mount_prefix, ""),
guest_form = if app.guest_access && app.auth_enabled {
format!(
r#"<details><summary>Have an app-only access token?</summary><p class="sub">This opens only this app, without a dashboard login. The app may also require its own account.</p><form method="post" action="{}"><input type="password" name="access_token" placeholder="App access token" autocomplete="off" required><button type="submit">Open this app</button></form></details>"#,
gate_url(mount_prefix, "guest")
)
} else {
String::new()
},
); );
page("Sign in", app, &body, status, mount_prefix) page("Sign in", app, &body, status, mount_prefix)
} }
@@ -1298,6 +1380,96 @@ fn totp_page(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[tokio::test]
async fn guest_login_is_app_only_and_revocation_blocks_subsequent_requests() {
let gate = test_gate().await;
let mut app = app();
app.guest_access = true;
*gate.port_map.write().await = identity::test_port_map(app.clone());
let token = crate::device_tokens::create_scoped_expiring(
&gate.data_dir,
"external:test:Guest",
Some(vec![app.app_id.clone()]),
Some(u64::MAX),
)
.await
.unwrap();
let mut request = Request::post(format!("{GATE_PREFIX}guest"))
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(format!("access_token={token}")))
.unwrap();
request.extensions_mut().insert(SecureTransport(true));
let response = gate
.handle(request, &app, "127.0.0.1".parse().unwrap())
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let cookies: Vec<_> = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|h| h.to_str().unwrap())
.collect();
assert_eq!(cookies.len(), 1);
assert!(
cookies[0].starts_with("archy_app_access_strfry=")
&& cookies[0].contains("; Secure")
&& cookies[0].contains("HttpOnly")
);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
cookies[0].split(';').next().unwrap().parse().unwrap(),
);
assert_eq!(
gate.authorize(&headers, &app.app_id).await,
Authorization::AllowGuest
);
assert_eq!(
gate.authorize(&headers, "lnd").await,
Authorization::Challenge
);
assert!(!gate.sessions.validate(&token).await);
assert!(crate::device_tokens::verify(&gate.data_dir, &token)
.await
.is_none());
let mut bearer = HeaderMap::new();
bearer.insert(
header::AUTHORIZATION,
format!("Bearer {token}").parse().unwrap(),
);
assert_eq!(
gate.authorize(&bearer, &app.app_id).await,
Authorization::AllowGuestToken
);
app.guest_access = false;
*gate.port_map.write().await = identity::test_port_map(app.clone());
assert_eq!(
gate.authorize(&bearer, &app.app_id).await,
Authorization::Challenge
);
assert_eq!(
gate.authorize(&headers, &app.app_id).await,
Authorization::Challenge
);
app.guest_access = true;
*gate.port_map.write().await = identity::test_port_map(app.clone());
crate::device_tokens::remove(&gate.data_dir, "external:test:Guest")
.await
.unwrap();
assert_eq!(
gate.authorize(&headers, &app.app_id).await,
Authorization::Challenge
);
}
#[test]
fn guest_and_remember_credentials_never_reach_the_app() {
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "session=owner; remember=master; csrf_token=csrf; archy_app_access_nextcloud=guest; own_app_session=keep".parse().unwrap());
strip_gate_cookies(&mut headers);
assert_eq!(headers[header::COOKIE], "own_app_session=keep");
}
#[test] #[test]
fn credentialless_allowlist_covers_the_manifest_that_broke_apps() { fn credentialless_allowlist_covers_the_manifest_that_broke_apps() {
// A <link rel="manifest"> fetch never carries the cookie, so these must // A <link rel="manifest"> fetch never carries the cookie, so these must
@@ -1334,6 +1506,7 @@ mod tests {
fn app() -> GatedPort { fn app() -> GatedPort {
GatedPort { GatedPort {
guest_access: false,
port: 8090, port: 8090,
app_id: "strfry".to_string(), app_id: "strfry".to_string(),
app_name: "Strfry Relay".to_string(), app_name: "Strfry Relay".to_string(),
+152 -16
View File
@@ -18,6 +18,7 @@ const TOKENS_FILE: &str = "device-tokens.json";
/// Cap on stored tokens; re-pairing the same device name replaces its entry, /// Cap on stored tokens; re-pairing the same device name replaces its entry,
/// so this only limits the number of *distinct* device names. /// so this only limits the number of *distinct* device names.
const MAX_TOKENS: usize = 32; const MAX_TOKENS: usize = 32;
static TOKEN_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceToken { pub struct DeviceToken {
@@ -39,9 +40,14 @@ pub struct DeviceToken {
/// app's API should not also open every other app on the node. /// app's API should not also open every other app on the node.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<Vec<String>>, pub apps: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
} }
impl DeviceToken { impl DeviceToken {
fn active(&self) -> bool {
self.expires_at.map(|end| end > now()).unwrap_or(true)
}
/// Whether this token may reach `app_id`. /// Whether this token may reach `app_id`.
pub fn allows_app(&self, app_id: &str) -> bool { pub fn allows_app(&self, app_id: &str) -> bool {
match &self.apps { match &self.apps {
@@ -51,22 +57,49 @@ impl DeviceToken {
} }
} }
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(u64::MAX)
}
fn tokens_path(data_dir: &Path) -> PathBuf { fn tokens_path(data_dir: &Path) -> PathBuf {
data_dir.join(TOKENS_FILE) data_dir.join(TOKENS_FILE)
} }
async fn load(data_dir: &Path) -> Vec<DeviceToken> { async fn load(data_dir: &Path) -> Vec<DeviceToken> {
load_strict(data_dir).await.unwrap_or_default()
}
async fn load_strict(data_dir: &Path) -> Result<Vec<DeviceToken>> {
match fs::read(tokens_path(data_dir)).await { match fs::read(tokens_path(data_dir)).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), Ok(bytes) => serde_json::from_slice(&bytes)
Err(_) => Vec::new(), .context("Read stored access credentials; existing file preserved"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e).context("Read stored access credentials"),
} }
} }
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> { async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
let bytes = serde_json::to_vec_pretty(tokens)?; let bytes = serde_json::to_vec_pretty(tokens)?;
fs::write(tokens_path(data_dir), bytes) use tokio::io::AsyncWriteExt;
.await let tmp = data_dir.join(format!(".device-tokens-{}.tmp", uuid::Uuid::new_v4()));
.context("write device-tokens.json") let result = async {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).mode(0o600);
let mut file = options.open(&tmp).await?;
file.write_all(&bytes).await?;
file.sync_all().await?;
fs::rename(&tmp, tokens_path(data_dir)).await?;
fs::File::open(data_dir).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(tmp).await;
}
result.context("write device-tokens.json")
} }
fn hash_hex(token: &str) -> String { fn hash_hex(token: &str) -> String {
@@ -94,6 +127,20 @@ pub async fn create_scoped(
name: &str, name: &str,
apps: Option<Vec<String>>, apps: Option<Vec<String>>,
) -> Result<String> { ) -> Result<String> {
create_scoped_expiring(data_dir, name, apps, None).await
}
pub async fn create_scoped_expiring(
data_dir: &Path,
name: &str,
apps: Option<Vec<String>>,
expires_at: Option<u64>,
) -> Result<String> {
let _guard = TOKEN_WRITE_LOCK.lock().await;
anyhow::ensure!(
expires_at.map(|end| end > now()).unwrap_or(true),
"Access expiry must be in the future"
);
// An empty list would be indistinguishable from "no restriction" to a // An empty list would be indistinguishable from "no restriction" to a
// careless reader while actually authorising nothing — reject it rather // careless reader while actually authorising nothing — reject it rather
// than mint a token whose behaviour nobody can predict from its record. // than mint a token whose behaviour nobody can predict from its record.
@@ -108,10 +155,10 @@ pub async fn create_scoped(
})?; })?;
let token = hex::encode(token_bytes); let token = hex::encode(token_bytes);
let mut tokens = load(data_dir).await; let mut tokens = load_strict(data_dir).await?;
tokens.retain(|t| t.name != name); tokens.retain(|t| t.name != name);
if tokens.len() >= MAX_TOKENS { if tokens.len() >= MAX_TOKENS {
tokens.remove(0); anyhow::bail!("Access credential limit reached. Revoke an unused credential first");
} }
tokens.push(DeviceToken { tokens.push(DeviceToken {
name: name.to_string(), name: name.to_string(),
@@ -121,35 +168,63 @@ pub async fn create_scoped(
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0), .unwrap_or(0),
apps, apps,
expires_at,
}); });
save(data_dir, &tokens).await?; save(data_dir, &tokens).await?;
Ok(token) Ok(token)
} }
/// Verify a candidate token. Returns the device name it was minted for. /// Verify a node-wide login token. App-only credentials must never be exchanged
/// for an administrator session through auth.login (including its password path).
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> { pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
let candidate_hash = hash_hex(candidate); let candidate_hash = hash_hex(candidate);
load(data_dir) load(data_dir)
.await .await
.iter() .iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())) .find(|t| {
t.apps.is_none() && t.active() && ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
})
.map(|t| t.name.clone()) .map(|t| t.name.clone())
} }
/// Verify a candidate token **for a specific app**, as the app gate does. /// Verify a candidate token **for a specific app**, as the app gate does.
/// Returns the device name when the token is valid *and* in scope. /// Returns the device name when the token is valid *and* in scope.
/// ///
/// Separate from `verify` on purpose: `verify` answers "is this a real /// Node-wide companion credentials retain their existing app access; app-only
/// token", which is the right question for node login, and would be the /// credentials work only for the recorded application(s), before their expiry.
/// wrong question here — a token scoped to one app would otherwise open
/// every app.
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> { pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
verified_app_token(data_dir, candidate, app_id)
.await
.map(|t| t.name)
}
/// Return one verified snapshot so callers can distinguish a guest credential
/// from a node-wide device without racing a second read of the token file.
pub async fn verified_app_token(
data_dir: &Path,
candidate: &str,
app_id: &str,
) -> Option<DeviceToken> {
let candidate_hash = hash_hex(candidate); let candidate_hash = hash_hex(candidate);
load(data_dir) load(data_dir)
.await .await
.iter() .iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()) && t.allows_app(app_id)) .find(|t| {
.map(|t| t.name.clone()) t.active()
&& ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
&& t.allows_app(app_id)
})
.cloned()
}
pub async fn verify_guest(data_dir: &Path, candidate: &str, app_id: &str) -> bool {
let candidate_hash = hash_hex(candidate);
load(data_dir).await.iter().any(|t| {
t.apps.is_some()
&& t.active()
&& t.allows_app(app_id)
&& ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
})
} }
/// List stored tokens (hashes only — plaintexts are unrecoverable). /// List stored tokens (hashes only — plaintexts are unrecoverable).
@@ -159,7 +234,8 @@ pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
/// Remove the token minted for `name`. Returns whether one existed. /// Remove the token minted for `name`. Returns whether one existed.
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> { pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
let mut tokens = load(data_dir).await; let _guard = TOKEN_WRITE_LOCK.lock().await;
let mut tokens = load_strict(data_dir).await?;
let before = tokens.len(); let before = tokens.len();
tokens.retain(|t| t.name != name); tokens.retain(|t| t.name != name);
let removed = tokens.len() != before; let removed = tokens.len() != before;
@@ -172,6 +248,66 @@ pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn concurrent_grants_survive_and_capacity_never_evicts_a_device() {
let dir = tempfile::tempdir().unwrap();
let owner = create(dir.path(), "phone").await.unwrap();
let mut tasks = tokio::task::JoinSet::new();
for i in 1..MAX_TOKENS {
let path = dir.path().to_owned();
tasks.spawn(async move { create(&path, &format!("device-{i}")).await.unwrap() });
}
while let Some(result) = tasks.join_next().await {
result.unwrap();
}
assert_eq!(list(dir.path()).await.len(), MAX_TOKENS);
assert!(create(dir.path(), "overflow").await.is_err());
assert_eq!(verify(dir.path(), &owner).await.as_deref(), Some("phone"));
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(tokens_path(dir.path()))
.await
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
#[tokio::test]
async fn guest_scope_expiry_and_corruption_fail_closed_without_replacing_credentials() {
let dir = tempfile::tempdir().unwrap();
let guest = create_scoped_expiring(
dir.path(),
"guest",
Some(vec!["nextcloud".into()]),
Some(now() + 3600),
)
.await
.unwrap();
assert!(verify(dir.path(), &guest).await.is_none());
assert!(verify_guest(dir.path(), &guest, "nextcloud").await);
assert!(verify_for_app(dir.path(), &guest, "nextcloud")
.await
.is_some());
assert!(verify_for_app(dir.path(), &guest, "lnd").await.is_none());
let mut records = load(dir.path()).await;
records[0].expires_at = Some(1);
save(dir.path(), &records).await.unwrap();
assert!(!verify_guest(dir.path(), &guest, "nextcloud").await);
assert!(verify_for_app(dir.path(), &guest, "nextcloud")
.await
.is_none());
fs::write(tokens_path(dir.path()), b"broken stored credential file")
.await
.unwrap();
assert!(create(dir.path(), "phone").await.is_err());
assert!(remove(dir.path(), "guest").await.is_err());
assert_eq!(
fs::read(tokens_path(dir.path())).await.unwrap(),
b"broken stored credential file"
);
}
#[tokio::test] #[tokio::test]
async fn mint_verify_replace_remove() { async fn mint_verify_replace_remove() {
+1
View File
@@ -29,6 +29,7 @@ pub const APP_LAUNCH_PORTS: &[u16] = &[
8175, 8175,
8176, 8176,
8187, 8187,
8191,
8240, 8240,
8334, 8334,
8336, 8336,
+139 -4
View File
@@ -7,6 +7,7 @@ use std::path::Path;
use tokio::sync::Mutex; use tokio::sync::Mutex;
mod firewall; mod firewall;
pub mod nsite;
pub mod serving; pub mod serving;
pub mod tor; pub mod tor;
@@ -45,6 +46,19 @@ pub struct Project {
pub fips_publication: Option<Publication>, pub fips_publication: Option<Publication>,
#[serde(default)] #[serde(default)]
pub tor_publication: Option<Publication>, pub tor_publication: Option<Publication>,
#[serde(default)]
pub nsite_receipt: Option<nsite::Receipt>,
#[serde(default)]
pub local_archive: Option<LocalArchive>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalArchive {
pub sha256: String,
pub size: usize,
pub pubkey: String,
pub created_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -85,6 +99,16 @@ impl Default for State {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)] #[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Change { pub enum Change {
// Only the local storage adapter can claim a verified archive receipt.
#[serde(skip_deserializing)]
RecordLocalArchive {
id: String,
receipt: LocalArchive,
},
RecordNsite {
id: String,
receipt: nsite::Receipt,
},
Connections { Connections {
routes: BTreeSet<Route>, routes: BTreeSet<Route>,
}, },
@@ -158,7 +182,7 @@ fn name(value: &str) -> Result<String> {
Ok(value.to_owned()) Ok(value.to_owned())
} }
fn public_ip(ip: std::net::IpAddr) -> bool { pub(crate) fn public_ip(ip: std::net::IpAddr) -> bool {
match ip { match ip {
std::net::IpAddr::V4(a) => { std::net::IpAddr::V4(a) => {
let o = a.octets(); let o = a.octets();
@@ -173,12 +197,15 @@ fn public_ip(ip: std::net::IpAddr) -> bool {
&& o[0] < 240 && o[0] < 240
&& !(o[0] == 100 && (64..=127).contains(&o[1])) && !(o[0] == 100 && (64..=127).contains(&o[1]))
&& !(o[0] == 198 && (o[1] == 18 || o[1] == 19)) && !(o[0] == 198 && (o[1] == 18 || o[1] == 19))
&& !(o[0] == 192 && o[1] == 0 && o[2] == 0)
} }
std::net::IpAddr::V6(a) => { std::net::IpAddr::V6(a) => {
let s = a.segments(); let s = a.segments();
// Only global unicast; excludes ULA/FIPS, mapped-v4, loopback, // Only global unicast; excludes ULA/FIPS, mapped-v4, loopback,
// multicast and link-local, plus documentation allocations. // multicast and link-local, plus documentation allocations.
(s[0] & 0xe000) == 0x2000 (s[0] & 0xe000) == 0x2000
&& !(s[0] == 0x2001 && s[1] < 0x200)
&& s[0] != 0x2002
&& !(s[0] == 0x2001 && s[1] == 0x0db8) && !(s[0] == 0x2001 && s[1] == 0x0db8)
&& !(s[0] == 0x3fff && s[1] < 0x1000) && !(s[0] == 0x3fff && s[1] < 0x1000)
} }
@@ -228,6 +255,28 @@ pub fn dns_records(domain: &Domain) -> Result<Vec<DnsRecord>> {
impl State { impl State {
pub fn apply(&mut self, change: Change) -> Result<Option<String>> { pub fn apply(&mut self, change: Change) -> Result<Option<String>> {
match change { match change {
Change::RecordLocalArchive { id, receipt } => {
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
if receipt.sha256 != nsite::hash(p.draft.as_bytes())
|| receipt.size != p.draft.len()
{
bail!("The draft changed while storing it. The stored file is retained; review the current draft");
}
p.local_archive = Some(receipt);
Ok(Some(id))
}
Change::RecordNsite { id, receipt } => {
receipt.validate(&id)?;
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
p.nsite_receipt = Some(receipt);
Ok(Some(id))
}
Change::Connections { routes } => { Change::Connections { routes } => {
self.connections = routes; self.connections = routes;
Ok(None) Ok(None)
@@ -249,6 +298,8 @@ impl State {
revisions: vec![], revisions: vec![],
fips_publication: None, fips_publication: None,
tor_publication: None, tor_publication: None,
nsite_receipt: None,
local_archive: None,
}, },
); );
Ok(Some(id)) Ok(Some(id))
@@ -275,7 +326,10 @@ impl State {
.projects .projects
.get_mut(&id) .get_mut(&id)
.context("Website project not found")?; .context("Website project not found")?;
if p.fips_publication.is_some() && !routes.contains(&Route::Fips) { if p.fips_publication.is_some()
&& !routes.contains(&Route::Fips)
&& !routes.contains(&Route::PublicWeb)
{
bail!("Unpublish the FIPS website before removing its route"); bail!("Unpublish the FIPS website before removing its route");
} }
if p.tor_publication.is_some() && !routes.contains(&Route::Tor) { if p.tor_publication.is_some() && !routes.contains(&Route::Tor) {
@@ -313,8 +367,10 @@ impl State {
.projects .projects
.get_mut(&id) .get_mut(&id)
.context("Website project not found")?; .context("Website project not found")?;
if !p.routes.contains(&Route::Fips) || p.draft.trim().is_empty() { if (!p.routes.contains(&Route::Fips) && !p.routes.contains(&Route::PublicWeb))
bail!("Save a website draft and select FIPS before publishing"); || p.draft.trim().is_empty()
{
bail!("Save a website draft and select FIPS or public web before publishing");
} }
let port = match &p.fips_publication { let port = match &p.fips_publication {
Some(old) => old.port, Some(old) => old.port,
@@ -482,6 +538,45 @@ pub async fn update(root: &Path, request: Update) -> Result<(State, Option<Strin
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn local_archive_receipts_cannot_be_claimed_by_clients_or_publish_routes() {
assert!(serde_json::from_value::<Change>(
serde_json::json!({"action":"record-local-archive", "id":"x", "receipt":{}})
)
.is_err());
let mut state = State::default();
let id = state
.apply(Change::Create {
name: "Local archive".into(),
})
.unwrap()
.unwrap();
state.projects.get_mut(&id).unwrap().draft = "<p>Private draft</p>".into();
let draft = &state.projects[&id].draft;
let mut receipt = LocalArchive {
sha256: nsite::hash(draft.as_bytes()),
size: draft.len(),
pubkey: "a".repeat(64),
created_at: chrono::Utc::now().to_rfc3339(),
};
state
.apply(Change::RecordLocalArchive {
id: id.clone(),
receipt: receipt.clone(),
})
.unwrap();
let p = &state.projects[&id];
assert!(
p.routes.is_empty()
&& p.fips_publication.is_none()
&& p.tor_publication.is_none()
&& p.nsite_receipt.is_none()
);
receipt.sha256 = "b".repeat(64);
assert!(state
.apply(Change::RecordLocalArchive { id, receipt })
.is_err());
}
#[tokio::test] #[tokio::test]
async fn concurrent_edit_is_rejected_and_project_survives_reload() { async fn concurrent_edit_is_rejected_and_project_survives_reload() {
let d = tempfile::tempdir().unwrap(); let d = tempfile::tempdir().unwrap();
@@ -545,6 +640,9 @@ mod tests {
"::1", "::1",
"192.168.1.2", "192.168.1.2",
"::ffff:8.8.8.8", "::ffff:8.8.8.8",
"2002:7f00:1::1",
"2001::1",
"192.0.0.1",
"node.fips", "node.fips",
"a.onion", "a.onion",
"example.com; bad", "example.com; bad",
@@ -570,6 +668,43 @@ mod tests {
} }
} }
#[test] #[test]
fn public_web_reuses_fips_upstream_without_requiring_a_second_route_choice() {
let mut state = State::default();
state.connections.insert(Route::PublicWeb);
let id = state
.apply(Change::Create {
name: "Public site".into(),
})
.unwrap()
.unwrap();
state.projects.get_mut(&id).unwrap().draft = "<h1>Public</h1>".into();
assert!(state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: false
})
.is_err());
state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: true,
})
.unwrap();
assert!(state.projects[&id].fips_publication.is_some());
assert!(!state.projects[&id].routes.contains(&Route::Fips));
let save = |routes| Change::Save {
id: id.clone(),
name: "Public site".into(),
routes,
domain: None,
html: "<h1>Public</h1>".into(),
};
state
.apply(save([Route::PublicWeb].into_iter().collect()))
.unwrap();
assert!(state.apply(save(BTreeSet::new())).is_err());
}
#[test]
fn multiple_routes_and_restore_do_not_publish() { fn multiple_routes_and_restore_do_not_publish() {
let mut s = State::default(); let mut s = State::default();
s.apply(Change::Connections { s.apply(Change::Connections {
+130
View File
@@ -0,0 +1,130 @@
//! NIP-5A named-site preparation only. Upload and explicit identity signing use
//! the dashboard's existing signer; this module never exports or creates keys.
use super::{Project, Route};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
pub fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
pub fn server(raw: &str) -> Result<String> {
let value = raw.trim().trim_end_matches('/');
let host = value
.strip_prefix("https://")
.ok_or_else(|| anyhow::anyhow!("Enter an HTTPS Blossom server origin"))?;
Ok(format!("https://{}", super::hostname(host)?))
}
pub fn prepare(project: &Project, blossom: &str) -> Result<Value> {
if !project.routes.contains(&Route::Nostr) || project.draft.trim().is_empty() {
bail!("Save a website draft and select Nostr before publishing");
}
let server = server(blossom)?;
// The first policy remains restrictive even if generated HTML adds another
// CSP. Hosted nsites use a separate origin, without dashboard privileges.
let html = format!("<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\"><meta name=\"referrer\" content=\"no-referrer\">{}", project.draft);
let digest = hash(html.as_bytes());
let identifier: String = project.id.chars().filter(|c| *c != '-').take(13).collect();
let aggregate = hash(format!("{digest} /index.html\n").as_bytes());
let now = chrono::Utc::now().timestamp();
Ok(json!({
"html":html, "sha256":digest, "server":server, "identifier":identifier,
"authorization": { "kind":24242, "created_at":now, "content":"Upload this website's index.html", "tags":[["t","upload"],["x",digest],["server",server.trim_start_matches("https://")],["expiration",(now+300).to_string()]] },
"manifest": { "kind":35128, "created_at":now, "content":"", "tags":[["d",identifier],["path","/index.html",digest],["x",aggregate,"aggregate"],["server",server],["title",project.name]] }
}))
}
/// A client-side delivery receipt, not a claim of gateway reachability or of
/// erasure from relays. Keep the signed event so interrupted sends can be retried.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Receipt {
pub identity_id: String,
pub server: String,
pub event: Value,
pub accepted_relays: Vec<String>,
pub deletion_requested: bool,
}
impl Receipt {
pub fn validate(&self, project_id: &str) -> Result<()> {
if self.identity_id.is_empty()
|| self.identity_id.len() > 200
|| self.accepted_relays.len() > 8
|| serde_json::to_vec(&self.event)?.len() > 16 * 1024
{
bail!("Invalid nsite receipt");
}
server(&self.server)?;
for key in ["id", "pubkey"] {
let s = self.event[key].as_str().unwrap_or("");
if s.len() != 64 || !s.bytes().all(|c| c.is_ascii_hexdigit()) {
bail!("Invalid signed nsite event");
}
}
if self.event["kind"] != 35128 {
bail!("Only named nsite receipts are supported");
}
let identifier: String = project_id.chars().filter(|c| *c != '-').take(13).collect();
let tags = self.event["tags"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("Missing nsite tags"))?;
if tags.iter().filter(|t| t[0] == "d").count() != 1
|| !tags.iter().any(|t| t == &json!(["d", identifier]))
{
bail!("Nsite receipt does not belong to this project");
}
if self
.accepted_relays
.iter()
.any(|r| !r.starts_with("wss://") || r.len() > 300 || r.chars().any(char::is_control))
{
bail!("Invalid relay receipt");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::publishing::{Change, State};
#[test]
fn named_manifest_scopes_auth_and_hashes_exact_uploaded_bytes() {
let mut state = State::default();
let id = state
.apply(Change::Create {
name: "Site".into(),
})
.unwrap()
.unwrap();
state
.apply(Change::Save {
id: id.clone(),
name: "Site".into(),
routes: [Route::Nostr].into_iter().collect(),
domain: None,
html: "<h1>Hello 🏝</h1>".into(),
})
.unwrap();
let p = prepare(&state.projects[&id], "https://blossom.example.org/").unwrap();
assert_eq!(p["sha256"], hash(p["html"].as_str().unwrap().as_bytes()));
assert_eq!(p["manifest"]["kind"], 35128);
assert_eq!(p["manifest"]["tags"][0][1].as_str().unwrap().len(), 13);
assert_eq!(
p["authorization"]["tags"][2],
json!(["server", "blossom.example.org"])
);
assert!(p["html"]
.as_str()
.unwrap()
.starts_with("<!doctype html><meta http-equiv=\"Content-Security-Policy\""));
for bad in [
"http://example.org",
"https://127.0.0.1",
"https://user:secret@example.org",
"https://example.org/path",
] {
assert!(server(bad).is_err());
}
}
}
+6
View File
@@ -93,6 +93,12 @@ impl EndpointRateLimiter {
// Identity/credential operations // Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300)); limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300)); limits.insert("identity.issue-credential".to_string(), (20, 300));
// Explicit publishing actions can allocate credentials or perform
// bounded network I/O. Saving/previewing never invokes these actions.
limits.insert("publishing.access-create".to_string(), (10, 60));
limits.insert("publishing.verify-https".to_string(), (10, 60));
limits.insert("publishing.blossom-store".to_string(), (10, 60));
limits.insert("publishing.generate".to_string(), (5, 300));
// Backup operations (resource-intensive) // Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600)); limits.insert("backup.create".to_string(), (10, 600));
limits.insert("backup.restore".to_string(), (5, 600)); limits.insert("backup.restore".to_string(), (5, 600));
+1
View File
@@ -11,6 +11,7 @@ chrono = "0.4"
hyper = { version = "0.14", features = ["full", "http1"] } hyper = { version = "0.14", features = ["full", "http1"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
sha2 = "0.10.9"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
uuid = { version = "1.0", features = ["v4"] } uuid = { version = "1.0", features = ["v4"] }
+13
View File
@@ -0,0 +1,13 @@
FROM docker.io/denoland/deno:debian-2.9.7
WORKDIR /app
# Upstream MIT source is pinned independently from the application version.
ADD https://codeload.github.com/hzrd149/blossom-server/tar.gz/a492dc61c4a581bbd0992546b2aec6f9aa543f75 /tmp/upstream.tar.gz
RUN echo 'd4f4ab9cbbf1b6d72d8cdb68fbb0b7b55dbe0c7e4a7414819f5c96dafd0af3fd /tmp/upstream.tar.gz' | sha256sum -c - && tar -xzf /tmp/upstream.tar.gz --strip-components=1 -C /app && rm /tmp/upstream.tar.gz
COPY patch.ts startup.ts ./
COPY ui/ ./archy-ui/
RUN deno run --allow-read=/app --allow-write=/app patch.ts && deno cache --frozen main.ts startup.ts && deno bundle --no-config --platform browser archy-ui/app.ts -o archy-ui/app.js
# Fetch native dependencies at build time, not on a user's first upload.
RUN deno eval 'await import("@libsql/client"); await import("sharp")'
ENV BLOSSOM_REQUIRE_CONFIG=1
EXPOSE 3000
ENTRYPOINT ["deno", "run", "--cached-only", "--frozen", "-A", "--deny-run", "/app/startup.ts"]
+32
View File
@@ -0,0 +1,32 @@
// Narrow packaging changes against the pinned upstream source. Fail instead of
// silently losing the bridge or re-enabling automatic deletion after an update.
const mainPath = '/app/main.ts';
let main = await Deno.readTextFile(mainPath);
const prune = 'const pruneEnabled = config.storage.rules.length > 0 ||\n config.storage.removeWhenNoOwners;';
if (!main.includes(prune)) throw new Error('Upstream prune integration changed');
main = main.replace(prune, '// Archipelago owns retention: no automatic deletion of published assets.\nconst pruneEnabled = false;');
await Deno.writeTextFile(mainPath, main);
const serverPath = '/app/src/server.ts';
let server = await Deno.readTextFile(serverPath);
const marker = ' app.route("/", buildBlossomRouter(db, storage, config));';
if (!server.includes(marker)) throw new Error('Upstream route integration changed');
server = server.replace(marker, `
// Uploaded HTML/SVG must never execute with the app gate or signer origin.
app.use('*', async (c, next) => {
await next();
if (/^\\/[a-f0-9]{64}(?:\\.[a-zA-Z0-9]+)?$/.test(c.req.path)) {
c.header('Content-Security-Policy', "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
c.header('Content-Disposition', 'attachment');
c.header('X-Content-Type-Options', 'nosniff');
}
});
// Static local UI and the canonical host-managed signer bridge only.
app.get('/', async c => c.html(await Deno.readTextFile('/app/archy-ui/index.html')));
app.get('/app.js', async c => c.body(await Deno.readTextFile('/app/archy-ui/app.js'), 200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' }));
app.get('/nostr-provider.js', async c => {
try { return c.body(await Deno.readTextFile('/bridge/nostr-provider.js'), 200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache, no-store, must-revalidate' }); }
catch { return c.text('Archipelago signer bridge is not installed', 503); }
});
app.get('/healthz', c => c.json({ ready: true, storage: 'local' }));
${marker}`);
await Deno.writeTextFile(serverPath, server);
+21
View File
@@ -0,0 +1,21 @@
// Public profile keys only; no private keys or dashboard credentials enter this
// container. Changes to the node's identity allowlist take effect on restart.
const keys = (Deno.env.get('ARCHY_BLOSSOM_PUBKEYS') ?? '').split(',').filter(Boolean);
if (!keys.length || keys.some(k => !/^[a-f0-9]{64}$/.test(k))) throw new Error('Create a profile identity in Archipelago before starting Blossom');
const config = JSON.parse(await Deno.readTextFile('/config/config.json'));
config.host = '0.0.0.0'; config.port = 3000;
config.database = { path: '/data/sqlite.db' };
config.storage = { backend: 'local', local: { dir: '/data/blobs' }, removeWhenNoOwners: false, rules: [{ type: '*', expiration: '100 years', pubkeys: keys }] };
config.upload = { enabled: true, requireAuth: true, requirePubkeyInRule: true, maxSize: 16777216, workers: 1 };
config.delete = { requireAuth: true };
config.list = { enabled: true, requireAuth: true, allowListOthers: false };
config.mirror = { enabled: false, requireAuth: true };
config.media = { enabled: false, requireAuth: true, requirePubkeyInRule: true };
config.report = { enabled: false };
config.landing = { enabled: false };
config.dashboard = { enabled: false };
// No URL/host facts are baked into the UI. BUD-11 uses the actual request host
// unless the operator explicitly configured the server's canonical domain.
await Deno.writeTextFile('/tmp/blossom-config.json', JSON.stringify(config));
Deno.args.splice(0, Deno.args.length, '/tmp/blossom-config.json');
await import('./main.ts');
+80
View File
@@ -0,0 +1,80 @@
import { sha256 } from 'npm:@noble/hashes@2.0.1/sha2.js';
declare global {
interface Window {
nostr?: { getPublicKey(): Promise<string>; signEvent(event: unknown): Promise<Record<string, unknown>> };
archipelagoNostr?: { selectIdentity?(): Promise<void> };
}
}
const element = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const fileInput = element<HTMLInputElement>('file');
const approve = element<HTMLInputElement>('approve');
const upload = element<HTMLButtonElement>('upload');
const refresh = element<HTMLButtonElement>('refresh');
let pubkey = '';
let working = false;
function update() {
upload.disabled = working || !pubkey || !approve.checked || !fileInput.files?.length;
refresh.disabled = working || !pubkey;
fileInput.disabled = working;
element<HTMLButtonElement>('identity').disabled = working;
}
async function perform(fn: () => Promise<void>) {
working = true; update(); element('status').textContent = '';
try { await fn(); } catch (e) { element('status').textContent = e instanceof Error ? e.message : 'Request failed'; }
finally { working = false; update(); }
}
async function auth(action: string, hash?: string) {
if (!window.nostr) throw new Error('Archipelago signer is unavailable. Reinstall the app bridge; never enter a private key here.');
if (await window.nostr.getPublicKey() !== pubkey) throw new Error('Identity changed. Choose your identity and review the file again.');
const now = Math.floor(Date.now() / 1000);
const tags = [['t', action], ['expiration', String(now + 300)], ['server', location.hostname]];
if (hash) tags.push(['x', hash]);
const signed = await window.nostr.signEvent({ kind: 24242, created_at: now, tags, content: `Authorize local Blossom ${action}` });
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 () => {
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();
});
fileInput.onchange = () => {
approve.checked = false;
const file = fileInput.files?.[0];
element('file-review').textContent = file ? `${file.name} · ${file.size} bytes · ${file.type || 'unknown type'}` : 'Choose a file up to 16 MiB.';
update();
};
approve.onchange = update;
upload.onclick = () => perform(async () => {
const file = fileInput.files?.[0];
if (!file || !approve.checked || file.size > 16777216) throw new Error('Choose and approve a file up to 16 MiB');
const bytes = new Uint8Array(await file.arrayBuffer());
const hash = Array.from(sha256(bytes), b => b.toString(16).padStart(2, '0')).join('');
const authorization = await auth('upload', hash);
const response = await fetch('/upload', { method: 'PUT', headers: { Authorization: authorization, 'Content-Type': file.type || 'application/octet-stream' }, body: bytes, credentials: 'same-origin', redirect: 'error' });
if (!response.ok) throw new Error(`Local upload failed (${response.status}). No external copy was requested.`);
const descriptor = await response.json();
if (descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw new Error('Storage returned an unexpected file descriptor');
approve.checked = false;
element('status').textContent = `Stored on this node. SHA-256: ${hash}. No Nostr announcement was published.`;
});
refresh.onclick = () => perform(async () => {
const authorization = await auth('list');
const response = await fetch(`/list/${pubkey}?limit=100`, { headers: { Authorization: authorization }, credentials: 'same-origin', redirect: 'error' });
if (!response.ok) throw new Error(`Could not list files (${response.status})`);
const files = await response.json();
if (!Array.isArray(files)) throw new Error('Unexpected file list');
const list = element('files'); list.replaceChildren();
for (const file of files.slice(0, 100)) {
if (!/^[a-f0-9]{64}$/.test(file.sha256)) continue;
const item = document.createElement('li');
const link = document.createElement('a');
link.href = '/' + file.sha256; link.download = file.sha256;
link.textContent = `${file.sha256} · ${file.size} bytes`;
item.append(link); list.append(item);
}
});
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src http: https:; base-uri 'none'; form-action 'none'"><title>Blossom · Archipelago</title><script src="/nostr-provider.js?v=tab-signer-v4"></script><script type="module" src="/app.js"></script><style>*{box-sizing:border-box}input{max-width:100%}body{font:16px system-ui;background:#11151c;color:#eee;max-width:760px;margin:auto;padding:32px}h1{font-size:32px}section{padding:24px;border:1px solid #384150;border-radius:16px;margin:20px 0}button,input{font:inherit}button{padding:10px 16px;border:0;border-radius:8px;background:#d9a86c;color:#161616;cursor:pointer}button:disabled{opacity:.45;cursor:default}p{line-height:1.5;color:#c8ccd4;overflow-wrap:anywhere}li{overflow-wrap:anywhere}code{overflow-wrap:anywhere}label{display:block;margin:16px 0}a{color:#e9b983}#status{white-space:pre-wrap}</style></head><body><h1>Blossom on your node</h1><p>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.</p><section><h2>Your identity</h2><button id="identity">Choose identity</button><p id="pubkey">Choose a profile identity to manage its files.</p><p>After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.</p></section><section><h2>Store a file</h2><input id="file" type="file"><p id="file-review">Choose a file up to 16 MiB. Review it before storing.</p><label><input id="approve" type="checkbox"> I want to store this exact file on this node.</label><button id="upload" disabled>Store locally</button><p>External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.</p></section><section><h2>Your files</h2><button id="refresh" disabled>Load my files</button><ul id="files"></ul></section><p id="status" role="status"></p></body></html>
+16
View File
@@ -173,6 +173,22 @@ override wins over the manifest in both directions and applies on the next
request — your app cannot assume the gate is or isn't in front of it, so it request — your app cannot assume the gate is or isn't in front of it, so it
must always enforce its own authorization for sensitive operations. must always enforce its own authorization for sensitive operations.
## Optional guest access
`metadata.guest_access: true` opts an application into Setup's expiring,
revocable app-only access credentials. It requires an explicitly declared gated
port, an enabled AppGate, and no `session_passthrough`. The signed catalog remains
authoritative for catalog apps; a disk manifest cannot override its policy.
Wallets, signing surfaces and node administration apps must not opt in.
A guest credential opens only the selected application, never dashboard login or
RPC. The application must still enforce its own accounts and permissions. Guest
credentials expire after the operator-selected interval (one hour to 30 days)
and can be revoked. Every subsequent HTTP request checks current scope, expiry
and revocation; an already established stream or WebSocket is not disconnected
by this first implementation. AppGate strips guest credentials before proxying.
Do not treat the guest gate as authorization for an application's internal API.
## Launch metadata ## Launch metadata
`metadata.launch` is consumed by catalog generation and the dashboard `metadata.launch` is consumed by catalog generation and the dashboard
+54 -4
View File
@@ -37,8 +37,11 @@ FIPS/onion addresses do not require a purchased domain. No automatic purchases.
AIUI creates node-owned static website projects with isolated previews, revisions, AIUI creates node-owned static website projects with isolated previews, revisions,
download, publish, rollback and unpublish. Local/open model operation is supported; download, publish, rollback and unpublish. Local/open model operation is supported;
no silent fallback to a proprietary model. A published website has a separate no silent fallback to a proprietary model. A published website has a separate
origin from management and cannot receive dashboard cookies, signing authority or origin from management and cannot read dashboard cookies or access signing
RPC access. Public copies may survive unpublishing from Nostr/Blossom. authority or RPC. Direct FIPS ports share a hostname, so a browser may send
host cookies to the trusted static handler; it neither reflects nor forwards
them, and published HTML runs under a script-blocking sandbox policy. Public
copies may survive unpublishing from Nostr/Blossom.
The user also requested removal of the File Browser Setup card because the app The user also requested removal of the File Browser Setup card because the app
is already bundled in the ISO. Keep the installed app and launcher unchanged. is already bundled in the ISO. Keep the installed app and launcher unchanged.
@@ -59,13 +62,60 @@ is already bundled in the ISO. Keep the installed app and launcher unchanged.
- [ ] Framework acceptance with confirmed identity, access and release coordination. - [ ] Framework acceptance with confirmed identity, access and release coordination.
- [ ] ngit review and exact accepted-commit mirror parity before any release. - [ ] ngit review and exact accepted-commit mirror parity before any release.
The user authorized Framework as a test node if deployment is needed. Access and The user authorized Framework as a free test node and a separate test proxy route
current release-agent reservation must be confirmed before live work. Preserve all on Yaya. Access has been verified on both actual nodes. Preserve all
wallet/channel/app data. Source tests are not node acceptance. Backend unit tests wallet/channel/app data. Source tests are not node acceptance. Backend unit tests
run only through `scripts/test-backend-isolated.sh`; use a worktree-local target. run only through `scripts/test-backend-isolated.sh`; use a worktree-local target.
### Current integration checkpoint
Blossom is installed and healthy on Framework through the normal app installer.
Protocol, real HTTP/HTTPS tab signing and lifecycle/data-preservation evidence is
recorded in `apps/blossom/README.md`. The combined dashboard/backend candidate has
not yet been deployed. No public Nostr test events or external file replicas have
been created. The temporary public proxy route and certificate were removed after
their standalone acceptance checks.
New source work includes local Blossom website archives, explicit app-only guest
credentials, and an on-demand HTTPS check against exact published page bytes.
Guest tokens cannot authenticate to node login; scope/expiry are checked on each
request, and revocation affects subsequent requests, not established streams.
Only opted-in gated app manifests expose guest access. Persistent credentials use
serialized, atomic 0600 writes and refuse corruption/capacity without evicting an
existing device. HTTPS checks pin validated public DNS addresses, validate TLS,
refuse redirects/proxies and bound response reads. They are point-in-time checks
from the node, not proof of outside-device access or future certificate renewal.
Public-web projects can explicitly publish a FIPS upstream for an existing proxy
without selecting FIPS again. The confirmation still explains its FIPS visibility.
Automated frp enrollment/end-to-node TLS and selective local public Blossom assets
remain unfinished. Source validation and standalone routes must not be described
as acceptance of those features or of the complete dashboard journey.
The current dashboard production build and supported AIUI build both pass and
are staged separately on Framework. The original backend and full web tree are
backed up for rollback; the live dashboard has not been switched. The selected
dashboard suite passed 36 tests; subsequent HTTPS UI coverage passed six tests,
and tightened Nostr signing/receipt coverage passed 12 tests. The latest combined
18-test run, TypeScript check and dashboard rebuild passed. Catalog drift is zero
(37 catalog entries, 64 manifests). Full isolated backend validation now passes
1,699 tests, zero failures and four explicit ignores. The focused app-gate run
passes 53 tests, and all three credential tests pass. The deployable backend build
is still pending at this checkpoint; passing tests is not live-node acceptance.
## Development evidence (2026-10-08, not release acceptance) ## Development evidence (2026-10-08, not release acceptance)
Latest addition: [Blossom candidate package and acceptance ledger](../apps/blossom/README.md).
Setup offers catalogue installation and skips that prompt for installed Blossom.
The candidate is built and protocol-tested on Framework, and normal installation
and the real HTTPS tab signer work. Further lifecycle acceptance is in progress.
The operator temporarily disabled dashboard 2FA for tests; restore it afterwards.
Nostr publication now includes a local
preparation/review step showing exact HTML, hash, identity, manifest and destinations;
upload and announcement require explicit consent. No public Nostr events or external
Blossom uploads have been performed. Local Blossom website-asset integration remains
outstanding. Earlier evidence below records its own point in development.
The isolated branch now contains versioned node-owned projects, multi-route The isolated branch now contains versioned node-owned projects, multi-route
preferences, both Setup screens, local Ollama draft generation, sandboxed static preferences, both Setup screens, local Ollama draft generation, sandboxed static
previews, revision restore and FIPS-only static publication/revocation. AIUI can previews, revision restore and FIPS-only static publication/revocation. AIUI can
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="28" fill="#191c28"/><g fill="#dca6c6"><ellipse cx="64" cy="40" rx="17" ry="24"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(72 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(144 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(216 64 64)"/><ellipse cx="64" cy="40" rx="17" ry="24" transform="rotate(288 64 64)"/></g><circle cx="64" cy="64" r="13" fill="#f1cf86"/></svg>

After

Width:  |  Height:  |  Size: 522 B

+13
View File
@@ -673,6 +673,19 @@
"tier": "optional", "tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png", "icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry" "repoUrl": "https://github.com/hoytech/strfry"
},
{
"id": "blossom",
"author": "hzrd149 / Archipelago",
"requires": [],
"tier": "optional",
"title": "Blossom",
"version": "6.4.1-archy.1",
"description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.",
"dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1",
"category": "data",
"repoUrl": "https://github.com/hzrd149/blossom-server",
"icon": "/assets/img/app-icons/blossom.svg"
} }
] ]
} }
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../publishing', () => ({ publishing: { status: vi.fn(), update: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import { publishing } from '../publishing'
import { namedNsiteUrl, prepareNsite, publishNsite, relayAddresses, retryNsite, requestNsiteDeletion, nsiteIdentities, storeLocalWebsite } from '../nsitePublishing'
import type { NsiteReceipt, SignedNsiteEvent } from '../nsitePublishing'
const identity = { id: 'profile', name: 'Me', nostr_pubkey: 'a'.repeat(64), is_node: false }
const event: SignedNsiteEvent = { id: 'b'.repeat(64), pubkey: identity.nostr_pubkey, kind: 35128, created_at: 1, tags: [['d', 'website123']], content: '', sig: 'c'.repeat(128) }
const receipt: NsiteReceipt = { identity_id: identity.id, server: 'https://blossom.example', event, accepted_relays: [], deletion_requested: false }
let accept = true
class Socket {
onopen?: () => void
onmessage?: (event: { data: string }) => void
onerror?: () => void
onclose?: () => void
constructor() { queueMicrotask(() => this.onopen?.()) }
send(raw: string) {
const sent = JSON.parse(raw)[1]
queueMicrotask(() => {
this.onmessage?.({ data: JSON.stringify(['OK', 'unrelated-id', true]) })
this.onmessage?.({ data: JSON.stringify(['OK', sent.id, accept]) })
})
}
close() {}
}
const prepared = { html: '<h1>Hello 🏝</h1>', sha256: 'd'.repeat(64), server: receipt.server, identifier: 'website123', authorization: { kind: 24242, tags: [['expiration', String(Math.floor(Date.now() / 1000) + 300)]] }, manifest: { ...event } }
async function publishReviewed(html: string) {
const p = await prepareNsite('project', 4, receipt.server, html)
return publishNsite('project', 4, identity, ['wss://relay.example'], p)
}
beforeEach(() => {
vi.clearAllMocks(); accept = true
vi.stubGlobal('WebSocket', Socket)
vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4 } } as never)
vi.mocked(publishing.update).mockResolvedValue({} as never)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.nsite-prepare') return prepared as never
if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never
if (request.method === 'identity.list') return { identities: [identity, { ...identity, id: 'node', is_node: true }] } as never
throw new Error('Unexpected RPC')
})
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }), { status: 201 })).mockResolvedValueOnce(new Response(prepared.html)))
})
describe('named nsite publishing', () => {
it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => {
const socket = vi.fn()
vi.stubGlobal('WebSocket', socket)
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'publishing.blossom-prepare') return { authorization: prepared.authorization } as never
if (request.method === 'identity.nostr-sign') return { ...event, ...(request.params as {event: object}).event } as never
if (request.method === 'publishing.blossom-store') return {} as never
throw new Error('Unexpected RPC')
})
await storeLocalWebsite('project', 4, identity)
expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.blossom-prepare', 'identity.nostr-sign', 'publishing.blossom-store'])
expect(fetch).not.toHaveBeenCalled()
expect(socket).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
await expect(storeLocalWebsite('project', 4, { ...identity, is_node: true })).rejects.toThrow('profile identity')
})
it('uses profile identities and rejects insecure relay URLs', async () => {
expect(await nsiteIdentities()).toEqual([identity])
expect(relayAddresses('wss://relay.example wss://relay.example')).toEqual(['wss://relay.example/'])
for (const value of ['ws://relay.example', 'wss://user:secret@relay.example', 'wss://relay.example/#key']) expect(() => relayAddresses(value)).toThrow()
})
it('preparation never signs, uploads or broadcasts', async () => {
await prepareNsite('project', 4, receipt.server, '<h1>Private draft</h1>')
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.map(([r]) => r.method)).toEqual(['publishing.nsite-prepare'])
})
it('refuses stale reviews before signing or uploading', async () => {
await expect(publishNsite('project', 3, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed after review')
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('uploads exact UTF-8 bytes, scopes signing to the chosen profile, and records delivery', async () => {
const result = await publishReviewed( '<meta http-equiv="refresh" content="0;url=https://tracker.example"><script>bad()</script><h1>Draft</h1>')
expect(result.accepted_relays).toEqual(['wss://relay.example'])
const prep = vi.mocked(rpcClient.call).mock.calls[0]![0].params as { html: string }
expect(prep.html).not.toContain('<script')
expect(prep.html).not.toContain('http-equiv')
expect(prep.html).toContain('<h1>Draft</h1>')
expect(fetch).toHaveBeenNthCalledWith(1, `${receipt.server}/upload`, expect.objectContaining({ method: 'PUT', credentials: 'omit', redirect: 'error', body: prepared.html }))
expect(publishing.update).toHaveBeenCalledTimes(2)
expect(vi.mocked(publishing.update).mock.calls[0]![1]).toMatchObject({ receipt: { accepted_relays: [] } })
expect(vi.mocked(publishing.update).mock.calls[1]![1]).toMatchObject({ receipt: { accepted_relays: ['wss://relay.example'] } })
const signs = vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'identity.nostr-sign')
expect(signs.every(([r]) => r.params?.id === identity.id)).toBe(true)
})
it('never pays or announces when storage requires payment', async () => {
vi.mocked(fetch).mockReset().mockResolvedValue(new Response('', { status: 402 }))
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('No payment was made')
expect(publishing.update).not.toHaveBeenCalled()
})
it('rejects altered signer output before upload or relay delivery', async () => {
vi.mocked(rpcClient.call).mockImplementation(async request => {
if (request.method === 'identity.nostr-sign') return { ...event, ...prepared.authorization, tags: [['t', 'upload']] } as never
throw new Error('Unexpected RPC')
})
await expect(publishNsite('project', 4, identity, ['wss://relay.example'], prepared)).rejects.toThrow('changed the reviewed event')
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).not.toHaveBeenCalled()
})
it('bounds untrusted upload receipts before announcing', async () => {
vi.mocked(fetch).mockReset().mockResolvedValue(new Response(' '.repeat(8193)))
await expect(publishReviewed('<h1>Draft</h1>')).rejects.toThrow('size limit')
expect(publishing.update).not.toHaveBeenCalled()
})
it('does not announce if the server changes uploaded bytes', async () => {
vi.mocked(fetch).mockReset().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }))).mockResolvedValueOnce(new Response('different'))
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('different website bytes')
expect(publishing.update).not.toHaveBeenCalled()
})
it('retains a pending manifest on rejection and retries without signing or uploading', async () => {
accept = false
await expect(publishReviewed( '<h1>Draft</h1>')).rejects.toThrow('No relay accepted')
vi.clearAllMocks(); accept = true
const result = await retryNsite('project', receipt, ['wss://relay.example'])
expect(result.accepted_relays).toHaveLength(1)
expect(fetch).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('requests deletion with the publishing identity without deleting shared blobs', async () => {
await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example'])
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { id: identity.id, event: expect.objectContaining({ kind: 5, tags: expect.arrayContaining([['e', event.id], ['a', `35128:${event.pubkey}:website123`]]) }) } }))
expect(fetch).not.toHaveBeenCalled()
expect(publishing.update).toHaveBeenCalledWith(4, expect.objectContaining({ receipt: expect.objectContaining({ deletion_requested: true }) }))
})
it('builds a portable named-site gateway URL without overwriting the root site', () => {
const url = new URL(namedNsiteUrl(receipt, 'https://gateway.example'))
expect(url.hostname.split('.')[0]).toHaveLength(50 + 'website123'.length)
expect(url.hostname).toContain('website123.gateway.example')
expect(() => namedNsiteUrl(receipt, 'http://gateway.example')).toThrow()
})
})
+151
View File
@@ -0,0 +1,151 @@
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
import { publishing } from './publishing'
export interface SignedNsiteEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string }
export interface NsiteReceipt { identity_id: string; server: string; event: SignedNsiteEvent; accepted_relays: string[]; deletion_requested: boolean }
export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean }
export interface PreparedNsite { html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
export function relayAddresses(raw: string): string[] {
const list = [...new Set(raw.split(/[\s,]+/).filter(Boolean).map(value => {
const url = new URL(value)
if (url.protocol !== 'wss:' || url.username || url.password || url.hash || value.length > 300) throw new Error('Use secure wss:// relay URLs without credentials or fragments')
return url.href
}))]
if (!list.length || list.length > 8) throw new Error('Choose between one and eight relays')
return list
}
export async function nsiteIdentities(): Promise<NsiteIdentity[]> {
const data = await rpcClient.call<{ identities: NsiteIdentity[] }>({ method: 'identity.list', maxRetries: 0 })
return data.identities.filter(i => !i.is_node && i.nostr_pubkey)
}
async function sign(identity: NsiteIdentity, event: Record<string, unknown>): Promise<SignedNsiteEvent> {
const signed = await rpcClient.call<SignedNsiteEvent>({ method: 'identity.nostr-sign', params: { id: identity.id, event }, maxRetries: 0 })
if (signed.pubkey !== identity.nostr_pubkey || signed.kind !== event.kind) throw new Error('Signer returned a different identity or event kind')
for (const key of ['created_at', 'content', 'tags'] as const) {
if (event[key] !== undefined && JSON.stringify(signed[key]) !== JSON.stringify(event[key])) throw new Error('Signer changed the reviewed event; this event will not be sent')
}
return signed
}
export async function storeLocalWebsite(projectId: string, version: number, identity: NsiteIdentity): Promise<void> {
if (identity.is_node) throw new Error('Choose a profile identity for local files')
const prepared = await rpcClient.call<{ authorization: Record<string, unknown> }>({ method: 'publishing.blossom-prepare', params: { id: projectId, version }, maxRetries: 0 })
const authorization = await sign(identity, prepared.authorization)
await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization }, timeout: 70000, maxRetries: 0 })
}
export function sendToRelay(url: string, event: SignedNsiteEvent): Promise<boolean> {
return new Promise(resolve => {
let socket: WebSocket
try { socket = new WebSocket(url) } catch { resolve(false); return }
let settled = false
const finish = (ok: boolean) => { if (settled) return; settled = true; clearTimeout(timer); socket.close(); resolve(ok) }
const timer = setTimeout(() => finish(false), 15000)
socket.onopen = () => socket.send(JSON.stringify(['EVENT', event]))
socket.onerror = () => finish(false)
socket.onclose = () => finish(false)
socket.onmessage = message => {
if (typeof message.data !== 'string' || message.data.length > 65536) return
try {
const reply = JSON.parse(message.data)
if (reply[0] === 'OK' && reply[1] === event.id) finish(reply[2] === true)
} catch { /* Ignore unrelated relay messages. */ }
}
})
}
async function broadcast(event: SignedNsiteEvent, relays: string[]): Promise<string[]> {
const result = await Promise.all(relays.map(async relay => ({ relay, ok: await sendToRelay(relay, event) })))
return result.filter(r => r.ok).map(r => r.relay)
}
async function record(projectId: string, receipt: NsiteReceipt): Promise<void> {
// Persist this operation's receipt without overwriting a newer draft or other
// transport. Only retry the optimistic conflict, never upload/sign/broadcast.
for (let attempt = 0; attempt < 3; attempt++) {
const status = await publishing.status()
try { await publishing.update(status.state.version, { action: 'record-nsite', id: projectId, receipt }); return }
catch (error) {
if (attempt === 2 || !(error instanceof Error) || !error.message.includes('changed')) throw error
}
}
}
async function readback(response: Response, expected: Uint8Array): Promise<void> {
if (!response.ok || !response.body) throw new Error('Uploaded website could not be fetched back')
const reader = response.body.getReader()
let offset = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (offset + value.length > expected.length || value.some((byte, index) => byte !== expected[offset + index])) throw new Error('Blossom returned different website bytes')
offset += value.length
}
if (offset !== expected.length) throw new Error('Blossom returned an incomplete website')
} finally { await reader.cancel() }
}
async function uploadDescriptor(response: Response): Promise<{ sha256: string; size: number }> {
if (!response.body) throw new Error('Blossom upload receipt is empty')
const reader = response.body.getReader()
const bytes = new Uint8Array(8192)
let size = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (size + value.length > bytes.length) throw new Error('Blossom upload receipt exceeds the size limit')
bytes.set(value, size); size += value.length
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)))
} finally { await reader.cancel() }
}
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string): Promise<PreparedNsite> {
return await rpcClient.call<PreparedNsite>({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 })
}
export async function publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise<NsiteReceipt> {
if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity')
const current = await publishing.status()
if (current.state.version !== version) throw new Error('The project changed after review. Review the publication again.')
const expiry = (p.authorization.tags as string[][] | undefined)?.find(t => t[0] === 'expiration')?.[1]
if (!expiry || Number(expiry) <= Date.now() / 1000) throw new Error('The review expired. Prepare and review the publication again.')
const authorization = await sign(identity, p.authorization)
const bytes = new TextEncoder().encode(p.html)
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization))))
const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html })
if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.')
if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`)
const descriptor = await uploadDescriptor(uploaded)
if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.')
await readback(await fetch(`${p.server}/${p.sha256}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), bytes)
const event = await sign(identity, p.manifest)
const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false }
// Save the exact signed manifest before network delivery, for recovery.
await record(projectId, receipt)
const delivered = { ...receipt, accepted_relays: await broadcast(event, relays) }
await record(projectId, delivered)
if (!delivered.accepted_relays.length) throw new Error('No relay accepted the manifest. The upload and signed manifest were retained; retry delivery from this project.')
return delivered
}
export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise<NsiteReceipt> {
if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request')
const accepted = await broadcast(receipt.event, relays)
const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] }
await record(projectId, next)
if (!accepted.length) throw new Error('No relay accepted this delivery attempt')
return next
}
export async function requestNsiteDeletion(projectId: string, receipt: NsiteReceipt, identity: NsiteIdentity, relays: string[]): Promise<void> {
if (identity.id !== receipt.identity_id || identity.nostr_pubkey !== receipt.event.pubkey) throw new Error('Choose the identity that published this nsite')
const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1]
if (!identifier) throw new Error('Missing named-site identifier')
const event = await sign(identity, { kind: 5, created_at: Math.floor(Date.now() / 1000), content: 'Remove this website manifest', tags: [['e', receipt.event.id], ['a', `35128:${receipt.event.pubkey}:${identifier}`], ['k', '35128']] })
const accepted = await broadcast(event, [...new Set([...receipt.accepted_relays, ...relays])])
if (!accepted.length) throw new Error('No relay accepted the deletion request. The nsite may remain available.')
await record(projectId, { ...receipt, deletion_requested: true })
}
export function namedNsiteUrl(receipt: NsiteReceipt, gateway: string): string {
const url = new URL(gateway)
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.pathname !== '/') throw new Error('Enter the gateway HTTPS origin without a path')
const identifier = receipt.event.tags.find(t => t[0] === 'd')?.[1] ?? ''
if (!/^[a-z0-9-]{1,13}$/.test(identifier) || identifier.endsWith('-') || !/^[a-f0-9]{64}$/.test(receipt.event.pubkey)) throw new Error('Invalid named nsite')
const author = BigInt(`0x${receipt.event.pubkey}`).toString(36).padStart(50, '0')
return `https://${author}${identifier}.${url.hostname}/`
}
+8 -1
View File
@@ -1,4 +1,5 @@
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import type { NsiteReceipt } from './nsitePublishing'
export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr' export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr'
export interface PublishDomain { hostname: string; destination: string | null } export interface PublishDomain { hostname: string; destination: string | null }
@@ -8,6 +9,8 @@ export interface WebsiteProject {
draft: string; revisions: WebsiteRevision[] draft: string; revisions: WebsiteRevision[]
fips_publication?: { port: number; html: string; created_at: string } | null fips_publication?: { port: number; html: string; created_at: string } | null
tor_publication?: { port: number; html: string; created_at: string } | null tor_publication?: { port: number; html: string; created_at: string } | null
nsite_receipt?: NsiteReceipt | null
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
} }
export interface PublishingState { export interface PublishingState {
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject> schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
@@ -16,12 +19,15 @@ export interface PublishingStatus {
state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[] listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[] onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean }[] nostr_relays?: string[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[]
grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[]
} }
export interface DnsPlan { export interface DnsPlan {
records: { record_type: string; name: string; value: string; ttl: number }[] records: { record_type: string; name: string; value: string; ttl: number }[]
verified: boolean; notes: string[]; instructions_url: string verified: boolean; notes: string[]; instructions_url: string
} }
export interface HttpsCheck { hostname: string; sha256: string; checked_at: string }
export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [ export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [
{ id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' }, { id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' },
{ id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' }, { id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' },
@@ -30,6 +36,7 @@ export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: str
] ]
export const publishing = { export const publishing = {
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }), status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
verifyHttps: (id: string, version: number) => rpcClient.call<HttpsCheck>({ method: 'publishing.verify-https', params: { id, version }, timeout: 30000, maxRetries: 0 }),
update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({ update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({
method: 'publishing.update', params: { version, change }, maxRetries: 0, method: 'publishing.update', params: { version, change }, maxRetries: 0,
}), }),
@@ -81,6 +81,7 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
*/ */
const PRE_CATALOG_GATED_PORTS: Record<string, number> = { const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
'archipelago-source': 8337, 'archipelago-source': 8337,
'blossom': GENERATED_APP_PORTS.blossom,
} }
export function appPortIsGateFronted(appId: string, port: number | string): boolean { export function appPortIsGateFronted(appId: string, port: number | string): boolean {
@@ -7,6 +7,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"archy-mempool-web": 4080, "archy-mempool-web": 4080,
"archy-nbxplorer": 32838, "archy-nbxplorer": 32838,
"bitcoin-ui": 8334, "bitcoin-ui": 8334,
"blossom": 8191,
"botfights": 9100, "botfights": 9100,
"btcpay-server": 23000, "btcpay-server": 23000,
"cuprate-ui": 18091, "cuprate-ui": 18091,
@@ -53,6 +54,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"bitcoin-core": "Bitcoin Core", "bitcoin-core": "Bitcoin Core",
"bitcoin-knots": "Bitcoin Knots", "bitcoin-knots": "Bitcoin Knots",
"bitcoin-ui": "Bitcoin UI", "bitcoin-ui": "Bitcoin UI",
"blossom": "Blossom",
"botfights": "BotFights", "botfights": "BotFights",
"btcpay-server": "BTCPay Server", "btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)", "core-lightning": "Core Lightning (CLN)",
@@ -1,11 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { pendingWebsiteHtml } from '@/services/websiteImport' import { pendingWebsiteHtml } from '@/services/websiteImport'
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing' import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
import type { DnsPlan, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing' import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
import { nsiteIdentities, prepareNsite, publishNsite, retryNsite, requestNsiteDeletion, namedNsiteUrl, relayAddresses, storeLocalWebsite } from '@/services/nsitePublishing'
import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
const route = useRoute() const route = useRoute()
const appStore = useAppStore()
const blossomInstalled = computed(() => !!appStore.data?.['package-data']?.blossom)
const websiteMode = computed(() => route.name === 'publish-website') const websiteMode = computed(() => route.name === 'publish-website')
const status = ref<PublishingStatus | null>(null) const status = ref<PublishingStatus | null>(null)
const error = ref('') const error = ref('')
@@ -20,8 +26,27 @@ const destination = ref('')
const prompt = ref('') const prompt = ref('')
const model = ref('') const model = ref('')
const dns = ref<DnsPlan | null>(null) const dns = ref<DnsPlan | null>(null)
const httpsCheck = ref<HttpsCheck | null>(null)
watch([projectId, hostname, destination, () => status.value?.state.version], () => { httpsCheck.value = null })
const acknowledgeFips = ref(false) const acknowledgeFips = ref(false)
const acknowledgeTor = ref(false) const acknowledgeTor = ref(false)
const identities = ref<NsiteIdentity[]>([])
const identityId = ref('')
const blossom = ref('')
const relays = ref('')
const gateway = ref('')
const nsiteUrl = ref('')
const guestApp = ref('')
const guestLabel = ref('Guest')
const guestHours = ref(24)
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
onDeactivated(() => { issuedAccess.value = null })
onBeforeUnmount(() => { issuedAccess.value = null })
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
const acknowledgeNostr = ref(false)
const acknowledgeUpload = ref(false)
const nsiteReview = ref<{ projectId: string; version: number; identity: NsiteIdentity; relays: string[]; prepared: PreparedNsite } | null>(null)
const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value)) const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value))
const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value)) const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value))
const current = computed(() => status.value?.state.projects[projectId.value]) const current = computed(() => status.value?.state.projects[projectId.value])
@@ -39,10 +64,12 @@ async function perform(work: () => Promise<void>) {
function selectProject(p: WebsiteProject) { function selectProject(p: WebsiteProject) {
projectId.value = p.id; name.value = p.name; selected.value = [...p.routes] projectId.value = p.id; name.value = p.name; selected.value = [...p.routes]
html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false
identityId.value = p.nsite_receipt?.identity_id ?? ''; blossom.value = p.nsite_receipt?.server ?? ''; acknowledgeNostr.value = false; nsiteUrl.value = ''
} }
async function refresh() { async function refresh() {
await perform(async () => { await perform(async () => {
status.value = await publishing.status() status.value = await publishing.status()
if (!relays.value) relays.value = (status.value.nostr_relays ?? []).join('\n')
if (!websiteMode.value) selected.value = [...status.value.state.connections] if (!websiteMode.value) selected.value = [...status.value.state.connections]
else if (current.value) selectProject(current.value) else if (current.value) selectProject(current.value)
else if (projects.value[0]) selectProject(projects.value[0]) else if (projects.value[0]) selectProject(projects.value[0])
@@ -81,6 +108,7 @@ async function save() {
} : { action: 'connections', routes: selected.value } } : { action: 'connections', routes: selected.value }
const result = await publishing.update(status.value.state.version, change) const result = await publishing.update(status.value.state.version, change)
status.value.state = result.state status.value.state = result.state
if (websiteMode.value) hostname.value = current.value?.domain?.hostname ?? ''
message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.' message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.'
}) })
} }
@@ -127,6 +155,82 @@ async function setTorPublication(enable: boolean) {
async function prepareDns() { async function prepareDns() {
await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) }) await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) })
} }
async function verifyHttps() {
await perform(async () => {
httpsCheck.value = null
if (!status.value) return
httpsCheck.value = await publishing.verifyHttps(projectId.value, status.value.state.version)
})
}
async function loadIdentities() { await perform(async () => { identities.value = await nsiteIdentities() }) }
async function createGuestAccess() {
await perform(async () => {
issuedAccess.value = null
issuedAccess.value = await rpcClient.call({ method: 'publishing.access-create', params: { app_id: guestApp.value, label: guestLabel.value, hours: guestHours.value }, maxRetries: 0 })
status.value = await publishing.status()
message.value = 'App-only access created. Copy the token now; it cannot be shown again.'
})
}
async function revokeGuestAccess(id: string) {
await perform(async () => {
await rpcClient.call({ method: 'publishing.access-revoke', params: { id }, maxRetries: 0 })
if (issuedAccess.value?.id === id) issuedAccess.value = null
status.value = await publishing.status()
message.value = 'Access revoked for new requests. Content already downloaded cannot be recalled.'
})
}
async function storeLocally() {
await perform(async () => {
const identity = identities.value.find(i => i.id === identityId.value)
if (!identity || !status.value || !current.value) throw new Error('Choose a profile identity and save a draft first')
await storeLocalWebsite(projectId.value, status.value.state.version, identity)
status.value = await publishing.status()
message.value = 'Saved draft stored in local Blossom and fetched back to verify its bytes. Nothing was announced or replicated externally.'
})
}
watch([projectId, blossom, relays, identityId, html, selected], () => { nsiteReview.value = null; acknowledgeNostr.value = false; acknowledgeUpload.value = false }, { deep: true })
async function reviewNsite() {
await perform(async () => {
if (!status.value || !current.value) return
const identity = identities.value.find(i => i.id === identityId.value)
if (!identity) throw new Error('Choose a profile identity first')
const targets = relayAddresses(relays.value)
const prepared = await prepareNsite(projectId.value, status.value.state.version, blossom.value, current.value.draft)
nsiteReview.value = { projectId: projectId.value, version: status.value.state.version, identity: { ...identity }, relays: [...targets], prepared }
acknowledgeNostr.value = false; acknowledgeUpload.value = false
})
}
async function handleNsite(action: 'publish' | 'retry' | 'delete') {
await perform(async () => {
if (!status.value || !current.value || !acknowledgeNostr.value) return
const targets = relayAddresses(relays.value)
const identity = identities.value.find(i => i.id === identityId.value)
const receipt = current.value.nsite_receipt
try {
if (action === 'retry' && receipt) await retryNsite(projectId.value, receipt, targets)
else {
if (!identity) throw new Error('Load identities and choose the profile identity you want to use')
if (action === 'delete' && receipt) await requestNsiteDeletion(projectId.value, receipt, identity, targets)
else {
const review = nsiteReview.value
if (!review || !acknowledgeUpload.value) throw new Error('Review the exact upload and explicitly approve replication first')
await publishNsite(review.projectId, review.version, review.identity, review.relays, review.prepared)
}
}
} finally {
acknowledgeNostr.value = false; acknowledgeUpload.value = false; nsiteReview.value = null
// A failed relay delivery can still leave a durable upload/manifest. Show
// that receipt so retry never silently uploads or signs a second copy.
status.value = await publishing.status()
}
message.value = action === 'delete' ? 'A relay accepted the deletion request. Other relays, Blossom servers and cached copies may retain the website.' : 'The uploaded bytes were checked and a relay accepted the named-site manifest. Gateway availability still needs checking.'
})
}
async function showNsiteAddress() {
await perform(async () => {
if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value)
})
}
function download() { function download() {
const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' })) const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' }))
const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click() const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click()
@@ -146,7 +250,28 @@ onMounted(refresh)
<p v-if="error" role="alert" class="rounded-xl p-4 bg-red-500/10 text-red-200">{{ error }}</p> <p v-if="error" role="alert" class="rounded-xl p-4 bg-red-500/10 text-red-200">{{ error }}</p>
<p v-if="message" role="status" class="rounded-xl p-4 bg-green-500/10 text-green-200">{{ message }}</p> <p v-if="message" role="status" class="rounded-xl p-4 bg-green-500/10 text-green-200">{{ message }}</p>
<p v-if="busy" role="status" class="text-white/60">Working…</p> <p v-if="busy" role="status" class="text-white/60">Working…</p>
<template v-if="status"> <template v-if="status">
<section v-if="websiteMode" class="glass-card p-5 space-y-3" data-testid="blossom-setup">
<h2 class="text-lg font-semibold">Local website files</h2>
<template v-if="blossomInstalled">
<p>Blossom is installed. You can skip installation.</p>
<RouterLink to="/dashboard/apps/blossom" class="underline">Manage local Blossom</RouterLink>
<template v-if="current">
<button class="glass-button px-4 py-2" :disabled="busy" @click="loadIdentities">Choose a storage identity</button>
<label class="block">Profile for local files<select v-model="identityId" :disabled="busy" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
<button class="glass-button px-4 py-2" :disabled="busy || !identityId || !current.draft || html !== current.draft" @click="storeLocally">Store saved website in local Blossom</button>
<p v-if="html !== current.draft" class="text-sm">Save your edits before storing this version in Blossom.</p>
<p v-if="current.local_archive" class="text-sm break-all">Verified local snapshot: {{ current.local_archive.size }} bytes · {{ new Date(current.local_archive.created_at).toLocaleString() }} · SHA-256 {{ current.local_archive.sha256 }}</p>
<p class="text-sm text-white/60">This stores the saved draft shown below. Later edits need another explicit store. Local files require node login; this does not create a public Blossom endpoint.</p>
</template>
</template>
<template v-else>
<p>Install Blossom from the app catalogue to store website files on this node. Create a profile identity first; Blossom uses the normal Archipelago signer.</p>
<RouterLink to="/dashboard/marketplace/blossom" class="glass-button inline-block px-4 py-2">Install Blossom</RouterLink>
<p class="text-sm text-white/60">Return here after installation. This step is optional for a simple HTML page served directly by the node.</p>
</template>
<p class="text-sm text-white/60">Installation and local uploads do not announce anything on Nostr. Publishing files externally requires a separate review of the content and destinations.</p>
</section>
<div class="rounded-xl p-4 border border-amber-300/20 bg-amber-400/10 text-amber-100 text-sm">{{ status.notice }}</div> <div class="rounded-xl p-4 border border-amber-300/20 bg-amber-400/10 text-amber-100 text-sm">{{ status.notice }}</div>
<fieldset :disabled="busy" class="space-y-6"> <fieldset :disabled="busy" class="space-y-6">
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="glass-card p-5 space-y-3"> <section v-if="websiteMode && pendingWebsiteHtml !== null" class="glass-card p-5 space-y-3">
@@ -204,7 +329,10 @@ onMounted(refresh)
<dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd> <dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd>
</dl> </dl>
<p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p> <p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p>
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. This setup is manual; the dashboard has not verified it. Removing this FIPS publication also disconnects this proxy route.</p> <p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. Removing the upstream publication also disconnects this proxy route.</p>
<button class="glass-button px-4 py-2" :disabled="hostname !== current.domain?.hostname || !current.routes.includes('public-web')" @click="verifyHttps">Check public HTTPS</button>
<p v-if="httpsCheck" class="text-sm text-green-200">Verified https://{{ httpsCheck.hostname }}/ at {{ new Date(httpsCheck.checked_at).toLocaleString() }}: valid TLS and exact published content. Checked from this node; also test from an outside device.</p>
<p v-else class="text-sm text-amber-200">Public HTTPS has not been verified for these saved settings.</p>
</div> </div>
<button class="glass-button px-4 py-2" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button> <button class="glass-button px-4 py-2" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button>
<div v-if="dns" class="space-y-3"> <div v-if="dns" class="space-y-3">
@@ -220,12 +348,35 @@ onMounted(refresh)
<p class="text-sm text-white/60">This inventory shows existing access policies. Local-only APIs are excluded. A local listener does not prove external reachability.</p> <p class="text-sm text-white/60">This inventory shows existing access policies. Local-only APIs are excluded. A local listener does not prove external reachability.</p>
<ul class="space-y-2"><li v-for="app in status.apps" :key="app.id + app.port" class="flex flex-wrap justify-between gap-2 text-sm"><span>{{ app.name }} · {{ app.port }}</span><span class="text-white/60">{{ app.listener_claimed ? 'Local proxy listening' : 'Listener not confirmed' }} · {{ app.authentication === 'node-session' ? 'Node login required' : 'App access policy' }}</span></li></ul> <ul class="space-y-2"><li v-for="app in status.apps" :key="app.id + app.port" class="flex flex-wrap justify-between gap-2 text-sm"><span>{{ app.name }} · {{ app.port }}</span><span class="text-white/60">{{ app.listener_claimed ? 'Local proxy listening' : 'Listener not confirmed' }} · {{ app.authentication === 'node-session' ? 'Node login required' : 'App access policy' }}</span></li></ul>
</section> </section>
<section v-if="!websiteMode" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Grant access to an app</h2>
<p class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
<label class="block">Application<select v-model="guestApp" class="field mt-2"><option value="">Choose an application</option><option v-for="app in shareableApps" :key="app.id" :value="app.id">{{ app.name }}</option></select></label>
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
<div v-if="guestTarget" class="space-y-2 text-sm">
<p v-if="status.fips_address" class="break-all">FIPS address: <a :href="`https://[${status.fips_address}]:${guestTarget.port}/`" target="_blank" rel="noopener noreferrer" class="underline">https://[{{ status.fips_address }}]:{{ guestTarget.port }}/</a></p>
<p v-if="selected.includes('public-web')">For your public reverse proxy, use the FIPS address above as its forward host and port {{ guestTarget.port }}, with upstream scheme HTTP. Keep the app gate enabled. Configure the application's public URL if it requires one.</p>
<p>This creates permission to use the app. A reachable FIPS connection, onion service or configured public proxy is also needed.</p>
</div>
<button class="glass-button px-4 py-2" :disabled="!guestApp || !guestLabel.trim()" @click="createGuestAccess">Create app-only access</button>
<div v-if="issuedAccess" class="rounded-xl border border-amber-300/30 p-4 space-y-2">
<p>Copy this token and share it privately with the intended guest. It is shown once and is never posted to Nostr or another service.</p>
<code class="block break-all select-all">{{ issuedAccess.token }}</code>
<p class="text-sm">The guest opens the app address and chooses “Have an app-only access token?”. API clients can use it as an Authorization Bearer token. It cannot log in to the dashboard.</p>
<button class="underline text-sm" @click="issuedAccess = null">Hide token</button>
</div>
<div v-for="grant in status.grants ?? []" :key="grant.id" class="flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-3">
<p>{{ grant.label }} · {{ grant.apps.join(', ') }} · {{ grant.expires_at ? new Date(grant.expires_at * 1000).toLocaleString() : 'No expiry' }}</p>
<button class="underline text-sm" @click="revokeGuestAccess(grant.id)">Revoke access</button>
</div>
</section>
<button class="glass-button px-5 py-3" :disabled="websiteMode && !current" @click="save">{{ websiteMode ? 'Save website draft and choices' : 'Save connection choices' }}</button> <button class="glass-button px-5 py-3" :disabled="websiteMode && !current" @click="save">{{ websiteMode ? 'Save website draft and choices' : 'Save connection choices' }}</button>
<section v-if="websiteMode && current && status.publication_enabled" class="glass-card p-5 space-y-3"> <section v-if="websiteMode && current && status.publication_enabled" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on FIPS</h2> <h2 class="text-lg font-semibold">Publish the saved version on FIPS or your proxy</h2>
<p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</p> <p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label> <label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || !current.routes.includes('fips') || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button> <button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || (!current.routes.includes('fips') && !current.routes.includes('public-web')) || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
<button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button> <button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button>
<div v-if="current.fips_publication" class="text-sm space-y-2"> <div v-if="current.fips_publication" class="text-sm space-y-2">
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p> <p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
@@ -247,6 +398,41 @@ onMounted(refresh)
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p> <p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
</div> </div>
</section> </section>
<section v-if="websiteMode && current && selected.includes('nostr')" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Publish a named nsite</h2>
<p class="text-sm text-white/60">Upload a public static copy to a Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p>
<button class="glass-button px-4 py-2" @click="loadIdentities">Load signing identities</button>
<label class="block">Profile identity<select v-model="identityId" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
<p class="text-xs text-white/50">The node's operational identity is excluded. Your private key stays in the existing signer.</p>
<label class="block">Blossom server<input v-model="blossom" class="field mt-2" placeholder="https://your-blossom-server.example" /></label>
<label class="block">Relays<textarea v-model="relays" rows="3" class="field mt-2" placeholder="wss://your-relay.example" /></label>
<p class="text-sm text-white/60">Choose servers you trust or host your own. The Blossom server must allow browser uploads and reads. Paid storage requires a separate arrangement; this flow never pays automatically.</p>
<button class="glass-button px-4 py-2" :disabled="!identityId || !blossom || !current.draft" @click="reviewNsite">Prepare publication review — stays on this node</button>
<div v-if="nsiteReview" class="rounded-xl border border-amber-300/30 p-4 space-y-3">
<h3 class="font-semibold">Review exactly what will leave your node</h3>
<p class="text-sm">Signing identity: {{ nsiteReview.identity.name }} <span class="font-mono break-all">{{ nsiteReview.identity.nostr_pubkey }}</span></p>
<p class="text-sm break-all">Upload destination: {{ nsiteReview.prepared.server }}</p>
<p class="text-sm break-all">Announcement relays: {{ nsiteReview.relays.join(', ') }}</p>
<p class="text-xs font-mono break-all">Content SHA-256: {{ nsiteReview.prepared.sha256 }}</p>
<iframe :srcdoc="websitePreview(nsiteReview.prepared.html)" sandbox="" referrerpolicy="no-referrer" title="Exact nsite publication preview" class="w-full h-64 rounded-xl bg-white" />
<details><summary>Inspect the exact uploaded HTML</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ nsiteReview.prepared.html }}</pre></details>
<details><summary>Inspect the public manifest</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(nsiteReview.prepared.manifest, null, 2) }}</pre></details>
<p class="text-sm text-amber-200">Check for personal information, credentials, private addresses and anything you do not want copied. Sanitising HTML does not remove sensitive text. Public copies cannot be guaranteed erased.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeUpload" type="checkbox" class="mt-1" /><span>I approve sending these exact website bytes to this Blossom server.</span></label>
</div>
<label class="flex items-start gap-3"><input v-model="acknowledgeNostr" type="checkbox" class="mt-1" /><span>I approve sending the displayed manifest or removal request to the listed relays when I press its action button. It identifies the author, and copies may remain after a deletion request.</span></label>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeNostr || !acknowledgeUpload || !nsiteReview || !current.routes.includes('nostr') || !current.draft || !identityId || !blossom" @click="handleNsite('publish')">Upload and publish saved website</button>
<template v-if="current.nsite_receipt">
<p class="text-sm">{{ current.nsite_receipt.deletion_requested ? 'Deletion requested; copies may remain.' : current.nsite_receipt.accepted_relays.length ? 'Relay delivery recorded; gateway access not verified.' : 'Signed manifest retained; relay delivery is pending.' }}</p>
<details><summary>Review retained manifest for retry or removal</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(current.nsite_receipt.event, null, 2) }}</pre></details>
<p class="text-sm break-all">Retry destinations: {{ relays }}. Removal also contacts relays that previously accepted this manifest: {{ current.nsite_receipt.accepted_relays.join(', ') || 'none recorded' }}.</p>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeNostr || current.nsite_receipt.deletion_requested" @click="handleNsite('retry')">Retry manifest delivery</button>
<button class="glass-button px-4 py-2 ml-2" :disabled="!acknowledgeNostr || !identityId" @click="handleNsite('delete')">Request removal from relays</button>
<label class="block">Compatible nsite gateway<input v-model="gateway" class="field mt-2" placeholder="https://your-nsite-gateway.example" /></label>
<button class="glass-button px-4 py-2" :disabled="!gateway" @click="showNsiteAddress">Show browser address</button>
<a v-if="nsiteUrl" :href="nsiteUrl" target="_blank" rel="noopener noreferrer" class="block break-all underline">{{ nsiteUrl }}</a>
</template>
</section>
<section v-if="websiteMode && current?.revisions.length" class="glass-card p-5 space-y-3"> <section v-if="websiteMode && current?.revisions.length" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Saved revisions</h2> <h2 class="text-lg font-semibold">Saved revisions</h2>
<div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div> <div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
@@ -1,22 +1,61 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn() })) const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn(), verifyHttps: vi.fn() }))
const page = vi.hoisted(() => ({ name: 'external-access' })) const page = vi.hoisted(() => ({ name: 'external-access' }))
const appStore = vi.hoisted(() => ({ data: { 'package-data': {} as Record<string, unknown> } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => appStore }))
vi.mock('vue-router', () => ({ useRoute: () => page, RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } })) vi.mock('vue-router', () => ({ useRoute: () => page, RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api })) vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api }))
import PublishingSetup from '../PublishingSetup.vue' import PublishingSetup from '../PublishingSetup.vue'
import { rpcClient } from '@/api/rpc-client'
const state = () => ({ schema: 1, version: 2, connections: ['fips'], projects: {} }) const state = () => ({ schema: 1, version: 2, connections: ['fips'], projects: {} })
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); page.name = 'external-access' vi.clearAllMocks(); page.name = 'external-access'
appStore.data['package-data'] = {}
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [], publication_enabled: false, notice: 'Saving does not publish.' }) api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [], publication_enabled: false, notice: 'Saving does not publish.' })
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null }) api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
}) })
describe('publishing setup', () => { describe('publishing setup', () => {
it('checks public HTTPS only on request and clears verification when the domain changes', async () => {
page.name = 'publish-website'
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<h1>Public</h1>', routes: ['public-web'], domain: { hostname: 'www.example.com', destination: '8.8.8.8' }, revisions: [], fips_publication: { html: '<h1>Public</h1>', port: 32000 } } } }, apps: [], fips_address: 'fd00::1', publication_enabled: true, notice: '' })
api.verifyHttps.mockResolvedValue({ hostname: 'www.example.com', sha256: 'synthetic', checked_at: '2026-10-08T00:00:00Z' })
const wrapper = mount(PublishingSetup); await flushPromises()
expect(api.verifyHttps).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === 'Check public HTTPS')!.trigger('click'); await flushPromises()
expect(api.verifyHttps).toHaveBeenCalledWith('site', 2)
expect(wrapper.text()).toContain('valid TLS and exact published content')
await wrapper.get('input[placeholder="www.yourdomain.com"]').setValue('other.example.com')
expect(wrapper.text()).not.toContain('valid TLS and exact published content')
})
it('creates only an explicit app-scoped grant and supports revocation without showing other credentials', async () => {
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [{ id: 'nextcloud', name: 'Nextcloud', port: 8080, guest_access: true }], grants: [{ id: 'external:test:Guest', label: 'Guest', apps: ['nextcloud'], expires_at: 2000000000 }], notice: '' })
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'external:test:Guest', token: 'synthetic-test-token', app_id: 'nextcloud', expires_at: 2000000000 })
const wrapper = mount(PublishingSetup); await flushPromises()
expect(rpcClient.call).not.toHaveBeenCalled()
await wrapper.get('select').setValue('nextcloud')
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
expect(wrapper.text()).toContain('synthetic-test-token')
await wrapper.findAll('button').find(b => b.text() === 'Revoke access')!.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenLastCalledWith({ method: 'publishing.access-revoke', params: { id: 'external:test:Guest' }, maxRetries: 0 })
expect(wrapper.text()).not.toContain('synthetic-test-token')
})
it('offers catalog installation and skips it when Blossom is already installed', async () => {
page.name = 'publish-website'
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('[data-testid="blossom-setup"]').text()).toContain('Install Blossom')
wrapper.unmount()
appStore.data['package-data'].blossom = { state: 'installed' }
const installed = mount(PublishingSetup); await flushPromises()
expect(installed.get('[data-testid="blossom-setup"]').text()).toContain('skip installation')
expect(installed.find('a[href="/dashboard/marketplace/blossom"]').exists()).toBe(false)
})
it('loads choices from the node and saves multiple routes without activating them', async () => { it('loads choices from the node and saves multiple routes without activating them', async () => {
const wrapper = mount(PublishingSetup); await flushPromises() const wrapper = mount(PublishingSetup); await flushPromises()
const inputs = wrapper.findAll('input[type="checkbox"]') const inputs = wrapper.findAll('input[type="checkbox"][value]')
expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true) expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
await inputs[2]!.setValue(true) await inputs[2]!.setValue(true)
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click') await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click')
@@ -37,7 +76,7 @@ describe('publishing setup', () => {
const wrapper = mount(PublishingSetup); await flushPromises() const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('iframe').attributes('sandbox')).toBe('') expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain("default-src 'none'") expect(wrapper.get('iframe').attributes('srcdoc')).toContain("default-src 'none'")
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(4) expect(wrapper.findAll('input[type="checkbox"][value]')).toHaveLength(4)
expect(wrapper.text()).toContain('reachability still needs verification') expect(wrapper.text()).toContain('reachability still needs verification')
}) })
}) })
+36
View File
@@ -0,0 +1,36 @@
// Run inside a disposable Blossom container with ONLY the synthetic profile below allowed.
// No real identity, external server or public relay is used. Retains one fixture for lifecycle checks.
import { finalizeEvent, getPublicKey } from 'nostr-tools';
const base = 'http://127.0.0.1:3000';
const key = new Uint8Array(32).fill(1);
const other = new Uint8Array(32).fill(2);
const body = '<!doctype html><script>throw new Error("must not execute")</script><p>Blossom qualification, synthetic data only.</p>';
const bytes = new TextEncoder().encode(body);
const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), x => x.toString(16).padStart(2,'0')).join('');
function auth(action: string, secret=key, server='127.0.0.1', expires=300) {
const now = Math.floor(Date.now()/1000);
return 'Nostr ' + btoa(JSON.stringify(finalizeEvent({ kind:24242,created_at:now,content:'Local synthetic qualification only',tags:[['t',action],['x',hash],['server',server],['expiration',String(now+expires)]]},secret)));
}
async function check(label: string, expected: number, path: string, init={}) {
const r=await fetch(base+path,init);
if(r.status!==expected) throw new Error(`${label}: expected ${expected}, got ${r.status}: ${await r.text()}`);
console.log(`PASS ${label}: ${r.status}`);return r;
}
const upload=(token?:string)=>({method:'PUT',headers:{'content-type':'text/html',...(token?{authorization:token}:{})},body});
await check('unauthenticated upload denied',401,'/upload',upload());
await check('unlisted identity denied',401,'/upload',upload(auth('upload',other)));
await check('wrong host denied',401,'/upload',upload(auth('upload',key,'wrong.invalid')));
await check('expired token denied',401,'/upload',upload(auth('upload',key,'127.0.0.1',-300)));
const stored=await (await check('signed profile upload',201,'/upload',upload(auth('upload')))).json();
if(stored.sha256!==hash || stored.size!==bytes.length) throw new Error('Wrong descriptor');
const read=await check('read stored bytes',200,'/'+hash);
if(await read.text()!==body) throw new Error('Stored bytes differ');
if(!read.headers.get('content-security-policy')?.includes('sandbox') || read.headers.get('content-disposition')!=='attachment') throw new Error('Active content not sandboxed');
console.log('PASS exact bytes and sandboxed attachment');
await check('anonymous list denied',401,'/list/'+getPublicKey(key));
await check('other identity cannot list owner',403,'/list/'+getPublicKey(key),{headers:{authorization:auth('list',other)}});
await check('owner list',200,'/list/'+getPublicKey(key),{headers:{authorization:auth('list')}});
await check('mirror disabled',403,'/mirror',{method:'PUT',headers:{authorization:auth('upload')}});
await check('canonical signer provider',200,'/nostr-provider.js');
await check('health',200,'/healthz');
console.log('PRESERVE_HASH '+hash);
+40
View File
@@ -0,0 +1,40 @@
// Run against the disposable packaged UI forwarded to 127.0.0.1:48191.
// The signer and upload transport are mocked; no real keys or public endpoints.
const { chromium } = require('../../../neode-ui/node_modules/@playwright/test');
const { createHash } = require('node:crypto');
(async () => {
const browser = await chromium.launch({headless:true});
const page = await browser.newPage({viewport:{width:390,height:844}});
page.on('console',m=>console.log('browser:',m.text())); page.on('pageerror',e=>console.log('page error:',e.message));
const outgoing=[]; let uploads=0;
await page.route('**/*', async route => {
const u=new URL(route.request().url());
if(u.origin!=='http://127.0.0.1:48191'){outgoing.push(u.origin);return route.abort();}
if(u.pathname==='/nostr-provider.js')return route.fulfill({contentType:'application/javascript',body:`window.signCalls=[];window.chooseCalls=0;window.deny=true;window.archipelagoNostr={selectIdentity:async()=>{window.chooseCalls++}};window.nostr={getPublicKey:async()=>'${'a'.repeat(64)}',signEvent:async e=>{window.signCalls.push(e);if(window.deny)throw new Error('User declined signing');return {...e,pubkey:'${'a'.repeat(64)}',id:'${'b'.repeat(64)}',sig:'${'c'.repeat(128)}'}}};`});
if(u.pathname==='/upload'){
uploads++; const body=route.request().postDataBuffer();
const token=JSON.parse(Buffer.from(route.request().headers().authorization.slice(6),'base64').toString());
const hash=createHash('sha256').update(body).digest('hex');
if(!token.tags.some(t=>t[0]==='x'&&t[1]===hash)||!token.tags.some(t=>t[0]==='server'&&t[1]==='127.0.0.1'))throw Error('Auth scope mismatch');
return route.fulfill({contentType:'application/json',body:JSON.stringify({sha256:hash,size:body.length})});
}
return route.continue();
});
await page.goto('http://127.0.0.1:48191/');
await page.waitForFunction(()=>typeof window.nostr==='object');
if(!await page.locator('#upload').isDisabled())throw Error('Upload enabled before consent');
await page.locator('#identity').click();
await page.waitForFunction(()=>document.querySelector('#pubkey').textContent==='a'.repeat(64));
await page.locator('#file').setInputFiles({name:'local-fixture.txt',mimeType:'text/plain',buffer:Buffer.from('Synthetic local file')});
await page.locator('#approve').check(); await page.locator('#upload').click();
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('User declined'));
if(uploads!==0)throw Error('Uploaded despite signing refusal');
await page.evaluate(()=>window.deny=false);
await page.locator('#upload').click();
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Stored on this node'));
if(uploads!==1 || outgoing.length)throw Error('Unexpected upload or external request');
if(await page.locator('#approve').isChecked())throw Error('Approval was retained after upload');
if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Mobile horizontal overflow');
console.log('PASS packaged local UI: identity chooser, explicit consent, signer denial, scoped upload, consent reset, mobile width, no external requests (mock signer/transport; real signer still pending)');
await browser.close();
})().catch(e=>{console.error(e);process.exit(1)});