diff --git a/.agents/docs/github-work-index.md b/.agents/docs/github-work-index.md index 8113a35..1576dd4 100644 --- a/.agents/docs/github-work-index.md +++ b/.agents/docs/github-work-index.md @@ -20,24 +20,41 @@ When adding work, update **this file**—do not sprinkle `#N` into other paths. ## Commerce / Stripe / entitlements -Parent: [Website + Stripe: product suite checkout and subscriptions](https://github.com/BreadchainCoop/sigstack-bot/issues/13) +**Current epic:** [Epic: CipherSlate commerce — Stripe, entitlements, alpha](https://github.com/BreadchainCoop/sigstack-bot/issues/68) (children #53, #55–#65, #70) +**Superseded:** [#13](https://github.com/BreadchainCoop/sigstack-bot/issues/13) (closed — education / early site scope done) + +### Fast alpha (outside epic) + +| # | Title | Notes | +|---|--------|--------| +| [69](https://github.com/BreadchainCoop/sigstack-bot/issues/69) | feat: alpha code redeem via !link (static site path) | Tonight MVP; not a child of #68 | + +### Related (not epic children) | # | Title | Notes | |---|--------|--------| | [52](https://github.com/BreadchainCoop/sigstack-bot/issues/52) | decision: stripe checkout and webhook hosting architecture | Decision | -| [53](https://github.com/BreadchainCoop/sigstack-bot/issues/53) | feat: entitlement model and encrypted store on cvm | `entitlements.enc` / `EntitlementsStore` | | [54](https://github.com/BreadchainCoop/sigstack-bot/issues/54) | ops: stripe products and prices for cipherslate plans | Catalog; SKUs follow `site/src/lib/content/en.ts` | + +### Epic children (#68) + +| # | Title | Notes | +|---|--------|--------| +| [53](https://github.com/BreadchainCoop/sigstack-bot/issues/53) | feat: entitlement model and encrypted store on cvm | Shipped; `entitlements.enc` / composition architecture | | [55](https://github.com/BreadchainCoop/sigstack-bot/issues/55) | feat: stripe checkout session api from plan sku | Checkout session + success redirect query shape | | [56](https://github.com/BreadchainCoop/sigstack-bot/issues/56) | feat: stripe webhooks and subscription lifecycle sync | | -| [57](https://github.com/BreadchainCoop/sigstack-bot/issues/57) | feat: !link and !claim-group for subscription binding | | +| [57](https://github.com/BreadchainCoop/sigstack-bot/issues/57) | feat: !link and !claim-group for subscription binding | Stripe `link_token` bind (alpha-only is #69) | | [58](https://github.com/BreadchainCoop/sigstack-bot/issues/58) | feat: gate product commands on entitlements | | -| [59](https://github.com/BreadchainCoop/sigstack-bot/issues/59) | feat: alpha promo codes — 3-month bundle-all free path | `bundle-all-alpha` | -| [60](https://github.com/BreadchainCoop/sigstack-bot/issues/60) | ops: alpha code generation and revocation tooling | | +| [59](https://github.com/BreadchainCoop/sigstack-bot/issues/59) | feat: alpha promo codes — 3-month bundle-all free path | Superseded by #69 + #70 | +| [60](https://github.com/BreadchainCoop/sigstack-bot/issues/60) | ops: alpha code generation and revocation tooling | Minimal mint in #69; fuller ops later | | [61](https://github.com/BreadchainCoop/sigstack-bot/issues/61) | feat: wire plans ctas to stripe checkout | | | [62](https://github.com/BreadchainCoop/sigstack-bot/issues/62) | feat: checkout success cancel and alpha claim pages | `site/` commerce landings | | [63](https://github.com/BreadchainCoop/sigstack-bot/issues/63) | feat: revise get-started flow and entitlement user comms | | | [64](https://github.com/BreadchainCoop/sigstack-bot/issues/64) | feat: deploy commerce service and entitlements on phala cvm | | | [65](https://github.com/BreadchainCoop/sigstack-bot/issues/65) | test: commerce and entitlement e2e test plan | | +| [70](https://github.com/BreadchainCoop/sigstack-bot/issues/70) | feat: alpha coexistence with Stripe link and paid gating | After Stripe `!link` spine | + +**Recommended order (epic [#68](https://github.com/BreadchainCoop/sigstack-bot/issues/68)):** foundation #53+#62 (done) → parallel #63/#65 + related #52/#54 → #54→#55→#56 → #57 → #58 → #70 → #61 → #64 → finish #65 before prod enforce. Fast alpha #69 is **outside** this epic. Full write-up on the epic issue. ## Product suite / architecture diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 4249750..f723ad0 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -34,6 +34,8 @@ jobs: - run: npm run build env: BASE_PATH: /sigstack-bot/cypherslate + PUBLIC_SIGNAL_USERNAME_LINK: ${{ vars.PUBLIC_SIGNAL_USERNAME_LINK }} + PUBLIC_STRIPE_PORTAL_URL: ${{ vars.PUBLIC_STRIPE_PORTAL_URL }} - name: Prepare Pages artifact for /sigstack-bot/cypherslate shell: bash run: | diff --git a/crates/signal-bot/Cargo.toml b/crates/signal-bot/Cargo.toml index 5261c0f..3ae3b3a 100644 --- a/crates/signal-bot/Cargo.toml +++ b/crates/signal-bot/Cargo.toml @@ -7,6 +7,10 @@ edition.workspace = true name = "signal-bot" path = "src/main.rs" +[[bin]] +name = "mint-alpha" +path = "src/bin/mint-alpha.rs" + [dependencies] # Workspace crates signal-bot-core = { path = "../signal-bot-core" } diff --git a/crates/signal-bot/src/bin/mint-alpha.rs b/crates/signal-bot/src/bin/mint-alpha.rs new file mode 100644 index 0000000..98c2a75 --- /dev/null +++ b/crates/signal-bot/src/bin/mint-alpha.rs @@ -0,0 +1,107 @@ +//! Ops CLI: mint pending alpha entitlement codes into the encrypted store. +//! +//! ```bash +//! # On CVM / with dstack + volume: +//! cargo run -p signal-bot --bin mint-alpha -- --count 5 +//! +//! # Env (defaults match compose): +//! # ENTITLEMENTS__STORAGE_PATH=/data/entitlements.enc +//! # DSTACK__SOCKET_PATH=/var/run/dstack.sock +//! # ENTITLEMENTS__LEGACY_COMPOSE_HASH= (optional) +//! ``` + +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use dstack_client::DstackClient; +use signal_bot::entitlements_store::EntitlementsStore; +use std::env; +use std::path::PathBuf; +use std::sync::Arc; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +fn legacy_hashes(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +#[tokio::main] +async fn main() -> Result<()> { + let _ = dotenvy::dotenv(); + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let args: Vec = env::args().skip(1).collect(); + let mut count: usize = 1; + let mut days: i64 = 90; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--count" => { + i += 1; + count = args + .get(i) + .context("--count needs a value")? + .parse() + .context("invalid --count")?; + } + "--days" => { + i += 1; + days = args + .get(i) + .context("--days needs a value")? + .parse() + .context("invalid --days")?; + } + "--help" | "-h" => { + eprintln!( + "Usage: mint-alpha [--count N] [--days 90]\n\ + Env: ENTITLEMENTS__STORAGE_PATH (default /data/entitlements.enc)\n\ + DSTACK__SOCKET_PATH (default /var/run/dstack.sock)\n\ + ENTITLEMENTS__LEGACY_COMPOSE_HASH (optional)" + ); + return Ok(()); + } + other => bail!("unknown argument: {other}"), + } + i += 1; + } + + let storage_path = + env::var("ENTITLEMENTS__STORAGE_PATH").unwrap_or_else(|_| "/data/entitlements.enc".into()); + let socket = env::var("DSTACK__SOCKET_PATH").unwrap_or_else(|_| "/var/run/dstack.sock".into()); + let legacy = env::var("ENTITLEMENTS__LEGACY_COMPOSE_HASH").unwrap_or_default(); + + let dstack = Arc::new(DstackClient::new(&socket)); + let store = EntitlementsStore::open( + dstack, + PathBuf::from(&storage_path), + true, + legacy_hashes(&legacy), + ) + .await; + + let codes = store + .mint_alpha_codes(count, days) + .map_err(|e| anyhow::anyhow!(e))?; + store.flush().await.map_err(|e| anyhow::anyhow!(e))?; + + info!( + count = codes.len(), + days, + path = %storage_path, + "minted alpha codes at {}", + Utc::now() + ); + + println!("# alpha codes (single-use, {days}-day expiry) — share /alpha?code="); + for code in &codes { + println!("{code}"); + } + Ok(()) +} diff --git a/crates/signal-bot/src/commands/link.rs b/crates/signal-bot/src/commands/link.rs new file mode 100644 index 0000000..8e47ebf --- /dev/null +++ b/crates/signal-bot/src/commands/link.rs @@ -0,0 +1,332 @@ +//! `!link ` and `!claim-group` — bind pending entitlements (alpha MVP). + +use crate::commands::CommandHandler; +use crate::entitlements_store::{ + is_reusable_alpha_code, EntitlementSource, EntitlementsStore, PlanSku, RedeemReuseError, +}; +use crate::error::AppResult; +use async_trait::async_trait; +use chrono::Utc; +use signal_bot_core::{starts_with_word, strip_word_prefix}; +use signal_client::BotMessage; +use std::sync::Arc; + +const LINK_USAGE: &str = "Usage: !link "; +const CLAIM_USAGE: &str = "Usage: !claim-group (run in the Signal group to claim)"; +const LINK_GROUP_WARN: &str = + "Tip: prefer DMing this bot with !link so your code is not visible in the group."; +const ALREADY_ACTIVE_MSG: &str = + "You already have an active alpha entitlement linked to this account."; + +pub struct LinkHandler { + entitlements: Arc, +} + +impl LinkHandler { + pub fn new(entitlements: Arc) -> Self { + Self { entitlements } + } + + fn owner_key(message: &BotMessage) -> String { + // Prefer envelope source (often UUID); phone is already in source when UUID absent. + message.source.trim().to_string() + } + + fn parse_link_code(text: &str) -> Option<&str> { + Some(strip_word_prefix(text, "!link")?.trim()) + } + + fn linked_reply( + bound_sku: PlanSku, + bound_source: EntitlementSource, + expires_at: Option>, + is_group: bool, + ) -> String { + let mut reply = format!( + "Linked. Plan: {:?} ({:?}). You have access through {}.", + bound_sku, + bound_source, + expires_at + .map(|t| t.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "the plan end date".into()) + ); + if bound_sku.is_group_claimable() { + reply.push_str( + "\n\nIn a group, run !claim-group to attach group-scope features to that chat.", + ); + } + if is_group { + reply = format!("{LINK_GROUP_WARN}\n\n{reply}"); + } + reply + } + + async fn handle_link(&self, message: &BotMessage) -> AppResult { + let Some(code) = Self::parse_link_code(&message.text) else { + return Ok(LINK_USAGE.into()); + }; + if code.is_empty() { + return Ok(format!("Code cannot be empty.\n{LINK_USAGE}")); + } + + let owner = Self::owner_key(message); + if owner.is_empty() { + return Ok("Could not determine your Signal identity. Try again from Signal.".into()); + } + + if is_reusable_alpha_code(code) { + return match self.entitlements.redeem_reusable_alpha(owner) { + Ok(bound) => Ok(Self::linked_reply( + bound.plan_sku, + bound.source, + bound.expires_at, + message.is_group, + )), + Err(RedeemReuseError::AlreadyActive) => Ok(ALREADY_ACTIVE_MSG.into()), + Err(RedeemReuseError::EmptyOwner) => { + Ok("Could not determine your Signal identity. Try again from Signal.".into()) + } + }; + } + + let pending = match self.entitlements.get_pending(code) { + Some(p) => p, + None => { + let owned = self.entitlements.get_individual(&owner); + if owned.iter().any(|r| { + r.plan_sku == PlanSku::BundleAllAlpha + && r.source == EntitlementSource::Alpha + && r.is_granting_at(Utc::now()) + }) { + return Ok(ALREADY_ACTIVE_MSG.into()); + } + return Ok( + "Unknown or already-used code. Check the code and try again, or ask for a new alpha code." + .into(), + ); + } + }; + + if !pending.is_granting_at(Utc::now()) { + let _ = self.entitlements.expire_due(Utc::now()); + return Ok("That code has expired. Ask for a new alpha code.".into()); + } + + match self.entitlements.bind_link_token(code, owner.clone()) { + Ok(bound) => Ok(Self::linked_reply( + bound.plan_sku, + bound.source, + bound.expires_at, + message.is_group, + )), + Err(e) => Ok(format!("Could not link that code: {e}")), + } + } + + async fn handle_claim_group(&self, message: &BotMessage) -> AppResult { + let Some(group_id) = message.group_id.as_deref() else { + return Ok(CLAIM_USAGE.into()); + }; + let owner = Self::owner_key(message); + if owner.is_empty() { + return Ok("Could not determine your Signal identity.".into()); + } + + let records = self.entitlements.get_individual(&owner); + let Some(record) = records.iter().find(|r| { + r.plan_sku.is_group_claimable() + && r.claimed_group_id.is_none() + && r.is_granting_at(Utc::now()) + }) else { + if records.iter().any(|r| { + r.plan_sku.is_group_claimable() + && r.claimed_group_id.as_deref() == Some(group_id) + && r.is_granting_at(Utc::now()) + }) { + return Ok("This group is already claimed for your entitlement.".into()); + } + return Ok( + "No claimable entitlement found. Link an alpha (or group) plan with !link first." + .into(), + ); + }; + + match self + .entitlements + .claim_group(&owner, &record.id, group_id.to_string()) + { + Ok(_) => Ok( + "Claimed this group for your entitlement. Group-scope features apply here.".into(), + ), + Err(e) => Ok(format!("Could not claim this group: {e}")), + } + } +} + +#[async_trait] +impl CommandHandler for LinkHandler { + fn matches(&self, message: &BotMessage) -> bool { + starts_with_word(&message.text, "!link") || starts_with_word(&message.text, "!claim-group") + } + + fn label(&self) -> &'static str { + "link" + } + + async fn execute(&self, message: &BotMessage) -> AppResult { + if starts_with_word(&message.text, "!claim-group") { + return self.handle_claim_group(message).await; + } + if starts_with_word(&message.text, "!link") { + return self.handle_link(message).await; + } + Ok(LINK_USAGE.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::entitlements_store::REUSABLE_ALPHA_CODE; + use chrono::Duration; + + fn dm(text: &str, source: &str) -> BotMessage { + BotMessage { + source: source.into(), + source_number: Some("+15551234567".into()), + source_name: Some("Ada".into()), + text: text.into(), + timestamp: 1, + message_timestamp: 1, + is_group: false, + group_id: None, + group_name: None, + receiving_account: "+15550000000".into(), + attachments: vec![], + quote: None, + } + } + + fn group_msg(text: &str, source: &str, group_id: &str) -> BotMessage { + let mut m = dm(text, source); + m.is_group = true; + m.group_id = Some(group_id.into()); + m + } + + #[tokio::test] + async fn link_binds_pending_alpha() { + let store = EntitlementsStore::new_in_memory(); + let codes = store.mint_alpha_codes(1, 90).unwrap(); + let handler = LinkHandler::new(store.clone()); + + let reply = handler + .execute(&dm(&format!("!link {}", codes[0]), "uuid-ada")) + .await + .unwrap(); + assert!(reply.contains("Linked"), "{reply}"); + assert_eq!(store.get_individual("uuid-ada").len(), 1); + assert!(store.get_pending(&codes[0]).is_none()); + } + + #[tokio::test] + async fn link_rejects_unknown_code() { + let store = EntitlementsStore::new_in_memory(); + let handler = LinkHandler::new(store); + let reply = handler + .execute(&dm("!link not-a-real-code", "uuid-ada")) + .await + .unwrap(); + assert!(reply.contains("Unknown"), "{reply}"); + } + + #[tokio::test] + async fn link_rejects_expired() { + let store = EntitlementsStore::new_in_memory(); + let past = Utc::now() - Duration::days(1); + store + .create_pending( + "expired-code".into(), + PlanSku::BundleAllAlpha, + EntitlementSource::Alpha, + Some(past), + None, + None, + ) + .unwrap(); + let handler = LinkHandler::new(store); + let reply = handler + .execute(&dm("!link expired-code", "uuid-ada")) + .await + .unwrap(); + assert!(reply.to_lowercase().contains("expired"), "{reply}"); + } + + #[tokio::test] + async fn claim_group_after_link() { + let store = EntitlementsStore::new_in_memory(); + let codes = store.mint_alpha_codes(1, 90).unwrap(); + let handler = LinkHandler::new(store.clone()); + handler + .execute(&dm(&format!("!link {}", codes[0]), "uuid-ada")) + .await + .unwrap(); + + let reply = handler + .execute(&group_msg("!claim-group", "uuid-ada", "group.main")) + .await + .unwrap(); + assert!(reply.contains("Claimed"), "{reply}"); + assert_eq!(store.get_group("group.main").len(), 1); + } + + #[tokio::test] + async fn reusable_bread_friend_grants_two_owners() { + let store = EntitlementsStore::new_in_memory(); + let handler = LinkHandler::new(store.clone()); + + let a = handler + .execute(&dm(&format!("!link {REUSABLE_ALPHA_CODE}"), "uuid-a")) + .await + .unwrap(); + assert!(a.contains("Linked"), "{a}"); + let b = handler + .execute(&dm("!link Bread-Friend", "uuid-b")) + .await + .unwrap(); + assert!(b.contains("Linked"), "{b}"); + assert_eq!(store.get_individual("uuid-a").len(), 1); + assert_eq!(store.get_individual("uuid-b").len(), 1); + + let again = handler + .execute(&dm(&format!("!link {REUSABLE_ALPHA_CODE}"), "uuid-a")) + .await + .unwrap(); + assert!(again.contains("already have an active alpha"), "{again}"); + } + + #[tokio::test] + async fn minted_single_use_still_consumed() { + let store = EntitlementsStore::new_in_memory(); + let codes = store.mint_alpha_codes(1, 90).unwrap(); + let handler = LinkHandler::new(store.clone()); + handler + .execute(&dm(&format!("!link {}", codes[0]), "uuid-one")) + .await + .unwrap(); + assert!(store.get_pending(&codes[0]).is_none()); + let second = handler + .execute(&dm(&format!("!link {}", codes[0]), "uuid-two")) + .await + .unwrap(); + assert!(second.contains("Unknown"), "{second}"); + } + + #[test] + fn matches_link_and_claim() { + let handler = LinkHandler::new(EntitlementsStore::new_in_memory()); + assert!(handler.matches(&dm("!link abc", "u"))); + assert!(handler.matches(&dm("!claim-group", "u"))); + assert!(!handler.matches(&dm("!help", "u"))); + } +} diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index 9c12233..6d74eb0 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -3,6 +3,7 @@ #[cfg(test)] mod command_aliases; mod help; +mod link; mod menu_locale; mod privacy; mod product_menus; @@ -16,6 +17,7 @@ mod translate_service; mod verify; pub use help::{CommandsHandler, HelpHandler, InfoHandler}; +pub use link::LinkHandler; pub use privacy::PrivacyHandler; pub use product_menus::{ HelpInChatHandler, HelpThreadsHandler, HelpTranscriptionHandler, InChatMenuHandler, diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index b4899fe..7c0c356 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -75,8 +75,9 @@ pub struct NearAiConfig { #[derive(Debug, Clone, Deserialize)] pub struct BotConfig { - /// Signal username (e.g., "nearai.54") - #[serde(default)] + /// Signal username nickname (e.g. `cipherslate`; Signal adds `.NN`). + /// Empty string disables startup username ensure. + #[serde(default = "default_signal_username_opt")] pub signal_username: Option, /// GitHub repository URL @@ -181,7 +182,7 @@ impl Default for SignalConfig { impl Default for BotConfig { fn default() -> Self { Self { - signal_username: None, + signal_username: Some(default_signal_username()), github_repo: None, log_level: default_log_level(), } @@ -284,6 +285,14 @@ fn default_log_level() -> String { "info".into() } +fn default_signal_username() -> String { + "cipherslate".into() +} + +fn default_signal_username_opt() -> Option { + Some(default_signal_username()) +} + fn default_dstack_socket() -> String { "/var/run/dstack.sock".into() } diff --git a/crates/signal-bot/src/ensure_username.rs b/crates/signal-bot/src/ensure_username.rs new file mode 100644 index 0000000..8940271 --- /dev/null +++ b/crates/signal-bot/src/ensure_username.rs @@ -0,0 +1,95 @@ +//! Ensure the bot has a Signal username for public discovery (no E.164 on the site). + +use signal_client::SignalClient; +use tracing::{info, warn}; + +/// Claim / refresh a Signal username via signal-cli. +/// +/// Non-fatal on failure so a username API hiccup does not block the bot. +/// On success, logs `username` + `username_link` for ops to set +/// `PUBLIC_SIGNAL_USERNAME_LINK` on the marketing site. +pub async fn ensure_signal_username(signal: &SignalClient, phone_number: &str, nickname: &str) { + let nickname = nickname.trim(); + if nickname.is_empty() { + info!("BOT__SIGNAL_USERNAME empty — skipping Signal username ensure"); + return; + } + + // Nickname only (strip accidental discriminator from env). + let nickname = nickname + .split_once('.') + .map(|(nick, _)| nick) + .unwrap_or(nickname) + .trim(); + if nickname.is_empty() { + warn!("BOT__SIGNAL_USERNAME has no nickname — skipping Signal username ensure"); + return; + } + + match signal.set_username(phone_number, nickname).await { + Ok(info) => { + let username = info.username.as_deref().unwrap_or("(unknown)"); + let link = info + .username_link + .as_deref() + .unwrap_or("(no link returned)"); + info!( + username, + username_link = link, + "Signal username ready — set site PUBLIC_SIGNAL_USERNAME_LINK to username_link after re-register" + ); + } + Err(err) => { + warn!( + error = %err, + nickname, + "Failed to ensure Signal username (non-fatal); set manually via signal-cli if needed" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn ensure_sets_username_and_logs_link() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/accounts/%2B15555555555/username")) + .and(body_json(serde_json::json!({ "username": "cipherslate" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "username": "cipherslate.01", + "username_link": "https://signal.me/#eu/x" + }))) + .mount(&mock_server) + .await; + + let client = SignalClient::new(mock_server.uri()).unwrap(); + ensure_signal_username(&client, "+15555555555", "cipherslate.99").await; + } + + #[tokio::test] + async fn ensure_skips_empty_nickname() { + let mock_server = MockServer::start().await; + let client = SignalClient::new(mock_server.uri()).unwrap(); + // No mock — would fail if called. + ensure_signal_username(&client, "+15555555555", " ").await; + } + + #[tokio::test] + async fn ensure_does_not_panic_on_api_error() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/accounts/%2B15555555555/username")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&mock_server) + .await; + + let client = SignalClient::new(mock_server.uri()).unwrap(); + ensure_signal_username(&client, "+15555555555", "cipherslate").await; + } +} diff --git a/crates/signal-bot/src/entitlements_store.rs b/crates/signal-bot/src/entitlements_store.rs index 060103c..e00817e 100644 --- a/crates/signal-bot/src/entitlements_store.rs +++ b/crates/signal-bot/src/entitlements_store.rs @@ -219,6 +219,30 @@ fn new_record_id() -> String { hex::encode(bytes) } +/// Temporary reusable friend code for alpha (each linker gets their own 90-day grant). +pub const REUSABLE_ALPHA_CODE: &str = "bread-friend"; + +/// True when `code` matches the reusable friend alpha code (trim + case-insensitive). +pub fn is_reusable_alpha_code(code: &str) -> bool { + code.trim().eq_ignore_ascii_case(REUSABLE_ALPHA_CODE) +} + +/// Error from [`EntitlementsStore::redeem_reusable_alpha`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RedeemReuseError { + AlreadyActive, + EmptyOwner, +} + +impl std::fmt::Display for RedeemReuseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AlreadyActive => write!(f, "owner already has an active alpha entitlement"), + Self::EmptyOwner => write!(f, "owner_uuid must be non-empty"), + } + } +} + /// Resolve effective feature → winning source after alpha/paid composition. /// /// Paid (`stripe`) wins on overlap; alpha covers remaining bundle features. @@ -811,6 +835,83 @@ impl EntitlementsStore { pub async fn persist_now(&self) -> Result<(), String> { self.persist().await } + + /// Flush encrypted snapshot to disk (ops CLIs after minting). + pub async fn flush(&self) -> Result<(), String> { + self.persist().await + } + + /// Mint `count` single-use pending alpha codes (`bundle-all-alpha`, +`days` expiry). + /// Returns plaintext tokens (show once for `/alpha?code=` distribution). + pub fn mint_alpha_codes( + self: &Arc, + count: usize, + days: i64, + ) -> Result, String> { + if count == 0 { + return Err("count must be >= 1".into()); + } + if days <= 0 { + return Err("days must be >= 1".into()); + } + let expires_at = Utc::now() + chrono::Duration::days(days); + let mut codes = Vec::with_capacity(count); + for _ in 0..count { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let token = hex::encode(bytes); + self.create_pending( + token.clone(), + PlanSku::BundleAllAlpha, + EntitlementSource::Alpha, + Some(expires_at), + None, + None, + )?; + codes.push(token); + } + Ok(codes) + } + + /// Redeem the reusable friend code for `owner_uuid` (does not touch pending tokens). + /// + /// Grants a fresh `bundle-all-alpha` for 90 days. Fails if the owner already has an + /// active alpha bundle grant. + pub fn redeem_reusable_alpha( + self: &Arc, + owner_uuid: String, + ) -> Result { + if owner_uuid.trim().is_empty() { + return Err(RedeemReuseError::EmptyOwner); + } + let now = Utc::now(); + let owned = self.get_individual(&owner_uuid); + if owned.iter().any(|r| { + r.plan_sku == PlanSku::BundleAllAlpha + && r.source == EntitlementSource::Alpha + && r.is_granting_at(now) + }) { + return Err(RedeemReuseError::AlreadyActive); + } + + let record = EntitlementRecord { + id: new_record_id(), + plan_sku: PlanSku::BundleAllAlpha, + source: EntitlementSource::Alpha, + status: EntitlementStatus::Active, + expires_at: Some(now + chrono::Duration::days(90)), + stripe_customer_id: None, + stripe_subscription_id: None, + owner_uuid: Some(owner_uuid), + link_token: None, + claimed_group_id: None, + created_at: now, + updated_at: now, + }; + self.upsert(record.clone()) + .map_err(|_| RedeemReuseError::EmptyOwner)?; + Ok(record) + } } #[cfg(test)] @@ -981,6 +1082,63 @@ mod tests { assert_eq!(loaded.individuals["u1"].len(), 1); } + #[test] + fn mint_alpha_codes_creates_pending() { + let store = EntitlementsStore::new_in_memory(); + let codes = store.mint_alpha_codes(3, 90).unwrap(); + assert_eq!(codes.len(), 3); + for code in &codes { + let pending = store.get_pending(code).unwrap(); + assert_eq!(pending.plan_sku, PlanSku::BundleAllAlpha); + assert_eq!(pending.source, EntitlementSource::Alpha); + assert!(pending.expires_at.is_some()); + } + } + + #[test] + fn reusable_alpha_code_match_is_case_insensitive() { + assert!(is_reusable_alpha_code("bread-friend")); + assert!(is_reusable_alpha_code("Bread-Friend")); + assert!(is_reusable_alpha_code(" BREAD-FRIEND ")); + assert!(!is_reusable_alpha_code("bread-fiend")); + } + + #[test] + fn redeem_reusable_alpha_allows_two_owners() { + let store = EntitlementsStore::new_in_memory(); + let a = store.redeem_reusable_alpha("uuid-a".into()).unwrap(); + let b = store.redeem_reusable_alpha("uuid-b".into()).unwrap(); + assert_eq!(a.plan_sku, PlanSku::BundleAllAlpha); + assert_eq!(b.source, EntitlementSource::Alpha); + assert_eq!(store.get_individual("uuid-a").len(), 1); + assert_eq!(store.get_individual("uuid-b").len(), 1); + assert!(store.pending_by_token.read().unwrap().is_empty()); + } + + #[test] + fn redeem_reusable_alpha_rejects_while_active() { + let store = EntitlementsStore::new_in_memory(); + store.redeem_reusable_alpha("uuid-a".into()).unwrap(); + assert_eq!( + store.redeem_reusable_alpha("uuid-a".into()).unwrap_err(), + RedeemReuseError::AlreadyActive + ); + } + + #[test] + fn redeem_reusable_alpha_allows_again_after_expiry() { + let store = EntitlementsStore::new_in_memory(); + let first = store.redeem_reusable_alpha("uuid-a".into()).unwrap(); + // Force expiry on the active row. + store + .set_status(&first.id, EntitlementStatus::Expired) + .unwrap(); + let again = store.redeem_reusable_alpha("uuid-a".into()).unwrap(); + assert_ne!(first.id, again.id); + assert_eq!(again.status, EntitlementStatus::Active); + assert_eq!(store.get_individual("uuid-a").len(), 2); + } + #[test] fn plan_sku_serde_matches_site_ids() { assert_eq!( diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index e4134c6..10ca0cb 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -21,8 +21,8 @@ use whisper_client::WhisperClient; /// Result of wiring the unified bot: handlers plus long-lived stores. pub struct BuiltHandlers { pub handlers: Vec>, - /// Encrypted entitlements store. Held for process lifetime; CRUD for - /// future webhook / `!link` / gating — not consumed by commands yet. + /// Encrypted entitlements store. Held for process lifetime; consumed by + /// `!link` / `!claim-group` (and future webhook / gating). pub entitlements: Arc, } @@ -184,6 +184,7 @@ pub async fn build_handlers( group_prefs.clone(), signal.clone(), ))); + handlers.push(Box::new(LinkHandler::new(entitlements.clone()))); handlers.push(Box::new(CommandsHandler::new(group_prefs.clone()))); handlers.push(Box::new(VerifyHandler::new(dstack.clone()))); handlers.push(Box::new(HelpHandler::new())); @@ -283,7 +284,7 @@ mod tests { .expect("translation handlers"); let handlers = built.handlers; - assert_eq!(handlers.len(), 22); + assert_eq!(handlers.len(), 23); let got = labels(&handlers); assert!(got.contains(&"translate_me")); assert!(got.contains(&"voice")); @@ -306,6 +307,7 @@ mod tests { assert!(got.contains(&"translate_langs")); assert!(got.contains(&"translate_langs_in_chat")); assert!(got.contains(&"rename")); + assert!(got.contains(&"link")); assert!(got.contains(&"commands")); assert!(!got.contains(&"set_language")); assert!(got.contains(&"help")); @@ -331,7 +333,7 @@ mod tests { .expect("translation handlers"); let handlers = built.handlers; - assert_eq!(handlers.len(), 21); + assert_eq!(handlers.len(), 22); let got = labels(&handlers); assert!(!got.contains(&"translate_all")); assert!(got.contains(&"translate_me")); diff --git a/crates/signal-bot/src/lib.rs b/crates/signal-bot/src/lib.rs index e52cfeb..83cdf9d 100644 --- a/crates/signal-bot/src/lib.rs +++ b/crates/signal-bot/src/lib.rs @@ -2,6 +2,7 @@ pub mod bot_identity; pub mod commands; pub mod config; pub mod dispatch; +pub mod ensure_username; pub mod entitlements_store; pub mod error; pub mod group_invite_acceptor; diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index 558588c..98920f0 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -5,6 +5,7 @@ use dstack_client::DstackClient; use signal_bot::bot_identity::BotIdentity; use signal_bot::config::Config; use signal_bot::dispatch::dispatch_message; +use signal_bot::ensure_username::ensure_signal_username; use signal_bot::error::AppResult; use signal_bot::group_invite_acceptor::{ run_invite_acceptor, InvitePolicy, DEFAULT_INVITE_POLL_INTERVAL, @@ -48,6 +49,24 @@ async fn main() -> AppResult<()> { } info!("Signal API healthy"); + if let Some(phone) = config + .signal + .phone_number + .as_deref() + .filter(|p| !p.trim().is_empty()) + { + let nickname = config + .bot + .signal_username + .as_deref() + .unwrap_or("cipherslate"); + ensure_signal_username(&signal, phone, nickname).await; + } else { + warn!( + "SIGNAL__PHONE_NUMBER unset — skipping Signal username ensure (needed for site Message link)" + ); + } + let bot_identity = BotIdentity::new(); let built = build_handlers( diff --git a/crates/signal-client/src/client.rs b/crates/signal-client/src/client.rs index 2c66999..84ea2dc 100644 --- a/crates/signal-client/src/client.rs +++ b/crates/signal-client/src/client.rs @@ -539,6 +539,35 @@ impl SignalClient { }) .await } + + /// Set (or refresh) a Signal username for an account. + /// + /// Pass a nickname (e.g. `cipherslate`); Signal assigns a discriminator + /// (`cipherslate.54`) and returns a shareable `username_link`. + #[instrument(skip(self))] + pub async fn set_username( + &self, + phone_number: &str, + nickname: &str, + ) -> Result { + let encoded_number = encode(phone_number); + let response = self + .client + .post(format!( + "{}/v1/accounts/{}/username", + self.base_url, encoded_number + )) + .json(&serde_json::json!({ "username": nickname })) + .send() + .await?; + + if !response.status().is_success() { + let msg = response.text().await.unwrap_or_default(); + return Err(SignalError::Api(format!("Set username failed: {msg}"))); + } + + Ok(response.json().await?) + } } /// Map incoming `groupInfo.groupId` (`internal_id`) to list-groups `id` for send. diff --git a/crates/signal-client/src/lib.rs b/crates/signal-client/src/lib.rs index 76efc20..fc2a974 100644 --- a/crates/signal-client/src/lib.rs +++ b/crates/signal-client/src/lib.rs @@ -649,6 +649,50 @@ mod tests { assert!(err.to_string().contains("bad members")); } + #[tokio::test] + async fn test_set_username() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/accounts/%2B15555555555/username")) + .and(body_json(serde_json::json!({ "username": "cipherslate" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "username": "cipherslate.54", + "username_link": "https://signal.me/#eu/abc" + }))) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let info = client + .set_username("+15555555555", "cipherslate") + .await + .unwrap(); + assert_eq!(info.username.as_deref(), Some("cipherslate.54")); + assert_eq!( + info.username_link.as_deref(), + Some("https://signal.me/#eu/abc") + ); + } + + #[tokio::test] + async fn test_set_username_error() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/accounts/%2B15555555555/username")) + .respond_with(ResponseTemplate::new(400).set_body_string("taken")) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let err = client + .set_username("+15555555555", "cipherslate") + .await + .unwrap_err(); + assert!(err.to_string().contains("taken")); + } + #[test] fn test_bot_message_source_fields() { let incoming = IncomingMessage { diff --git a/crates/signal-client/src/types.rs b/crates/signal-client/src/types.rs index 7ce0dcb..e05d65f 100644 --- a/crates/signal-client/src/types.rs +++ b/crates/signal-client/src/types.rs @@ -246,6 +246,13 @@ pub struct Account { pub registered: bool, } +/// Response from `POST /v1/accounts/{number}/username`. +#[derive(Debug, Clone, Deserialize)] +pub struct UsernameInfo { + pub username: Option, + pub username_link: Option, +} + /// Parsed message for bot processing. #[derive(Debug, Clone)] pub struct BotMessage { diff --git a/docker/.env.example b/docker/.env.example index 24c4242..7ad9cb8 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -14,6 +14,6 @@ LOG_LEVEL=info GROUP_PREFERENCES_PERSIST=true ENTITLEMENTS_PERSIST=true BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot -BOT_SIGNAL_USERNAME= +BOT_SIGNAL_USERNAME=cipherslate RATE_LIMIT_GLOBAL_PER_MINUTE=10 RATE_LIMIT_PER_NUMBER_PER_HOUR=3 diff --git a/docker/.phala.env.example b/docker/.phala.env.example index ddd389b..a3993dd 100644 --- a/docker/.phala.env.example +++ b/docker/.phala.env.example @@ -27,7 +27,7 @@ TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 # ENTITLEMENTS_LEGACY_COMPOSE_HASH= LOG_LEVEL=info BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot -BOT_SIGNAL_USERNAME= +BOT_SIGNAL_USERNAME=cipherslate RATE_LIMIT_GLOBAL_PER_MINUTE=10 RATE_LIMIT_PER_NUMBER_PER_HOUR=3 # Optional: inject an ops SSH pubkey so `phala ssh -i …` works. diff --git a/docker/compose.yaml b/docker/compose.yaml index 0093188..a7f8e33 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -43,7 +43,7 @@ services: - TRANSLATE_ALL__ENABLED=${TRANSLATE_ALL_ENABLED:-true} - TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE=${TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE:-30} - BOT__LOG_LEVEL=${LOG_LEVEL:-info} - - BOT__SIGNAL_USERNAME=${BOT_SIGNAL_USERNAME:-} + - BOT__SIGNAL_USERNAME=${BOT_SIGNAL_USERNAME:-cipherslate} - BOT__GITHUB_REPO=${BOT_GITHUB_REPO:-https://github.com/BreadchainCoop/sigstack-bot} - DSTACK__SOCKET_PATH=/var/run/dstack.sock - GROUP_PREFERENCES__PERSIST=${GROUP_PREFERENCES_PERSIST:-true} diff --git a/docker/phala.yaml b/docker/phala.yaml index f2f93ae..6950ad8 100644 --- a/docker/phala.yaml +++ b/docker/phala.yaml @@ -48,7 +48,7 @@ services: - TRANSLATE_ALL__ENABLED=${TRANSLATE_ALL_ENABLED:-true} - TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE=${TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE:-30} - BOT__LOG_LEVEL=${LOG_LEVEL:-info} - - BOT__SIGNAL_USERNAME=${BOT_SIGNAL_USERNAME:-} + - BOT__SIGNAL_USERNAME=${BOT_SIGNAL_USERNAME:-cipherslate} - BOT__GITHUB_REPO=${BOT_GITHUB_REPO:-https://github.com/BreadchainCoop/sigstack-bot} - DSTACK__SOCKET_PATH=/var/run/dstack.sock - GROUP_PREFERENCES__PERSIST=true diff --git a/site/README.md b/site/README.md index 761dee2..8dc203e 100644 --- a/site/README.md +++ b/site/README.md @@ -42,6 +42,9 @@ Optional public env (GitHub Pages-safe; never put secret keys here): | Variable | Effect | | -------- | ------ | | `PUBLIC_STRIPE_PORTAL_URL` | Success page “Manage billing” link; if unset, shows “coming soon” stub | +| `PUBLIC_SIGNAL_USERNAME_LINK` | Alpha / checkout success “Message CipherSlate” button (`signal.me/#eu/…` username share link; never put the bot E.164 here) | + +After the bot claims its Signal username (startup `BOT__SIGNAL_USERNAME=cipherslate`), copy the logged `username_link` into the GitHub Actions variable `PUBLIC_SIGNAL_USERNAME_LINK` and redeploy Pages. ## Scripts diff --git a/site/playwright.config.ts b/site/playwright.config.ts index 89a5dc3..1e727eb 100644 --- a/site/playwright.config.ts +++ b/site/playwright.config.ts @@ -8,7 +8,12 @@ export default defineConfig({ command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', url: `${origin}${basePath}/`, reuseExistingServer: !process.env.CI, - timeout: 180_000 + timeout: 180_000, + env: { + ...process.env, + PUBLIC_SIGNAL_USERNAME_LINK: + process.env.PUBLIC_SIGNAL_USERNAME_LINK || 'https://signal.me/#eu/e2e-test-username-link' + } }, use: { baseURL: `${origin}${basePath}/` diff --git a/site/src/app.d.ts b/site/src/app.d.ts index 0a148ed..50d6a67 100644 --- a/site/src/app.d.ts +++ b/site/src/app.d.ts @@ -12,6 +12,7 @@ declare global { interface ImportMetaEnv { readonly PUBLIC_STRIPE_PORTAL_URL?: string; + readonly PUBLIC_SIGNAL_USERNAME_LINK?: string; } export {}; diff --git a/site/src/lib/checkoutLanding.spec.ts b/site/src/lib/checkoutLanding.spec.ts index fb954b8..b67bb15 100644 --- a/site/src/lib/checkoutLanding.spec.ts +++ b/site/src/lib/checkoutLanding.spec.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getContent } from '$lib/content'; import { LINK_CODE_MAX_LENGTH, + copyLinkCommand, linkCommand, planLabelFromSku, readLinkCode @@ -55,3 +56,28 @@ describe('linkCommand', () => { expect(linkCommand('abc')).toBe('!link abc'); }); }); + +describe('copyLinkCommand', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('writes the link command and returns true', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + await expect(copyLinkCommand('abc')).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('!link abc'); + }); + + it('returns false when clipboard write fails', async () => { + vi.stubGlobal('navigator', { + clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } + }); + await expect(copyLinkCommand('abc')).resolves.toBe(false); + }); + + it('returns false when clipboard API is missing', async () => { + vi.stubGlobal('navigator', {}); + await expect(copyLinkCommand('abc')).resolves.toBe(false); + }); +}); diff --git a/site/src/lib/checkoutLanding.ts b/site/src/lib/checkoutLanding.ts index e6cda96..6e25664 100644 --- a/site/src/lib/checkoutLanding.ts +++ b/site/src/lib/checkoutLanding.ts @@ -39,3 +39,19 @@ export function planLabelFromSku(sku: string | null, content: SiteContent): stri export function linkCommand(code: string): string { return `!link ${code}`; } + +/** + * Copy `!link ` to the clipboard. + * Returns false when Clipboard API is unavailable or write fails. + */ +export async function copyLinkCommand(code: string): Promise { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return false; + } + try { + await navigator.clipboard.writeText(linkCommand(code)); + return true; + } catch { + return false; + } +} diff --git a/site/src/lib/components/LinkInSignal.svelte b/site/src/lib/components/LinkInSignal.svelte new file mode 100644 index 0000000..83cb4f1 --- /dev/null +++ b/site/src/lib/components/LinkInSignal.svelte @@ -0,0 +1,99 @@ + + + + + diff --git a/site/src/lib/content/en.ts b/site/src/lib/content/en.ts index a379af3..0d7d4f4 100644 --- a/site/src/lib/content/en.ts +++ b/site/src/lib/content/en.ts @@ -101,7 +101,7 @@ export const en: SiteContent = { steps: [ { title: 'Add CipherSlate', - body: 'Invite the bot to your Signal group. It auto-accepts group invites.' + body: 'Organizers: invite CipherSlate to your Signal group (it auto-accepts). Alpha or paid users: open a DM first via the Message CipherSlate button on the alpha or checkout pages, then link with `!link `.' }, { title: 'Open the hub', @@ -124,6 +124,12 @@ export const en: SiteContent = { plans: { title: 'Plans', lead: 'Pick a Bundle for all three products, or pay à la carte. Individual plans cover you; Group plans cover a whole Signal chat where the product works that way.', + alphaBand: { + eyebrow: 'Alpha', + title: 'Try CipherSlate free with an alpha code', + lead: 'Full Bundle access for 90 days—no checkout. Redeem your code, open a Signal DM, and send `!link`.', + ctaLabel: 'Redeem alpha code' + }, bundle: { eyebrow: 'Package', title: 'CipherSlate Bundle', @@ -136,8 +142,8 @@ export const en: SiteContent = { scope: 'individual', blurb: 'Language Threads, In-chat me, and Transcription for one person. Self-subscribe in any group where the bot is present.', - priceLabel: '$5', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/get-started', ctaLabel: 'Get started' }, @@ -147,8 +153,8 @@ export const en: SiteContent = { scope: 'group', blurb: 'Everything in Individual, plus In-chat all for one Signal group—bilingual quote-replies for every member.', - priceLabel: '$17', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/get-started', ctaLabel: 'Get started' } @@ -167,8 +173,8 @@ export const en: SiteContent = { name: 'Language Threads · me', scope: 'individual', blurb: 'You join or create a Language Thread for yourself (`!translate-me-thread`).', - priceLabel: '$2', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/products#language-threads', ctaLabel: 'Learn more' }, @@ -178,8 +184,8 @@ export const en: SiteContent = { scope: 'group', blurb: 'Language Threads for one multilingual main—sidecars for every language lane the group needs.', - priceLabel: '$11', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/products#language-threads', ctaLabel: 'Learn more' } @@ -195,8 +201,8 @@ export const en: SiteContent = { name: 'In-chat · me', scope: 'individual', blurb: 'Auto-translate your messages only (`!translate-me-on`).', - priceLabel: '$2', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/products#in-chat', ctaLabel: 'Learn more' }, @@ -205,8 +211,8 @@ export const en: SiteContent = { name: 'In-chat · all', scope: 'group', blurb: 'Group-wide bilingual quote-replies (`!translate-all-on`).', - priceLabel: '$11', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/products#in-chat', ctaLabel: 'Learn more' } @@ -222,8 +228,8 @@ export const en: SiteContent = { name: 'Transcription', scope: 'individual', blurb: 'Per-person auto transcription (`!transcribe-on`).', - priceLabel: '$2', - period: '/mo', + priceLabel: 'TBD', + period: '', ctaHref: '/products#transcription', ctaLabel: 'Learn more' } @@ -235,8 +241,7 @@ export const en: SiteContent = { group: 'Group' }, footnote: - 'Prices are illustrative placeholders. Checkout is not live yet—start in Signal with the organizer checklist.', - alphaPrompt: 'Have an alpha code?' + 'Paid pricing is not finalized yet. Alpha access is free with a code; checkout is not live.' }, checkoutSuccess: { title: 'You are subscribed', @@ -246,7 +251,16 @@ export const en: SiteContent = { planPurchasedGeneric: 'Your plan is ready.', linkHeading: 'Link in Signal', linkBody: - 'Open a DM with CipherSlate (preferred — avoid pasting link codes in a group) and send:', + 'Prefer a DM (avoid pasting link codes in a group). Copy the command, open CipherSlate in Signal, paste, and send:', + linkSteps: [ + 'Copy the `!link` command below', + 'Open a DM with CipherSlate (Message button)', + 'Paste the command and send' + ], + copyLabel: 'Copy command', + copyDoneLabel: 'Copied', + messageCta: 'Message CipherSlate', + signalLinkMissing: 'Signal link not configured yet—ask your organizer how to DM CipherSlate.', missingCode: 'We could not find a link code in this page URL. Check your Stripe receipt email for `!link `, then send that command in a DM with CipherSlate.', portalCta: 'Manage billing', @@ -277,7 +291,16 @@ export const en: SiteContent = { errorTooLong: 'That code is too long. Check the code you were given and try again.', linkHeading: 'Link in Signal', linkBody: - 'Open a DM with CipherSlate (preferred — avoid pasting link codes in a group) and send:', + 'Prefer a DM (avoid pasting link codes in a group). Copy the command, open CipherSlate in Signal, paste, and send:', + linkSteps: [ + 'Copy the `!link` command below', + 'Open a DM with CipherSlate (Message button)', + 'Paste the command and send' + ], + copyLabel: 'Copy command', + copyDoneLabel: 'Copied', + messageCta: 'Message CipherSlate', + signalLinkMissing: 'Signal link not configured yet—ask your organizer how to DM CipherSlate.', getStartedCta: 'Organizer checklist', plansCta: 'See paid plans' } diff --git a/site/src/lib/content/types.ts b/site/src/lib/content/types.ts index 8fc0e04..9d00cd8 100644 --- a/site/src/lib/content/types.ts +++ b/site/src/lib/content/types.ts @@ -67,6 +67,12 @@ export type SiteContent = { plans: { title: string; lead: string; + alphaBand: { + eyebrow: string; + title: string; + lead: string; + ctaLabel: string; + }; bundle: { eyebrow: string; title: string; @@ -79,7 +85,6 @@ export type SiteContent = { products: PlanProduct[]; scopeLabels: Record; footnote: string; - alphaPrompt: string; }; checkoutSuccess: { title: string; @@ -89,6 +94,11 @@ export type SiteContent = { planPurchasedGeneric: string; linkHeading: string; linkBody: string; + linkSteps: string[]; + copyLabel: string; + copyDoneLabel: string; + messageCta: string; + signalLinkMissing: string; missingCode: string; portalCta: string; portalComingSoon: string; @@ -114,6 +124,11 @@ export type SiteContent = { errorTooLong: string; linkHeading: string; linkBody: string; + linkSteps: string[]; + copyLabel: string; + copyDoneLabel: string; + messageCta: string; + signalLinkMissing: string; getStartedCta: string; plansCta: string; }; diff --git a/site/src/routes/alpha/+page.svelte b/site/src/routes/alpha/+page.svelte index 0292b79..05bf824 100644 --- a/site/src/routes/alpha/+page.svelte +++ b/site/src/routes/alpha/+page.svelte @@ -4,16 +4,19 @@ import { resolve } from '$app/paths'; import type { Pathname } from '$app/types'; import { page } from '$app/state'; + import { env } from '$env/dynamic/public'; import { getLocale } from '$lib/paraglide/runtime'; import { getContent } from '$lib/content'; - import { LINK_CODE_MAX_LENGTH, linkCommand, readLinkCode } from '$lib/checkoutLanding'; + import { LINK_CODE_MAX_LENGTH, readLinkCode } from '$lib/checkoutLanding'; import Button from '$lib/components/Button.svelte'; + import LinkInSignal from '$lib/components/LinkInSignal.svelte'; const { pages, meta } = $derived(getContent(getLocale())); const copy = $derived(pages.alpha); // Query params are client-only (static prerender cannot vary by searchParams). const codeFromUrl = $derived(browser ? readLinkCode(page.url) : null); + const signalUsernameLink = $derived(env.PUBLIC_SIGNAL_USERNAME_LINK?.trim() || ''); let draft = $state(''); let error = $state(null); @@ -54,11 +57,17 @@ {#if browser && codeFromUrl} - +
@@ -142,22 +151,4 @@ .form-actions { margin-top: var(--space-4); } - - .link-panel { - margin-top: var(--space-5); - } - - .link-panel h2 { - margin-top: 0; - font-size: 1.15rem; - } - - .link-cmd { - margin: var(--space-4) 0 0; - } - - .link-cmd code { - font-size: 1.05rem; - padding: 0.35em 0.55em; - } diff --git a/site/src/routes/checkout/success/+page.svelte b/site/src/routes/checkout/success/+page.svelte index 1dfbae1..045eefc 100644 --- a/site/src/routes/checkout/success/+page.svelte +++ b/site/src/routes/checkout/success/+page.svelte @@ -3,8 +3,9 @@ import { page } from '$app/state'; import { getLocale } from '$lib/paraglide/runtime'; import { getContent } from '$lib/content'; - import { linkCommand, planLabelFromSku, readLinkCode } from '$lib/checkoutLanding'; + import { planLabelFromSku, readLinkCode } from '$lib/checkoutLanding'; import Button from '$lib/components/Button.svelte'; + import LinkInSignal from '$lib/components/LinkInSignal.svelte'; import { env } from '$env/dynamic/public'; // Stripe Checkout should redirect here as: @@ -23,6 +24,7 @@ : copy.planPurchasedGeneric ); const portalUrl = $derived(env.PUBLIC_STRIPE_PORTAL_URL?.trim() || ''); + const signalUsernameLink = $derived(env.PUBLIC_SIGNAL_USERNAME_LINK?.trim() || ''); @@ -37,11 +39,17 @@ {#if browser} {#if code} - + {:else}

{copy.missingCode}

{/if} @@ -62,29 +70,12 @@ margin-bottom: var(--space-5); } - .link-panel h2 { - margin-top: 0; - font-size: 1.15rem; - } - - .link-cmd { - margin: var(--space-4) 0 0; - } - - .link-cmd code { - font-size: 1.05rem; - padding: 0.35em 0.55em; - } - .missing { max-width: 40rem; margin-bottom: var(--space-5); } .portal-stub { - display: inline-flex; - align-items: center; - padding: 0.7rem 0; - font-size: 0.95rem; + align-self: center; } diff --git a/site/src/routes/plans/+page.svelte b/site/src/routes/plans/+page.svelte index 29ef626..cfd1d5e 100644 --- a/site/src/routes/plans/+page.svelte +++ b/site/src/routes/plans/+page.svelte @@ -1,6 +1,4 @@