diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index 33d4b00..f2edcb0 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -2,7 +2,7 @@ ## Product direction -Interoperable Signal products (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)): +Interoperable Signal products: 1. **Voice transcription** — same process (NEAR AI Whisper Large V3, not an in-CVM sidecar) 2. **In-chat group translation** — see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md) @@ -136,9 +136,9 @@ Before finishing Rust work: `npm run ci` / `pnpm run ci` (never bare `pnpm ci`). ## Registration proxy -Ops helper on the one CVM: **:8081** (phone B). Multi-tenant “create your personal AI bot” web UX is out of scope; Stripe client site is issue #10 follow-up. +Ops helper on the one CVM: **:8081** (phone B). Multi-tenant “create your personal AI bot” web UX is out of scope; Stripe checkout for the product storefront is a separate workstream. ## Website -- **`site/`** — product educational storefront (SvelteKit static, GitHub Pages). Live URL after Pages is enabled: https://breadchaincoop.github.io/sigstack-bot/ · see [`site/README.md`](../../site/README.md) and issue [#23](https://github.com/BreadchainCoop/sigstack-bot/issues/23). Stripe checkout remains issue [#13](https://github.com/BreadchainCoop/sigstack-bot/issues/13). +- **`site/`** — product educational storefront (SvelteKit static, GitHub Pages). Live URL after Pages is enabled: https://breadchaincoop.github.io/sigstack-bot/ · see [`site/README.md`](../../site/README.md). Stripe checkout is not live yet. - **`web/`** — legacy personal-AI framing. Do **not** treat it as the product suite storefront. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 6cbb6a0..b4ce7ee 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -6,6 +6,7 @@ Durable project context for coding agents (Cursor, Claude Code, etc.). Feature w |------|---------| | [`../../AGENTS.md`](../../AGENTS.md) | Root agent entrypoint (CE loop, commands, index) | | [`DEVELOPMENT.md`](DEVELOPMENT.md) | TEE trust model, one-CVM ops — read when planning TEE/compose work | +| [`github-work-index.md`](github-work-index.md) | **Only** in-repo GitHub issue/PR map — do not scatter `#N` elsewhere | | [`../skills/`](../skills/) | Domain skills (Rust, Docker, Stripe); coexist with CE plugin skills | CE artifacts live under [`docs/brainstorms/`](../../docs/brainstorms/), [`docs/plans/`](../../docs/plans/), [`docs/solutions/`](../../docs/solutions/), and [`todos/`](../../todos/). Product docs for humans stay in [`docs/`](../../docs/). Humans: [`README.md`](../../README.md). diff --git a/.agents/docs/github-work-index.md b/.agents/docs/github-work-index.md new file mode 100644 index 0000000..8113a35 --- /dev/null +++ b/.agents/docs/github-work-index.md @@ -0,0 +1,76 @@ +# GitHub work index + +**Only** place in this repo that may cite GitHub issue or PR numbers. + +Product docs, READMEs, code comments, and plans describe work by **name** and link here (or to durable docs) when a tracker ID is needed. See [`.cursor/rules/no-issue-pr-references.mdc`](../../.cursor/rules/no-issue-pr-references.mdc). + +Repo: [BreadchainCoop/sigstack-bot](https://github.com/BreadchainCoop/sigstack-bot) +Issues: https://github.com/BreadchainCoop/sigstack-bot/issues +Pull requests: https://github.com/BreadchainCoop/sigstack-bot/pulls + +When adding work, update **this file**—do not sprinkle `#N` into other paths. + +## How to use + +| Need | Do | +|------|-----| +| Relate code/docs to a ticket | Link or name the topic; agents look up the number here | +| Open / comment / close a ticket | Use `gh` against the URLs below | +| Ship a PR | Tracker refs go in the **PR body** on GitHub, not in commit subjects required for the tree | + +## Commerce / Stripe / entitlements + +Parent: [Website + Stripe: product suite checkout and subscriptions](https://github.com/BreadchainCoop/sigstack-bot/issues/13) + +| # | 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` | +| [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 | | +| [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 | | +| [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 | | + +## Product suite / architecture + +| # | Title | Notes | +|---|--------|--------| +| [10](https://github.com/BreadchainCoop/sigstack-bot/issues/10) | Umbrella: product suite, website + Stripe, and Phala CVM split | Closed umbrella; one-CVM is current | +| [8](https://github.com/BreadchainCoop/sigstack-bot/issues/8) | Product: extract voice transcription to its own CVM (4 GB) | Closed; STT is remote Whisper, not a second CVM | +| [9](https://github.com/BreadchainCoop/sigstack-bot/issues/9) | Product: split translation into in-group vs Language Threads | Closed | +| [14](https://github.com/BreadchainCoop/sigstack-bot/issues/14) | Product: In-chat (group) translation | Closed | +| [15](https://github.com/BreadchainCoop/sigstack-bot/issues/15) | Product: Parallel Translation | Closed / retired into Language Threads | +| [16](https://github.com/BreadchainCoop/sigstack-bot/issues/16) | Product: Language Threads | Closed | +| [36](https://github.com/BreadchainCoop/sigstack-bot/issues/36) | feat: bilingual threads | Closed | + +## Site / menus / i18n + +| # | Title | Notes | +|---|--------|--------| +| [23](https://github.com/BreadchainCoop/sigstack-bot/issues/23) | Website: educate users with docs and product diagrams | Closed; `site/` | +| [27](https://github.com/BreadchainCoop/sigstack-bot/issues/27) | feat: localize bot menus for all !list-langs languages | Open | +| [51](https://github.com/BreadchainCoop/sigstack-bot/issues/51) | docs(site): per-product language support dropdown and eu/sw disclosure | Closed | + +## Other open + +| # | Title | +|---|--------| +| [40](https://github.com/BreadchainCoop/sigstack-bot/issues/40) | Add gated entry flow for new group chat members | +| [41](https://github.com/BreadchainCoop/sigstack-bot/issues/41) | Commands | +| [44](https://github.com/BreadchainCoop/sigstack-bot/issues/44) | User verification with kick for open groups | + +## Pull requests + +Do not list ephemeral PR numbers in product docs. For the current branch’s PR, use `gh pr view` / `gh pr list`. Add a short row here only when a PR is a durable cross-reference agents need often (rare). + +| # | Title | Notes | +|---|--------|--------| +| — | — | (none pinned) | diff --git a/.cursor/rules/no-issue-pr-references.mdc b/.cursor/rules/no-issue-pr-references.mdc new file mode 100644 index 0000000..c3fd0c9 --- /dev/null +++ b/.cursor/rules/no-issue-pr-references.mdc @@ -0,0 +1,42 @@ +--- +description: Never put GitHub issue or PR numbers in repo content +alwaysApply: true +--- + +# No issue / PR numbers outside the work index + +Do **not** cite GitHub issues, pull requests, or tracker IDs in committed content—except in the single index: + +**[`.agents/docs/github-work-index.md`](../../.agents/docs/github-work-index.md)** + +That file is the navigation map (topic → issue/PR URL). Product docs, READMEs, code, and plans stay free of `#N`. + +## Forbidden everywhere else + +- Source code (comments, rustdoc, docstrings) +- Product/architecture docs under `docs/` (do not duplicate this index there) +- READMEs, compose/env examples, AGENTS.md body text (AGENTS may **link to the index**) +- Plans, brainstorms, solutions — describe work by name; link the index if a ticket must be found + +Forbidden forms: `issue #62`, `#62`, `Issue 27`, `…/issues/62`, `…/pull/12`, “for #55”, “parent #13”, headings like `## Commerce landings (issue #62)`. + +## Allowed + +- Describe work without a number (“encrypted entitlements store”, “commerce landings”) +- Link durable repo docs (`docs/one-cvm-architecture.md`, `site/README.md`) +- Link **only** [`.agents/docs/github-work-index.md`](../../.agents/docs/github-work-index.md) when a tracker ID is needed +- Upstream library docs that are not issue/PR pages +- Tracker refs in **GitHub** issue/PR bodies (not in the tree) + +```text +# BAD +/// Encrypted entitlements (issue #53). +## Commerce landings (issue #62) + +# GOOD +/// Encrypted entitlements store; CRUD for future webhook / !link / gating. +## Commerce landings +# Need the ticket? → .agents/docs/github-work-index.md +``` + +When adding or renaming tracked work, **update the index**—do not scatter new `#N` citations. Strip leftovers in other files when you touch them. diff --git a/AGENTS.md b/AGENTS.md index 82f2530..c17d730 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ docker compose -f docker/compose.yaml --env-file docker/.env up -d | Doc | Why | |-----|-----| | [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, Phala one-CVM ops, **CVM volume / Signal identity** | +| [`.agents/docs/github-work-index.md`](.agents/docs/github-work-index.md) | **Only** in-repo map of GitHub issues/PRs (do not scatter `#N` elsewhere) | | [`docs/one-cvm-architecture.md`](docs/one-cvm-architecture.md) | One CVM / one phone, **CVM storage (keep intact)** | | [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product (NEAR Whisper; in-process fan-out) | | [`docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md`](docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md) | Why STT is remote; never re-home Whisper in a CPU TEE | @@ -69,14 +70,14 @@ docker compose -f docker/compose.yaml --env-file docker/.env up -d | [`docs/solutions/`](docs/solutions/) | Compounded learnings from prior work | | [`docs/plans/`](docs/plans/) | CE implementation plans | | [`.agents/skills/`](.agents/skills/) | Domain skills (Rust, Docker, Stripe) | -| [`.cursor/rules/`](.cursor/rules/) | Cursor project rules (commits, compound loop, **CVM storage**) | +| [`.cursor/rules/`](.cursor/rules/) | Cursor project rules (commits, compound loop, **CVM storage**, **no scattered issue/PR refs**) | ## CVM storage (do not wipe) **Never destroy live Phala volumes or replace the registered translation CVM for a routine upgrade.** Prod is **one** CVM (`0e82fa77-8b15-4dbd-89c4-9045ab911353`). Keep: 1. **Registered Signal phone** (`signal-config-translation` = phone B) — losing the volume unlinks the bot until ops re-registers (and takes over the number). -2. **Encrypted user prefs** (`group-prefs-translation` → `/data/group_prefs.enc`) — `!translate-me-on`, `!translate-all-on`, Language Threads bridges. Losing this forces every user to turn features back on. +2. **Encrypted user prefs + entitlements** (`group-prefs-translation` → `/data/group_prefs.enc` and `/data/entitlements.enc`) — `!translate-me-on`, `!translate-all-on`, Language Threads bridges, paid/alpha entitlements. Losing this forces every user to turn features back on and re-link commerce access. Upgrade with `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353`. Do not create a new CVM, rename those volumes, or `down -v`. Do not recreate a transcription CVM or re-register phone A. TEE RAM wipe on reboot is expected; disk volumes are the identity. Details: [docs/one-cvm-architecture.md — CVM storage](docs/one-cvm-architecture.md#cvm-storage-keep-intact). diff --git a/README.md b/README.md index 4d395c7..860cdd3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CipherSlate -TEE-hosted Signal bot for **voice transcription** and **group translation**, designed as an interoperable product suite (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)). +TEE-hosted Signal bot for **voice transcription** and **group translation**, designed as an interoperable product suite. Not a general AI chat assistant. Conversation history, tool-calling, and x402 credits have been removed from this fork. @@ -15,7 +15,7 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr Add **one** bot to a Signal group. -**Organizer website:** [`site/`](site/) (SvelteKit → GitHub Pages) — https://breadchaincoop.github.io/sigstack-bot/ after Pages is enabled. See [site/README.md](site/README.md) and issue [#23](https://github.com/BreadchainCoop/sigstack-bot/issues/23). Legacy [`web/`](web/) is not the product storefront. +**Organizer website:** [`site/`](site/) (SvelteKit → GitHub Pages) — https://breadchaincoop.github.io/sigstack-bot/ after Pages is enabled. See [site/README.md](site/README.md). Legacy [`web/`](web/) is not the product storefront. Details: [docs/one-cvm-architecture.md](docs/one-cvm-architecture.md) · [docs/voice-transcription.md](docs/voice-transcription.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/language-threads.md](docs/language-threads.md) · [docs/bilingual-threads.md](docs/bilingual-threads.md) diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index 8b850b3..b4899fe 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -34,6 +34,10 @@ pub struct Config { /// Encrypted persistence for per-group bot preferences #[serde(default)] pub group_preferences: GroupPreferencesConfig, + + /// Encrypted persistence for paid/alpha entitlements (separate from feature prefs) + #[serde(default)] + pub entitlements: EntitlementsConfig, } #[derive(Debug, Clone, Deserialize)] @@ -146,6 +150,23 @@ pub struct GroupPreferencesConfig { pub legacy_compose_hash: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct EntitlementsConfig { + /// Persist entitlements snapshot (TEE-encrypted) + #[serde(default = "default_true")] + pub persist: bool, + + /// Encrypted entitlements file path (same `/data` volume as group prefs) + #[serde(default = "default_entitlements_path")] + pub storage_path: String, + + /// Previous dstack `compose_hash` values (comma-separated). Used only when + /// DeriveKey is missing so AppInfo-encrypted `entitlements.enc` still decrypts + /// after a compose/image bump; the bot then re-saves with an app-id-only key. + #[serde(default)] + pub legacy_compose_hash: String, +} + // Default implementations impl Default for SignalConfig { fn default() -> Self { @@ -218,6 +239,27 @@ impl GroupPreferencesConfig { } } +impl Default for EntitlementsConfig { + fn default() -> Self { + Self { + persist: default_true(), + storage_path: default_entitlements_path(), + legacy_compose_hash: String::new(), + } + } +} + +impl EntitlementsConfig { + pub fn legacy_compose_hashes(&self) -> Vec { + self.legacy_compose_hash + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + } +} + fn default_signal_service() -> String { "http://signal-api:8080".into() } @@ -279,6 +321,10 @@ fn default_group_preferences_path() -> String { "/data/group_prefs.enc".into() } +fn default_entitlements_path() -> String { + "/data/entitlements.enc".into() +} + impl Config { /// Load configuration from environment variables. pub fn load() -> Result { @@ -331,6 +377,7 @@ mod tests { whisper: WhisperConfig::default(), translate_all: TranslateAllConfig::default(), group_preferences: GroupPreferencesConfig::default(), + entitlements: EntitlementsConfig::default(), } } @@ -373,6 +420,11 @@ mod tests { assert!(GroupPreferencesConfig::default() .legacy_compose_hash .is_empty()); + assert!(EntitlementsConfig::default().persist); + assert_eq!( + EntitlementsConfig::default().storage_path, + "/data/entitlements.enc" + ); } #[test] diff --git a/crates/signal-bot/src/entitlements_store.rs b/crates/signal-bot/src/entitlements_store.rs new file mode 100644 index 0000000..060103c --- /dev/null +++ b/crates/signal-bot/src/entitlements_store.rs @@ -0,0 +1,1014 @@ +//! Encrypted entitlement records (paid / alpha plans), separate from feature prefs. +//! +//! # Plan composition (alpha vs paid) +//! +//! Alpha `bundle-all-alpha` grants full bundle feature coverage while `active`/`past_due` +//! and unexpired. A **paid** (`source: stripe`) entitlement that overlaps a feature +//! **replaces** alpha for that feature only; non-overlapping alpha coverage remains. +//! Stripe catalog SKUs must match the site offer ids in `site/src/lib/content/en.ts` +//! (plus `bundle-all-alpha`); do not invent `-me` aliases here. + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Key, Nonce, +}; +use chrono::{DateTime, Utc}; +use dstack_client::DstackClient; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use tokio::fs; +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; + +const DATA_VERSION: u32 = 1; +const KEY_DERIVATION_PATH: &str = "signal-bot/entitlements"; +const NONCE_SIZE: usize = 12; + +/// Plan SKU strings aligned with `site/src/lib/content/en.ts` offer `id`s, plus alpha. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PlanSku { + BundleIndividual, + BundleGroup, + ThreadsIndividual, + ThreadsGroup, + InChatMe, + InChatAll, + TranscriptionIndividual, + /// Commerce alpha: full Bundle-all for a limited window (`source: alpha`). + BundleAllAlpha, +} + +impl PlanSku { + /// Group-scoped catalog SKUs (claimed against a Signal group `internal_id`). + pub fn is_group_scope(self) -> bool { + matches!( + self, + Self::BundleGroup | Self::ThreadsGroup | Self::InChatAll + ) + } + + /// Feature grants implied by this SKU (before alpha/paid composition). + pub fn grants(self) -> &'static [FeatureGrant] { + match self { + Self::BundleIndividual => &[ + FeatureGrant::ThreadsIndividual, + FeatureGrant::InChatMe, + FeatureGrant::TranscriptionIndividual, + ], + Self::BundleAllAlpha => &[ + FeatureGrant::ThreadsIndividual, + FeatureGrant::InChatMe, + FeatureGrant::TranscriptionIndividual, + FeatureGrant::ThreadsGroup, + FeatureGrant::InChatAll, + ], + Self::BundleGroup => &[FeatureGrant::ThreadsGroup, FeatureGrant::InChatAll], + Self::ThreadsIndividual => &[FeatureGrant::ThreadsIndividual], + Self::ThreadsGroup => &[FeatureGrant::ThreadsGroup], + Self::InChatMe => &[FeatureGrant::InChatMe], + Self::InChatAll => &[FeatureGrant::InChatAll], + Self::TranscriptionIndividual => &[FeatureGrant::TranscriptionIndividual], + } + } + + /// Whether this SKU may be claimed onto a Signal group. + pub fn is_group_claimable(self) -> bool { + self.is_group_scope() || matches!(self, Self::BundleAllAlpha) + } +} + +/// Billable feature axes used by future command / inference gates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FeatureGrant { + ThreadsIndividual, + ThreadsGroup, + InChatMe, + InChatAll, + TranscriptionIndividual, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntitlementSource { + Stripe, + Alpha, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntitlementStatus { + Active, + PastDue, + Expired, + Canceled, +} + +/// One entitlement row (individual pending/linked, and/or claimed to a group). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntitlementRecord { + pub id: String, + pub plan_sku: PlanSku, + pub source: EntitlementSource, + pub status: EntitlementStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stripe_customer_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stripe_subscription_id: Option, + /// Signal user UUID preferred; phone E.164 when UUID unavailable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_uuid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claimed_group_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl EntitlementRecord { + pub fn is_granting_at(&self, now: DateTime) -> bool { + match self.status { + EntitlementStatus::Active | EntitlementStatus::PastDue => {} + EntitlementStatus::Expired | EntitlementStatus::Canceled => return false, + } + if let Some(expires) = self.expires_at { + if expires <= now { + return false; + } + } + true + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct EntitlementsSnapshot { + version: u32, + /// Owner key (Signal UUID or phone) → records. + #[serde(default)] + individuals: HashMap>, + /// Group `internal_id` → claimed group-scoped records. + #[serde(default)] + groups: HashMap>, + /// Pending checkout / alpha redeem awaiting `!link`. + #[serde(default)] + pending_by_token: HashMap, +} + +fn appinfo_fallback_key(app_id: &str, compose_hash: Option<&str>) -> [u8; 32] { + let mut hasher = Sha256::new(); + if let Some(compose_hash) = compose_hash { + hasher.update(compose_hash.as_bytes()); + } + hasher.update(app_id.as_bytes()); + hasher.update(KEY_DERIVATION_PATH.as_bytes()); + let hash = hasher.finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&hash); + key +} + +fn decrypt_entitlements_blob(data: &[u8], key: &[u8; 32]) -> Result { + if data.len() < NONCE_SIZE { + return Err("entitlements file too short".into()); + } + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let nonce = Nonce::from_slice(&data[..NONCE_SIZE]); + let ciphertext = &data[NONCE_SIZE..]; + let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| { + "Failed to decrypt entitlements (TEE deployment may have changed)".to_string() + })?; + serde_json::from_slice(&plaintext).map_err(|e| format!("parse entitlements: {e}")) +} + +fn encrypt_entitlements_blob( + snapshot: &EntitlementsSnapshot, + key: &[u8; 32], +) -> Result, String> { + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let plaintext = + serde_json::to_vec(snapshot).map_err(|e| format!("serialize entitlements: {e}"))?; + let ciphertext = cipher + .encrypt(nonce, plaintext.as_ref()) + .map_err(|e| format!("encrypt entitlements: {e}"))?; + let mut data = nonce_bytes.to_vec(); + data.extend(ciphertext); + Ok(data) +} + +fn push_unique_key(out: &mut Vec<(String, [u8; 32])>, label: String, key: [u8; 32]) { + if out.iter().any(|(_, existing)| existing == &key) { + return; + } + out.push((label, key)); +} + +fn new_record_id() -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Resolve effective feature → winning source after alpha/paid composition. +/// +/// Paid (`stripe`) wins on overlap; alpha covers remaining bundle features. +pub fn compose_effective_grants( + individual: &[EntitlementRecord], + group: &[EntitlementRecord], + now: DateTime, +) -> HashMap { + let mut paid: HashSet = HashSet::new(); + let mut alpha: HashSet = HashSet::new(); + + for record in individual.iter().chain(group.iter()) { + if !record.is_granting_at(now) { + continue; + } + let target = match record.source { + EntitlementSource::Stripe => &mut paid, + EntitlementSource::Alpha => &mut alpha, + }; + for grant in record.plan_sku.grants() { + // bundle-all-alpha includes group features even on individual records. + if record.plan_sku.is_group_scope() && record.claimed_group_id.is_none() { + // Unclaimed group SKU does not yet grant group features. + if matches!(grant, FeatureGrant::ThreadsGroup | FeatureGrant::InChatAll) { + continue; + } + } + target.insert(*grant); + } + } + + let mut out = HashMap::new(); + for grant in paid { + out.insert(grant, EntitlementSource::Stripe); + } + for grant in alpha { + out.entry(grant).or_insert(EntitlementSource::Alpha); + } + out +} + +/// In-memory entitlements with optional TEE-encrypted persistence. +pub struct EntitlementsStore { + individuals: RwLock>>, + groups: RwLock>>, + pending_by_token: RwLock>, + dstack: Option>, + storage_path: Option, + cached_key: RwLock>, + persist_lock: Mutex<()>, + legacy_compose_hashes: Vec, +} + +impl EntitlementsStore { + /// Memory-only store (lost on restart). + pub fn new_in_memory() -> Arc { + Arc::new(Self { + individuals: RwLock::new(HashMap::new()), + groups: RwLock::new(HashMap::new()), + pending_by_token: RwLock::new(HashMap::new()), + dstack: None, + storage_path: None, + cached_key: RwLock::new(None), + persist_lock: Mutex::new(()), + legacy_compose_hashes: Vec::new(), + }) + } + + /// Load from encrypted storage when `persist` is true; otherwise in-memory only. + pub async fn open( + dstack: Arc, + storage_path: PathBuf, + persist: bool, + legacy_compose_hashes: Vec, + ) -> Arc { + let store = Arc::new(Self { + individuals: RwLock::new(HashMap::new()), + groups: RwLock::new(HashMap::new()), + pending_by_token: RwLock::new(HashMap::new()), + dstack: if persist { Some(dstack) } else { None }, + storage_path: if persist { Some(storage_path) } else { None }, + cached_key: RwLock::new(None), + persist_lock: Mutex::new(()), + legacy_compose_hashes, + }); + + if persist { + match store.load().await { + Ok(count) => info!("Loaded {count} entitlement records"), + Err(e) => warn!("Could not load entitlements (starting fresh): {e}"), + } + } + + store + } + + #[cfg(test)] + pub async fn with_test_key( + dstack: DstackClient, + storage_path: PathBuf, + key: [u8; 32], + ) -> Arc { + let store = Arc::new(Self { + individuals: RwLock::new(HashMap::new()), + groups: RwLock::new(HashMap::new()), + pending_by_token: RwLock::new(HashMap::new()), + dstack: Some(Arc::new(dstack)), + storage_path: Some(storage_path), + cached_key: RwLock::new(Some(key)), + persist_lock: Mutex::new(()), + legacy_compose_hashes: Vec::new(), + }); + let _ = store.load().await; + store + } + + fn schedule_persist(self: &Arc) { + if self.storage_path.is_none() { + return; + } + let store = Arc::clone(self); + tokio::spawn(async move { + if let Err(e) = store.persist().await { + warn!("Failed to persist entitlements: {e}"); + } + }); + } + + /// Insert or replace a record under its owner (and group index when claimed). + pub fn upsert(self: &Arc, record: EntitlementRecord) -> Result<(), String> { + let owner = record + .owner_uuid + .clone() + .ok_or_else(|| "upsert requires owner_uuid".to_string())?; + let id = record.id.clone(); + let claimed = record.claimed_group_id.clone(); + + { + let mut individuals = self.individuals.write().unwrap(); + let list = individuals.entry(owner).or_default(); + if let Some(pos) = list.iter().position(|r| r.id == id) { + list[pos] = record.clone(); + } else { + list.push(record.clone()); + } + } + + self.reindex_group_claim(&id, claimed.as_deref(), Some(&record)); + self.schedule_persist(); + Ok(()) + } + + fn reindex_group_claim( + &self, + record_id: &str, + new_group: Option<&str>, + record: Option<&EntitlementRecord>, + ) { + let mut groups = self.groups.write().unwrap(); + for list in groups.values_mut() { + list.retain(|r| r.id != record_id); + } + groups.retain(|_, list| !list.is_empty()); + + if let (Some(gid), Some(rec)) = (new_group, record) { + if rec.plan_sku.is_group_claimable() { + groups.entry(gid.to_string()).or_default().push(rec.clone()); + } + } + } + + pub fn get_individual(&self, owner_key: &str) -> Vec { + self.individuals + .read() + .unwrap() + .get(owner_key) + .cloned() + .unwrap_or_default() + } + + pub fn get_group(&self, group_id: &str) -> Vec { + self.groups + .read() + .unwrap() + .get(group_id) + .cloned() + .unwrap_or_default() + } + + pub fn list_for_owner(&self, owner_key: &str) -> Vec { + self.get_individual(owner_key) + } + + pub fn get_pending(&self, link_token: &str) -> Option { + self.pending_by_token + .read() + .unwrap() + .get(link_token) + .cloned() + } + + /// Create a pending entitlement awaiting Signal link (`!link`). + pub fn create_pending( + self: &Arc, + link_token: String, + plan_sku: PlanSku, + source: EntitlementSource, + expires_at: Option>, + stripe_customer_id: Option, + stripe_subscription_id: Option, + ) -> Result { + if link_token.trim().is_empty() { + return Err("link_token must be non-empty".into()); + } + { + let pending = self.pending_by_token.read().unwrap(); + if pending.contains_key(&link_token) { + return Err(format!("link_token already pending: {link_token}")); + } + } + + let now = Utc::now(); + let record = EntitlementRecord { + id: new_record_id(), + plan_sku, + source, + status: EntitlementStatus::Active, + expires_at, + stripe_customer_id, + stripe_subscription_id, + owner_uuid: None, + link_token: Some(link_token.clone()), + claimed_group_id: None, + created_at: now, + updated_at: now, + }; + + self.pending_by_token + .write() + .unwrap() + .insert(link_token, record.clone()); + self.schedule_persist(); + Ok(record) + } + + /// Bind a pending `link_token` to a Signal owner; moves into `individuals`. + pub fn bind_link_token( + self: &Arc, + link_token: &str, + owner_uuid: String, + ) -> Result { + if owner_uuid.trim().is_empty() { + return Err("owner_uuid must be non-empty".into()); + } + let mut record = self + .pending_by_token + .write() + .unwrap() + .remove(link_token) + .ok_or_else(|| format!("unknown link_token: {link_token}"))?; + + record.owner_uuid = Some(owner_uuid.clone()); + record.link_token = None; + record.updated_at = Utc::now(); + + { + let mut individuals = self.individuals.write().unwrap(); + individuals + .entry(owner_uuid) + .or_default() + .push(record.clone()); + } + self.schedule_persist(); + Ok(record) + } + + /// Claim a group-scoped (or alpha) entitlement onto a Signal group. + pub fn claim_group( + self: &Arc, + owner_uuid: &str, + record_id: &str, + group_id: String, + ) -> Result { + if group_id.trim().is_empty() { + return Err("group_id must be non-empty".into()); + } + + let mut individuals = self.individuals.write().unwrap(); + let list = individuals + .get_mut(owner_uuid) + .ok_or_else(|| format!("no entitlements for owner {owner_uuid}"))?; + let pos = list + .iter() + .position(|r| r.id == record_id) + .ok_or_else(|| format!("record {record_id} not found for owner"))?; + + let sku = list[pos].plan_sku; + if !sku.is_group_claimable() { + return Err(format!("plan_sku {sku:?} is not claimable to a group")); + } + + list[pos].claimed_group_id = Some(group_id.clone()); + list[pos].updated_at = Utc::now(); + let record = list[pos].clone(); + drop(individuals); + + self.reindex_group_claim(record_id, Some(&group_id), Some(&record)); + self.schedule_persist(); + Ok(record) + } + + pub fn set_status( + self: &Arc, + record_id: &str, + status: EntitlementStatus, + ) -> Result { + let updated = self.mutate_record(record_id, |r| { + r.status = status; + r.updated_at = Utc::now(); + })?; + self.schedule_persist(); + Ok(updated) + } + + /// Mark granting records with `expires_at <= now` as `expired`. Returns count changed. + pub fn expire_due(self: &Arc, now: DateTime) -> usize { + let mut changed = 0usize; + { + let mut individuals = self.individuals.write().unwrap(); + for list in individuals.values_mut() { + for record in list.iter_mut() { + if matches!( + record.status, + EntitlementStatus::Active | EntitlementStatus::PastDue + ) { + if let Some(expires) = record.expires_at { + if expires <= now { + record.status = EntitlementStatus::Expired; + record.updated_at = now; + changed += 1; + } + } + } + } + } + } + { + let mut pending = self.pending_by_token.write().unwrap(); + for record in pending.values_mut() { + if matches!( + record.status, + EntitlementStatus::Active | EntitlementStatus::PastDue + ) { + if let Some(expires) = record.expires_at { + if expires <= now { + record.status = EntitlementStatus::Expired; + record.updated_at = now; + changed += 1; + } + } + } + } + } + // Refresh group index from individuals (statuses may have changed). + self.rebuild_group_index_from_individuals(); + if changed > 0 { + self.schedule_persist(); + } + changed + } + + fn rebuild_group_index_from_individuals(&self) { + let individuals = self.individuals.read().unwrap(); + let mut groups: HashMap> = HashMap::new(); + for list in individuals.values() { + for record in list { + if let Some(gid) = &record.claimed_group_id { + groups.entry(gid.clone()).or_default().push(record.clone()); + } + } + } + *self.groups.write().unwrap() = groups; + } + + fn mutate_record( + &self, + record_id: &str, + f: impl FnOnce(&mut EntitlementRecord), + ) -> Result { + { + let mut individuals = self.individuals.write().unwrap(); + for list in individuals.values_mut() { + if let Some(record) = list.iter_mut().find(|r| r.id == record_id) { + f(record); + let updated = record.clone(); + let claimed = updated.claimed_group_id.clone(); + drop(individuals); + self.reindex_group_claim(record_id, claimed.as_deref(), Some(&updated)); + return Ok(updated); + } + } + } + { + let mut pending = self.pending_by_token.write().unwrap(); + if let Some(record) = pending.values_mut().find(|r| r.id == record_id) { + f(record); + return Ok(record.clone()); + } + } + Err(format!("record {record_id} not found")) + } + + /// Effective grants for an owner in a group context (individual + that group's claims). + pub fn effective_grants( + &self, + owner_key: &str, + group_id: Option<&str>, + now: DateTime, + ) -> HashMap { + let individual = self.get_individual(owner_key); + let group = group_id.map(|g| self.get_group(g)).unwrap_or_default(); + compose_effective_grants(&individual, &group, now) + } + + async fn derive_key(&self) -> Result<[u8; 32], String> { + if let Some(key) = *self.cached_key.read().unwrap() { + return Ok(key); + } + let (preferred, _) = self.encryption_keys().await?; + *self.cached_key.write().unwrap() = Some(preferred); + Ok(preferred) + } + + async fn encryption_keys(&self) -> Result<([u8; 32], Vec<(String, [u8; 32])>), String> { + let dstack = self + .dstack + .as_ref() + .ok_or_else(|| "persistence not configured".to_string())?; + + let mut candidates = Vec::new(); + let mut derive_key = None; + match dstack.derive_key(KEY_DERIVATION_PATH, None).await { + Ok(key_bytes) => { + if key_bytes.len() < 32 { + return Err(format!( + "Derived key too short: {} bytes (need 32)", + key_bytes.len() + )); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes[..32]); + info!("Using DeriveKey endpoint for entitlements encryption"); + derive_key = Some(key); + push_unique_key(&mut candidates, "DeriveKey".into(), key); + } + Err(e) => { + warn!("DeriveKey not available for entitlements, using AppInfo fallback: {e}"); + } + } + + let app_info = dstack + .get_app_info() + .await + .map_err(|e| format!("Failed to get AppInfo for key derivation: {e}"))?; + let app_id = app_info.app_id.as_deref().unwrap_or("unknown"); + let compose_hash = app_info.compose_hash.as_deref().unwrap_or("unknown"); + + let stable = appinfo_fallback_key(app_id, None); + info!("Using AppInfo-derived key for entitlements (app_id: {app_id}, no compose_hash)"); + push_unique_key(&mut candidates, "AppInfo app_id".into(), stable); + push_unique_key( + &mut candidates, + format!("AppInfo compose_hash {compose_hash}"), + appinfo_fallback_key(app_id, Some(compose_hash)), + ); + for hash in &self.legacy_compose_hashes { + push_unique_key( + &mut candidates, + format!("legacy compose_hash {hash}"), + appinfo_fallback_key(app_id, Some(hash)), + ); + } + + let preferred = derive_key.unwrap_or(stable); + Ok((preferred, candidates)) + } + + fn snapshot(&self) -> EntitlementsSnapshot { + EntitlementsSnapshot { + version: DATA_VERSION, + individuals: self.individuals.read().unwrap().clone(), + groups: self.groups.read().unwrap().clone(), + pending_by_token: self.pending_by_token.read().unwrap().clone(), + } + } + + async fn persist(&self) -> Result<(), String> { + let _guard = self.persist_lock.lock().await; + + let path = self + .storage_path + .as_ref() + .ok_or_else(|| "persistence not configured".to_string())?; + + let key = self.derive_key().await?; + let data = encrypt_entitlements_blob(&self.snapshot(), &key)?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| format!("create storage dir: {e}"))?; + } + + let temp_path = path.with_extension("tmp"); + fs::write(&temp_path, &data) + .await + .map_err(|e| format!("write temp file: {e}"))?; + fs::rename(&temp_path, path) + .await + .map_err(|e| format!("rename temp file: {e}"))?; + + debug!( + "Saved encrypted entitlements ({} bytes) to {path:?}", + data.len() + ); + Ok(()) + } + + async fn load(&self) -> Result { + let path = self + .storage_path + .as_ref() + .ok_or_else(|| "persistence not configured".to_string())?; + + if !path.exists() { + info!("Entitlements file not found at {path:?}, starting fresh"); + return Ok(0); + } + + let data = fs::read(path) + .await + .map_err(|e| format!("read entitlements: {e}"))?; + + let (snapshot, used_key, preferred_key) = self.decrypt_with_candidates(&data).await?; + + if snapshot.version != DATA_VERSION { + warn!( + "Entitlements version {} != expected {DATA_VERSION}", + snapshot.version + ); + } + + let count = snapshot.individuals.values().map(Vec::len).sum::() + + snapshot.pending_by_token.len(); + *self.individuals.write().unwrap() = snapshot.individuals; + *self.groups.write().unwrap() = snapshot.groups; + *self.pending_by_token.write().unwrap() = snapshot.pending_by_token; + *self.cached_key.write().unwrap() = Some(preferred_key); + if used_key != preferred_key { + info!("Re-encrypting entitlements with the stable persist key"); + self.persist().await?; + } + Ok(count) + } + + async fn decrypt_with_candidates( + &self, + data: &[u8], + ) -> Result<(EntitlementsSnapshot, [u8; 32], [u8; 32]), String> { + if let Some(key) = *self.cached_key.read().unwrap() { + let snapshot = decrypt_entitlements_blob(data, &key)?; + return Ok((snapshot, key, key)); + } + + let (preferred, candidates) = self.encryption_keys().await?; + let mut last_err = "no candidate keys".to_string(); + for (label, key) in candidates { + match decrypt_entitlements_blob(data, &key) { + Ok(snapshot) => { + info!("Decrypted entitlements with {label}"); + return Ok((snapshot, key, preferred)); + } + Err(e) => last_err = e, + } + } + Err(last_err) + } + + #[cfg(test)] + pub async fn persist_now(&self) -> Result<(), String> { + self.persist().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use tempfile::tempdir; + + fn sample_record(owner: &str, sku: PlanSku, source: EntitlementSource) -> EntitlementRecord { + let now = Utc::now(); + EntitlementRecord { + id: new_record_id(), + plan_sku: sku, + source, + status: EntitlementStatus::Active, + expires_at: None, + stripe_customer_id: None, + stripe_subscription_id: None, + owner_uuid: Some(owner.into()), + link_token: None, + claimed_group_id: None, + created_at: now, + updated_at: now, + } + } + + #[tokio::test] + async fn encrypted_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("entitlements.enc"); + let key = [7u8; 32]; + let dstack = DstackClient::new("/nonexistent/dstack.sock"); + + let store = EntitlementsStore::with_test_key(dstack, path.clone(), key).await; + let mut individual = sample_record("uuid-1", PlanSku::InChatMe, EntitlementSource::Stripe); + individual.stripe_customer_id = Some("cus_test".into()); + store.upsert(individual.clone()).unwrap(); + + let mut group_sku = + sample_record("uuid-1", PlanSku::ThreadsGroup, EntitlementSource::Stripe); + group_sku.claimed_group_id = Some("group.internal".into()); + store.upsert(group_sku.clone()).unwrap(); + + store.persist_now().await.unwrap(); + + let store2 = EntitlementsStore::with_test_key(DstackClient::new("/x"), path, key).await; + let loaded = store2.get_individual("uuid-1"); + assert_eq!(loaded.len(), 2); + assert!(loaded.iter().any(|r| r.plan_sku == PlanSku::InChatMe)); + assert!(loaded.iter().any(|r| r.plan_sku == PlanSku::ThreadsGroup + && r.claimed_group_id.as_deref() == Some("group.internal"))); + assert_eq!(store2.get_group("group.internal").len(), 1); + } + + #[test] + fn expire_due_marks_past_expires_at() { + let store = EntitlementsStore::new_in_memory(); + let now = Utc::now(); + let mut past = sample_record("u1", PlanSku::InChatMe, EntitlementSource::Alpha); + past.expires_at = Some(now - Duration::hours(1)); + let mut future = sample_record("u1", PlanSku::ThreadsIndividual, EntitlementSource::Alpha); + future.expires_at = Some(now + Duration::hours(1)); + store.upsert(past.clone()).unwrap(); + store.upsert(future.clone()).unwrap(); + + assert_eq!(store.expire_due(now), 1); + let list = store.get_individual("u1"); + let past_rec = list.iter().find(|r| r.id == past.id).unwrap(); + let future_rec = list.iter().find(|r| r.id == future.id).unwrap(); + assert_eq!(past_rec.status, EntitlementStatus::Expired); + assert_eq!(future_rec.status, EntitlementStatus::Active); + } + + #[test] + fn link_binding_moves_pending_to_individuals() { + let store = EntitlementsStore::new_in_memory(); + let pending = store + .create_pending( + "tok-abc".into(), + PlanSku::BundleIndividual, + EntitlementSource::Stripe, + None, + Some("cus_1".into()), + Some("sub_1".into()), + ) + .unwrap(); + assert!(store.get_pending("tok-abc").is_some()); + assert!(store.get_individual("uuid-owner").is_empty()); + + let bound = store + .bind_link_token("tok-abc", "uuid-owner".into()) + .unwrap(); + assert_eq!(bound.id, pending.id); + assert_eq!(bound.owner_uuid.as_deref(), Some("uuid-owner")); + assert!(bound.link_token.is_none()); + assert!(store.get_pending("tok-abc").is_none()); + assert_eq!(store.get_individual("uuid-owner").len(), 1); + } + + #[test] + fn claim_group_indexes_under_groups() { + let store = EntitlementsStore::new_in_memory(); + let record = sample_record("owner-1", PlanSku::InChatAll, EntitlementSource::Stripe); + let id = record.id.clone(); + store.upsert(record).unwrap(); + + let claimed = store + .claim_group("owner-1", &id, "group.main".into()) + .unwrap(); + assert_eq!(claimed.claimed_group_id.as_deref(), Some("group.main")); + assert_eq!(store.get_group("group.main").len(), 1); + assert_eq!( + store.get_individual("owner-1")[0] + .claimed_group_id + .as_deref(), + Some("group.main") + ); + } + + #[test] + fn composition_paid_replaces_alpha_on_overlap() { + let now = Utc::now(); + let alpha = { + let mut r = sample_record("u", PlanSku::BundleAllAlpha, EntitlementSource::Alpha); + r.expires_at = Some(now + Duration::days(90)); + r + }; + let paid = sample_record("u", PlanSku::InChatMe, EntitlementSource::Stripe); + + let grants = compose_effective_grants(&[alpha, paid], &[], now); + assert_eq!( + grants.get(&FeatureGrant::InChatMe), + Some(&EntitlementSource::Stripe) + ); + assert_eq!( + grants.get(&FeatureGrant::ThreadsIndividual), + Some(&EntitlementSource::Alpha) + ); + assert_eq!( + grants.get(&FeatureGrant::TranscriptionIndividual), + Some(&EntitlementSource::Alpha) + ); + } + + #[test] + fn decrypts_blob_encrypted_with_legacy_compose_hash_key() { + let mut individuals = HashMap::new(); + individuals.insert( + "u1".into(), + vec![sample_record( + "u1", + PlanSku::TranscriptionIndividual, + EntitlementSource::Stripe, + )], + ); + let snapshot = EntitlementsSnapshot { + version: DATA_VERSION, + individuals, + groups: HashMap::new(), + pending_by_token: HashMap::new(), + }; + let legacy = appinfo_fallback_key("app-1", Some("old-compose")); + let stable = appinfo_fallback_key("app-1", None); + let blob = encrypt_entitlements_blob(&snapshot, &legacy).unwrap(); + + assert!(decrypt_entitlements_blob(&blob, &stable).is_err()); + let loaded = decrypt_entitlements_blob(&blob, &legacy).unwrap(); + assert_eq!(loaded.individuals["u1"].len(), 1); + } + + #[test] + fn plan_sku_serde_matches_site_ids() { + assert_eq!( + serde_json::to_string(&PlanSku::BundleIndividual).unwrap(), + "\"bundle-individual\"" + ); + assert_eq!( + serde_json::to_string(&PlanSku::InChatMe).unwrap(), + "\"in-chat-me\"" + ); + assert_eq!( + serde_json::to_string(&PlanSku::BundleAllAlpha).unwrap(), + "\"bundle-all-alpha\"" + ); + } + + #[test] + fn appinfo_key_without_compose_hash_differs_from_legacy_mix() { + let stable = appinfo_fallback_key("app-1", None); + let legacy = appinfo_fallback_key("app-1", Some("old-compose")); + assert_ne!(stable, legacy); + } + + #[test] + fn unclaimed_group_sku_does_not_grant_group_features() { + let now = Utc::now(); + let unclaimed = sample_record("u", PlanSku::InChatAll, EntitlementSource::Stripe); + let grants = compose_effective_grants(&[unclaimed], &[], now); + assert!(!grants.contains_key(&FeatureGrant::InChatAll)); + } +} diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 842588f..e4134c6 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -3,6 +3,7 @@ use crate::bot_identity::BotIdentity; use crate::commands::*; use crate::config::Config; +use crate::entitlements_store::EntitlementsStore; use crate::error::AppResult; use crate::group_preferences_store::GroupPreferencesStore; use crate::transcribe_prefs::GroupTranscribePrefs; @@ -17,13 +18,21 @@ use std::sync::Arc; use tracing::{info, warn}; 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. + pub entitlements: Arc, +} + /// Unified bot: Language Threads → voice → in-chat → hub menus → quote translate → verify/help. pub async fn build_handlers( config: &Config, signal: Arc, dstack: Arc, bot_identity: Arc, -) -> AppResult>> { +) -> AppResult { let near_cfg = config .near_ai .as_ref() @@ -83,6 +92,21 @@ pub async fn build_handlers( ); } + let entitlements = EntitlementsStore::open( + dstack.clone(), + PathBuf::from(&config.entitlements.storage_path), + config.entitlements.persist, + config.entitlements.legacy_compose_hashes(), + ) + .await; + + if config.entitlements.persist { + info!( + "Entitlements persistence enabled: {}", + config.entitlements.storage_path + ); + } + let translate_me = TranslateMeHandler::new( group_prefs.clone(), near_ai.clone(), @@ -167,15 +191,18 @@ pub async fn build_handlers( handlers.push(Box::new(PrivacyHandler::new())); info!("Unified bot: hub menus + voice + in-chat + Language Threads"); - Ok(handlers) + Ok(BuiltHandlers { + handlers, + entitlements, + }) } #[cfg(test)] mod tests { use super::*; use crate::config::{ - BotConfig, DstackConfig, GroupPreferencesConfig, NearAiConfig, SignalConfig, - TranslateAllConfig, WhisperConfig, + BotConfig, DstackConfig, EntitlementsConfig, GroupPreferencesConfig, NearAiConfig, + SignalConfig, TranslateAllConfig, WhisperConfig, }; use std::time::Duration; use wiremock::matchers::{method, path}; @@ -203,6 +230,11 @@ mod tests { storage_path: "/tmp/sigstack-bot-test-prefs.enc".into(), ..Default::default() }, + entitlements: EntitlementsConfig { + persist: false, + storage_path: "/tmp/sigstack-bot-test-entitlements.enc".into(), + ..Default::default() + }, } } @@ -246,9 +278,10 @@ mod tests { let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); let identity = BotIdentity::new(); - let handlers = build_handlers(&config, signal, dstack, identity) + let built = build_handlers(&config, signal, dstack, identity) .await .expect("translation handlers"); + let handlers = built.handlers; assert_eq!(handlers.len(), 22); let got = labels(&handlers); @@ -293,9 +326,10 @@ mod tests { let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); let identity = BotIdentity::new(); - let handlers = build_handlers(&config, signal, dstack, identity) + let built = build_handlers(&config, signal, dstack, identity) .await .expect("translation handlers"); + let handlers = built.handlers; assert_eq!(handlers.len(), 21); let got = labels(&handlers); diff --git a/crates/signal-bot/src/lib.rs b/crates/signal-bot/src/lib.rs index 28471be..e52cfeb 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 entitlements_store; pub mod error; pub mod group_invite_acceptor; pub mod group_preferences_store; diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index 160ec57..558588c 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -50,7 +50,7 @@ async fn main() -> AppResult<()> { let bot_identity = BotIdentity::new(); - let handlers = build_handlers( + let built = build_handlers( &config, signal.clone(), dstack.clone(), @@ -58,7 +58,8 @@ async fn main() -> AppResult<()> { ) .await?; - let handlers = Arc::new(handlers); + let handlers = Arc::new(built.handlers); + let _entitlements = built.entitlements; info!("Registered {} command handlers", handlers.len()); { diff --git a/docker/.env.example b/docker/.env.example index bea3e59..24c4242 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -12,6 +12,7 @@ TRANSLATE_ALL_ENABLED=true TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 LOG_LEVEL=info GROUP_PREFERENCES_PERSIST=true +ENTITLEMENTS_PERSIST=true BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot BOT_SIGNAL_USERNAME= RATE_LIMIT_GLOBAL_PER_MINUTE=10 diff --git a/docker/.phala.env.example b/docker/.phala.env.example index d80b796..ddd389b 100644 --- a/docker/.phala.env.example +++ b/docker/.phala.env.example @@ -20,9 +20,11 @@ WHISPER_TIMEOUT=120s TRANSLATE_ALL_ENABLED=true TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 -# Previous dstack compose_hash so group_prefs.enc still decrypts after an image bump -# when DeriveKey is unavailable. The bot then re-saves with an app-id-only key. +# Previous dstack compose_hash so group_prefs.enc / entitlements.enc still decrypt +# after an image bump when DeriveKey is unavailable. The bot then re-saves with +# an app-id-only key. # GROUP_PREFERENCES_LEGACY_COMPOSE_HASH= +# ENTITLEMENTS_LEGACY_COMPOSE_HASH= LOG_LEVEL=info BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot BOT_SIGNAL_USERNAME= diff --git a/docker/compose.yaml b/docker/compose.yaml index 64e3280..0093188 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -48,6 +48,8 @@ services: - DSTACK__SOCKET_PATH=/var/run/dstack.sock - GROUP_PREFERENCES__PERSIST=${GROUP_PREFERENCES_PERSIST:-true} - GROUP_PREFERENCES__STORAGE_PATH=/data/group_prefs.enc + - ENTITLEMENTS__PERSIST=${ENTITLEMENTS_PERSIST:-true} + - ENTITLEMENTS__STORAGE_PATH=/data/entitlements.enc volumes: - /var/run/dstack.sock:/var/run/dstack.sock:ro - group-prefs-translation:/data diff --git a/docker/phala.yaml b/docker/phala.yaml index 77aebb7..f2f93ae 100644 --- a/docker/phala.yaml +++ b/docker/phala.yaml @@ -54,6 +54,9 @@ services: - GROUP_PREFERENCES__PERSIST=true - GROUP_PREFERENCES__STORAGE_PATH=/data/group_prefs.enc - GROUP_PREFERENCES__LEGACY_COMPOSE_HASH=${GROUP_PREFERENCES_LEGACY_COMPOSE_HASH:-} + - ENTITLEMENTS__PERSIST=true + - ENTITLEMENTS__STORAGE_PATH=/data/entitlements.enc + - ENTITLEMENTS__LEGACY_COMPOSE_HASH=${ENTITLEMENTS_LEGACY_COMPOSE_HASH:-} volumes: - /var/run/dstack.sock:/var/run/dstack.sock:ro - group-prefs-translation:/data diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9ba66d9..e3df47b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -8,6 +8,5 @@ Current product direction: - [language-threads.md](language-threads.md) - [../.agents/docs/DEVELOPMENT.md](../.agents/docs/DEVELOPMENT.md) - [../README.md](../README.md) -- [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10) diff --git a/docs/one-cvm-architecture.md b/docs/one-cvm-architecture.md index d7b7af8..2c37852 100644 --- a/docs/one-cvm-architecture.md +++ b/docs/one-cvm-architecture.md @@ -2,7 +2,7 @@ Prod runs hub, translation, and voice on **one** Phala CVM as **one** Signal member (the surviving translation number, phone B). Users add **one** bot to a group. STT is remote **NEAR AI Whisper Large V3**; after a transcript, in-chat and Language Threads fan out **in-process** (Signal does not echo this bot’s own posts). -See also: [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10) and the architecture learning [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). +See also: the architecture learning [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). ## Bot @@ -67,10 +67,10 @@ The suite stays cohesive only if the live CVM keeps disk state. **TEE RAM is wip | Volume | Compose | What it holds | If wiped | |--------|---------|---------------|----------| | `signal-config-translation` | `signal-api` (phone B) | Bot **registered Signal phone** | Bot disappears from Signal until ops re-register | -| `group-prefs-translation` | `signal-bot` → `/data/group_prefs.enc` | Encrypted prefs: `!translate-me-on`, `!translate-all-on`, Language Threads bridges, menu language | Users must re-enable features | +| `group-prefs-translation` | `signal-bot` → `/data/group_prefs.enc` and `/data/entitlements.enc` | Encrypted feature prefs (`!translate-me-on`, `!translate-all-on`, Language Threads, menu language) **and** encrypted entitlements (paid/alpha plans, pending link tokens) | Users must re-enable features; paid/alpha access is lost until Stripe backfill / re-link | | `registry-data` | registration proxy | Ops registration helper state | Re-register via proxy; does not by itself drop Signal CLI | -Do **not** rename `signal-config-translation` / `group-prefs-translation` / `registry-data`. Do not migrate `group-prefs-transcription` (`!transcribe-on` default off is the product). Unused transcription volumes from the old two-phone compose may remain on disk; they are not attached. +Do **not** rename `signal-config-translation` / `group-prefs-translation` / `registry-data`. Do not migrate `group-prefs-transcription` (`!transcribe-on` default off is the product). Unused transcription volumes from the old two-phone compose may remain on disk; they are not attached. Do **not** add a separate entitlements volume — both encrypted files share `group-prefs-translation` → `/data`. **Upgrade the live CVM in place** (`phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353` or the dashboard compose update). Do **not** create a replacement CVM or `down -v` for a routine image bump. First bring-up of a **new** CVM is the exception (empty volumes; register the phone there). @@ -81,7 +81,11 @@ Do **not** rename `signal-config-translation` / `group-prefs-translation` / `reg | New CVM / `phala cvms delete` / volume rename | Empty | Yes | Yes | | Prefs decrypt fail (key mismatch) | File present, unreadable | Yes (bot starts empty) | No (Signal volume is separate) | -Prefs are encrypted with dstack `DeriveKey` (path `signal-bot/group-preferences`), bound to the CVM **app id**, so a compose/image change should still decrypt. If DeriveKey is unavailable the AppInfo fallback is **app-id-only** (stable across compose bumps). Blobs encrypted with the old `compose_hash` mix still decrypt when `GROUP_PREFERENCES_LEGACY_COMPOSE_HASH` is set; the bot then re-saves with the stable key. After upgrade, logs should show `Loaded group preferences for N groups`, not `starting fresh` or `TEE deployment may have changed`. Confirm Signal accounts still listed on `signal-api`. +Prefs are encrypted with dstack `DeriveKey` (path `signal-bot/group-preferences`), bound to the CVM **app id**, so a compose/image change should still decrypt. Entitlements use the same pattern at path `signal-bot/entitlements` (`/data/entitlements.enc`). If DeriveKey is unavailable the AppInfo fallback is **app-id-only** (stable across compose bumps). Blobs encrypted with the old `compose_hash` mix still decrypt when `GROUP_PREFERENCES_LEGACY_COMPOSE_HASH` / `ENTITLEMENTS_LEGACY_COMPOSE_HASH` is set; the bot then re-saves with the stable key. After upgrade, logs should show `Loaded group preferences for N groups` and `Loaded N entitlement records`, not `starting fresh` or `TEE deployment may have changed`. Confirm Signal accounts still listed on `signal-api`. + +#### Entitlement plan composition + +Alpha `bundle-all-alpha` grants full Bundle-all feature coverage while active/past_due and unexpired. A **paid** (`stripe`) entitlement that overlaps a feature **replaces** alpha for that feature only; non-overlapping alpha coverage remains. Feature prefs (`group_prefs.enc`) stay separate from access records (`entitlements.enc`). Do not change volume names in [`docker/phala.yaml`](../docker/phala.yaml) without a deliberate migration. diff --git a/docs/plans/2026-08-14-001-feat-localize-bot-menus-plan.md b/docs/plans/2026-08-14-001-feat-localize-bot-menus-plan.md index 832800a..2d8cfbd 100644 --- a/docs/plans/2026-08-14-001-feat-localize-bot-menus-plan.md +++ b/docs/plans/2026-08-14-001-feat-localize-bot-menus-plan.md @@ -2,7 +2,6 @@ title: Localize Bot Menus - Plan type: feat date: 2026-08-14 -origin: https://github.com/BreadchainCoop/sigstack-bot/issues/27 artifact_contract: ce-unified-plan/v1 artifact_readiness: implementation-ready product_contract_source: ce-plan-bootstrap @@ -14,7 +13,7 @@ execution: code ## Goal Capsule - **Objective:** Users pick a menu language with `!set-lang`. Every `ALL_LANGUAGES` code is a valid choice. Menus render in that language when a bundle exists, otherwise English. Mobile stacked layout stays intact. -- **Authority:** This plan. Product behavior lives on R-IDs. Mechanism lives on KTDs. Issue 27 is origin, not a second spec. +- **Authority:** This plan. Product behavior lives on R-IDs. Mechanism lives on KTDs. The originating work request is not a second spec. - **Stop if:** The work would wipe or replace CVM volumes, call NEAR to translate menus at runtime, grow `MenuLanguage` with one variant per language, or localize command tokens. - **Execution profile:** Test-first on storage, resolver, and `!set-lang`. Spanish copy lands after the English fallback path is proven. - **Tail:** Docs in U6. In-place Phala deploy only. Compound a solutions note after ship. @@ -131,7 +130,7 @@ A Spanish speaker in a mixed group still gets English `!help`. The leftover `Men ### Product Contract preservation -Product Contract authored in this plan (`ce-plan-bootstrap`). Issue 27 open questions are closed here. Sidecar compact menu is `!commands`, not hub `!help`. Pairing messages are out because they are gone. `!privacy` is one-bot copy, not two roles. +Product Contract authored in this plan (`ce-plan-bootstrap`). Open questions from the originating work request are closed here. Sidecar compact menu is `!commands`, not hub `!help`. Pairing messages are out because they are gone. `!privacy` is one-bot copy, not two roles. --- @@ -341,7 +340,7 @@ External research was skipped. Local patterns cover persistence, commands, and l ### U6. Docs for i18n approach and CVM prefs - **Goal:** Document structure, fallback, how a new `ALL_LANGUAGES` code gets a menu, and storage shape. -- **Requirements:** R14, R15 (documentation of the approach is an issue 27 acceptance line). +- **Requirements:** R14, R15 (documentation of the approach is an acceptance line for the originating work). - **Dependencies:** U1–U5 - **Files:** - `docs/solutions/signal-mobile-menus.md` @@ -376,7 +375,7 @@ Do not treat decrypt-failure empty-start as success. **Global** -- R1–R15 satisfied. Issue 27 acceptance boxes can be checked from this work. +- R1–R15 satisfied. Originating acceptance boxes can be checked from this work. - No abandoned locale-crate or LLM-menu spike left in the tree. - `npm run ci` green. - Docs match shipped behavior. diff --git a/docs/spikes/2026-6-23-phase0-whisper-spike.md b/docs/spikes/2026-6-23-phase0-whisper-spike.md index d2550df..1ba5d21 100644 --- a/docs/spikes/2026-6-23-phase0-whisper-spike.md +++ b/docs/spikes/2026-6-23-phase0-whisper-spike.md @@ -43,7 +43,7 @@ See fixtures: - `docs/spikes/fixtures/voice-note-dm.json` - `docs/spikes/fixtures/voice-note-group.json` -**Source:** [signal-cli-rest-api swagger](https://bbernhard.github.io/signal-cli-rest-api/) (`receive.Attachment`, `receive.DataMessage`), [issue #52](https://github.com/bbernhard/signal-cli-rest-api/issues/52). +**Source:** [signal-cli-rest-api swagger](https://bbernhard.github.io/signal-cli-rest-api/) (`receive.Attachment`, `receive.DataMessage`). ### Live capture (needs user) diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md index ac1bf54..a5d177a 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -2,7 +2,7 @@ Status: **implemented** on the unified CipherSlate on the surviving Phala CVM. -Speech → text via **NEAR AI Whisper Large V3** (GPU TEE). Audio is decrypted in this CVM, stripped of Signal metadata, and uploaded as a generic file. See [one-CVM architecture](one-cvm-architecture.md), [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md), and [issue #8](https://github.com/BreadchainCoop/sigstack-bot/issues/8) under umbrella [#10](https://github.com/BreadchainCoop/sigstack-bot/issues/10). +Speech → text via **NEAR AI Whisper Large V3** (GPU TEE). Audio is decrypted in this CVM, stripped of Signal metadata, and uploaded as a generic file. See [one-CVM architecture](one-cvm-architecture.md) and [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). Users add **one** bot to a group. `!transcription` is the **voice command menu** only (no invite, no pairing). Voice is **default off** (`!transcribe-on` / quote `!transcribe`). diff --git a/site/README.md b/site/README.md index 0865d07..761dee2 100644 --- a/site/README.md +++ b/site/README.md @@ -1,6 +1,6 @@ # CipherSlate site -Educational / marketing site for CipherSlate (issue [#23](https://github.com/BreadchainCoop/sigstack-bot/issues/23)). +Educational / marketing site for CipherSlate. **Stack:** SvelteKit (static) + Paraglide (en/es/fr via cookie) + Vitest + Playwright **Host:** GitHub Pages at `https://breadchaincoop.github.io/sigstack-bot/cypherslate/` @@ -20,6 +20,29 @@ Production base path defaults to `/sigstack-bot/cypherslate`. For root-local pre BASE_PATH= npm run build && BASE_PATH= npm run preview ``` +## Commerce landings + +Static post-checkout and alpha routes (no checkout API required yet): + +| Route | Purpose | +| ----- | ------- | +| `/checkout/success/` | After Stripe Checkout — show `!link` from `?code=` and plan from `?plan=` | +| `/checkout/cancel/` | Canceled checkout — return to Plans | +| `/alpha/` | Alpha program explainer + optional code entry | + +Expected Stripe redirect shapes: + +```text +…/checkout/success/?code=&plan= +…/checkout/cancel/ +``` + +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 | + ## Scripts | Script | Purpose | diff --git a/site/src/app.d.ts b/site/src/app.d.ts index da08e6d..0a148ed 100644 --- a/site/src/app.d.ts +++ b/site/src/app.d.ts @@ -10,4 +10,8 @@ declare global { } } +interface ImportMetaEnv { + readonly PUBLIC_STRIPE_PORTAL_URL?: string; +} + export {}; diff --git a/site/src/lib/checkoutLanding.spec.ts b/site/src/lib/checkoutLanding.spec.ts new file mode 100644 index 0000000..fb954b8 --- /dev/null +++ b/site/src/lib/checkoutLanding.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { getContent } from '$lib/content'; +import { + LINK_CODE_MAX_LENGTH, + linkCommand, + planLabelFromSku, + readLinkCode +} from './checkoutLanding'; + +describe('readLinkCode', () => { + it('returns trimmed code from query', () => { + expect(readLinkCode(new URL('https://example.com/checkout/success/?code=%20abc-1%20'))).toBe( + 'abc-1' + ); + }); + + it('returns null when missing or blank', () => { + expect(readLinkCode(new URL('https://example.com/checkout/success/'))).toBeNull(); + expect(readLinkCode(new URL('https://example.com/checkout/success/?code='))).toBeNull(); + expect(readLinkCode(new URL('https://example.com/checkout/success/?code=%20%20'))).toBeNull(); + }); + + it('returns null when over soft max length', () => { + const tooLong = 'x'.repeat(LINK_CODE_MAX_LENGTH + 1); + expect( + readLinkCode(new URL(`https://example.com/checkout/success/?code=${tooLong}`)) + ).toBeNull(); + }); + + it('accepts a code at the soft max length', () => { + const ok = 'y'.repeat(LINK_CODE_MAX_LENGTH); + expect(readLinkCode(new URL(`https://example.com/checkout/success/?code=${ok}`))).toBe(ok); + }); +}); + +describe('planLabelFromSku', () => { + const content = getContent('en'); + + it('resolves bundle and a-la-carte offer ids', () => { + expect(planLabelFromSku('bundle-group', content)).toBe('Bundle · Group'); + expect(planLabelFromSku('bundle-individual', content)).toBe('Bundle · Individual'); + expect(planLabelFromSku('in-chat-all', content)).toBe('In-chat · all'); + expect(planLabelFromSku('transcription-individual', content)).toBe('Transcription'); + }); + + it('returns null for missing or unknown sku', () => { + expect(planLabelFromSku(null, content)).toBeNull(); + expect(planLabelFromSku(' ', content)).toBeNull(); + expect(planLabelFromSku('not-a-real-sku', content)).toBeNull(); + }); +}); + +describe('linkCommand', () => { + it('prefixes !link', () => { + expect(linkCommand('abc')).toBe('!link abc'); + }); +}); diff --git a/site/src/lib/checkoutLanding.ts b/site/src/lib/checkoutLanding.ts new file mode 100644 index 0000000..e6cda96 --- /dev/null +++ b/site/src/lib/checkoutLanding.ts @@ -0,0 +1,41 @@ +import type { SiteContent } from '$lib/content/types'; + +/** Soft cap so junk query strings are not rendered into the page. */ +export const LINK_CODE_MAX_LENGTH = 128; + +/** + * Read and sanitize `?code=` from a landing URL. + * Returns null when missing, blank, or over the soft max length. + */ +export function readLinkCode(url: URL): string | null { + const raw = url.searchParams.get('code'); + if (raw == null) return null; + const code = raw.trim(); + if (!code) return null; + if (code.length > LINK_CODE_MAX_LENGTH) return null; + return code; +} + +/** + * Map a plan SKU / offer id from `?plan=` to a display name from site content. + */ +export function planLabelFromSku(sku: string | null, content: SiteContent): string | null { + if (!sku) return null; + const id = sku.trim(); + if (!id) return null; + + const fromBundle = content.pages.plans.bundle.offers.find((o) => o.id === id); + if (fromBundle) return fromBundle.name; + + for (const product of content.pages.plans.products) { + const offer = product.offers.find((o) => o.id === id); + if (offer) return offer.name; + } + + return null; +} + +/** Format the Signal command shown on success / alpha landings. */ +export function linkCommand(code: string): string { + return `!link ${code}`; +} diff --git a/site/src/lib/content/en.ts b/site/src/lib/content/en.ts index fb1c08b..a379af3 100644 --- a/site/src/lib/content/en.ts +++ b/site/src/lib/content/en.ts @@ -235,7 +235,51 @@ export const en: SiteContent = { group: 'Group' }, footnote: - 'Prices are illustrative placeholders. Checkout is not live yet—start in Signal with the organizer checklist.' + 'Prices are illustrative placeholders. Checkout is not live yet—start in Signal with the organizer checklist.', + alphaPrompt: 'Have an alpha code?' + }, + checkoutSuccess: { + title: 'You are subscribed', + lead: 'Next, link CipherSlate to your Signal account so your plan unlocks in chat.', + eyebrow: 'Checkout', + planPurchased: 'Plan purchased: {plan}.', + 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:', + 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', + portalComingSoon: 'Manage billing (coming soon)', + getStartedCta: 'Organizer checklist' + }, + checkoutCancel: { + title: 'Checkout canceled', + lead: 'No charge was made and no entitlement was created. You can pick a plan whenever you are ready.', + eyebrow: 'Checkout', + plansCta: 'Back to Plans', + getStartedCta: 'Organizer checklist' + }, + alpha: { + title: 'Alpha access', + lead: 'Redeem an alpha code for the full CipherSlate Bundle — Language Threads, in-chat translation, and transcription — for 90 days, no Stripe checkout.', + eyebrow: 'Alpha', + benefitsHeading: 'What you get', + benefits: [ + 'Full Bundle access (individual and group scopes) for 90 days', + 'Same Signal linking flow as paid plans (`!link `)', + 'After expiry, subscribe on Plans to keep using products' + ], + codeLabel: 'Alpha code', + codePlaceholder: 'Enter your code', + submitLabel: 'Continue', + errorEmpty: 'Enter an alpha code to continue.', + 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:', + getStartedCta: 'Organizer checklist', + plansCta: 'See paid plans' } }, legalPrivacy: { diff --git a/site/src/lib/content/types.ts b/site/src/lib/content/types.ts index 7f5f4c2..8fc0e04 100644 --- a/site/src/lib/content/types.ts +++ b/site/src/lib/content/types.ts @@ -79,6 +79,43 @@ export type SiteContent = { products: PlanProduct[]; scopeLabels: Record; footnote: string; + alphaPrompt: string; + }; + checkoutSuccess: { + title: string; + lead: string; + eyebrow: string; + planPurchased: string; + planPurchasedGeneric: string; + linkHeading: string; + linkBody: string; + missingCode: string; + portalCta: string; + portalComingSoon: string; + getStartedCta: string; + }; + checkoutCancel: { + title: string; + lead: string; + eyebrow: string; + plansCta: string; + getStartedCta: string; + }; + alpha: { + title: string; + lead: string; + eyebrow: string; + benefitsHeading: string; + benefits: string[]; + codeLabel: string; + codePlaceholder: string; + submitLabel: string; + errorEmpty: string; + errorTooLong: string; + linkHeading: string; + linkBody: string; + getStartedCta: string; + plansCta: string; }; }; legalPrivacy: { diff --git a/site/src/routes/alpha/+page.svelte b/site/src/routes/alpha/+page.svelte new file mode 100644 index 0000000..0292b79 --- /dev/null +++ b/site/src/routes/alpha/+page.svelte @@ -0,0 +1,163 @@ + + + + {copy.title} — {meta.siteName} + + + +

