diff --git a/.github/workflows/share-web.yml b/.github/workflows/share-web.yml new file mode 100644 index 00000000..eee17abc --- /dev/null +++ b/.github/workflows/share-web.yml @@ -0,0 +1,94 @@ +name: Share web + +on: + pull_request: + paths: + - "share-web/**" + - ".github/workflows/share-web.yml" + workflow_dispatch: + inputs: + environment: + description: "Deployment target (dry-run on pull requests; deploy only after approval)" + required: true + default: staging + type: choice + options: [staging, proxy, production] + +permissions: + contents: read + +concurrency: + group: share-web-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Verify share web + runs-on: ubuntu-latest + defaults: + run: + working-directory: share-web + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npm run typecheck + - run: npm run test + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run e2e + - run: npx wrangler deploy --dry-run --env staging + + deploy-staging: + if: github.event_name == 'workflow_dispatch' && inputs.environment == 'staging' + needs: verify + runs-on: ubuntu-latest + environment: staging + defaults: + run: + working-directory: share-web + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npm run build + - run: npm run deploy:staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + deploy-production: + if: github.event_name == 'workflow_dispatch' && inputs.environment == 'production' + needs: verify + runs-on: ubuntu-latest + environment: production + defaults: + run: + working-directory: share-web + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npm run build + - run: npm run deploy:production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + deploy-proxy: + if: github.event_name == 'workflow_dispatch' && inputs.environment == 'proxy' + needs: verify + runs-on: ubuntu-latest + environment: production + defaults: + run: + working-directory: share-web + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npm run build + - run: npm run deploy:proxy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/Cargo.lock b/Cargo.lock index 611929f5..1b3052fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -962,6 +962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -3983,6 +3984,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -4805,6 +4809,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" name = "sivtr" version = "0.6.0" dependencies = [ + "aes-gcm", "anyhow", "arboard", "base64 0.23.1", @@ -4860,6 +4865,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2 0.11.0", "shell-words", "strip-ansi-escapes", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 8b9a7745..35d22f11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ getrandom = "0.4" fs2 = "0.4" rusqlite = { version = "0.40", features = ["bundled"] } sha2 = "0.11" +aes-gcm = "0.10" iroh = "1.0" base64 = "0.23" anyhow = "1" diff --git a/crates/sivtr-core/Cargo.toml b/crates/sivtr-core/Cargo.toml index 87357c8c..c5cd7f49 100644 --- a/crates/sivtr-core/Cargo.toml +++ b/crates/sivtr-core/Cargo.toml @@ -13,6 +13,7 @@ categories = ["command-line-utilities", "development-tools"] strip-ansi-escapes = "0.2" unicode-width = "0.2" regex = "1" +sha2 = "0.11" chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/sivtr-core/src/config/mod.rs b/crates/sivtr-core/src/config/mod.rs index db985ab9..c40dcddc 100644 --- a/crates/sivtr-core/src/config/mod.rs +++ b/crates/sivtr-core/src/config/mod.rs @@ -18,6 +18,8 @@ pub struct SivtrConfig { pub theme: ThemeConfig, /// MCP stdio server settings. pub mcp: McpConfig, + /// Browser publication service settings. + pub publish: PublishConfig, } /// Editor configuration. @@ -88,6 +90,15 @@ pub struct McpConfig { pub idle_exit_secs: u64, } +/// Endpoint for encrypted browser publications. The endpoint is deliberately +/// the only configurable publication integration point in v1; override +/// `endpoint` in `config.toml` to use a self-hosted or staging service. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PublishConfig { + pub endpoint: String, +} + // --- Defaults --- impl Default for HistoryConfig { @@ -113,6 +124,14 @@ impl Default for McpConfig { } } +impl Default for PublishConfig { + fn default() -> Self { + Self { + endpoint: "https://share.hnnulwh.cn".to_string(), + } + } +} + // --- Loading / Saving --- impl SivtrConfig { @@ -182,7 +201,7 @@ mod tests { ..SivtrConfig::default() }; - let toml = to_toml_string(&config).unwrap(); + let toml = to_toml_string(&config).expect("serialize hotkey config"); assert!(toml.contains("[hotkey]")); assert!(toml.contains("chord = \"alt+y\"")); @@ -197,7 +216,7 @@ mod tests { ..SivtrConfig::default() }; - let toml = to_toml_string(&config).unwrap(); + let toml = to_toml_string(&config).expect("serialize Codex config"); assert!(toml.contains("[codex]")); assert!(toml.contains("session_dirs = [")); @@ -211,13 +230,25 @@ mod tests { ..SivtrConfig::default() }; - let toml = to_toml_string(&config).unwrap(); + let toml = to_toml_string(&config).expect("serialize MCP config"); assert!(toml.contains("[mcp]")); assert!(toml.contains("idle_exit_secs = 60")); assert_eq!(SivtrConfig::default().mcp.idle_exit_secs, 60); } + #[test] + fn serializes_publish_endpoint() { + let toml = to_toml_string(&SivtrConfig::default()).expect("serialize publish config"); + assert!(toml.contains("[publish]")); + assert!(toml.contains("endpoint = \"https://share.hnnulwh.cn\"")); + + assert!( + toml::from_str::("[publish]\nendpont = \"https://example.com\"\n") + .is_err() + ); + } + #[test] fn theme_config_round_trips_and_rejects_typos() { let config = SivtrConfig { @@ -227,7 +258,7 @@ mod tests { ..SivtrConfig::default() }; - let toml = to_toml_string(&config).unwrap(); + let toml = to_toml_string(&config).expect("serialize theme config"); assert!(toml.contains("[theme]")); assert!(toml.contains("mode = \"light\"")); diff --git a/crates/sivtr-core/src/lib.rs b/crates/sivtr-core/src/lib.rs index 3e7c20b7..dd578dc9 100644 --- a/crates/sivtr-core/src/lib.rs +++ b/crates/sivtr-core/src/lib.rs @@ -6,6 +6,8 @@ pub mod config; pub mod export; pub mod history; pub mod origin; +pub mod privacy; +pub mod publication; pub mod query; pub mod record; pub mod search; diff --git a/crates/sivtr-core/src/privacy.rs b/crates/sivtr-core/src/privacy.rs new file mode 100644 index 00000000..8f435388 --- /dev/null +++ b/crates/sivtr-core/src/privacy.rs @@ -0,0 +1,189 @@ +//! Shared, best-effort privacy helpers. +//! +//! This module deliberately only removes high-signal credential formats. It +//! is a reduction in accidental disclosure, not a security boundary: callers +//! must still ask the user to review the resulting snapshot before publishing. + +use anyhow::Result; +use regex::Regex; +use serde_json::Value; +use std::sync::LazyLock; + +const REDACTED: &str = "[REDACTED]"; + +static PATTERNS: LazyLock, regex::Error>> = LazyLock::new(|| { + Ok(vec![ + ( + "github_pat", + Regex::new(r"(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})")?, + ), + ("openai_key", Regex::new(r"sk-[A-Za-z0-9_-]{16,}")?), + ("sivtr_token", Regex::new(r"s-[A-Za-z0-9]{16,}")?), + ("slack_token", Regex::new(r"xox[abprs]-[A-Za-z0-9-]{10,}")?), + ("aws_id", Regex::new(r"AKIA[0-9A-Z]{16}")?), + ( + "aws_secret", + Regex::new(r#"(?i)aws_secret_access_key['"\s:=]+[A-Za-z0-9/+=]{40}"#)?, + ), + ( + "assigned_secret", + Regex::new( + r#"(?i)(api[_-]?key|token|password|secret|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{12,}['"]?"#, + )?, + ), + ("bearer", Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-.=]{16,}")?), + ( + "pem_key", + Regex::new( + r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----", + )?, + ), + ]) +}); + +static WARNING_PATTERNS: LazyLock, regex::Error>> = LazyLock::new( + || { + Ok(vec![ + ( + "absolute_path", + Regex::new( + r#"(?i)(?:[A-Z]:[\\/]|/(?:Users|home|root|tmp|var|etc|opt|srv|usr|mnt|media|data|workspace)/|\\\\)[^\s`]+"#, + )?, + ), + ( + "email", + Regex::new(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b")?, + ), + ( + "internal_url", + Regex::new( + r"(?i)https?://(?:localhost|127\.0\.0\.1|10\.(?:[0-9]{1,3}\.){2}[0-9]{1,3}|192\.168\.(?:[0-9]{1,3}\.)[0-9]{1,3}|[A-Za-z0-9-]+\.local)(?::\d+)?(?:/[^\s]*)?", + )?, + ), + ]) + }, +); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TextPrivacyReport { + pub redactions: usize, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PrivacyReport { + pub redactions: usize, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrivacyWarning { + pub kind: String, + pub item_index: usize, +} + +/// Redact high-signal credentials and report non-mutating disclosure risks. +pub fn redact_text_with_report(value: &str) -> Result<(String, TextPrivacyReport)> { + let mut current = value.to_string(); + let mut report = TextPrivacyReport::default(); + let patterns = PATTERNS + .as_ref() + .map_err(|error| anyhow::anyhow!("failed to compile privacy redaction pattern: {error}"))?; + for (name, regex) in patterns { + let count = regex.find_iter(¤t).count(); + if count > 0 { + report.redactions += count; + current = regex.replace_all(¤t, REDACTED).into_owned(); + report.warnings.push((*name).to_string()); + } + } + let warning_patterns = WARNING_PATTERNS + .as_ref() + .map_err(|error| anyhow::anyhow!("failed to compile privacy warning pattern: {error}"))?; + for (name, regex) in warning_patterns { + if regex.is_match(¤t) { + report.warnings.push((*name).to_string()); + } + } + report.warnings.sort(); + report.warnings.dedup(); + Ok((current, report)) +} + +/// Shared redaction entry point used by the existing remote sharing path. +pub fn redact_text(value: &str) -> Result { + Ok(redact_text_with_report(value)?.0) +} + +/// Redact every textual value in a JSON tool payload. Kept public so future +/// transports can reuse the exact same credential patterns. +pub fn redact_json(value: &mut Value) -> Result<()> { + match value { + Value::String(text) => *text = redact_text(text)?, + Value::Array(items) => { + for item in items { + redact_json(item)?; + } + } + Value::Object(object) => { + for item in object.values_mut() { + redact_json(item)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +/// Count warning-only risks in a public item and attach the item position. +pub fn warnings_for_item(text: &str, item_index: usize) -> Result> { + let (_, report) = redact_text_with_report(text)?; + Ok(report + .warnings + .into_iter() + .filter(|kind| { + kind != "github_pat" + && kind != "openai_key" + && kind != "sivtr_token" + && kind != "slack_token" + && kind != "aws_id" + && kind != "aws_secret" + && kind != "assigned_secret" + && kind != "bearer" + && kind != "pem_key" + }) + .map(|kind| PrivacyWarning { kind, item_index }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_existing_remote_token_shapes() { + let (text, report) = redact_text_with_report( + "ghp_aBcDeF0123456789ghij github_pat_11AA22bb33CC44dd55EE66ff77GG88hh99 sk-proj-abcdefghijklmnop", + ) + .expect("privacy patterns should compile"); + assert_eq!(text, "[REDACTED] [REDACTED] [REDACTED]"); + assert_eq!(report.redactions, 3); + } + + #[test] + fn warns_without_changing_paths_and_emails() { + let (text, report) = redact_text_with_report(r"C:\Users\alice\repo alice@example.com") + .expect("privacy patterns should compile"); + assert_eq!(text, r"C:\Users\alice\repo alice@example.com"); + assert!(report.warnings.iter().any(|item| item == "absolute_path")); + assert!(report.warnings.iter().any(|item| item == "email")); + let (unix, unix_report) = + redact_text_with_report("/root/.ssh/id_rsa /workspace/company/repo") + .expect("privacy patterns should compile"); + assert_eq!(unix, "/root/.ssh/id_rsa /workspace/company/repo"); + assert!(unix_report + .warnings + .iter() + .any(|item| item == "absolute_path")); + } +} diff --git a/crates/sivtr-core/src/publication.rs b/crates/sivtr-core/src/publication.rs new file mode 100644 index 00000000..7d3bbab0 --- /dev/null +++ b/crates/sivtr-core/src/publication.rs @@ -0,0 +1,430 @@ +//! Provider-neutral, privacy-minimized public conversation snapshots. + +use anyhow::{bail, ensure, Result}; +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::privacy; +use crate::record::{WorkPartKind, WorkRecord, WorkRecordKind, WorkRef}; + +pub const PUBLICATION_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PublicationExpiry { + TwoHours, + OneDay, + ThreeDays, + #[default] + SevenDays, + ThirtyDays, +} + +impl PublicationExpiry { + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "2h" => Ok(Self::TwoHours), + "1d" => Ok(Self::OneDay), + "3d" => Ok(Self::ThreeDays), + "7d" => Ok(Self::SevenDays), + "30d" => Ok(Self::ThirtyDays), + _ => { + bail!("invalid publication expiry `{value}`; expected 2h, 1d, 3d, 7d, or 30d") + } + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::TwoHours => "2h", + Self::OneDay => "1d", + Self::ThreeDays => "3d", + Self::SevenDays => "7d", + Self::ThirtyDays => "30d", + } + } + + fn duration(self) -> Duration { + match self { + Self::TwoHours => Duration::hours(2), + Self::OneDay => Duration::days(1), + Self::ThreeDays => Duration::days(3), + Self::SevenDays => Duration::days(7), + Self::ThirtyDays => Duration::days(30), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct PublicationPolicy { + pub title: Option, + pub expires: PublicationExpiry, + /// Injectable for deterministic tests; production callers leave this None. + pub published_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PublicConversationV1 { + pub schema_version: u32, + pub title: String, + pub provider: String, + pub published_at: String, + pub expires_at: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PublicConversationItem { + pub role: PublicRole, + pub text: String, + pub occurred_at: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PublicRole { + User, + Assistant, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationRisk { + pub kind: String, + pub count: usize, + pub item_indices: Vec, +} + +#[derive(Debug, Clone)] +pub struct PublicationDraft { + pub snapshot: PublicConversationV1, + pub canonical_json: String, + pub content_sha256: String, + pub redaction_count: usize, + pub risks: Vec, + pub source_provider: String, + pub source_refs: Vec, +} + +impl PublicationDraft { + pub fn item_count(&self) -> usize { + self.snapshot.items.len() + } + + pub fn turn_count(&self) -> usize { + self.source_refs.len() + } +} + +/// Validate and project a WorkSet's materialized records into the only public +/// shape supported by v1. The core never receives a CLI WorkSet type. +pub fn create_publication_draft( + records: &[WorkRecord], + anchors: &[WorkRef], + policy: &PublicationPolicy, +) -> Result { + ensure!(!records.is_empty(), "cannot publish an empty WorkSet"); + let expected = if anchors.is_empty() { + records + .iter() + .map(|record| record.work_ref.whole()) + .collect() + } else { + ensure!( + anchors.iter().all(|anchor| anchor.part().is_none()), + "publish v1 requires record-level anchors, not part anchors" + ); + anchors.iter().map(WorkRef::whole).collect::>() + }; + ensure!( + expected.len() == records.len(), + "publication anchors and records must have the same length" + ); + + // Search defaults to newest-first; publish snapshots are chronological. + let mut order: Vec = (0..records.len()).collect(); + order.sort_by_key(|&i| records[i].work_ref.index()); + + let first = &records[order[0]]; + ensure!( + first.kind == WorkRecordKind::ChatTurn, + "publish v1 only supports agent conversations, not terminal records" + ); + ensure!( + first.work_ref.is_local(), + "publish v1 only supports local WorkSets" + ); + ensure!( + first.work_ref.part().is_none(), + "publish v1 requires record-level anchors, not part anchors" + ); + let provider = first + .work_ref + .provider() + .ok_or_else(|| anyhow::anyhow!("publish v1 requires an agent provider"))?; + let session = first.work_ref.session().to_string(); + let mut source_refs = Vec::with_capacity(records.len()); + let mut items = Vec::new(); + let mut redaction_count = 0; + let mut risk_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut previous_index = None; + + for &idx in &order { + let record = &records[idx]; + ensure!( + record.work_ref.whole() == expected[idx], + "publication anchors must match records in order" + ); + ensure!( + record.kind == WorkRecordKind::ChatTurn, + "publish v1 only supports agent conversations" + ); + ensure!( + record.source.channel == crate::record::WorkChannel::Chat, + "publication contains a non-chat record" + ); + ensure!( + record.source.provider.as_deref() == Some(provider.command_name()), + "publication provider metadata does not match its WorkRef" + ); + ensure!( + record.work_ref.is_local(), + "publication contains a remote or group record" + ); + ensure!( + record.work_ref.part().is_none(), + "publication anchors must target whole records" + ); + ensure!( + record.work_ref.provider() == Some(provider), + "publication cannot mix agent providers" + ); + ensure!( + record.work_ref.session() == session, + "publication cannot mix agent sessions" + ); + if let Some(previous) = previous_index { + ensure!( + record.work_ref.index() == previous + 1, + "publication record indices must be strictly continuous" + ); + } + previous_index = Some(record.work_ref.index()); + source_refs.push(record.work_ref.to_string()); + + for part in &record.parts { + let role = match part.kind() { + WorkPartKind::User => PublicRole::User, + WorkPartKind::Assistant => PublicRole::Assistant, + _ => continue, + }; + let raw = part.text().into_owned(); + let (text, report) = privacy::redact_text_with_report(&raw)?; + redaction_count += report.redactions; + let item_index = (!text.trim().is_empty()).then_some(items.len() + 1); + for kind in report.warnings { + let entry = risk_map + .entry(kind.clone()) + .or_insert_with(|| PublicationRisk { + kind, + count: 0, + item_indices: Vec::new(), + }); + entry.count += 1; + if let Some(item_index) = item_index { + entry.item_indices.push(item_index); + } + } + if !text.trim().is_empty() { + items.push(PublicConversationItem { + role, + text, + occurred_at: part + .occurred_at + .clone() + .or_else(|| record.time.primary_at().map(str::to_string)), + }); + } + } + } + ensure!( + items.iter().any(|item| item.role == PublicRole::Assistant), + "publication must contain at least one assistant reply" + ); + + let now = policy.published_at.unwrap_or_else(Utc::now); + let expires_at = now + policy.expires.duration(); + let title_raw = policy + .title + .clone() + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| first.title.clone()); + let (title, title_report) = privacy::redact_text_with_report(&title_raw)?; + redaction_count += title_report.redactions; + for kind in title_report.warnings { + let entry = risk_map + .entry(kind.clone()) + .or_insert_with(|| PublicationRisk { + kind, + count: 0, + item_indices: Vec::new(), + }); + entry.count += 1; + } + let snapshot = PublicConversationV1 { + schema_version: PUBLICATION_SCHEMA_VERSION, + title: if title.trim().is_empty() { + "Sivtr conversation".to_string() + } else { + title + }, + provider: provider.command_name().to_string(), + published_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), + expires_at: expires_at.to_rfc3339_opts(SecondsFormat::Millis, true), + items, + }; + let canonical_json = serde_json::to_string(&snapshot)?; + let content_sha256 = hex_sha256(canonical_json.as_bytes()); + let risks = risk_map + .into_values() + .map(|mut risk| { + risk.item_indices.sort_unstable(); + risk.item_indices.dedup(); + risk + }) + .collect(); + Ok(PublicationDraft { + snapshot, + canonical_json, + content_sha256, + redaction_count, + risks, + source_provider: provider.command_name().to_string(), + source_refs, + }) +} + +fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::{ + WorkChannel, WorkPart, WorkPartData, WorkRecord, WorkRef, WorkSessionRef, WorkSource, + WorkTime, + }; + + fn record(index: usize, assistant: &str) -> WorkRecord { + WorkRecord { + schema_version: 3, + work_ref: WorkRef::agent(crate::ai::AgentProvider::Codex, "session", index), + kind: WorkRecordKind::ChatTurn, + source: WorkSource { + channel: WorkChannel::Chat, + provider: Some("codex".into()), + }, + session: WorkSessionRef { + id: "session".into(), + canonical_id: None, + path: None, + }, + cwd: Some("C:\\secret".into()), + time: WorkTime::default(), + status: None, + title: "Demo".into(), + parts: vec![ + WorkPart { + seq: 1, + occurred_at: None, + data: WorkPartData::User { + content: "hello".into(), + }, + }, + WorkPart { + seq: 2, + occurred_at: None, + data: WorkPartData::Assistant { + content: assistant.into(), + }, + }, + ], + } + } + + #[test] + fn projects_only_dialogue_and_redacts_secrets() { + let records = vec![record(3, "token=sk-abcd1234efgh5678ijkl")]; + let draft = create_publication_draft(&records, &[], &PublicationPolicy::default()).unwrap(); + assert_eq!(draft.item_count(), 2); + assert_eq!(draft.snapshot.items[1].text, "token=[REDACTED]"); + assert_eq!(draft.redaction_count, 1); + let json = serde_json::to_string(&draft.snapshot).unwrap(); + assert!(!json.contains("work_ref")); + assert!(!json.contains("cwd")); + assert!(!json.contains("session")); + } + + #[test] + fn rejects_gaps_and_mixed_sessions() { + let records = vec![record(1, "a"), record(3, "b")]; + assert!(create_publication_draft(&records, &[], &PublicationPolicy::default()).is_err()); + let mut mixed = record(2, "b"); + mixed.work_ref = WorkRef::agent(crate::ai::AgentProvider::Codex, "other", 2); + assert!(create_publication_draft( + &[record(1, "a"), mixed], + &[], + &PublicationPolicy::default() + ) + .is_err()); + assert!(create_publication_draft( + &[record(1, "a")], + &[WorkRef::agent(crate::ai::AgentProvider::Codex, "session", 1).with_part(1)], + &PublicationPolicy::default() + ) + .is_err()); + } + + #[test] + fn newest_first_records_are_sorted_before_continuity_check() { + let records = vec![record(3, "c"), record(2, "b"), record(1, "a")]; + let draft = create_publication_draft(&records, &[], &PublicationPolicy::default()).unwrap(); + assert_eq!(draft.turn_count(), 3); + assert_eq!( + draft.source_refs, + vec![ + "codex/session/1".to_string(), + "codex/session/2".to_string(), + "codex/session/3".to_string(), + ] + ); + let assistant: Vec<_> = draft + .snapshot + .items + .iter() + .filter(|item| item.role == PublicRole::Assistant) + .map(|item| item.text.as_str()) + .collect(); + assert_eq!(assistant, ["a", "b", "c"]); + } + + #[test] + fn publication_expiry_parses_supported_lifetimes() { + assert_eq!( + PublicationExpiry::parse("2h").unwrap(), + PublicationExpiry::TwoHours + ); + assert_eq!( + PublicationExpiry::parse("3d").unwrap(), + PublicationExpiry::ThreeDays + ); + assert_eq!(PublicationExpiry::TwoHours.as_str(), "2h"); + assert_eq!(PublicationExpiry::ThreeDays.as_str(), "3d"); + assert_eq!(PublicationExpiry::TwoHours.duration(), Duration::hours(2)); + assert_eq!(PublicationExpiry::ThreeDays.duration(), Duration::days(3)); + assert!(PublicationExpiry::parse("4h").is_err()); + assert!(PublicationExpiry::parse("90d").is_err()); + } +} diff --git a/docs-site/src/content/docs/zh-cn/explanation/architecture.md b/docs-site/src/content/docs/zh-cn/explanation/architecture.md index 48ab0eeb..755824ca 100644 --- a/docs-site/src/content/docs/zh-cn/explanation/architecture.md +++ b/docs-site/src/content/docs/zh-cn/explanation/architecture.md @@ -54,6 +54,7 @@ sivtr/ | `commands/capture/` | run、pipe、copy、init、flush、import、diff、clear、browse | | `commands/memory/` | search、filter、var、nav、zoom、show、work、WorkSet store | | `commands/remote/` | serve、share、remote(git-remote 风格命名)、peer、workspace list | +| `commands/publish.rs` | 本地 WorkSet 的隐私投影、AES-GCM envelope、公开链接状态与撤销 | | `commands/system/` | config、doctor、history、hotkey、codex export、migrate、version | | `remote/` | 设备 daemon、identity、SQLite state、protocol、本地 IPC | | `app.rs` | 捕获输出 browser 状态机 | diff --git a/docs-site/src/content/docs/zh-cn/explanation/local-first-privacy.md b/docs-site/src/content/docs/zh-cn/explanation/local-first-privacy.md index 04614500..12bf799b 100644 --- a/docs-site/src/content/docs/zh-cn/explanation/local-first-privacy.md +++ b/docs-site/src/content/docs/zh-cn/explanation/local-first-privacy.md @@ -40,6 +40,14 @@ sivtr remote add desk # peer 在其 workspace 里给 remote 起名 完整指南见 [远程访问](/zh-cn/usage/remote-access/)。 +## 浏览器只读发布 + +`sivtr publish` 是另一条明确的外发边界:它不是实时设备到设备 mount,而是从本地 WorkSet 生成一次不可变快照。v1 只允许单个本地 Agent session 的连续对话轮次,并且只投影 User/Assistant;所有原始 WorkSet、WorkRef、`cwd`、session path、工具结构和 provider 事件都会留在本机。 + +公开服务只接收 AES-256-GCM 密文。解密密钥位于链接 fragment,不会随 HTTP 请求发送给托管服务;因此查看者无需账号,发布者设备离线也不影响查看。链接持有者均可阅读,默认 7 天失效(可选 2h/1d/3d/30d);旧的 90d 链接仍可读取,可以在本机用 `sivtr publish revoke` 提前撤销。管理凭据只保存在独立的 `publication-state.db`;数据库丢失时 v1 没有账号恢复路径。 + +发布前必须检查 `preview` 的最终文本和风险报告。高置信度 token、私钥、Bearer 和 secret assignment 会自动替换为 `[REDACTED]`;路径、邮箱和内网地址不会擅自改写,命令行发布需要显式使用 `--allow-warnings`,TUI 发布则会要求明确确认。 + ## 共享 mirror 应尽量只读 在本机多账号之间共享导出 session 时,建议给消费者只读权限: diff --git a/docs-site/src/content/docs/zh-cn/index.md b/docs-site/src/content/docs/zh-cn/index.md index 3e867466..e8558ad6 100644 --- a/docs-site/src/content/docs/zh-cn/index.md +++ b/docs-site/src/content/docs/zh-cn/index.md @@ -65,6 +65,7 @@ sivtr search agent --match "panic" --format timeline | 让 Agent 学会 memory workflow | [Skill 与可复用流程](/zh-cn/usage/skills/) | | 查看社区玩法 | [玩法实例](/zh-cn/playbooks/) | | 搜索和按 ref 展示记忆 | [搜索和展示结果](/zh-cn/usage/search-and-show/) | +| 发布浏览器只读对话链接 | [发布对话链接](/zh-cn/usage/publish/) | | 分享并添加远端记忆 | [远程访问](/zh-cn/usage/remote-access/) | | 快速打开 picker | [启动器和热键](/zh-cn/usage/launchers-and-hotkeys/) | | 查询精确语法 | [CLI 参考](/zh-cn/reference/cli/) | diff --git a/docs-site/src/content/docs/zh-cn/project/roadmap.md b/docs-site/src/content/docs/zh-cn/project/roadmap.md index a488cce8..f14e3874 100644 --- a/docs-site/src/content/docs/zh-cn/project/roadmap.md +++ b/docs-site/src/content/docs/zh-cn/project/roadmap.md @@ -142,7 +142,7 @@ TUI 应保持快速和键盘优先,但需要从单个输出浏览扩展到多 so - [ ] peer rename / verify / disconnect 辅助命令。 - [ ] 用 UDS 或 named-pipe 替换 localhost TCP 控制面。 - [ ] 旧服务端协议版本协商。 -- [ ] 更细的 selective disclosure(按 session 分享,而不是整个 workspace)。 +- [x] 更细的 selective disclosure(`publish` 按单个本地 session 的连续轮次生成浏览器只读快照;更广泛 evidence bundle 仍未完成)。 ## Privacy and lifecycle diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 00acf994..7905c734 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -427,6 +427,32 @@ sivtr show @last --full sivtr show @ctx -f timeline ``` +## publish + +`publish` 把本地 WorkSet 投影成不可变、端侧加密的浏览器只读快照。它与 `share` 不同:`share` 是需要 Sivtr/daemon 的实时 workspace mount;`publish` 上传的只有密文,查看者无需登录或安装 Sivtr,分享者设备离线也能查看。 + +```bash +sivtr publish preview [--title ] [--expires 7d] [--format human|json] +sivtr publish create <SOURCE> [--title <TITLE>] [--expires 7d] [--yes] [--allow-warnings] +sivtr publish list [--json] +sivtr publish link <PUBLICATION_ID> +sivtr publish revoke <PUBLICATION_ID> [--yes] +``` + +v1 只接受同一 provider、同一 session 中连续的本地 Agent record,并只发布 User/Assistant 文本。Terminal、remote/group、part anchor、ToolCall/ToolResult/Thinking/Skill、附件和跨 session 证据包都会被排除或拒绝。公开快照不包含 WorkSet、WorkRef、`cwd`、session path 或本地 provider 原始事件。 + +`preview` 完全离线生成最终快照和风险报告;已识别的 token、私钥、Bearer 和 secret assignment 自动替换为 `[REDACTED]`,绝对路径、邮箱和内网地址只警告。创建前会显示轮次数、大小、脱敏项和期限;非交互环境必须使用 `--yes`,存在未自动处理的风险时还必须使用 `--allow-warnings`。`[publish].endpoint` 默认是 `https://share.hnnulwh.cn`,可在 `config.toml` 里改成自建或其他兼容服务。成功创建时 stdout 只输出完整链接,说明和警告写 stderr,方便复制。 + +密钥只放在 URL fragment(`#k=...`),托管服务只保存 AES-256-GCM 密文、管理 token 哈希、期限和 envelope 版本。链接默认 7 天,可选 `1d/7d/30d/90d`,不提供永久链接;修改内容必须创建新链接。链接持有者均可查看,管理 token 只保存在本机的独立 `publication-state.db` 中。 + +典型流程: + +```bash +sivtr search codex/<session-id> --save share_ready --refs +sivtr publish preview @share_ready +sivtr publish create @share_ready --expires 7d +``` + ## zoom ```bash diff --git a/docs-site/src/content/docs/zh-cn/reference/data-locations.md b/docs-site/src/content/docs/zh-cn/reference/data-locations.md index 16bb666c..a8a62084 100644 --- a/docs-site/src/content/docs/zh-cn/reference/data-locations.md +++ b/docs-site/src/content/docs/zh-cn/reference/data-locations.md @@ -112,6 +112,7 @@ sivtr hotkey stop | --- | --- | | `identity.key` | iroh 使用的稳定设备身份 | | `remote-state.db` | SQLite:peers、shares、grants、invites、mounts、audit | +| `publication-state.db` | SQLite:本机公开快照的 id、期限、来源摘要、查看密钥和撤销凭据;不保存公开快照明文 | | `daemon.json` | 运行中 daemon 控制信息(port、token、node id) | | `daemon.lock` | 单实例锁 | | `daemon.log` | daemon 日志(`sivtr serve logs`) | diff --git a/docs-site/src/content/docs/zh-cn/usage/configuration.md b/docs-site/src/content/docs/zh-cn/usage/configuration.md index a06b5c2d..ade5eb81 100644 --- a/docs-site/src/content/docs/zh-cn/usage/configuration.md +++ b/docs-site/src/content/docs/zh-cn/usage/configuration.md @@ -40,8 +40,13 @@ mode = "auto" [mcp] idle_exit_secs = 60 + +[publish] +endpoint = "https://share.hnnulwh.cn" ``` +`[publish].endpoint` 是加密公开链接服务地址。当前正式入口使用 `https://share.hnnulwh.cn`,由 Nginx 反向代理到同一台服务器上仅监听 loopback 的 Sivtr publication 服务;staging 或其他托管方式可以改为兼容同一 `/api/v1` 契约的 endpoint。该配置不会增加第二套认证路径。 + 字段级说明见[配置文件](/zh-cn/reference/config-file/)。 ## History 保留 diff --git a/docs-site/src/content/docs/zh-cn/usage/publish.md b/docs-site/src/content/docs/zh-cn/usage/publish.md new file mode 100644 index 00000000..a6a03a0e --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/usage/publish.md @@ -0,0 +1,300 @@ +--- +title: 发布浏览器只读对话链接 +description: 用 Sivtr 把本地 Agent 对话安全地发布成浏览器可打开的只读链接。 +--- + +`sivtr publish` 可以把本地的一段 Agent 对话变成一个浏览器链接。查看者不需要安装 Sivtr,也不需要登录;你的电脑关机后,链接仍然可以打开。 + +它发布的是一次性的“快照”,不是实时共享。对话之后再发生变化,旧链接不会自动更新,需要重新生成一个新链接。 + +## 先记住三件事 + +1. PowerShell 里的 `@share_ready` 要加引号:`'@share_ready'`。 +2. `publish` 只能发布同一个本地 Agent session 中连续的对话轮次。搜索默认只留最近 5 条且 newest-first;发布前会按 index 升序排列,但仍要求这些轮次在 session 里相邻。 +3. 链接本身就是查看凭据。拿到完整链接的人都可以查看,不要把链接放到公开 issue 或不可信群聊中。 + +## 先配置 endpoint + +`[publish].endpoint` 默认是 `https://share.hnnulwh.cn`。自建或改用 Cloudflare Worker 时,在 `config.toml` 里改成你的服务地址即可。CLI 不会在多个后端之间自动切换。 + +```toml +[publish] +endpoint = "https://share.hnnulwh.cn" +``` + +## 第一步:确认 CLI 版本 + +先检查当前终端实际使用的是不是包含 `publish` 的版本: + +```powershell +sivtr --version +sivtr publish --help +``` + +如果看到: + +```text +error: unrecognized subcommand 'publish' +``` + +说明当前 `sivtr.exe` 太旧。升级后,`publish --help` 应该能看到 `preview`、`create`、`list`、`link` 和 `revoke`。 + +## 第二步:准备一段要分享的对话 + +`publish` 的输入是 WorkSet。最常见的流程是从一个 Codex session 取出一段连续轮次并保存: + +```powershell +sivtr search codex/<session-id> --sort oldest --latest 50 --save share_ready --refs +``` + +把 `<session-id>` 换成实际的 session ID。例如: + +```powershell +sivtr search codex/abc123 --sort oldest --latest 50 --save share_ready --refs +``` + +`--latest 50` 先取该 session 最近 50 轮(搜索在未指定 `--latest`/`--limit` 时默认只留 5 条)。`--sort oldest` 让保存下来的 WorkSet 按时间正序,方便预览;`publish` 自己也会再按 record index 排序。 + +保存成功后,WorkSet 名字就是 `share_ready`。在 PowerShell 中引用它时必须写成: + +```powershell +'@share_ready' +``` + +如果已经有合适的 WorkSet,也可以使用它的名字;例如 `@review` 要写成 `'@review'`。 + +### 选择范围时的建议 + +尽量只选择真正需要分享的连续对话轮次。不要直接使用混合了多个 session 的 `@last`,否则发布时会被拒绝。也不要把终端日志、远程 workspace 或多个 Agent session 混在一起。带关键词的 BM25 搜索可能会跳过中间轮次,那种 WorkSet 不能直接 publish。 + +## 第三步:本地预览 + +`preview` 完全在本地运行,不会上传内容: + +```powershell +sivtr publish preview '@share_ready' --format human +``` + +预览会告诉你: + +- 标题和来源 provider; +- 将发布多少轮对话; +- 快照大小和有效期; +- 自动脱敏了多少项; +- 是否发现路径、邮箱、内网地址等风险提示。 + +预览内容时重点检查: + +- 是否包含不想公开的 User 消息或 Assistant 回复; +- 是否包含文件路径、邮箱、内网地址; +- 是否有不应该出现的密钥或账号信息; +- 对话起止范围是否正确。 + +识别出的 token、私钥、Bearer 和 secret assignment 会自动替换成 `[REDACTED]`。路径、邮箱和内网地址默认只警告,不会擅自改写正常对话。 + +## 第四步:创建链接 + +确认预览没有问题后,创建一个 7 天有效的链接: + +```powershell +sivtr publish create '@share_ready' --expires 7d --yes +``` + +有效期可以选择: + +```text +1d 1 天 +7d 7 天,默认值 +30d 30 天 +90d 90 天 +``` + +没有永久链接选项。 + +如果风险报告里还有未自动处理的路径、邮箱或内网地址警告,**无论是否在交互终端**,都必须加上 `--allow-warnings`: + +```powershell +sivtr publish create '@share_ready' --expires 7d --yes --allow-warnings +``` + +`create` 成功后,完整链接会输出到 stdout,方便复制。链接 host 来自 `[publish].endpoint`,通常类似: + +```text +https://share.hnnulwh.cn/s/7d_xxxxxxxxxxxxxxxxxxxxxx#k=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +其中 `#k=...` 是解密密钥。它位于 URL fragment,不会发送给服务器;但是浏览器地址栏中的完整链接持有者可以查看内容。 + +### 在 PowerShell 中保存链接 + +如果想把链接保存到变量而不立即打印: + +```powershell +$link = sivtr publish create '@share_ready' --expires 7d --yes --allow-warnings +$link +``` + +不要把 `$link` 写入公开日志、issue 或聊天记录。 + +## 查看、查找和撤销链接 + +查看本机发布记录,但不显示完整解密链接: + +```powershell +sivtr publish list +``` + +使用 JSON 输出: + +```powershell +sivtr publish list --json +``` + +需要重新打印某个完整链接时,使用 publication ID: + +```powershell +sivtr publish link 7d_xxxxxxxxxxxxxxxxxxxxxx +``` + +提前撤销: + +```powershell +sivtr publish revoke 7d_xxxxxxxxxxxxxxxxxxxxxx --yes +``` + +撤销会立即让链接不可访问。重复撤销同一条本机已撤销记录可以安全执行。管理凭据只保存在本机的 `publication-state.db` 中;如果这个数据库丢失,v1 没有账号恢复或远程找回管理权的功能。 + +## `publish` 和 `share` 的区别 + +| 功能 | `publish` | `share` | +| --- | --- | --- | +| 结果 | 不可变的浏览器只读快照 | 实时 workspace mount | +| 查看者 | 不需要 Sivtr,不需要登录 | 通常需要 Sivtr/daemon 和授权 | +| 分享者是否需要在线 | 不需要 | 通常需要 | +| 内容变化 | 不会自动更新,需创建新链接 | 读取共享 workspace 的当前内容 | +| 服务端看到的内容 | 只有加密密文 | 按远程共享协议提供数据 | + +## 数据会不会经过你的服务器? + +不会把完整的本地 WorkSet 原样上传,但 `publish create` 也不是“完全离线”。准确的过程是:本地生成和加密,服务器保存密文,查看者浏览器解密。 + +```text +本地 WorkSet + ↓ 本地筛选、脱敏、生成公开快照 + ↓ 本地 AES-256-GCM 加密 +你的服务器 + ↓ 返回加密 envelope +查看者浏览器 + ↓ 使用链接 fragment 中的密钥解密 +显示 User / Assistant 对话 +``` + +### 哪些步骤只在本地发生 + +- `sivtr publish preview` 完全在本地运行,不联网; +- WorkSet materialize、连续轮次校验和敏感信息扫描在本地完成; +- 原始对话、WorkSet、WorkRef、`cwd`、session path 不会上传; +- 明文快照先在本地压缩,再用独立的 AES-256-GCM 密钥加密。 + +### 服务器会保存什么 + +你的 `share.hnnulwh.cn` 服务器会收到并保存加密后的 envelope,另外保存发布所需的期限、创建时间、版本和管理 token 哈希。服务器不能从这些内容还原对话,也不会保存标题、provider 或来源 refs。 + +查看链接时,浏览器会从服务器取回密文。`#k=...` 位于 URL fragment,不会随 HTTP 请求发送给服务器;真正的解密发生在查看者浏览器中。因此分享者电脑可以关机,但查看者打开链接时服务器仍需在线。 + +服务器及 Nginx 仍可能知道: + +- publication ID、请求时间、IP、状态码和响应大小; +- 某个链接被创建、查看或撤销; +- 到期时间和密文文件本身。 + +应用日志不会记录请求体、管理 token、fragment 密钥或解密内容;但服务器上的 Nginx 默认访问日志仍可能记录请求路径和访问 IP。 + +### 需要注意的安全边界 + +1. **完整链接就是查看凭据。** 拿到完整链接的人不需要登录即可查看。浏览器历史、剪贴板、聊天软件同步、截图和浏览器扩展都可能造成链接泄露。 +2. **服务器被攻破时仍有 Viewer 完整性风险。** 正常服务器拿不到 fragment 密钥;但如果攻击者能替换服务器上的 Viewer JavaScript,就可能读取浏览器地址栏中的密钥。因此这是应用层加密,不代表能抵御已经被入侵的服务器。 +3. **本机状态库保存密钥。** `publication-state.db` 保存查看密钥和撤销用的管理 token,不保存公开快照明文。能读取你 Sivtr 数据目录的人可能重新取得链接或执行撤销。 +4. **脱敏不是绝对保证。** 已知 token、私钥、Bearer 和 secret assignment 会自动替换;路径、邮箱、内网地址只警告;未识别的敏感内容仍可能进入快照。 + +### 实用建议 + +- 高敏感对话优先使用 `--expires 1d`;阅读完成后立即撤销; +- 不要把完整链接写入公开 issue、公共群聊或公开日志; +- 保护 Windows 用户账户和 `publication-state.db`,不要将其同步到公开云盘或代码仓库; +- 保持 HTTPS 证书、Nginx 和 Node 服务更新,并定期轮换、清理访问日志; +- 第一次使用时先发布不含敏感信息的测试对话。 + +## 当前 v1 不会发布什么 + +v1 只支持同一 provider、同一 session 中连续的本地 Agent 对话轮次,并只保留 User 和 Assistant 文本。以下内容会被拒绝、排除或不进入公开快照: + +- Terminal 记录; +- remote/group 内容; +- 跨 session 或跨 provider 内容; +- ToolCall、ToolResult、Thinking、Skill; +- WorkSet、WorkRef、`cwd`、session path; +- provider 原始事件、附件和图片。 + +## 常见错误 + +### `unrecognized subcommand 'publish'` + +当前终端使用的是旧版 `sivtr.exe`。执行: + +```powershell +sivtr --version +Get-Command sivtr -All +``` + +确认 PATH 中实际使用的二进制已经升级,并重新打开一个 PowerShell 窗口。 + +### `[publish].endpoint is not set` + +`config.toml` 里的 `[publish].endpoint` 被改成了空字符串。写成实际服务地址后再创建,例如默认的 `https://share.hnnulwh.cn`。 + +### `failed to resolve publication source '@share_ready'` + +本机没有名为 `share_ready` 的 WorkSet。先执行搜索并保存: + +```powershell +sivtr search codex/<session-id> --sort oldest --latest 50 --save share_ready --refs +``` + +PowerShell 里不要省略 `'@share_ready'` 的引号。 + +### `publication cannot mix agent sessions` + +你选择的 WorkSet 包含多个 Agent session。缩小搜索范围,只选择一个 session 中连续的对话轮次,再重新保存 WorkSet。 + +### `publication record indices must be strictly continuous` + +WorkSet 里的轮次排序后仍有缺口(例如关键词搜索跳过了中间几轮)。改成按 session 取一段连续窗口:`--sort oldest --latest N`,不要混入不相关命中。 + +### `non-interactive publish requires --yes` + +脚本或重定向环境不是交互终端,创建时加上: + +```powershell +--yes +``` + +### `publish with privacy warnings requires --allow-warnings` + +预览仍有未自动处理的路径、邮箱或内网地址。看完预览并确认可以公开后,显式加上: + +```powershell +--allow-warnings +``` + +交互终端里只回答确认提示是不够的;有警告时必须带这个 flag。 + +## 隐私和数据位置 + +本机的 `publication-state.db` 保存标题、来源摘要、期限、查看密钥和撤销凭据,但不保存公开快照明文。 + +服务器只保存加密后的 envelope 和撤销/过期所需的元数据。服务器不能从 URL 请求中得到 `#k=...` fragment,也不会生成标题预览或搜索引擎内容。 + +如果对话包含高敏感内容,建议使用更短的 `1d` 有效期,并在确认查看者完成阅读后主动撤销。 + +更多精确参数见 [CLI 参考](/zh-cn/reference/cli/),配置说明见 [配置](/zh-cn/usage/configuration/)。 diff --git a/share-web/.gitignore b/share-web/.gitignore new file mode 100644 index 00000000..debeac63 --- /dev/null +++ b/share-web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.wrangler/ +test-results/ +playwright-report/ diff --git a/share-web/README.md b/share-web/README.md new file mode 100644 index 00000000..b2dbb2e3 --- /dev/null +++ b/share-web/README.md @@ -0,0 +1,79 @@ +# Sivtr share web + +This is the independent browser viewer and encrypted publication service. It +does not share the Astro/Starlight documentation build. The production +`share.hnnulwh.cn` deployment runs the Node 22 self-hosted adapter behind +Nginx; the Cloudflare Worker + R2 adapter remains available for deployments +whose network can reach Cloudflare reliably. Both adapters expose the same +opaque `/api/v1` contract. + +Only the encrypted `SIVTPUB1` envelope and deletion/expiry metadata are +stored. The title, provider, refs, `cwd`, content hash, plaintext, viewing key, +and raw management token never cross the API boundary or enter server logs. + +## Local verification + +```powershell +npm.cmd ci +npm.cmd run typecheck +npm.cmd run test +npm.cmd run build +npm.cmd run dry-run +``` + +The current repository uses npm for a reproducible local check. CI also runs +the approved Bun frozen-install path. Production deployment requires the +Cloudflare account secrets and an approved GitHub Environment. + +## R2 lifecycle + +Apply [`r2-lifecycle.json`](./r2-lifecycle.json) through the Cloudflare R2 +lifecycle API to each bucket before using that environment: + +- `sivtr-publications` for production and proxy; +- `sivtr-publications-staging` for staging. + +The Worker still checks the exact `expires_at` on every GET/DELETE; lifecycle +cleanup is only the eventual physical-delete safety net. + +## Self-hosting `share.hnnulwh.cn` + +The local adapter requires only Node 22. It binds to `127.0.0.1:8791`; do not +expose that port in the server firewall. Nginx terminates public TLS and +forwards requests locally. Envelopes are created atomically under +`/var/lib/sivtr-share/v1/<expiry-class>/`; each `.bin` has a small adjacent +`.json` containing only the management-token SHA-256, creation/expiry times, +and envelope version. Exact expiry is enforced on every read and a periodic +cleanup removes expired or incomplete pairs. + +```bash +useradd --system --home /var/lib/sivtr-share --shell /usr/sbin/nologin sivtr-share +install -d -o root -g root -m 0755 /opt/sivtr-share/server /opt/sivtr-share/dist +install -d -o sivtr-share -g sivtr-share -m 0700 /var/lib/sivtr-share + +# Copy server/self-host.mjs, dist/, and the unit before these commands. +install -o root -g root -m 0644 deploy/systemd/sivtr-share.service /etc/systemd/system/sivtr-share.service +systemctl daemon-reload +systemctl enable --now sivtr-share +``` + +Before a certificate exists, install the HTTP bootstrap Nginx config. After +DNS points `share.hnnulwh.cn` to the server, install the certificate and key +under `/etc/nginx/ssl/share.hnnulwh.cn/` with root ownership and mode `0600`, +then replace the bootstrap with +[`deploy/nginx/share.hnnulwh.cn.conf`](./deploy/nginx/share.hnnulwh.cn.conf). +Always run `nginx -t` before reload. The CLI already defaults to +`https://share.hnnulwh.cn`. + +The current Alibaba Cloud DV certificate is not managed by Certbot. Renew and +redeploy it before its expiry date; replacing the two files followed by +`nginx -t && systemctl reload nginx` is sufficient and does not require a +publication-service restart. + +## Domain and kill switch + +Configure DNS and TLS for `share.hnnulwh.cn` before production. The public URL +must remain that domain. Set `Environment=CREATE_ENABLED=false` in a systemd +override and restart the service to stop new publications during an abuse or +cost incident while leaving reads and revocations available. For Cloudflare +deployments, use the equivalent Worker variable. diff --git a/share-web/deploy/nginx/share.hnnulwh.cn.bootstrap.conf b/share-web/deploy/nginx/share.hnnulwh.cn.bootstrap.conf new file mode 100644 index 00000000..3872a38a --- /dev/null +++ b/share-web/deploy/nginx/share.hnnulwh.cn.bootstrap.conf @@ -0,0 +1,20 @@ +# Temporary HTTP-only bootstrap used before the Let's Encrypt certificate is +# issued. Replace it with share.hnnulwh.cn.conf after certbot succeeds. + +server { + listen 80; + listen [::]:80; + server_name share.hnnulwh.cn; + + client_max_body_size 6m; + + location / { + proxy_pass http://127.0.0.1:8791; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + proxy_http_version 1.1; + proxy_read_timeout 60s; + } +} diff --git a/share-web/deploy/nginx/share.hnnulwh.cn.conf b/share-web/deploy/nginx/share.hnnulwh.cn.conf new file mode 100644 index 00000000..678c9f65 --- /dev/null +++ b/share-web/deploy/nginx/share.hnnulwh.cn.conf @@ -0,0 +1,30 @@ +# Production Nginx reverse proxy for share.hnnulwh.cn. + +server { + listen 80; + listen [::]:80; + server_name share.hnnulwh.cn; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name share.hnnulwh.cn; + + ssl_certificate /etc/nginx/ssl/share.hnnulwh.cn/share.hnnulwh.cn.pem; + ssl_certificate_key /etc/nginx/ssl/share.hnnulwh.cn/share.hnnulwh.cn.key; + + client_max_body_size 6m; + + location / { + proxy_pass http://127.0.0.1:8791; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_http_version 1.1; + proxy_read_timeout 60s; + } +} diff --git a/share-web/deploy/nginx/share.hnnulwh.cn.conf.example b/share-web/deploy/nginx/share.hnnulwh.cn.conf.example new file mode 100644 index 00000000..5c6d8a31 --- /dev/null +++ b/share-web/deploy/nginx/share.hnnulwh.cn.conf.example @@ -0,0 +1,34 @@ +# Sivtr publication reverse proxy for the local self-hosted service. +# Copy this file to /etc/nginx/sites-available/share.hnnulwh.cn after DNS +# points share.hnnulwh.cn at this server. + +server { + listen 80; + listen [::]:80; + server_name share.hnnulwh.cn; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name share.hnnulwh.cn; + + ssl_certificate /etc/nginx/ssl/share.hnnulwh.cn/share.hnnulwh.cn.pem; + ssl_certificate_key /etc/nginx/ssl/share.hnnulwh.cn/share.hnnulwh.cn.key; + + # The application enforces the 5 MiB envelope limit; keep a small margin. + client_max_body_size 6m; + + location / { + proxy_pass http://127.0.0.1:8791; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_http_version 1.1; + proxy_read_timeout 60s; + } +} diff --git a/share-web/deploy/systemd/sivtr-share.service b/share-web/deploy/systemd/sivtr-share.service new file mode 100644 index 00000000..a184f712 --- /dev/null +++ b/share-web/deploy/systemd/sivtr-share.service @@ -0,0 +1,26 @@ +[Unit] +Description=Sivtr encrypted publication service +After=network.target + +[Service] +Type=simple +User=sivtr-share +Group=sivtr-share +WorkingDirectory=/opt/sivtr-share +Environment=HOST=127.0.0.1 +Environment=PORT=8791 +Environment=DATA_DIR=/var/lib/sivtr-share +Environment=DIST_DIR=/opt/sivtr-share/dist +Environment=CREATE_ENABLED=true +ExecStart=/usr/bin/node /opt/sivtr-share/server/self-host.mjs +Restart=on-failure +RestartSec=3 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/sivtr-share + +[Install] +WantedBy=multi-user.target diff --git a/share-web/e2e/viewer.spec.ts b/share-web/e2e/viewer.spec.ts new file mode 100644 index 00000000..db42a490 --- /dev/null +++ b/share-web/e2e/viewer.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +const fixture = JSON.parse(readFileSync(new URL("../tests/fixtures/rust-publication-v1.json", import.meta.url), "utf8")) as { publication_id: string; key: string; envelope_base64url: string }; +const xssFixture = JSON.parse(readFileSync(new URL("../tests/fixtures/xss-publication-v1.json", import.meta.url), "utf8")) as { publication_id: string; key: string; envelope_base64url: string }; + +test("missing fragment key is explicit and does not request a ciphertext", async ({ page }) => { + const requests: string[] = []; + page.on("request", (request) => requests.push(request.url())); + await page.goto("/s/7d_0123456789abcdefghijkl"); + await expect(page.locator(".error")).toContainText(/缺少链接密钥|missing its decryption key/); + expect(requests.some((url) => url.includes("/api/v1/publications/"))).toBe(false); +}); + +test("decrypts the Rust-generated v1 fixture in the browser", async ({ page }) => { + await page.route(`**/api/v1/publications/${fixture.publication_id}`, async (route) => { + await route.fulfill({ status: 200, contentType: "application/octet-stream", body: Buffer.from(fixture.envelope_base64url, "base64url") }); + }); + await page.goto(`/s/${fixture.publication_id}#k=${fixture.key}`); + await expect(page.locator("h1")).toHaveText("t"); + await expect(page.locator(".meta")).toContainText("codex"); +}); + +test("renders hostile Markdown as text without executing script", async ({ page }) => { + await page.route(`**/api/v1/publications/${xssFixture.publication_id}`, async (route) => { + await route.fulfill({ status: 200, contentType: "application/octet-stream", body: Buffer.from(xssFixture.envelope_base64url, "base64url") }); + }); + let dialogOpened = false; + page.on("dialog", () => { dialogOpened = true; }); + await page.goto(`/s/${xssFixture.publication_id}#k=${xssFixture.key}`); + await expect(page.locator("h1")).toHaveText("XSS fixture"); + await expect(page.locator(".markdown").first()).toContainText("<script>alert(1)</script>"); + expect(await page.locator(".markdown script").count()).toBe(0); + expect(dialogOpened).toBe(false); +}); diff --git a/share-web/package-lock.json b/share-web/package-lock.json new file mode 100644 index 00000000..abdd373f --- /dev/null +++ b/share-web/package-lock.json @@ -0,0 +1,3346 @@ +{ + "name": "@sivtr/share-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sivtr/share-web", + "dependencies": { + "@cloudflare/workers-types": "^5.20260825.1", + "dompurify": "^3.2.6", + "fflate": "^0.8.2", + "marked": "^16.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + "vite": "^7.1.0", + "vitest": "^3.2.0", + "wrangler": "^4.30.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260820.1.tgz", + "integrity": "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260820.1.tgz", + "integrity": "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260820.1.tgz", + "integrity": "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260820.1.tgz", + "integrity": "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260820.1.tgz", + "integrity": "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260825.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260825.1.tgz", + "integrity": "sha512-/XZntbK+BlJWC5jxkaDNhnDLr2Bf2627sZ6VMrxqKrnc84pc6gNu5NdAyaTkZn1LzQVFIa3SL7V/7veLF59P7w==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/miniflare": { + "version": "5.20260820.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260820.0-alpha.tgz", + "integrity": "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260820.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260820.1.tgz", + "integrity": "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260820.1", + "@cloudflare/workerd-darwin-arm64": "1.20260820.1", + "@cloudflare/workerd-linux-64": "1.20260820.1", + "@cloudflare/workerd-linux-arm64": "1.20260820.1", + "@cloudflare/workerd-windows-64": "1.20260820.1" + } + }, + "node_modules/wrangler": { + "version": "4.125.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.125.0.tgz", + "integrity": "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260820.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260820.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260820.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/wrangler/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/share-web/package.json b/share-web/package.json new file mode 100644 index 00000000..6864a588 --- /dev/null +++ b/share-web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@sivtr/share-web", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "start:self-host": "node server/self-host.mjs", + "e2e": "playwright test", + "deploy:staging": "wrangler deploy --env staging", + "deploy:proxy": "wrangler deploy --env proxy", + "deploy:production": "wrangler deploy --env production", + "dry-run": "wrangler deploy --dry-run --env staging", + "dry-run:proxy": "wrangler deploy --dry-run --env proxy" + }, + "dependencies": { + "@cloudflare/workers-types": "^5.20260825.1", + "dompurify": "^3.2.6", + "fflate": "^0.8.2", + "marked": "^16.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + "vite": "^7.1.0", + "vitest": "^3.2.0", + "wrangler": "^4.30.0" + } +} diff --git a/share-web/playwright.config.ts b/share-web/playwright.config.ts new file mode 100644 index 00000000..104ac61e --- /dev/null +++ b/share-web/playwright.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "e2e", + use: { baseURL: "http://127.0.0.1:8787", trace: "retain-on-failure" }, + webServer: { command: "wrangler dev --local --port 8787", port: 8787, reuseExistingServer: true }, +}); diff --git a/share-web/r2-lifecycle.json b/share-web/r2-lifecycle.json new file mode 100644 index 00000000..92a7983b --- /dev/null +++ b/share-web/r2-lifecycle.json @@ -0,0 +1,10 @@ +{ + "rules": [ + { "id": "expire-2h", "conditions": { "prefix": "v1/2h/" }, "enabled": true, "deleteObjectsTransition": { "condition": { "type": "Age", "maxAge": 7200 } } }, + { "id": "expire-1d", "conditions": { "prefix": "v1/1d/" }, "enabled": true, "deleteObjectsTransition": { "condition": { "type": "Age", "maxAge": 86400 } } }, + { "id": "expire-3d", "conditions": { "prefix": "v1/3d/" }, "enabled": true, "deleteObjectsTransition": { "condition": { "type": "Age", "maxAge": 259200 } } }, + { "id": "expire-7d", "conditions": { "prefix": "v1/7d/" }, "enabled": true, "deleteObjectsTransition": { "condition": { "type": "Age", "maxAge": 604800 } } }, + { "id": "expire-30d", "conditions": { "prefix": "v1/30d/" }, "enabled": true, "deleteObjectsTransition": { "condition": { "type": "Age", "maxAge": 2592000 } } }, + { "id": "expire-90d", "conditions": { "prefix": "v1/90d/" }, "enabled": true, "deleteObjectsTransition": { "condition": { "type": "Age", "maxAge": 7776000 } } } + ] +} diff --git a/share-web/server/self-host.mjs b/share-web/server/self-host.mjs new file mode 100644 index 00000000..5b6ff21d --- /dev/null +++ b/share-web/server/self-host.mjs @@ -0,0 +1,364 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MAX_ENVELOPE_BYTES = 5 * 1024 * 1024; +const MAX_PUBLICATION_CLOCK_SKEW_MS = 60_000; +const MAGIC = Buffer.from("SIVTPUB1", "ascii"); +const ID_RE = /^(90d|30d|3d|7d|2h|1d)_([A-Za-z0-9_-]{22})$/; +const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; +const EXPIRY_MS = { + "2h": 7_200_000, + "1d": 86_400_000, + "3d": 259_200_000, + "7d": 604_800_000, + "30d": 2_592_000_000, + "90d": 7_776_000_000, +}; +const SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", +}; +const MIME_TYPES = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", +}; + +class HttpError extends Error { + constructor(status, code) { + super(code); + this.name = "HttpError"; + this.status = status; + this.code = code; + } +} + +export async function createPublicationServer(options = {}) { + const moduleDir = fileURLToPath(new URL(".", import.meta.url)); + const dataDir = resolve(options.dataDir ?? process.env.DATA_DIR ?? "/var/lib/sivtr-share"); + const distDir = resolve(options.distDir ?? process.env.DIST_DIR ?? join(moduleDir, "..", "dist")); + const now = options.now ?? (() => Date.now()); + const logger = options.logger ?? ((event) => console.log(JSON.stringify(event))); + const createEnabled = options.createEnabled ?? process.env.CREATE_ENABLED !== "false"; + const limits = { + create: options.limits?.create ?? 5, + get: options.limits?.get ?? 120, + revoke: options.limits?.revoke ?? 20, + windowMs: options.limits?.windowMs ?? 60_000, + }; + const rateBuckets = new Map(); + + await mkdir(dataDir, { recursive: true, mode: 0o700 }); + await cleanupExpired({ dataDir, now: now() }); + + const server = createServer(async (request, response) => { + const started = now(); + let status = 500; + let size; + let error; + try { + const result = await route(request); + status = result.status; + size = result.size; + await send(response, result); + } catch (cause) { + const known = cause instanceof HttpError; + status = known ? cause.status : 500; + error = known ? cause.code : cause instanceof Error ? cause.name.slice(0, 32) : "unknown"; + await send(response, json(known ? error : "internal_error", status)); + } finally { + logger({ method: request.method, route: routeLabel(request.url), status, latency_ms: Math.max(0, now() - started), size, error }); + } + }); + + const cleanupTimer = setInterval(() => { + cleanupExpired({ dataDir, now: now() }).catch((cause) => { + logger({ method: "SYSTEM", route: "cleanup", status: 500, error: cause instanceof Error ? cause.name.slice(0, 32) : "unknown" }); + }); + }, options.cleanupIntervalMs ?? 60 * 60 * 1000); + cleanupTimer.unref(); + server.on("close", () => clearInterval(cleanupTimer)); + + async function route(request) { + const url = new URL(request.url ?? "/", "http://localhost"); + const apiMatch = url.pathname.match(/^\/api\/v1\/publications\/([^/]+)$/); + if (apiMatch) return handleApi(request, apiMatch[1]); + if (request.method !== "GET") throw new HttpError(404, "not_found"); + return serveAsset(url.pathname); + } + + async function handleApi(request, id) { + const publication = parseId(id, dataDir); + if (!publication) throw new HttpError(404, "not_found"); + if (request.method === "PUT") return putPublication(request, publication); + if (request.method === "GET") return getPublication(request, publication); + if (request.method === "DELETE") return deletePublication(request, publication); + return json("method_not_allowed", 405, { Allow: "GET, PUT, DELETE" }); + } + + async function putPublication(request, publication) { + if (!createEnabled) throw new HttpError(503, "creation_disabled"); + enforceRateLimit(rateBuckets, `create:${clientIp(request)}`, limits.create, limits.windowMs, now()); + const declaredLength = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(declaredLength) && declaredLength > MAX_ENVELOPE_BYTES) throw new HttpError(413, "payload_too_large"); + const token = header(request, "x-sivtr-management-token"); + if (!token || !TOKEN_RE.test(token)) throw new HttpError(404, "not_found"); + if (header(request, "content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/octet-stream") throw new HttpError(415, "unsupported_media_type"); + const body = await readRequestBody(request); + if (!validEnvelope(body)) throw new HttpError(400, "invalid_envelope"); + + const createdAt = publicationTime(header(request, "x-sivtr-published-at"), now()); + if (!createdAt) throw new HttpError(400, "invalid_publication_time"); + const expiresAt = new Date(createdAt.getTime() + EXPIRY_MS[publication.expiry]); + const metadata = { + management_token_sha256: sha256(token), + created_at: createdAt.toISOString(), + expires_at: expiresAt.toISOString(), + envelope_version: 1, + }; + + await mkdir(publication.directory, { recursive: true, mode: 0o700 }); + let envelopeHandle; + try { + envelopeHandle = await open(publication.envelopePath, "wx", 0o600); + await envelopeHandle.writeFile(body); + await envelopeHandle.sync(); + } catch (cause) { + if (cause?.code === "EEXIST") throw new HttpError(409, "conflict"); + throw cause; + } finally { + await envelopeHandle?.close(); + } + + let metadataHandle; + try { + metadataHandle = await open(publication.metadataPath, "wx", 0o600); + await metadataHandle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8"); + await metadataHandle.sync(); + } catch (cause) { + await safeUnlink(publication.envelopePath); + if (cause?.code === "EEXIST") throw new HttpError(409, "conflict"); + throw cause; + } finally { + await metadataHandle?.close(); + } + return empty(201); + } + + async function getPublication(request, publication) { + enforceRateLimit(rateBuckets, `get:${clientIp(request)}:${publication.random}`, limits.get, limits.windowMs, now()); + const metadata = await loadActiveMetadata(publication, now()); + if (!metadata) throw new HttpError(404, "not_found"); + let body; + try { + body = await readFile(publication.envelopePath); + } catch (cause) { + if (cause?.code === "ENOENT") throw new HttpError(404, "not_found"); + throw cause; + } + return { status: 200, body, size: body.byteLength, headers: { "Content-Type": "application/octet-stream", "Content-Length": String(body.byteLength) } }; + } + + async function deletePublication(request, publication) { + enforceRateLimit(rateBuckets, `revoke:${clientIp(request)}`, limits.revoke, limits.windowMs, now()); + const token = header(request, "x-sivtr-management-token"); + if (!token || !TOKEN_RE.test(token)) throw new HttpError(404, "not_found"); + const metadata = await loadActiveMetadata(publication, now()); + if (!metadata || !safeHashEqual(metadata.management_token_sha256, sha256(token))) throw new HttpError(404, "not_found"); + await Promise.all([safeUnlink(publication.envelopePath), safeUnlink(publication.metadataPath)]); + return empty(204); + } + + async function serveAsset(pathname) { + const requested = pathname === "/" || pathname.startsWith("/s/") ? "/index.html" : pathname; + const safePath = normalize(requested).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, ""); + const assetPath = resolve(distDir, safePath); + if (assetPath !== distDir && !assetPath.startsWith(`${distDir}\\`) && !assetPath.startsWith(`${distDir}/`)) throw new HttpError(404, "not_found"); + let info; + try { + info = await stat(assetPath); + } catch (cause) { + if (cause?.code === "ENOENT") throw new HttpError(404, "not_found"); + throw cause; + } + if (!info.isFile()) throw new HttpError(404, "not_found"); + const type = MIME_TYPES[extname(assetPath).toLowerCase()] ?? "application/octet-stream"; + const body = await readFile(assetPath); + return { status: 200, body, size: body.byteLength, headers: { "Content-Type": type, "Content-Length": String(body.byteLength), ...(type.startsWith("text/html") ? { "X-Robots-Tag": "noindex, nofollow" } : {}) } }; + } + + return server; +} + +export async function cleanupExpired({ dataDir, now = Date.now() }) { + const root = join(resolve(dataDir), "v1"); + for (const expiry of Object.keys(EXPIRY_MS)) { + const directory = join(root, expiry); + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (cause) { + if (cause?.code === "ENOENT") continue; + throw cause; + } + const names = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)); + for (const name of names) { + if (!name.endsWith(".json")) continue; + const random = name.slice(0, -5); + if (!/^[A-Za-z0-9_-]{22}$/.test(random)) continue; + const metadataPath = join(directory, name); + const envelopePath = join(directory, `${random}.bin`); + const metadata = await readMetadata(metadataPath); + if (!metadata || isExpired(metadata.expires_at, now) || !names.has(`${random}.bin`)) { + await Promise.all([safeUnlink(metadataPath), safeUnlink(envelopePath)]); + } + } + for (const name of names) { + if (!name.endsWith(".bin") || names.has(`${name.slice(0, -4)}.json`)) continue; + const path = join(directory, name); + const info = await stat(path); + if (info.mtimeMs <= now - 5 * 60_000) await safeUnlink(path); + } + } +} + +function parseId(id, dataDir) { + const match = ID_RE.exec(id); + if (!match) return null; + const directory = join(dataDir, "v1", match[1]); + return { id, expiry: match[1], random: match[2], directory, envelopePath: join(directory, `${match[2]}.bin`), metadataPath: join(directory, `${match[2]}.json`) }; +} + +async function loadActiveMetadata(publication, now) { + const metadata = await readMetadata(publication.metadataPath); + if (!metadata || isExpired(metadata.expires_at, now)) { + if (metadata) await Promise.all([safeUnlink(publication.envelopePath), safeUnlink(publication.metadataPath)]); + return null; + } + return metadata; +} + +async function readMetadata(path) { + try { + const value = JSON.parse(await readFile(path, "utf8")); + if (typeof value !== "object" || value === null) return null; + return value; + } catch (cause) { + if (cause?.code === "ENOENT" || cause instanceof SyntaxError) return null; + throw cause; + } +} + +function validEnvelope(body) { + return body.byteLength >= 8 + 2 + 12 + 16 + 1 && body.subarray(0, 8).equals(MAGIC) && body[8] === 1 && body[9] === 1; +} + +async function readRequestBody(request) { + const chunks = []; + let total = 0; + for await (const chunk of request) { + total += chunk.byteLength; + if (total > MAX_ENVELOPE_BYTES) throw new HttpError(413, "payload_too_large"); + chunks.push(chunk); + } + if (total === 0) throw new HttpError(413, "payload_too_large"); + return Buffer.concat(chunks, total); +} + +function enforceRateLimit(buckets, key, limit, windowMs, timestamp) { + const current = buckets.get(key); + if (!current || current.resetAt <= timestamp) { + if (buckets.size >= 10_000) { + for (const [bucketKey, bucket] of buckets) if (bucket.resetAt <= timestamp) buckets.delete(bucketKey); + if (buckets.size >= 10_000 && !buckets.has(key)) throw new HttpError(429, "rate_limited"); + } + buckets.set(key, { count: 1, resetAt: timestamp + windowMs }); + return; + } + current.count += 1; + if (current.count > limit) throw new HttpError(429, "rate_limited"); +} + +function clientIp(request) { + return header(request, "x-real-ip") ?? header(request, "x-forwarded-for")?.split(",")[0].trim() ?? request.socket.remoteAddress ?? "unknown"; +} + +function header(request, name) { + const value = request.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function safeHashEqual(left, right) { + if (typeof left !== "string" || left.length !== right.length) return false; + return timingSafeEqual(Buffer.from(left, "ascii"), Buffer.from(right, "ascii")); +} + +function isExpired(value, now) { + const timestamp = Date.parse(value); + return !Number.isFinite(timestamp) || timestamp <= now; +} + +function publicationTime(value, current) { + if (!value) return new Date(current); + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp) || Math.abs(timestamp - current) > MAX_PUBLICATION_CLOCK_SKEW_MS) return null; + return new Date(timestamp); +} + +function json(code, status, headers = {}) { + const body = Buffer.from(JSON.stringify({ error: code })); + return { status, body, size: body.byteLength, headers: { "Content-Type": "application/json; charset=utf-8", "Content-Length": String(body.byteLength), ...headers } }; +} + +function empty(status) { + return { status, body: null, size: 0, headers: {} }; +} + +async function send(response, result) { + response.writeHead(result.status, { "Cache-Control": "no-store, max-age=0", ...SECURITY_HEADERS, ...result.headers }); + response.end(result.body); +} + +function routeLabel(rawUrl) { + const pathname = new URL(rawUrl ?? "/", "http://localhost").pathname; + if (/^\/api\/v1\/publications\//.test(pathname)) return "/api/v1/publications/:id"; + if (pathname.startsWith("/s/")) return "/s/:id"; + if (pathname.startsWith("/assets/")) return "/assets/:file"; + return pathname.slice(0, 64); +} + +async function safeUnlink(path) { + try { + await unlink(path); + } catch (cause) { + if (cause?.code !== "ENOENT") throw cause; + } +} + +async function main() { + const host = process.env.HOST ?? "127.0.0.1"; + const port = Number(process.env.PORT ?? "8791"); + const server = await createPublicationServer(); + server.listen(port, host, () => console.log(JSON.stringify({ kind: "startup", host, port }))); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((cause) => { + console.error(JSON.stringify({ kind: "startup_error", error: cause instanceof Error ? cause.name : "unknown" })); + process.exitCode = 1; + }); +} + +export { MAX_ENVELOPE_BYTES, parseId, validEnvelope }; diff --git a/share-web/server/self-host.test.mjs b/share-web/server/self-host.test.mjs new file mode 100644 index 00000000..21456b42 --- /dev/null +++ b/share-web/server/self-host.test.mjs @@ -0,0 +1,109 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPublicationServer } from "./self-host.mjs"; + +const TOKEN = "A".repeat(43); +const WRONG_TOKEN = "B".repeat(43); +const ID = `7d_${"c".repeat(22)}`; + +const running = []; + +afterEach(async () => { + await Promise.all(running.splice(0).map(({ server, root }) => new Promise((resolve) => server.close(() => resolve())).then(() => rm(root, { recursive: true, force: true })))); +}); + +describe("self-hosted publication service", () => { + it("creates, reads, and revokes an immutable encrypted envelope", async () => { + const fixture = await start(); + const envelope = validEnvelope("private conversation bytes"); + + expect((await put(fixture.base, ID, TOKEN, envelope)).status).toBe(201); + expect((await put(fixture.base, ID, TOKEN, envelope)).status).toBe(409); + + const stored = await fetch(`${fixture.base}/api/v1/publications/${ID}`); + expect(stored.status).toBe(200); + expect(Buffer.from(await stored.arrayBuffer())).toEqual(envelope); + expect(stored.headers.get("cache-control")).toContain("no-store"); + + const metadataText = await readFile(join(fixture.dataDir, "v1", "7d", `${"c".repeat(22)}.json`), "utf8"); + const metadata = JSON.parse(metadataText); + expect(Object.keys(metadata).sort()).toEqual(["created_at", "envelope_version", "expires_at", "management_token_sha256"]); + expect(metadataText).not.toContain("private conversation bytes"); + expect(metadataText).not.toContain(TOKEN); + + expect((await remove(fixture.base, ID, WRONG_TOKEN)).status).toBe(404); + expect((await remove(fixture.base, ID, TOKEN)).status).toBe(204); + expect((await fetch(`${fixture.base}/api/v1/publications/${ID}`)).status).toBe(404); + }); + + it("enforces exact expiry and deletes expired local files", async () => { + let clock = Date.parse("2026-08-25T00:00:00Z"); + const fixture = await start({ now: () => clock }); + const id = `1d_${"d".repeat(22)}`; + const publishedAt = new Date(clock).toISOString(); + expect((await put(fixture.base, id, TOKEN, validEnvelope(), publishedAt)).status).toBe(201); + const metadataText = await readFile(join(fixture.dataDir, "v1", "1d", `${"d".repeat(22)}.json`), "utf8"); + expect(JSON.parse(metadataText).created_at).toBe(publishedAt); + clock += 86_400_000; + expect((await fetch(`${fixture.base}/api/v1/publications/${id}`)).status).toBe(404); + await expect(readFile(join(fixture.dataDir, "v1", "1d", `${"d".repeat(22)}.json`))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("serves the fixed viewer shell with restrictive security headers", async () => { + const fixture = await start(); + const response = await fetch(`${fixture.base}/s/${ID}#k=never-sent`); + expect(response.status).toBe(200); + expect(await response.text()).toContain("Sivtr test viewer"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); + expect(response.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + }); + + it("validates media type and applies the configured per-IP create limit", async () => { + const fixture = await start({ limits: { create: 1, get: 120, revoke: 20, windowMs: 60_000 } }); + const invalidType = await fetch(`${fixture.base}/api/v1/publications/${ID}`, { + method: "PUT", + headers: { "x-sivtr-management-token": TOKEN, "content-type": "text/plain" }, + body: validEnvelope(), + }); + expect(invalidType.status).toBe(415); + + // Rate limiting happens before body processing, so the next create from + // the same address is rejected without creating any file. + const secondId = `7d_${"e".repeat(22)}`; + expect((await put(fixture.base, secondId, TOKEN, validEnvelope())).status).toBe(429); + }); +}); + +async function start(overrides = {}) { + const root = await mkdtemp(join(tmpdir(), "sivtr-share-test-")); + const dataDir = join(root, "data"); + const distDir = join(root, "dist"); + await mkdir(distDir, { recursive: true }); + await writeFile(join(distDir, "index.html"), "<!doctype html><title>Sivtr test viewer"); + const server = await createPublicationServer({ dataDir, distDir, logger: () => {}, ...overrides }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const fixture = { server, root, dataDir, base: `http://127.0.0.1:${address.port}` }; + running.push(fixture); + return fixture; +} + +function validEnvelope(suffix = "x") { + return Buffer.concat([Buffer.from("SIVTPUB1", "ascii"), Buffer.from([1, 1]), Buffer.alloc(12), Buffer.from(suffix), Buffer.alloc(16)]); +} + +function put(base, id, token, body, publishedAt) { + const headers = { "x-sivtr-management-token": token, "content-type": "application/octet-stream" }; + if (publishedAt) headers["x-sivtr-published-at"] = publishedAt; + return fetch(`${base}/api/v1/publications/${id}`, { + method: "PUT", + headers, + body, + }); +} + +function remove(base, id, token) { + return fetch(`${base}/api/v1/publications/${id}`, { method: "DELETE", headers: { "x-sivtr-management-token": token } }); +} diff --git a/share-web/src/worker.ts b/share-web/src/worker.ts new file mode 100644 index 00000000..fdeea73a --- /dev/null +++ b/share-web/src/worker.ts @@ -0,0 +1,226 @@ +import type { R2Bucket, RateLimit } from "@cloudflare/workers-types"; + +export interface Env { + ASSETS: Fetcher; + PUBLICATIONS: R2Bucket; + CREATE_ENABLED?: string; + CREATE_LIMITER?: RateLimit; + GET_LIMITER?: RateLimit; + REVOKE_LIMITER?: RateLimit; +} + +const MAX_ENVELOPE_BYTES = 5 * 1024 * 1024; +const MAX_PUBLICATION_CLOCK_SKEW_MS = 60_000; +const MAGIC = new TextEncoder().encode("SIVTPUB1"); +const ID_RE = /^(90d|30d|3d|7d|2h|1d)_([A-Za-z0-9_-]{22})$/; + +type ExpiryClass = "2h" | "1d" | "3d" | "7d" | "30d" | "90d"; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const started = Date.now(); + let response: Response; + try { + response = await route(request, env, ctx); + } catch (error) { + logEvent("error", request, 0, Date.now() - started, errorCategory(error)); + response = json({ error: "internal_error" }, 500); + } + logEvent("request", request, response.status, Date.now() - started, undefined, response.headers.get("content-length")); + return withSecurityHeaders(response); + }, +}; + +async function route(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const apiMatch = url.pathname.match(/^\/api\/v1\/publications\/([^/]+)$/); + if (apiMatch) { + const id = apiMatch[1]; + return api(request, env, ctx, id); + } + if (request.method !== "GET") return json({ error: "not_found" }, 404); + // `/s/:id` is intentionally a fixed shell; the server never inspects or + // renders the encrypted snapshot, including its title. + if (url.pathname === "/" || url.pathname.startsWith("/s/")) { + return env.ASSETS.fetch(new Request(new URL("/", request.url), request)); + } + return env.ASSETS.fetch(request); +} + +async function api(request: Request, env: Env, ctx: ExecutionContext, id: string): Promise { + const parsed = parseId(id); + if (!parsed) return json({ error: "not_found" }, 404); + if (request.method === "PUT") return put(request, env, parsed); + if (request.method === "GET") return get(request, env, parsed); + if (request.method === "DELETE") return remove(request, env, parsed); + return json({ error: "method_not_allowed" }, 405, { Allow: "GET, PUT, DELETE" }); +} + +function parseId(id: string): { id: string; expiry: ExpiryClass; random: string; key: string } | null { + const match = ID_RE.exec(id); + if (!match) return null; + return { id, expiry: match[1] as ExpiryClass, random: match[2], key: `v1/${match[1]}/${match[2]}` }; +} + +async function put(request: Request, env: Env, publication: NonNullable>): Promise { + if (env.CREATE_ENABLED === "false") return json({ error: "creation_disabled" }, 503); + if (await limited(env.CREATE_LIMITER, clientIp(request))) return json({ error: "rate_limited" }, 429); + const declaredLength = Number(request.headers.get("content-length") ?? "NaN"); + if (Number.isFinite(declaredLength) && (declaredLength <= 0 || declaredLength > MAX_ENVELOPE_BYTES)) { + return json({ error: "payload_too_large" }, 413); + } + const token = request.headers.get("x-sivtr-management-token"); + if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return json({ error: "not_found" }, 404); + const body = await readCappedBody(request.body, MAX_ENVELOPE_BYTES); + if (!body) return json({ error: "payload_too_large" }, 413); + if (!validEnvelope(body)) return json({ error: "invalid_envelope" }, 400); + const createdAt = publicationTime(request.headers.get("x-sivtr-published-at"), Date.now()); + if (!createdAt) return json({ error: "invalid_publication_time" }, 400); + const expiresAt = new Date(createdAt.getTime() + expiryMs(publication.expiry)); + const managementHash = await sha256(token); + const existing = await env.PUBLICATIONS.head(publication.key); + if (existing) return json({ error: "conflict" }, 409); + const stored = await env.PUBLICATIONS.put(publication.key, body, { + httpMetadata: { contentType: "application/octet-stream", cacheControl: "no-store" }, + customMetadata: { + management_token_sha256: managementHash, + created_at: createdAt.toISOString(), + expires_at: expiresAt.toISOString(), + envelope_version: "1", + }, + // Conditional create prevents an id collision from overwriting a snapshot. + onlyIf: { etagDoesNotMatch: "*" }, + }); + if (!stored) return json({ error: "conflict" }, 409); + return new Response(null, { status: 201, headers: { "Cache-Control": "no-store" } }); +} + +async function get(request: Request, env: Env, publication: NonNullable>): Promise { + if (await limited(env.GET_LIMITER, `${clientIp(request)}:${publication.id}`)) return json({ error: "rate_limited" }, 429); + const object = await env.PUBLICATIONS.get(publication.key); + if (!object || isExpired(object.customMetadata?.expires_at)) return json({ error: "not_found" }, 404); + return new Response(object.body as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store, max-age=0", + "Content-Length": String(object.size), + }, + }); +} + +async function remove(request: Request, env: Env, publication: NonNullable>): Promise { + if (await limited(env.REVOKE_LIMITER, `${clientIp(request)}:${publication.id}`)) return json({ error: "rate_limited" }, 429); + const token = request.headers.get("x-sivtr-management-token"); + if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return json({ error: "not_found" }, 404); + const object = await env.PUBLICATIONS.head(publication.key); + if (!object || isExpired(object.customMetadata?.expires_at)) return json({ error: "not_found" }, 404); + const expected = object.customMetadata?.management_token_sha256; + const actual = await sha256(token); + if (!expected || !timingSafeEqual(expected, actual)) return json({ error: "not_found" }, 404); + await env.PUBLICATIONS.delete(publication.key); + return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } }); +} + +async function readCappedBody(stream: ReadableStream | null, maxBytes: number): Promise { + if (!stream) return null; + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + if (total === 0) return null; + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function validEnvelope(body: Uint8Array): boolean { + if (body.byteLength < 8 + 2 + 12 + 16 + 1) return false; + for (let index = 0; index < MAGIC.length; index += 1) if (body[index] !== MAGIC[index]) return false; + return body[8] === 1 && body[9] === 1; +} + +function expiryMs(expiry: ExpiryClass): number { + return { + "2h": 7_200_000, + "1d": 86_400_000, + "3d": 259_200_000, + "7d": 604_800_000, + "30d": 2_592_000_000, + "90d": 7_776_000_000, + }[expiry]; +} + +function isExpired(value: string | undefined): boolean { + if (!value) return true; + const timestamp = Date.parse(value); + return !Number.isFinite(timestamp) || timestamp <= Date.now(); +} + +function publicationTime(value: string | null, now: number): Date | null { + if (!value) return new Date(now); + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp) || Math.abs(timestamp - now) > MAX_PUBLICATION_CLOCK_SKEW_MS) return null; + return new Date(timestamp); +} + +async function limited(binding: RateLimit | undefined, key: string): Promise { + if (!binding) return false; + return !(await binding.limit({ key })).success; +} + +function clientIp(request: Request): string { + return request.headers.get("cf-connecting-ip") ?? "unknown"; +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function timingSafeEqual(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} + +function json(body: unknown, status: number, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...headers } }); +} + +function withSecurityHeaders(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("Referrer-Policy", "no-referrer"); + headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"); + if (response.headers.get("Content-Type")?.startsWith("text/html")) headers.set("X-Robots-Tag", "noindex, nofollow"); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +function errorCategory(error: unknown): string { + return error instanceof Error ? error.name.slice(0, 32) : "unknown"; +} + +function logEvent(kind: string, request: Request, status: number, latencyMs: number, error?: string, size?: string | null): void { + // Deliberately omit body, token, fragment key, complete publication id, and + // decrypted metadata. The route/method/status tuple is enough for ops. + console.log(JSON.stringify({ kind, method: request.method, route: new URL(request.url).pathname.split("/").slice(0, 4).join("/"), status, latency_ms: latencyMs, size: size ? Number(size) : undefined, error })); +} + +export { expiryMs, isExpired, parseId, readCappedBody, validEnvelope }; diff --git a/share-web/tests/fixtures/rust-publication-v1.json b/share-web/tests/fixtures/rust-publication-v1.json new file mode 100644 index 00000000..9439466d --- /dev/null +++ b/share-web/tests/fixtures/rust-publication-v1.json @@ -0,0 +1,6 @@ +{ + "publication_id": "7d_0123456789abcdefghijkl", + "key": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc", + "envelope_base64url": "U0lWVFBVQjEBAQECAwQFBgcICQoLDKT34J5q6uIs5cFf1NStIADYo7CLTqAj1VAraIMCAsxGGWx0YzVOjn2n6aQwuAJ6mP9Xeq9d3_WWtdFzLwtD2zM4lfBwP4RYZPsOGuNxZD78vnP9lKdnxrx0jO5NLsIT1EG87DF-NaG5yDfmoAuuNTSjipH5w8zcGbzXjAVdCiE9qDmZ0gfEEDFPyA", + "source": "Rust encrypt_snapshot_with_nonce; nonce=0102030405060708090a0b0c" +} diff --git a/share-web/tests/fixtures/xss-publication-v1.json b/share-web/tests/fixtures/xss-publication-v1.json new file mode 100644 index 00000000..184a3733 --- /dev/null +++ b/share-web/tests/fixtures/xss-publication-v1.json @@ -0,0 +1,5 @@ +{ + "publication_id": "7d_abcdefghijABCDEFGHIJ12", + "key": "CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg", + "envelope_base64url": "U0lWVFBVQjEBAQwLCgkIBwYFBAMCAarqtESts6sfIe4jAjDmNInLSOjq_c0Tf5SE0oZhObZdldu_OEcEdy2LCiKhA1kGNcyNOun8SKz8w3c1kV2yJbxbH00XplxNVMHbGsiwq7-lEtKopJoyvwu2dvHv_YI3zZMHi5PfcHssqnQ2FdOJH_JBh872mu5VTkURG_ovvF4gGnpT0LqYMd5VHmaL6cwo3XH4IBYolYGGbTe_2Fk8R4JNQkbgfXh3FV32Un-RhCt8dUAfzyzCwiPqtI80OgfZEaRWpQfk06KAPMi6oZeBnTgxhFom" +} diff --git a/share-web/tests/worker.test.ts b/share-web/tests/worker.test.ts new file mode 100644 index 00000000..ddf7dda5 --- /dev/null +++ b/share-web/tests/worker.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import worker, { expiryMs, isExpired, parseId, readCappedBody, validEnvelope } from "../src/worker"; + +describe("publication route primitives", () => { + it("parses expiry-class ids without accepting arbitrary paths", () => { + const parsed = parseId("7d_0123456789abcdefghijkl"); + expect(parsed?.key).toBe("v1/7d/0123456789abcdefghijkl"); + expect(parseId("7d_bad")).toBeNull(); + }); + + it("uses the locked expiry classes", () => { + expect(expiryMs("2h")).toBe(7_200_000); + expect(expiryMs("1d")).toBe(86_400_000); + expect(expiryMs("3d")).toBe(259_200_000); + expect(expiryMs("90d")).toBe(7_776_000_000); + expect(isExpired("2000-01-01T00:00:00.000Z")).toBe(true); + }); + + it("does not confuse 3d with 30d prefixes", () => { + const token = "0123456789abcdefghijkl"; + expect(parseId(`3d_${token}`)?.expiry).toBe("3d"); + expect(parseId(`30d_${token}`)?.expiry).toBe("30d"); + expect(parseId(`2h_${token}`)?.key).toBe(`v1/2h/${token}`); + }); + + it("only accepts the v1 gzip envelope header", () => { + const envelope = new Uint8Array(39); + envelope.set(new TextEncoder().encode("SIVTPUB1")); + envelope[8] = 1; + envelope[9] = 1; + expect(validEnvelope(envelope)).toBe(true); + envelope[9] = 0; + expect(validEnvelope(envelope)).toBe(false); + }); + + it("keeps a recent client publication timestamp as the shared expiry authority", async () => { + const objects = new Map }>(); + const bucket = { + async head(key: string) { + return objects.get(key) ?? null; + }, + async put(key: string, body: Uint8Array, options: { customMetadata: Record; onlyIf?: unknown }) { + objects.set(key, { bytes: new Uint8Array(body), customMetadata: options.customMetadata }); + return {}; + }, + }; + const env = { PUBLICATIONS: bucket, CREATE_ENABLED: "true" } as any; + const id = "7d_0123456789abcdefghijkx"; + const publishedAt = new Date(Date.now() - 1_000).toISOString(); + const envelope = new Uint8Array(39); + envelope.set(new TextEncoder().encode("SIVTPUB1")); + envelope[8] = 1; + envelope[9] = 1; + + const response = await worker.fetch( + new Request(`https://share.sivtr.dev/api/v1/publications/${id}`, { + method: "PUT", + headers: { + "x-sivtr-management-token": "A".repeat(43), + "x-sivtr-published-at": publishedAt, + "content-type": "application/octet-stream", + }, + body: envelope as unknown as BodyInit, + }), + env, + {} as any, + ); + + expect(response.status).toBe(201); + expect(objects.get("v1/7d/0123456789abcdefghijkx")?.customMetadata.created_at).toBe(publishedAt); + }); + + it("keeps PUT/GET/DELETE opaque and makes revoke immediately unreadable", async () => { + const objects = new Map }>(); + const bucket = { + async head(key: string) { + const object = objects.get(key); + return object ? { size: object.bytes.byteLength, customMetadata: object.customMetadata } : null; + }, + async put(key: string, body: Uint8Array, options: { customMetadata: Record; onlyIf?: unknown }) { + if (objects.has(key)) return null; + objects.set(key, { bytes: new Uint8Array(body), customMetadata: options.customMetadata }); + return {}; + }, + async get(key: string) { + const object = objects.get(key); + if (!object) return null; + return { body: new Response(object.bytes as unknown as BodyInit).body, size: object.bytes.byteLength, customMetadata: object.customMetadata }; + }, + async delete(key: string) { objects.delete(key); }, + }; + const env = { PUBLICATIONS: bucket, ASSETS: { fetch: async () => new Response("shell", { headers: { "Content-Type": "text/html" } }) }, CREATE_ENABLED: "true" } as any; + const id = "7d_0123456789abcdefghijkl"; + const token = "A".repeat(43); + const envelope = new Uint8Array(39); + envelope.set(new TextEncoder().encode("SIVTPUB1")); + envelope[8] = 1; + envelope[9] = 1; + const put = await worker.fetch(new Request(`https://share.sivtr.dev/api/v1/publications/${id}`, { method: "PUT", headers: { "x-sivtr-management-token": token, "content-type": "application/octet-stream" }, body: envelope as unknown as BodyInit }), env, {} as any); + expect(put.status).toBe(201); + const get = await worker.fetch(new Request(`https://share.sivtr.dev/api/v1/publications/${id}`), env, {} as any); + expect(get.status).toBe(200); + const wrongDelete = await worker.fetch(new Request(`https://share.sivtr.dev/api/v1/publications/${id}`, { method: "DELETE", headers: { "x-sivtr-management-token": "B".repeat(43) } }), env, {} as any); + expect(wrongDelete.status).toBe(404); + const remove = await worker.fetch(new Request(`https://share.sivtr.dev/api/v1/publications/${id}`, { method: "DELETE", headers: { "x-sivtr-management-token": token } }), env, {} as any); + expect(remove.status).toBe(204); + const after = await worker.fetch(new Request(`https://share.sivtr.dev/api/v1/publications/${id}`), env, {} as any); + expect(after.status).toBe(404); + }); + + it("stops reading a PUT body once it exceeds 5 MiB", async () => { + const stream = new ReadableStream({ + start(controller) { + const chunk = new Uint8Array(64 * 1024); + for (let sent = 0; sent <= 5 * 1024 * 1024; sent += chunk.byteLength) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + const body = await readCappedBody(stream, 5 * 1024 * 1024); + expect(body).toBeNull(); + }); +}); diff --git a/share-web/tsconfig.json b/share-web/tsconfig.json new file mode 100644 index 00000000..987f6f82 --- /dev/null +++ b/share-web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["@cloudflare/workers-types", "vitest/globals"], + "skipLibCheck": true + }, + "include": ["src", "tests", "vite.config.ts", "vitest.config.ts"] +} diff --git a/share-web/viewer/index.html b/share-web/viewer/index.html new file mode 100644 index 00000000..cd28cb7b --- /dev/null +++ b/share-web/viewer/index.html @@ -0,0 +1,15 @@ + + + + + + + Sivtr shared conversation + + +
+

正在加载公开对话…

+
+ + + diff --git a/share-web/viewer/src/main.ts b/share-web/viewer/src/main.ts new file mode 100644 index 00000000..ef7ebfa0 --- /dev/null +++ b/share-web/viewer/src/main.ts @@ -0,0 +1,162 @@ +import DOMPurify from "dompurify"; +import { Gunzip } from "fflate"; +import { marked } from "marked"; +import "./style.css"; + +type Snapshot = { + schema_version: number; + title: string; + provider: string; + published_at: string; + expires_at: string; + items: Array<{ role: "user" | "assistant"; text: string; occurred_at: string | null }>; +}; + +const MAX_ENVELOPE_BYTES = 5 * 1024 * 1024; +const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024; + +const app = document.querySelector("#app")!; +const isEnglish = navigator.language.toLowerCase().startsWith("en"); + +void load(); + +async function load(): Promise { + const id = window.location.pathname.match(/^\/s\/([^/]+)$/)?.[1]; + const key = new URLSearchParams(window.location.hash.slice(1)).get("k"); + if (!key) return showError(t("缺少链接密钥", "The link is missing its decryption key."), false); + if (!id || !validBase64Key(key)) return showError(t("链接密钥格式错误", "The link key is malformed."), false); + try { + const response = await fetch(`/api/v1/publications/${encodeURIComponent(id)}`, { cache: "no-store" }); + if (response.status === 404) return showError(t("链接已撤销、过期或不存在", "This link was revoked, expired, or does not exist."), false); + if (!response.ok) throw new Error("network"); + const envelope = new Uint8Array(await response.arrayBuffer()); + if (envelope.byteLength > MAX_ENVELOPE_BYTES) throw new Error("decrypt"); + const snapshot = await decryptEnvelope(envelope, key, id); + if (snapshot.schema_version !== 1) return showError(t("不支持的快照版本", "This snapshot version is not supported."), false); + render(snapshot); + } catch (error) { + if (error instanceof DOMException || (error instanceof Error && error.message === "decrypt")) { + return showError(t("链接密钥错误或快照已损坏", "The key is wrong or the snapshot is corrupted."), false); + } + showError(t("网络暂时失败", "The network failed temporarily."), true); + } +} + +function validBase64Key(value: string): boolean { + if (!/^[A-Za-z0-9_-]{43}$/.test(value)) return false; + try { return base64url(value).byteLength === 32; } catch { return false; } +} + +async function decryptEnvelope(envelope: Uint8Array, encodedKey: string, id: string): Promise { + try { + if (envelope.byteLength < 39 || new TextDecoder().decode(envelope.slice(0, 8)) !== "SIVTPUB1" || envelope[8] !== 1 || envelope[9] !== 1) throw new Error("invalid envelope"); + const nonce = envelope.slice(10, 22); + const ciphertext = envelope.slice(22); + const key = await crypto.subtle.importKey("raw", base64url(encodedKey), "AES-GCM", false, ["decrypt"]); + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: new TextEncoder().encode(`sivtr-publication-v1:${id}`), tagLength: 128 }, key, ciphertext); + const json = new TextDecoder().decode(gunzipBounded(new Uint8Array(plaintext), MAX_SNAPSHOT_BYTES)); + return JSON.parse(json) as Snapshot; + } catch { + throw new Error("decrypt"); + } +} + +function gunzipBounded(data: Uint8Array, maxBytes: number): Uint8Array { + const chunks: Uint8Array[] = []; + let total = 0; + const gunzip = new Gunzip((chunk) => { + total += chunk.byteLength; + if (total > maxBytes) throw new Error("too large"); + chunks.push(chunk); + }); + gunzip.push(data, true); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +function base64url(value: string): Uint8Array { + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4)); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +function render(snapshot: Snapshot): void { + document.title = `${snapshot.title} · Sivtr`; + app.replaceChildren(); + const header = document.createElement("header"); + const title = document.createElement("h1"); + title.textContent = snapshot.title; + const meta = document.createElement("p"); + meta.className = "meta"; + meta.textContent = `${snapshot.provider} · ${formatDate(snapshot.published_at)} · ${t("只读快照", "read-only snapshot")}`; + header.append(title, meta); + app.append(header); + const conversation = document.createElement("section"); + conversation.className = "conversation"; + for (const item of snapshot.items) { + const article = document.createElement("article"); + article.className = `message ${item.role}`; + const label = document.createElement("h2"); + label.textContent = item.role === "user" ? "User" : "Assistant"; + const body = document.createElement("div"); + body.className = "markdown"; + const escaped = item.text.replaceAll("<", "<").replaceAll(">", ">"); + const html = marked.parse(escaped, { gfm: true, breaks: true, async: false }) as string; + body.innerHTML = DOMPurify.sanitize(html, { FORBID_TAGS: ["style", "script", "iframe", "object", "form", "base"] }); + body.querySelectorAll("a").forEach((anchor) => { + anchor.rel = "noopener noreferrer"; + anchor.target = "_blank"; + }); + body.querySelectorAll("pre").forEach((pre) => { + const wrapper = document.createElement("div"); + wrapper.className = "code-block"; + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = "copy-code"; + copy.textContent = t("复制", "Copy"); + copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(pre.innerText); + copy.textContent = t("已复制", "Copied"); + window.setTimeout(() => { copy.textContent = t("复制", "Copy"); }, 1200); + } catch { + copy.textContent = t("复制失败", "Copy failed"); + } + }); + pre.replaceWith(wrapper); + wrapper.append(pre, copy); + }); + article.append(label, body); + conversation.append(article); + } + app.append(conversation); +} + +function showError(message: string, retry: boolean): void { + app.replaceChildren(); + const panel = document.createElement("section"); + panel.className = "error"; + const heading = document.createElement("h1"); + heading.textContent = "Sivtr"; + const text = document.createElement("p"); + text.textContent = message; + panel.append(heading, text); + if (retry) { + const button = document.createElement("button"); + button.textContent = t("重试", "Retry"); + button.addEventListener("click", () => void load()); + panel.append(button); + } + app.append(panel); +} + +function formatDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? value : new Intl.DateTimeFormat(isEnglish ? "en" : "zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(date); +} + +function t(chinese: string, english: string): string { return isEnglish ? english : chinese; } diff --git a/share-web/viewer/src/style.css b/share-web/viewer/src/style.css new file mode 100644 index 00000000..ccc96a68 --- /dev/null +++ b/share-web/viewer/src/style.css @@ -0,0 +1,23 @@ +:root { color-scheme: light dark; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #101319; color: #edf2f7; } +body { margin: 0; } +main { max-width: 920px; margin: 0 auto; padding: 3rem 1.25rem 5rem; } +header { border-bottom: 1px solid #334155; margin-bottom: 2rem; padding-bottom: 1.25rem; } +h1 { font-size: clamp(1.5rem, 4vw, 2.25rem); margin: 0 0 .5rem; overflow-wrap: anywhere; } +.meta { color: #94a3b8; font-size: .9rem; margin: 0; } +.conversation { display: grid; gap: 1.25rem; } +.message { border: 1px solid #334155; border-radius: 12px; padding: 1rem 1.15rem; overflow-wrap: anywhere; } +.message.user { background: #172033; } +.message.assistant { background: #18251f; } +.message h2 { font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: #94a3b8; margin: 0 0 .75rem; } +.markdown { line-height: 1.65; } +.code-block { position: relative; } +.markdown pre { overflow: auto; background: #0b0f14; padding: .8rem; border-radius: 8px; } +.copy-code { position: absolute; right: .5rem; top: .5rem; font-size: .75rem; } +.markdown code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.markdown a { color: #7dd3fc; } +.error { text-align: center; padding: 15vh 1rem; } +.error p { color: #cbd5e1; } +button { border: 1px solid #64748b; background: #1e293b; color: inherit; border-radius: 7px; padding: .55rem 1rem; cursor: pointer; } +button:hover { background: #334155; } +.status { color: #94a3b8; } +@media (max-width: 600px) { main { padding-top: 1.5rem; } .message { padding: .8rem; } } diff --git a/share-web/vite.config.ts b/share-web/vite.config.ts new file mode 100644 index 00000000..4389ad26 --- /dev/null +++ b/share-web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "viewer", + build: { + outDir: "../dist", + emptyOutDir: true, + sourcemap: false, + }, +}); diff --git a/share-web/vitest.config.ts b/share-web/vitest.config.ts new file mode 100644 index 00000000..4ae0b73e --- /dev/null +++ b/share-web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts", "server/**/*.test.mjs"], + }, +}); diff --git a/share-web/wrangler.toml b/share-web/wrangler.toml new file mode 100644 index 00000000..09ca0696 --- /dev/null +++ b/share-web/wrangler.toml @@ -0,0 +1,112 @@ +name = "sivtr-share-web" +main = "src/worker.ts" +compatibility_date = "2026-08-25" + +[assets] +directory = "./dist" +binding = "ASSETS" +not_found_handling = "none" +# Apply the Worker security-header wrapper to viewer HTML and the API. +run_worker_first = ["/s/*", "/api/*"] + +[[r2_buckets]] +binding = "PUBLICATIONS" +bucket_name = "sivtr-publications" + +[[ratelimits]] +name = "CREATE_LIMITER" +namespace_id = "1001" +simple = { limit = 5, period = 60 } + +[[ratelimits]] +name = "GET_LIMITER" +namespace_id = "1002" +simple = { limit = 120, period = 60 } + +[[ratelimits]] +name = "REVOKE_LIMITER" +namespace_id = "1003" +simple = { limit = 20, period = 60 } + +[vars] +CREATE_ENABLED = "true" + +[env.staging] +name = "sivtr-share-web-staging" + +[[env.staging.r2_buckets]] +binding = "PUBLICATIONS" +bucket_name = "sivtr-publications-staging" + +[[env.staging.ratelimits]] +name = "CREATE_LIMITER" +namespace_id = "1101" +simple = { limit = 5, period = 60 } + +[[env.staging.ratelimits]] +name = "GET_LIMITER" +namespace_id = "1102" +simple = { limit = 120, period = 60 } + +[[env.staging.ratelimits]] +name = "REVOKE_LIMITER" +namespace_id = "1103" +simple = { limit = 20, period = 60 } + +[env.staging.vars] +CREATE_ENABLED = "true" + +[env.proxy] +# Production Worker/R2 backend exposed through the user's own Nginx server. +# This environment intentionally has no custom-domain route: Nginx forwards +# https://share.hnnulwh.cn to the workers.dev hostname printed by Wrangler. +name = "sivtr-share-web-proxy" +workers_dev = true + +[[env.proxy.r2_buckets]] +binding = "PUBLICATIONS" +bucket_name = "sivtr-publications" + +[[env.proxy.ratelimits]] +name = "CREATE_LIMITER" +namespace_id = "1001" +simple = { limit = 5, period = 60 } + +[[env.proxy.ratelimits]] +name = "GET_LIMITER" +namespace_id = "1002" +simple = { limit = 120, period = 60 } + +[[env.proxy.ratelimits]] +name = "REVOKE_LIMITER" +namespace_id = "1003" +simple = { limit = 20, period = 60 } + +[env.proxy.vars] +CREATE_ENABLED = "true" + +[env.production] +name = "sivtr-share-web" +routes = [{ pattern = "share.sivtr.dev", custom_domain = true }] + +[[env.production.r2_buckets]] +binding = "PUBLICATIONS" +bucket_name = "sivtr-publications" + +[[env.production.ratelimits]] +name = "CREATE_LIMITER" +namespace_id = "1001" +simple = { limit = 5, period = 60 } + +[[env.production.ratelimits]] +name = "GET_LIMITER" +namespace_id = "1002" +simple = { limit = 120, period = 60 } + +[[env.production.ratelimits]] +name = "REVOKE_LIMITER" +namespace_id = "1003" +simple = { limit = 20, period = 60 } + +[env.production.vars] +CREATE_ENABLED = "true" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ee5e9cbf..fd3dbb05 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -342,6 +342,9 @@ pub enum Commands { /// Show a captured terminal or AI workspace ref Show(ShowArgs), + /// Publish an encrypted, browser-readable conversation snapshot + Publish(PublishCommand), + /// Manage the read-only MCP server for agent hosts Mcp(McpCommand), @@ -916,6 +919,87 @@ pub struct ShowArgs { pub json: bool, } +#[derive(Parser, Debug)] +pub struct PublishCommand { + #[command(subcommand)] + pub action: PublishAction, +} + +#[derive(Subcommand, Debug)] +pub enum PublishAction { + /// Build the final public snapshot locally without contacting the service + Preview(PublishPreviewArgs), + /// Create an immutable encrypted public snapshot + Create(PublishCreateArgs), + /// List local publication metadata (never prints full links) + List(PublishListArgs), + /// Print one complete browser link + Link(PublishIdArgs), + /// Revoke one publication + Revoke(PublishRevokeArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct PublishPreviewArgs { + /// Source ref or WorkSet reference (for example @share_ready or @) + pub source: String, + /// Optional public title + #[arg(long)] + pub title: Option, + /// Link lifetime: 2h, 1d, 3d, 7d, or 30d + #[arg(long, default_value = "7d")] + pub expires: String, + /// Output format + #[arg(long, value_enum, default_value_t = PublishFormat::Human)] + pub format: PublishFormat, +} + +#[derive(Args, Debug, Clone)] +pub struct PublishCreateArgs { + /// Source ref or WorkSet reference (for example @share_ready or @) + pub source: String, + /// Optional public title + #[arg(long)] + pub title: Option, + /// Link lifetime: 2h, 1d, 3d, 7d, or 30d + #[arg(long, default_value = "7d")] + pub expires: String, + /// Confirm without an interactive prompt + #[arg(long)] + pub yes: bool, + /// Allow non-automatic privacy warnings in non-interactive mode + #[arg(long)] + pub allow_warnings: bool, +} + +#[derive(Args, Debug, Clone)] +pub struct PublishListArgs { + /// Print machine-readable metadata + #[arg(long)] + pub json: bool, +} + +#[derive(Args, Debug, Clone)] +pub struct PublishIdArgs { + /// Publication id returned by `publish create` + pub publication_id: String, +} + +#[derive(Args, Debug, Clone)] +pub struct PublishRevokeArgs { + /// Publication id returned by `publish create` + pub publication_id: String, + /// Confirm without an interactive prompt + #[arg(long)] + pub yes: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum PublishFormat { + Human, + Json, +} + #[derive(Parser, Debug)] pub struct WorkCommand { #[command(subcommand)] @@ -1633,6 +1717,52 @@ mod tests { assert!(Cli::try_parse_from(["sivtr", "show", "@last", "--refs", "--json"]).is_err()); } + #[test] + fn publish_parses_preview_and_create_flags() { + let cli = Cli::try_parse_from([ + "sivtr", + "publish", + "preview", + "@share_ready", + "--expires", + "30d", + "--format", + "json", + ]) + .unwrap(); + match cli.command { + Some(Commands::Publish(command)) => match command.action { + PublishAction::Preview(args) => { + assert_eq!(args.source, "@share_ready"); + assert_eq!(args.expires, "30d"); + assert_eq!(args.format, PublishFormat::Json); + } + _ => panic!("expected publish preview"), + }, + _ => panic!("expected publish command"), + } + + let cli = Cli::try_parse_from([ + "sivtr", + "publish", + "create", + "@", + "--yes", + "--allow-warnings", + ]) + .unwrap(); + match cli.command { + Some(Commands::Publish(command)) => match command.action { + PublishAction::Create(args) => { + assert!(args.yes); + assert!(args.allow_warnings); + } + _ => panic!("expected publish create"), + }, + _ => panic!("expected publish command"), + } + } + #[test] fn work_sessions_accepts_provider_and_json() { let cli = diff --git a/src/commands/memory/workset/source.rs b/src/commands/memory/workset/source.rs index eae7fda1..cc339afb 100644 --- a/src/commands/memory/workset/source.rs +++ b/src/commands/memory/workset/source.rs @@ -405,7 +405,8 @@ pub fn run_on_share( Ok(mut set) => { if redact { for record in set.records_mut() { - *record = crate::remote::redact::redact_record(record); + *record = crate::remote::redact::redact_record(record) + .context("redact shared record")?; } } Ok(set.into_parts()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7ad16121..912abf24 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod browse; pub mod interactive; pub mod memory; +pub mod publish; pub mod remote; pub mod select; pub mod system; diff --git a/src/commands/publish.rs b/src/commands/publish.rs new file mode 100644 index 00000000..6a888d08 --- /dev/null +++ b/src/commands/publish.rs @@ -0,0 +1,834 @@ +//! Browser publication: local projection, client-side encryption, and the +//! small local registry needed to revoke bearer links later. + +use aes_gcm::{ + aead::{AeadInPlace, KeyInit}, + Aes256Gcm, Key, Nonce, +}; +use anyhow::{bail, ensure, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use flate2::{write::GzEncoder, Compression}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Serialize; +use sivtr_core::{ + config::SivtrConfig, + origin::Reach, + publication::{ + create_publication_draft, PublicationDraft, PublicationExpiry, PublicationPolicy, + }, + workspace, +}; +use std::io::{IsTerminal, Write}; +use std::path::Path; +use std::time::Duration; + +use crate::cli::{ + PublishAction, PublishCommand, PublishCreateArgs, PublishFormat, PublishIdArgs, + PublishListArgs, PublishPreviewArgs, PublishRevokeArgs, +}; +use crate::commands::memory::{filter::Filter, workset}; +use crate::output; + +const ENVELOPE_LIMIT: usize = 5 * 1024 * 1024; +const SNAPSHOT_PLAINTEXT_LIMIT: usize = 16 * 1024 * 1024; +const ENVELOPE_MAGIC: &[u8; 8] = b"SIVTPUB1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PublicationStatus { + Pending, + Active, + Revoked, + Expired, + Failed, +} + +impl PublicationStatus { + fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Revoked => "revoked", + Self::Expired => "expired", + Self::Failed => "failed", + } + } +} + +impl std::str::FromStr for PublicationStatus { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "active" => Ok(Self::Active), + "revoked" => Ok(Self::Revoked), + "expired" => Ok(Self::Expired), + "failed" => Ok(Self::Failed), + _ => bail!("unknown publication status `{value}`"), + } + } +} + +#[derive(Debug, Clone)] +struct PublicationRow { + id: String, + endpoint: String, + viewer_key: String, + management_token: String, + title: String, + provider: String, + source_refs: String, + content_sha256: String, + redaction_count: i64, + warning_count: i64, + created_at: String, + expires_at: String, + status: PublicationStatus, + last_error: Option, +} + +#[derive(Debug, Serialize)] +struct PublicationListItem { + publication_id: String, + title: String, + provider: String, + status: String, + created_at: String, + expires_at: String, + redaction_count: i64, + warning_count: i64, + content_sha256: String, + last_error: Option, +} + +pub fn execute(command: PublishCommand) -> Result<()> { + match command.action { + PublishAction::Preview(args) => preview(args), + PublishAction::Create(args) => create(args), + PublishAction::List(args) => list(args), + PublishAction::Link(args) => link(args), + PublishAction::Revoke(args) => revoke(args), + } +} + +fn load_draft(source: &str, title: Option, expiry: &str) -> Result { + let expires = PublicationExpiry::parse(expiry).context("parse publication expiry")?; + ensure_local_publication_source(source).context("validate publication source scope")?; + let mut set = workset::query(source, Filter::none(), None) + .with_context(|| format!("failed to resolve publication source `{source}`"))?; + set.materialize_parts() + .context("materialize publication WorkSet")?; + create_publication_draft( + set.records(), + set.anchors(), + &PublicationPolicy { + title, + expires, + published_at: None, + }, + ) + .context("create publication draft") +} + +fn ensure_local_publication_source(source: &str) -> Result<()> { + // Resolve named scopes through the origin registry before querying so a + // remote alias or group cannot start a daemon or dial a peer. + let Some((scope, _)) = source.split_once(':') else { + return Ok(()); + }; + if scope.eq_ignore_ascii_case("local") || is_windows_drive_path(source) { + return Ok(()); + } + let cwd = std::env::current_dir().context("failed to resolve current directory")?; + let registry = crate::origins::collect(&cwd).context("failed to resolve publication scope")?; + let entry = registry.resolve(scope)?.ok_or_else(|| { + anyhow::anyhow!("publication scope `{scope}` is not a registered local workspace") + })?; + ensure!( + matches!(&entry.reach, Reach::Local { .. }), + "publication scope `{scope}` is remote or grouped; only local WorkSets are publishable" + ); + Ok(()) +} + +fn is_windows_drive_path(source: &str) -> bool { + source.len() >= 3 + && source.as_bytes()[0].is_ascii_alphabetic() + && source.as_bytes()[1] == b':' + && matches!(source.as_bytes()[2], b'/' | b'\\') +} + +fn preview(args: PublishPreviewArgs) -> Result<()> { + let draft = load_draft(&args.source, args.title, &args.expires)?; + match args.format { + PublishFormat::Json => { + println!("{}", serde_json::to_string_pretty(&draft.snapshot)?); + print_risks(&draft); + } + PublishFormat::Human => { + println!("标题: {}", draft.snapshot.title); + println!("Provider: {}", draft.snapshot.provider); + println!("轮次数: {}", draft.turn_count()); + println!("消息数: {}", draft.item_count()); + println!("预计过期: {}", draft.snapshot.expires_at); + println!("内容 SHA-256: {}", draft.content_sha256); + println!("自动脱敏: {} 项", draft.redaction_count); + if draft.risks.is_empty() { + println!("风险提示: 无"); + } else { + println!("风险提示:"); + for risk in &draft.risks { + println!( + " - {}: {} 项{}", + risk.kind, + risk.count, + format_item_indices(&risk.item_indices) + ); + } + } + println!(); + for item in &draft.snapshot.items { + println!( + "[{}]", + match item.role { + sivtr_core::publication::PublicRole::User => "User", + sivtr_core::publication::PublicRole::Assistant => "Assistant", + } + ); + println!("{}", item.text); + println!(); + } + } + } + Ok(()) +} + +fn create(args: PublishCreateArgs) -> Result<()> { + let draft = load_draft(&args.source, args.title, &args.expires)?; + let envelope_preview = compress_snapshot(&draft)?; + let envelope_size = envelope_preview + .len() + .checked_add(8 + 2 + 12 + 16) + .ok_or_else(|| anyhow::anyhow!("encrypted publication envelope size overflow"))?; + if envelope_size > ENVELOPE_LIMIT { + bail!( + "encrypted publication envelope is {} bytes; v1 maximum is 5 MiB; narrow the WorkSet", + envelope_size + ); + } + let has_warnings = draft.risks.iter().any(|risk| is_warning_only(&risk.kind)); + print_create_summary(&draft, &args.expires, envelope_size); + if has_warnings { + output::warning("存在未自动处理的路径、邮箱或内网地址风险;请确认公开内容"); + } + let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + if !args.yes { + if !interactive { + bail!("non-interactive publish requires --yes"); + } + let confirmed = dialoguer::Confirm::new() + .with_prompt("创建只读公开链接?") + .default(false) + .interact()?; + if !confirmed { + bail!("publication cancelled"); + } + } + require_allow_warnings(has_warnings, args.allow_warnings)?; + + let config = SivtrConfig::load()?; + let endpoint = resolve_endpoint(&config)?; + let expiry = PublicationExpiry::parse(&args.expires)?; + let id = format!("{}_{}", expiry.as_str(), random_token(16)?); + let viewer_key = random_token(32)?; + let management_token = random_token(32)?; + let now = Utc::now().to_rfc3339(); + let expires_at = draft.snapshot.expires_at.clone(); + let mut db = PublicationDb::open()?; + db.insert_pending(&PublicationRow { + id: id.clone(), + endpoint: endpoint.clone(), + viewer_key: viewer_key.clone(), + management_token: management_token.clone(), + title: draft.snapshot.title.clone(), + provider: draft.snapshot.provider.clone(), + source_refs: serde_json::to_string(&draft.source_refs)?, + content_sha256: draft.content_sha256.clone(), + redaction_count: draft.redaction_count as i64, + warning_count: draft + .risks + .iter() + .filter(|risk| is_warning_only(&risk.kind)) + .map(|risk| risk.count as i64) + .sum(), + created_at: now, + expires_at, + status: PublicationStatus::Pending, + last_error: None, + })?; + let envelope = match encrypt_snapshot(&draft, &id, &viewer_key) { + Ok(value) => value, + Err(error) => { + let _ = db.mark_failed(&id, &error.to_string()); + return Err(error); + } + }; + if let Err(error) = upload(&endpoint, &id, &management_token, &envelope) { + let _ = db.mark_failed(&id, &error.to_string()); + return Err(error); + } + if let Err(error) = db.mark_active(&id) { + bail!("remote publication may have been created, but local state update failed: {error:#}; keep the local database backup for revoke"); + } + println!("{}", publication_url(&endpoint, &id, &viewer_key)); + output::detail("publication", &id); + output::detail("expires", &draft.snapshot.expires_at); + Ok(()) +} + +fn list(args: PublishListArgs) -> Result<()> { + let mut db = PublicationDb::open()?; + db.refresh_expired()?; + let rows = db.rows()?; + let items = rows.iter().map(list_item).collect::>(); + if args.json { + println!("{}", serde_json::to_string_pretty(&items)?); + } else if items.is_empty() { + println!("暂无公开链接"); + } else { + for item in items { + println!( + "{}\t{}\t{}\t{}\t{}", + item.publication_id, item.status, item.title, item.provider, item.expires_at + ); + } + } + Ok(()) +} + +fn link(args: PublishIdArgs) -> Result<()> { + let db = PublicationDb::open()?; + let row = db + .find(&args.publication_id)? + .ok_or_else(|| anyhow::anyhow!("unknown publication id `{}`", args.publication_id))?; + if row.status != PublicationStatus::Active { + bail!( + "publication `{}` is {} and has no usable link", + row.id, + row.status.as_str() + ); + } + if is_expired(&row.expires_at) { + bail!("publication `{}` has expired", row.id); + } + println!( + "{}", + publication_url(&row.endpoint, &row.id, &row.viewer_key) + ); + Ok(()) +} + +fn revoke(args: PublishRevokeArgs) -> Result<()> { + let db = PublicationDb::open()?; + let row = db + .find(&args.publication_id)? + .ok_or_else(|| anyhow::anyhow!("unknown publication id `{}`", args.publication_id))?; + if row.status == PublicationStatus::Revoked { + return Ok(()); + } + if !args.yes { + if !std::io::stdin().is_terminal() { + bail!("non-interactive revoke requires --yes"); + } + if !dialoguer::Confirm::new() + .with_prompt(format!("撤销 {}?", row.id)) + .default(false) + .interact()? + { + bail!("revoke cancelled"); + } + } + match delete_remote(&row.endpoint, &row.id, &row.management_token) { + Ok(()) => { + db.mark_revoked(&row.id)?; + output::success(format!("revoked {}", row.id)); + Ok(()) + } + Err(error) => { + let _ = db.record_error(&row.id, &error.to_string()); + Err(error) + } + } +} + +fn print_create_summary(draft: &PublicationDraft, expiry: &str, envelope_size: usize) { + output::detail("title", &draft.snapshot.title); + output::detail("turns", draft.turn_count()); + output::detail("messages", draft.item_count()); + output::detail("envelope", format!("{envelope_size} bytes")); + output::detail("redactions", draft.redaction_count); + output::detail("expiry", expiry); + output::detail( + "source", + "local WorkSet; original refs and paths stay local", + ); +} + +fn print_risks(draft: &PublicationDraft) { + if draft.risks.is_empty() { + eprintln!("risk warnings: none"); + } else { + for risk in &draft.risks { + eprintln!( + "risk {}: {} item(s){}", + risk.kind, + risk.count, + format_item_indices(&risk.item_indices) + ); + } + } +} + +fn format_item_indices(indices: &[usize]) -> String { + if indices.is_empty() { + String::new() + } else { + format!( + " (message {})", + indices + .iter() + .map(usize::to_string) + .collect::>() + .join(", ") + ) + } +} + +fn is_warning_only(kind: &str) -> bool { + matches!(kind, "absolute_path" | "email" | "internal_url") +} + +fn require_allow_warnings(has_warnings: bool, allow_warnings: bool) -> Result<()> { + if has_warnings && !allow_warnings { + bail!("publish with privacy warnings requires --allow-warnings"); + } + Ok(()) +} + +fn resolve_endpoint(config: &SivtrConfig) -> Result { + let endpoint = config.publish.endpoint.trim().trim_end_matches('/'); + if endpoint.is_empty() { + bail!( + "[publish].endpoint is not set; add the publication service URL to config.toml (for example https://share.hnnulwh.cn)" + ); + } + Ok(endpoint.to_string()) +} + +fn random_token(length: usize) -> Result { + let mut bytes = vec![0_u8; length]; + getrandom::fill(&mut bytes).context("OS random source unavailable")?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn compress_snapshot(draft: &PublicationDraft) -> Result> { + ensure_snapshot_plaintext_limit(draft.canonical_json.len())?; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(draft.canonical_json.as_bytes()) + .context("write compressed publication snapshot")?; + encoder + .finish() + .context("finish compressed publication snapshot") +} + +fn ensure_snapshot_plaintext_limit(len: usize) -> Result<()> { + if len > SNAPSHOT_PLAINTEXT_LIMIT { + bail!( + "publication snapshot is {len} bytes uncompressed; maximum is 16 MiB; narrow the WorkSet" + ); + } + Ok(()) +} + +fn encrypt_snapshot(draft: &PublicationDraft, id: &str, viewer_key: &str) -> Result> { + let mut nonce_bytes = [0_u8; 12]; + getrandom::fill(&mut nonce_bytes).context("OS random source unavailable")?; + encrypt_snapshot_with_nonce(draft, id, viewer_key, nonce_bytes) +} + +fn encrypt_snapshot_with_nonce( + draft: &PublicationDraft, + id: &str, + viewer_key: &str, + nonce_bytes: [u8; 12], +) -> Result> { + let key_bytes = URL_SAFE_NO_PAD + .decode(viewer_key) + .context("invalid generated viewer key")?; + let key = Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&nonce_bytes); + let mut compressed = compress_snapshot(draft)?; + let aad = format!("sivtr-publication-v1:{id}"); + let tag = cipher + .encrypt_in_place_detached(nonce, aad.as_bytes(), &mut compressed) + .map_err(|_| anyhow::anyhow!("AES-GCM encryption failed"))?; + let mut envelope = Vec::with_capacity(8 + 2 + 12 + compressed.len() + tag.len()); + envelope.extend_from_slice(ENVELOPE_MAGIC); + envelope.extend_from_slice(&[1, 1]); // envelope v1, gzip compression + envelope.extend_from_slice(&nonce_bytes); + envelope.extend_from_slice(&compressed); + envelope.extend_from_slice(&tag); + if envelope.len() > ENVELOPE_LIMIT { + bail!("encrypted publication envelope exceeds 5 MiB"); + } + Ok(envelope) +} + +fn publication_url(endpoint: &str, id: &str, viewer_key: &str) -> String { + format!( + "{}/s/{}#k={}", + endpoint.trim_end_matches('/'), + id, + viewer_key + ) +} + +fn agent(timeout: Duration) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .build() + .new_agent() +} + +fn upload(endpoint: &str, id: &str, management_token: &str, envelope: &[u8]) -> Result<()> { + let url = format!("{endpoint}/api/v1/publications/{id}"); + let response = agent(Duration::from_secs(30)) + .put(&url) + .header("Content-Type", "application/octet-stream") + .header("X-Sivtr-Management-Token", management_token) + .send(envelope) + .with_context(|| format!("publication upload failed: {url}"))?; + if !response.status().is_success() { + bail!("publication upload returned HTTP {}", response.status()); + } + Ok(()) +} + +fn delete_remote(endpoint: &str, id: &str, management_token: &str) -> Result<()> { + let url = format!("{endpoint}/api/v1/publications/{id}"); + let response = agent(Duration::from_secs(30)) + .delete(&url) + .header("X-Sivtr-Management-Token", management_token) + .call() + .with_context(|| format!("publication revoke failed: {url}"))?; + if !response.status().is_success() { + bail!("publication revoke returned HTTP {}", response.status()); + } + Ok(()) +} + +fn list_item(row: &PublicationRow) -> PublicationListItem { + PublicationListItem { + publication_id: row.id.clone(), + title: row.title.clone(), + provider: row.provider.clone(), + status: row.status.as_str().to_string(), + created_at: row.created_at.clone(), + expires_at: row.expires_at.clone(), + redaction_count: row.redaction_count, + warning_count: row.warning_count, + content_sha256: row.content_sha256.clone(), + last_error: row.last_error.clone(), + } +} + +struct PublicationDb { + connection: Connection, +} + +impl PublicationDb { + fn open() -> Result { + let dir = workspace::data_dir(); + std::fs::create_dir_all(&dir)?; + restrict_directory(&dir)?; + let path = dir.join("publication-state.db"); + let connection = Connection::open(&path)?; + restrict_file(&path)?; + Self::from_connection(connection) + } + + fn from_connection(connection: Connection) -> Result { + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS publications ( + publication_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, + viewer_key TEXT NOT NULL, + management_token TEXT NOT NULL, + title TEXT NOT NULL, + provider TEXT NOT NULL, + source_refs TEXT NOT NULL, + content_sha256 TEXT NOT NULL, + redaction_count INTEGER NOT NULL, + warning_count INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + last_error TEXT + );", + )?; + Ok(Self { connection }) + } + + fn insert_pending(&mut self, row: &PublicationRow) -> Result<()> { + self.connection.execute( + "INSERT INTO publications (publication_id, endpoint, viewer_key, management_token, title, provider, source_refs, content_sha256, redaction_count, warning_count, created_at, expires_at, status, last_error) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", + params![row.id, row.endpoint, row.viewer_key, row.management_token, row.title, row.provider, row.source_refs, row.content_sha256, row.redaction_count, row.warning_count, row.created_at, row.expires_at, row.status.as_str(), row.last_error], + )?; + Ok(()) + } + + fn update_status( + &self, + id: &str, + status: PublicationStatus, + error: Option<&str>, + ) -> Result<()> { + self.connection.execute( + "UPDATE publications SET status = ?1, last_error = ?2 WHERE publication_id = ?3", + params![status.as_str(), error, id], + )?; + Ok(()) + } + + fn mark_active(&self, id: &str) -> Result<()> { + self.update_status(id, PublicationStatus::Active, None) + } + fn mark_failed(&self, id: &str, error: &str) -> Result<()> { + self.update_status(id, PublicationStatus::Failed, Some(error)) + } + fn mark_revoked(&self, id: &str) -> Result<()> { + self.update_status(id, PublicationStatus::Revoked, None) + } + fn record_error(&self, id: &str, error: &str) -> Result<()> { + self.connection.execute( + "UPDATE publications SET last_error = ?1 WHERE publication_id = ?2", + params![error, id], + )?; + Ok(()) + } + + fn find(&self, id: &str) -> Result> { + self.connection.query_row("SELECT publication_id, endpoint, viewer_key, management_token, title, provider, source_refs, content_sha256, redaction_count, warning_count, created_at, expires_at, status, last_error FROM publications WHERE publication_id = ?1", params![id], row_from_query).optional().map_err(Into::into) + } + + fn rows(&self) -> Result> { + let mut statement = self.connection.prepare("SELECT publication_id, endpoint, viewer_key, management_token, title, provider, source_refs, content_sha256, redaction_count, warning_count, created_at, expires_at, status, last_error FROM publications ORDER BY created_at DESC")?; + let rows = statement + .query_map([], row_from_query)? + .collect::>>()?; + Ok(rows) + } + + fn refresh_expired(&mut self) -> Result<()> { + let now = Utc::now(); + let rows = self + .connection + .prepare("SELECT publication_id, expires_at FROM publications WHERE status IN ('pending', 'active')")? + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))? + .collect::>>()?; + for (id, expires_at) in rows { + if is_expired_at(&expires_at, now) { + self.connection.execute( + "UPDATE publications SET status = 'expired' WHERE publication_id = ?1", + params![id], + )?; + } + } + Ok(()) + } +} + +fn is_expired(value: &str) -> bool { + is_expired_at(value, Utc::now()) +} + +fn is_expired_at(value: &str, now: DateTime) -> bool { + DateTime::parse_from_rfc3339(value) + .map(|timestamp| timestamp.with_timezone(&Utc) <= now) + .unwrap_or(true) +} + +#[cfg(unix)] +fn restrict_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_directory(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn restrict_file(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_file(_path: &Path) -> Result<()> { + Ok(()) +} + +fn row_from_query(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(PublicationRow { + id: row.get(0)?, + endpoint: row.get(1)?, + viewer_key: row.get(2)?, + management_token: row.get(3)?, + title: row.get(4)?, + provider: row.get(5)?, + source_refs: row.get(6)?, + content_sha256: row.get(7)?, + redaction_count: row.get(8)?, + warning_count: row.get(9)?, + created_at: row.get(10)?, + expires_at: row.get(11)?, + status: row + .get::<_, String>(12)? + .parse() + .map_err(|_| rusqlite::Error::InvalidQuery)?, + last_error: row.get(13)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_has_publication_header_and_aad_is_id_bound() { + let snapshot = sivtr_core::publication::PublicConversationV1 { + schema_version: 1, + title: "t".into(), + provider: "codex".into(), + published_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2026-01-08T00:00:00Z".into(), + items: vec![], + }; + let draft = PublicationDraft { + canonical_json: serde_json::to_string(&snapshot).unwrap(), + snapshot, + content_sha256: "x".into(), + redaction_count: 0, + risks: vec![], + source_provider: "codex".into(), + source_refs: vec![], + }; + let key = URL_SAFE_NO_PAD.encode([7_u8; 32]); + let envelope = encrypt_snapshot(&draft, "7d_abc", &key).unwrap(); + assert_eq!(&envelope[..8], ENVELOPE_MAGIC); + assert_eq!(envelope[8], 1); + assert_eq!(envelope[9], 1); + assert_ne!(encrypt_snapshot(&draft, "7d_abc", &key).unwrap(), envelope); + let fixture = encrypt_snapshot_with_nonce( + &draft, + "7d_0123456789abcdefghijkl", + &key, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + ) + .unwrap(); + assert_eq!(fixture.len(), 160); + } + + #[test] + fn uncompressed_snapshot_over_16mib_is_rejected() { + assert!(ensure_snapshot_plaintext_limit(SNAPSHOT_PLAINTEXT_LIMIT).is_ok()); + assert!(ensure_snapshot_plaintext_limit(SNAPSHOT_PLAINTEXT_LIMIT + 1).is_err()); + } + + #[test] + fn warnings_always_require_explicit_allow() { + assert!(require_allow_warnings(true, false).is_err()); + assert!(require_allow_warnings(true, true).is_ok()); + assert!(require_allow_warnings(false, false).is_ok()); + } + + #[test] + fn empty_endpoint_is_rejected() { + let mut config = SivtrConfig::default(); + config.publish.endpoint.clear(); + assert!(resolve_endpoint(&config).is_err()); + config.publish.endpoint = "https://share.hnnulwh.cn/".into(); + assert_eq!( + resolve_endpoint(&config).unwrap(), + "https://share.hnnulwh.cn" + ); + } + + #[test] + fn only_drive_paths_bypass_origin_scope_validation() { + assert!(is_windows_drive_path("C:\\logs")); + assert!(!is_windows_drive_path("r:codex/session/1")); + } + + #[test] + fn url_keeps_key_in_fragment() { + let url = publication_url( + "https://share.hnnulwh.cn", + "7d_0123456789abcdefghijkl", + "key", + ); + assert_eq!( + url, + "https://share.hnnulwh.cn/s/7d_0123456789abcdefghijkl#k=key" + ); + } + + #[test] + fn local_registry_tracks_pending_active_failed_and_revoked() { + let connection = Connection::open_in_memory().unwrap(); + let mut db = PublicationDb::from_connection(connection).unwrap(); + let row = PublicationRow { + id: "7d_0123456789abcdefghijkl".into(), + endpoint: "https://share.hnnulwh.cn".into(), + viewer_key: "k".into(), + management_token: "m".into(), + title: "title".into(), + provider: "codex".into(), + source_refs: "[]".into(), + content_sha256: "hash".into(), + redaction_count: 0, + warning_count: 0, + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + status: PublicationStatus::Pending, + last_error: None, + }; + db.insert_pending(&row).unwrap(); + assert_eq!( + db.find(&row.id).unwrap().unwrap().status, + PublicationStatus::Pending + ); + db.mark_active(&row.id).unwrap(); + assert_eq!( + db.find(&row.id).unwrap().unwrap().status, + PublicationStatus::Active + ); + db.mark_failed(&row.id, "network").unwrap(); + assert_eq!( + db.find(&row.id).unwrap().unwrap().status, + PublicationStatus::Failed + ); + db.mark_revoked(&row.id).unwrap(); + assert_eq!( + db.find(&row.id).unwrap().unwrap().status, + PublicationStatus::Revoked + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 01608b17..a0855ff7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,9 @@ fn run() -> Result<()> { Some(Commands::Show(args)) => { commands::memory::show::execute(&args)?; } + Some(Commands::Publish(command)) => { + commands::publish::execute(command)?; + } Some(Commands::Mcp(cmd)) => { commands::system::mcp::execute(cmd)?; } diff --git a/src/remote/redact.rs b/src/remote/redact.rs index 845e11ed..6fa5788b 100644 --- a/src/remote/redact.rs +++ b/src/remote/redact.rs @@ -1,127 +1,56 @@ -//! Basic secret redaction for served records. +//! Shared secret redaction for the live remote-sharing transport. //! -//! Terminal and agent output routinely contains credentials (API keys, tokens, -//! connection strings). Since `sivtr serve` exposes workspace memory over the -//! network, responses pass through here first. This is a coarse, best-effort -//! redactor — it is not a security boundary on its own. Strong authentication -//! and network scoping (localhost default, opt-in LAN bind) are the real -//! boundary; this layer reduces the blast radius if a secret slipped into -//! captured output. +//! The implementation lives in `sivtr-core::privacy` so public publication +//! and device-to-device sharing cannot silently drift apart. -use regex::Regex; +use anyhow::Result; +use sivtr_core::privacy; use sivtr_core::record::{WorkPart, WorkPartData, WorkRecord}; -const REDACTED: &str = "[REDACTED]"; - -/// Patterns that match common leaked credential formats. Intentionally narrow -/// to high-signal prefixes to avoid redacting ordinary text; matched spans are -/// replaced wholesale. -fn patterns() -> Vec<(&'static str, Regex)> { - // Compiled once per call; the serve path is not hot, and keeping this lazy - // avoids a module-level unwrap. - vec![ - // GitHub personal access tokens / fine-grained - ("github_pat", Regex::new(r"gh[pousr]_[A-Za-z0-9]{16,}").unwrap()), - // OpenAI / Anthropic-style API keys - ("openai_key", Regex::new(r"sk-[A-Za-z0-9]{16,}").unwrap()), - // sivtr serve connection tokens (s- namespace) — redact our own tokens so a - // generated token that leaks into captured output is masked too. - ("sivtr_token", Regex::new(r"s-[A-Za-z0-9]{16,}").unwrap()), - // Slack tokens - ("slack_token", Regex::new(r"xox[abprs]-[A-Za-z0-9-]{10,}").unwrap()), - // AWS access key ids - ("aws_id", Regex::new(r"AKIA[0-9A-Z]{16}").unwrap()), - // AWS secret access key assignments - ("aws_secret", Regex::new(r#"(?i)aws_secret_access_key['"\s:=]+[A-Za-z0-9/+=]{40}"#).unwrap()), - // Generic secret assignments with a value - ( - "assigned_secret", - Regex::new(r#"(?i)(api[_-]?key|token|password|secret|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{12,}['"]?"#).unwrap(), - ), - // Bearer tokens in Authorization headers - ("bearer", Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{16,}").unwrap()), - // Private keys (PEM blocks) — whole block collapsed - ( - "pem_key", - Regex::new(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----").unwrap(), - ), - ] -} - -/// Redact obvious secrets in a free-text field. -fn redact_text(value: &str, patterns: &[(&'static str, Regex)]) -> String { - let mut current = value.to_string(); - for (_, re) in patterns { - current = re.replace_all(¤t, REDACTED).into_owned(); - } - current -} - -/// Return a clone of `record` with secret-bearing text fields redacted: -/// title, each part's text/ansi/label. Structural fields (refs, times, status) -/// are untouched. -pub fn redact_record(record: &WorkRecord) -> WorkRecord { - let patterns = patterns(); +pub fn redact_record(record: &WorkRecord) -> Result { let mut out = record.clone(); - out.title = redact_text(&out.title, &patterns); + out.title = privacy::redact_text(&out.title)?; out.parts = out .parts .into_iter() - .map(|part| redact_part(part, &patterns)) - .collect(); - out + .map(redact_part) + .collect::>>()?; + Ok(out) } -pub fn redact_part(mut part: WorkPart, patterns: &[(&'static str, Regex)]) -> WorkPart { +pub fn redact_part(mut part: WorkPart) -> Result { match &mut part.data { WorkPartData::Prompt { content, ansi } | WorkPartData::Output { content, ansi } => { - *content = redact_text(content, patterns); + *content = privacy::redact_text(content)?; if let Some(value) = ansi { - *value = redact_text(value, patterns); + *value = privacy::redact_text(value)?; } } WorkPartData::Command { content } | WorkPartData::User { content } | WorkPartData::Assistant { content } | WorkPartData::Thinking { content } - | WorkPartData::Error { content } => *content = redact_text(content, patterns), + | WorkPartData::Error { content } => *content = privacy::redact_text(content)?, WorkPartData::ToolCall { tool, input, .. } => { if let Some(value) = tool { - *value = redact_text(value, patterns); + *value = privacy::redact_text(value)?; } - redact_json(input, patterns); + privacy::redact_json(input)?; } WorkPartData::ToolResult { tool, output, .. } => { if let Some(value) = tool { - *value = redact_text(value, patterns); + *value = privacy::redact_text(value)?; } - redact_json(output, patterns); + privacy::redact_json(output)?; } WorkPartData::Skill { skill, content } => { if let Some(value) = skill { - *value = redact_text(value, patterns); - } - *content = redact_text(content, patterns); - } - } - part -} - -fn redact_json(value: &mut serde_json::Value, patterns: &[(&'static str, Regex)]) { - match value { - serde_json::Value::String(text) => *text = redact_text(text, patterns), - serde_json::Value::Array(items) => { - for item in items { - redact_json(item, patterns); + *value = privacy::redact_text(value)?; } + *content = privacy::redact_text(content)?; } - serde_json::Value::Object(object) => { - for value in object.values_mut() { - redact_json(value, patterns); - } - } - serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {} } + Ok(part) } #[cfg(test)] @@ -130,42 +59,37 @@ mod tests { #[test] fn redacts_known_token_formats() { - let patterns = patterns(); assert_eq!( - redact_text("token ghp_aBcDeF0123456789ghij", &patterns), + privacy::redact_text("token ghp_aBcDeF0123456789ghij") + .expect("privacy patterns should compile"), "token [REDACTED]" ); assert_eq!( - redact_text("key=sk-abcd1234efgh5678ijkl", &patterns), + privacy::redact_text("key=sk-abcd1234efgh5678ijkl") + .expect("privacy patterns should compile"), "key=[REDACTED]" ); assert_eq!( - redact_text("token s-deadbeefcafef00d1234567890abcdef", &patterns), - "token [REDACTED]" - ); - assert_eq!( - redact_text("Authorization: Bearer abcdef1234567890XYZ", &patterns), + privacy::redact_text("Authorization: Bearer abcdef1234567890XYZ") + .expect("privacy patterns should compile"), "Authorization: [REDACTED]" ); } #[test] fn redacts_pem_private_key_blocks() { - let patterns = patterns(); - let input = "before -----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA1234567890 ------END RSA PRIVATE KEY----- after"; - let out = redact_text(input, &patterns); + let input = "before -----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1234567890\n-----END RSA PRIVATE KEY----- after"; + let out = privacy::redact_text(input).expect("privacy patterns should compile"); assert!(!out.contains("MIIEpAIBAA")); assert!(out.contains("[REDACTED]")); - assert!(out.contains("before")); - assert!(out.contains("after")); } #[test] fn does_not_redact_plain_text() { - let patterns = patterns(); - let out = redact_text("the build succeeded with 42 warnings", &patterns); - assert_eq!(out, "the build succeeded with 42 warnings"); + assert_eq!( + privacy::redact_text("the build succeeded with 42 warnings") + .expect("privacy patterns should compile"), + "the build succeeded with 42 warnings" + ); } }