{copy.eyebrow}

+

{copy.title}

+

{copy.lead}

+ +
+

{copy.benefitsHeading}

+
    + {#each copy.benefits as benefit (benefit)} +
  • {benefit}
  • + {/each} +
+
+ +{#if browser && codeFromUrl} + +
+ + +
+{:else if !codeFromUrl} +
+ + + {#if error} + + {/if} +
+ + +
+
+{/if} + + diff --git a/site/src/routes/checkout/cancel/+page.svelte b/site/src/routes/checkout/cancel/+page.svelte new file mode 100644 index 0000000..fb2d66a --- /dev/null +++ b/site/src/routes/checkout/cancel/+page.svelte @@ -0,0 +1,22 @@ + + + + {copy.title} — {meta.siteName} + + + +

{copy.eyebrow}

+

{copy.title}

+

{copy.lead}

+ +
+ + +
diff --git a/site/src/routes/checkout/success/+page.svelte b/site/src/routes/checkout/success/+page.svelte new file mode 100644 index 0000000..1dfbae1 --- /dev/null +++ b/site/src/routes/checkout/success/+page.svelte @@ -0,0 +1,90 @@ + + + + {copy.title} — {meta.siteName} + + + +

{copy.eyebrow}

+

{copy.title}

+

{copy.lead}

+

{planLine}

+ +{#if browser} + {#if code} + + {:else} +

{copy.missingCode}

+ {/if} +{/if} + +
+ {#if portalUrl} + + {:else} + {copy.portalComingSoon} + {/if} + +
+ + diff --git a/site/src/routes/plans/+page.svelte b/site/src/routes/plans/+page.svelte index 5bced2a..29ef626 100644 --- a/site/src/routes/plans/+page.svelte +++ b/site/src/routes/plans/+page.svelte @@ -1,4 +1,6 @@