From 9cba0aedb33da2aeec39e43ca54fbd39258778cc Mon Sep 17 00:00:00 2001 From: daopunk Date: Tue, 29 Sep 2026 19:08:15 -0500 Subject: [PATCH 1/3] feat: align get-started and gate denials with pay-link-enable path Closes #63 --- .agents/docs/github-work-index.md | 2 +- crates/signal-bot/src/entitlement_gate.rs | 47 +++++++++++++++++++---- site/src/lib/content/en.ts | 26 +++++++------ site/src/lib/content/index.spec.ts | 2 +- site/src/routes/get-started/+page.svelte | 8 ++-- site/src/routes/smoke.e2e.ts | 13 ++++--- 6 files changed, 68 insertions(+), 30 deletions(-) diff --git a/.agents/docs/github-work-index.md b/.agents/docs/github-work-index.md index ce458c6..5283f68 100644 --- a/.agents/docs/github-work-index.md +++ b/.agents/docs/github-work-index.md @@ -50,7 +50,7 @@ When adding work, update **this file**—do not sprinkle `#N` into other paths. | [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 | Shipped; Plans Subscribe → commerce `POST /v1/checkout/sessions` | | [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 | | +| [63](https://github.com/BreadchainCoop/sigstack-bot/issues/63) | feat: revise get-started flow and entitlement user comms | Subscribe→link→invite→enable; gate deny copy | | [64](https://github.com/BreadchainCoop/sigstack-bot/issues/64) | feat: deploy commerce service and entitlements on phala cvm | Shipped on live CVM; enforce default false until E2E green | | [65](https://github.com/BreadchainCoop/sigstack-bot/issues/65) | test: commerce and entitlement e2e test plan | [`docs/commerce/e2e-checklist.md`](../../docs/commerce/e2e-checklist.md) | diff --git a/crates/signal-bot/src/entitlement_gate.rs b/crates/signal-bot/src/entitlement_gate.rs index 5ff55e2..61721bc 100644 --- a/crates/signal-bot/src/entitlement_gate.rs +++ b/crates/signal-bot/src/entitlement_gate.rs @@ -26,8 +26,9 @@ use signal_bot_core::starts_with_word; use signal_client::BotMessage; use std::sync::Arc; -pub const DENY_NEED_LINK: &str = - "Access locked. DM this bot with !link to unlock (checkout code or alpha)."; +pub const DENY_NEED_LINK: &str = "Complete linking: DM this bot with !link from your checkout success page or alpha redeem. Subscribe or redeem: https://breadchaincoop.github.io/sigstack-bot/sigstack/plans/"; + +pub const DENY_NEED_SUBSCRIBE: &str = "Your access ended (expired or canceled). Subscribe to continue: https://breadchaincoop.github.io/sigstack-bot/sigstack/plans/ — or redeem a new alpha code."; pub const DENY_NEED_ENABLE: &str = "Sigstack is not enabled in this group yet. A linked member must run !enable-sigstack."; @@ -35,7 +36,11 @@ pub const DENY_NEED_ENABLE: &str = /// Why access was denied (for reply copy). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GateDeny { + /// No individual entitlement (never linked / unpaid pending). NeedLink, + /// Had an entitlement that is no longer granting (expired / canceled). + NeedSubscribe, + /// Linked but group not enabled with `!enable-sigstack`. NeedEnable, } @@ -43,11 +48,24 @@ impl GateDeny { pub fn message(self) -> &'static str { match self { Self::NeedLink => DENY_NEED_LINK, + Self::NeedSubscribe => DENY_NEED_SUBSCRIBE, Self::NeedEnable => DENY_NEED_ENABLE, } } } +fn deny_for_unentitled(store: &EntitlementsStore, owner: &str) -> GateDeny { + if owner.is_empty() { + return GateDeny::NeedLink; + } + // Any individual row means they linked before; guide them to re-subscribe. + if !store.get_individual(owner).is_empty() { + GateDeny::NeedSubscribe + } else { + GateDeny::NeedLink + } +} + fn owner_key(message: &BotMessage) -> String { message.source.trim().to_string() } @@ -69,7 +87,7 @@ pub fn check_access(store: &EntitlementsStore, message: &BotMessage) -> Result<( return if entitled { Ok(()) } else { - Err(GateDeny::NeedLink) + Err(deny_for_unentitled(store, &owner)) }; } @@ -87,7 +105,7 @@ pub fn check_access(store: &EntitlementsStore, message: &BotMessage) -> Result<( if entitled { Ok(()) } else { - Err(GateDeny::NeedLink) + Err(deny_for_unentitled(store, &owner)) } } @@ -215,7 +233,7 @@ mod tests { for cmd in PRODUCT_DM_CMDS { assert_eq!( check_access(&store, &dm(cmd, "uuid-ada")).unwrap_err(), - GateDeny::NeedLink, + GateDeny::NeedSubscribe, "{cmd}" ); } @@ -313,10 +331,25 @@ mod tests { #[test] fn deny_copy_mentions_checkout_or_alpha() { - assert!(DENY_NEED_LINK.contains("checkout") || DENY_NEED_LINK.contains("alpha")); + assert!(DENY_NEED_LINK.contains("!link")); + assert!(DENY_NEED_LINK.contains("/plans/")); + assert!(DENY_NEED_SUBSCRIBE.contains("Subscribe") || DENY_NEED_SUBSCRIBE.contains("alpha")); assert!(DENY_NEED_ENABLE.contains("!enable-sigstack")); } + #[test] + fn canceled_dm_gets_subscribe_copy() { + let store = EntitlementsStore::new_in_memory(); + let mut canceled = paid_active("uuid-ada", PlanSku::AllAccess3); + canceled.status = EntitlementStatus::Canceled; + store.upsert(canceled).unwrap(); + assert_eq!( + check_access(&store, &dm("!help", "uuid-ada")).unwrap_err(), + GateDeny::NeedSubscribe + ); + assert!(GateDeny::NeedSubscribe.message().contains("/plans/")); + } + // --- Alpha + Stripe coexistence under enforce (epic coexistence issue) --- fn enforce_gate(store: Arc) -> EntitlementGate { @@ -442,7 +475,7 @@ mod tests { assert_eq!( gate.allow(&dm("!help", "uuid-dead")).await.unwrap_err(), - GateDeny::NeedLink + GateDeny::NeedSubscribe ); } } diff --git a/site/src/lib/content/en.ts b/site/src/lib/content/en.ts index 025306e..9dc1404 100644 --- a/site/src/lib/content/en.ts +++ b/site/src/lib/content/en.ts @@ -96,20 +96,24 @@ export const en: SiteContent = { }, getStarted: { title: 'Getting started', - lead: 'After you link, invite Sigstack to your Signal group and enable it.', - eyebrow: 'Organizers', + lead: 'Pay or redeem alpha, link your Signal account, invite Sigstack, then enable each group. Every member in an enabled group gets all-access products.', + eyebrow: 'Setup', steps: [ { - title: 'Invite Sigstack', - body: 'Create or open a Signal group and invite Sigstack (it auto-accepts).' + title: 'Subscribe or redeem alpha', + body: 'On Plans, Subscribe for an all-access pack, or redeem an alpha code. You get a link code on the success / alpha page.' }, { - title: 'Enable the group', - body: 'Send !enable-sigstack so everyone in that chat can use Sigstack.' + title: 'Link in Signal', + body: 'Open a DM with Sigstack and send !link (from your checkout success page or alpha redeem). Linking binds your Signal account—it is not the same as inviting the bot to a group.' + }, + { + title: 'Invite Sigstack', + body: 'Create or open a Signal group and invite Sigstack (it auto-accepts). Inviting alone does not unlock products until you link and enable.' }, { - title: 'Open the hub', - body: 'Send !help for product menus: Language Threads, in-chat, and transcription.' + title: 'Enable the group', + body: 'In that group, send !enable-sigstack so everyone in the chat can use Language Threads, in-chat translation, and transcription. Then !help for product menus.' } ], hubCommandsHeading: 'Hub commands', @@ -164,7 +168,7 @@ export const en: SiteContent = { checkoutFailed: 'Could not start checkout. Try again in a moment.' }, footnote: - 'Alpha is free with a code. Paid Subscribe opens Stripe Checkout; after payment, link in Signal with the code on the success page. Get started covers organizer setup.' + 'Alpha is free with a code. Paid Subscribe opens Stripe Checkout; after payment, send !link in a Signal DM, invite Sigstack, then !enable-sigstack. Get started walks through the full path.' }, checkoutSuccess: { title: 'You are subscribed', @@ -182,14 +186,14 @@ export const en: SiteContent = { '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 Sigstack.', portalCta: 'Manage billing', portalComingSoon: 'Manage billing (coming soon)', - getStartedCta: 'Organizer checklist' + getStartedCta: 'Full setup steps' }, 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' + getStartedCta: 'Full setup steps' }, alpha: { title: 'Alpha', diff --git a/site/src/lib/content/index.spec.ts b/site/src/lib/content/index.spec.ts index a4eb8e5..3cb5055 100644 --- a/site/src/lib/content/index.spec.ts +++ b/site/src/lib/content/index.spec.ts @@ -9,7 +9,7 @@ describe('getContent', () => { expect(c.pages.home.lead).not.toContain('Placeholder'); expect(c.pages.home.paths).toHaveLength(3); expect(c.pages.home.paths[0]?.href).toBe('/products#language-threads'); - expect(c.pages.getStarted.steps).toHaveLength(3); + expect(c.pages.getStarted.steps).toHaveLength(4); expect(c.pages.getStarted.hubCommands.length).toBeGreaterThan(0); expect(c.pages.products.sections).toHaveLength(3); expect(c.legalLicense.title).toBe('Apache License 2.0'); diff --git a/site/src/routes/get-started/+page.svelte b/site/src/routes/get-started/+page.svelte index bb35b68..4115a63 100644 --- a/site/src/routes/get-started/+page.svelte +++ b/site/src/routes/get-started/+page.svelte @@ -30,9 +30,7 @@
- - - + + +
diff --git a/site/src/routes/smoke.e2e.ts b/site/src/routes/smoke.e2e.ts index a9fd5c9..d7fb0bf 100644 --- a/site/src/routes/smoke.e2e.ts +++ b/site/src/routes/smoke.e2e.ts @@ -21,12 +21,15 @@ test.describe('smoke', () => { await expect(page.getByRole('link', { name: 'Products' }).first()).toBeVisible(); }); - test('get started shows organizer steps and hub commands', async ({ page }) => { + test('get started shows subscribe-link-invite-enable path', async ({ page }) => { await page.goto('./get-started/'); await expect(page.getByRole('heading', { level: 1, name: 'Getting started' })).toBeVisible(); + await expect(page.getByText('Subscribe or redeem alpha', { exact: true })).toBeVisible(); + await expect(page.getByText('Link in Signal', { exact: true })).toBeVisible(); await expect(page.getByText('Invite Sigstack', { exact: true })).toBeVisible(); + await expect(page.getByText('Enable the group', { exact: true })).toBeVisible(); await expect(page.getByText('!help / !info')).toBeVisible(); - await expect(page.getByRole('link', { name: 'Language Threads' }).first()).toBeVisible(); + await expect(page.getByRole('link', { name: 'Plans' }).first()).toBeVisible(); }); test('products dropdown pins language threads section', async ({ page }) => { @@ -125,7 +128,7 @@ test.describe('smoke', () => { 'href', E2E_SIGNAL_USERNAME_LINK ); - await expect(page.getByRole('link', { name: 'Organizer checklist' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Full setup steps' })).toBeVisible(); }); test('checkout success without code shows receipt fallback', async ({ page }) => { @@ -160,7 +163,7 @@ test.describe('smoke', () => { 'href', E2E_SIGNAL_USERNAME_LINK ); - await expect(page.getByRole('link', { name: 'Organizer checklist' })).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Full setup steps' })).toHaveCount(0); await expect(page.getByRole('heading', { name: 'Link in Signal' })).toBeVisible(); await expect(page.getByText('!enable-sigstack', { exact: false })).toBeVisible(); await expect(page.locator('.link-steps')).toHaveCount(0); @@ -172,7 +175,7 @@ test.describe('smoke', () => { await expect(page.getByText('!link shared-token')).toBeVisible(); await expect(page.getByRole('button', { name: 'Copy command' })).toBeVisible(); await expect(page.getByRole('link', { name: 'See paid plans' })).toHaveCount(0); - await expect(page.getByRole('link', { name: 'Organizer checklist' })).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Full setup steps' })).toHaveCount(0); }); test('alpha empty submit shows validation without navigating', async ({ page }) => { From ca71dd9910df8cb4114b272be965e08fddeb5958 Mon Sep 17 00:00:00 2001 From: daopunk Date: Tue, 29 Sep 2026 21:45:58 -0500 Subject: [PATCH 2/3] feat: add alpha list and revoke ops to mint-alpha cli Closes #60 --- .agents/docs/github-work-index.md | 2 +- crates/signal-bot/src/bin/mint-alpha.rs | 159 +++++++++++++++---- crates/signal-bot/src/entitlements_store.rs | 160 ++++++++++++++++++++ docs/commerce/alpha-ops.md | 65 ++++++++ 4 files changed, 353 insertions(+), 33 deletions(-) create mode 100644 docs/commerce/alpha-ops.md diff --git a/.agents/docs/github-work-index.md b/.agents/docs/github-work-index.md index 5283f68..0ea6cb6 100644 --- a/.agents/docs/github-work-index.md +++ b/.agents/docs/github-work-index.md @@ -47,7 +47,7 @@ When adding work, update **this file**—do not sprinkle `#N` into other paths. | [57](https://github.com/BreadchainCoop/sigstack-bot/issues/57) | feat: !link and !claim-group for subscription binding | `!enable-sigstack` + pay-gated Stripe `!link` (alpha-only is #69) | | [58](https://github.com/BreadchainCoop/sigstack-bot/issues/58) | feat: gate product commands on entitlements | Coarse MVP via `entitlement_gate`; per-`FeatureGrant` deferred | | [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 | +| [60](https://github.com/BreadchainCoop/sigstack-bot/issues/60) | ops: alpha code generation and revocation tooling | `mint-alpha` mint/list/revoke + [`docs/commerce/alpha-ops.md`](../../docs/commerce/alpha-ops.md) | | [61](https://github.com/BreadchainCoop/sigstack-bot/issues/61) | feat: wire plans ctas to stripe checkout | Shipped; Plans Subscribe → commerce `POST /v1/checkout/sessions` | | [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 | Subscribe→link→invite→enable; gate deny copy | diff --git a/crates/signal-bot/src/bin/mint-alpha.rs b/crates/signal-bot/src/bin/mint-alpha.rs index 98c2a75..5fa2d44 100644 --- a/crates/signal-bot/src/bin/mint-alpha.rs +++ b/crates/signal-bot/src/bin/mint-alpha.rs @@ -1,7 +1,12 @@ -//! Ops CLI: mint pending alpha entitlement codes into the encrypted store. +//! Ops CLI: mint, list, and revoke alpha entitlement codes. //! //! ```bash -//! # On CVM / with dstack + volume: +//! # On CVM / with dstack + volume (never commit codes): +//! cargo run -p signal-bot --bin mint-alpha -- mint --count 5 +//! cargo run -p signal-bot --bin mint-alpha -- list +//! cargo run -p signal-bot --bin mint-alpha -- revoke +//! +//! # Back-compat (same as `mint`): //! cargo run -p signal-bot --bin mint-alpha -- --count 5 //! //! # Env (defaults match compose): @@ -28,15 +33,36 @@ fn legacy_hashes(raw: &str) -> Vec { .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(); +fn usage() { + eprintln!( + "Usage:\n\ + \tmint-alpha mint [--count N] [--days 90]\n\ + \tmint-alpha list\n\ + \tmint-alpha revoke \n\ + \tmint-alpha --count N [--days 90] (same as mint)\n\ + Env: ENTITLEMENTS__STORAGE_PATH (default /data/entitlements.enc)\n\ + DSTACK__SOCKET_PATH (default /var/run/dstack.sock)\n\ + ENTITLEMENTS__LEGACY_COMPOSE_HASH (optional)" + ); +} - let args: Vec = env::args().skip(1).collect(); +async fn open_store() -> Result> { + 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)); + Ok(EntitlementsStore::open( + dstack, + PathBuf::from(&storage_path), + true, + legacy_hashes(&legacy), + ) + .await) +} + +fn parse_mint_flags(args: &[String]) -> Result<(usize, i64)> { let mut count: usize = 1; let mut days: i64 = 90; let mut i = 0; @@ -58,34 +84,18 @@ async fn main() -> Result<()> { .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}"), + other => bail!("unknown mint argument: {other}"), } i += 1; } + Ok((count, days)) +} +async fn cmd_mint(args: &[String]) -> Result<()> { + let (count, days) = parse_mint_flags(args)?; 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 store = open_store().await?; let codes = store .mint_alpha_codes(count, days) .map_err(|e| anyhow::anyhow!(e))?; @@ -105,3 +115,88 @@ async fn main() -> Result<()> { } Ok(()) } + +async fn cmd_list() -> Result<()> { + let store = open_store().await?; + let rows = store.list_alpha(); + println!("state\tstatus\texpires_at\towner\trecord_id\tcode"); + for row in rows { + let expires = row + .expires_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()); + let owner = row.owner_uuid.as_deref().unwrap_or("-"); + let code = row.code.as_deref().unwrap_or("-"); + println!( + "{}\t{}\t{}\t{}\t{}\t{}", + row.state, + row.status.as_str(), + expires, + owner, + row.record_id, + code + ); + } + Ok(()) +} + +async fn cmd_revoke(target: &str) -> Result<()> { + let store = open_store().await?; + let affected = store.revoke_alpha(target).map_err(|e| anyhow::anyhow!(e))?; + store.flush().await.map_err(|e| anyhow::anyhow!(e))?; + info!(n = affected.len(), target, "revoked alpha"); + for rec in &affected { + let owner = rec.owner_uuid.as_deref().unwrap_or("-"); + println!( + "revoked\t{}\t{}\t{}\t{}", + rec.status.as_str(), + owner, + rec.id, + rec.link_token.as_deref().unwrap_or("-") + ); + } + Ok(()) +} + +#[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(); + if args.is_empty() || matches!(args[0].as_str(), "--help" | "-h") { + usage(); + return Ok(()); + } + + // Back-compat: `mint-alpha --count N` (no subcommand). + if args[0].starts_with("--") { + return cmd_mint(&args).await; + } + + match args[0].as_str() { + "mint" => cmd_mint(&args[1..]).await, + "list" => { + if args.len() > 1 { + bail!("list takes no arguments"); + } + cmd_list().await + } + "revoke" => { + let target = args + .get(1) + .context("revoke needs ")?; + if args.len() > 2 { + bail!("revoke takes exactly one argument"); + } + cmd_revoke(target).await + } + other => { + usage(); + bail!("unknown command: {other}"); + } + } +} diff --git a/crates/signal-bot/src/entitlements_store.rs b/crates/signal-bot/src/entitlements_store.rs index a53dab3..8dc0c47 100644 --- a/crates/signal-bot/src/entitlements_store.rs +++ b/crates/signal-bot/src/entitlements_store.rs @@ -117,6 +117,30 @@ pub enum EntitlementStatus { Canceled, } +impl EntitlementStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::PastDue => "past_due", + Self::Expired => "expired", + Self::Canceled => "canceled", + } + } +} + +/// Row for ops `mint-alpha list` (pending codes + bound alpha entitlements). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlphaOpsRow { + /// `"pending"` (unused code) or `"bound"` (linked to a Signal owner). + pub state: &'static str, + /// Pending link token when unused; usually `None` after bind. + pub code: Option, + pub record_id: String, + pub status: EntitlementStatus, + pub expires_at: Option>, + pub owner_uuid: Option, +} + /// One entitlement row (individual pending/linked, and/or claimed to a group). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EntitlementRecord { @@ -1178,6 +1202,120 @@ impl EntitlementsStore { Ok(codes) } + /// Pending + bound alpha rows for ops listing (`mint-alpha list`). + pub fn list_alpha(&self) -> Vec { + let mut rows = Vec::new(); + { + let pending = self.pending_by_token.read().unwrap(); + for (token, rec) in pending.iter() { + if rec.source != EntitlementSource::Alpha { + continue; + } + rows.push(AlphaOpsRow { + state: "pending", + code: Some(token.clone()), + record_id: rec.id.clone(), + status: rec.status, + expires_at: rec.expires_at, + owner_uuid: rec.owner_uuid.clone(), + }); + } + } + { + let individuals = self.individuals.read().unwrap(); + for recs in individuals.values() { + for rec in recs { + if rec.source != EntitlementSource::Alpha { + continue; + } + rows.push(AlphaOpsRow { + state: "bound", + code: rec.link_token.clone(), + record_id: rec.id.clone(), + status: rec.status, + expires_at: rec.expires_at, + owner_uuid: rec.owner_uuid.clone(), + }); + } + } + } + rows.sort_by(|a, b| { + a.state + .cmp(b.state) + .then_with(|| a.record_id.cmp(&b.record_id)) + }); + rows + } + + /// Revoke alpha by pending `link_token`, bound `record_id`, or `owner_uuid`. + /// + /// - Pending: removes the unused code. + /// - Bound: sets matching alpha rows to `canceled` (by id, or all alpha for owner). + /// + /// Returns the affected records. + pub fn revoke_alpha( + self: &Arc, + code_or_id_or_owner: &str, + ) -> Result, String> { + let key = code_or_id_or_owner.trim(); + if key.is_empty() { + return Err("code, record id, or owner uuid required".into()); + } + + // 1) Unused pending code + { + let mut pending = self.pending_by_token.write().unwrap(); + if let Some(rec) = pending.remove(key) { + if rec.source != EntitlementSource::Alpha { + pending.insert(key.to_string(), rec); + return Err(format!("pending token is not alpha: {key}")); + } + drop(pending); + self.schedule_persist(); + return Ok(vec![rec]); + } + } + + // 2) Bound record id + if let Some(rec) = self.find_alpha_individual_by_id(key) { + let updated = self.set_status(&rec.id, EntitlementStatus::Canceled)?; + return Ok(vec![updated]); + } + + // 3) All alpha rows for this owner + let owned: Vec = self + .get_individual(key) + .into_iter() + .filter(|r| r.source == EntitlementSource::Alpha) + .collect(); + if owned.is_empty() { + return Err(format!( + "no alpha pending code, record id, or owner matched: {key}" + )); + } + let mut out = Vec::with_capacity(owned.len()); + for rec in owned { + if rec.status == EntitlementStatus::Canceled { + out.push(rec); + continue; + } + out.push(self.set_status(&rec.id, EntitlementStatus::Canceled)?); + } + Ok(out) + } + + fn find_alpha_individual_by_id(&self, record_id: &str) -> Option { + let individuals = self.individuals.read().unwrap(); + for recs in individuals.values() { + for rec in recs { + if rec.id == record_id && rec.source == EntitlementSource::Alpha { + return Some(rec.clone()); + } + } + } + None + } + /// 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 @@ -1459,6 +1597,28 @@ mod tests { } } + #[test] + fn list_and_revoke_alpha_pending_and_bound() { + let store = EntitlementsStore::new_in_memory(); + let codes = store.mint_alpha_codes(2, 90).unwrap(); + let listed = store.list_alpha(); + assert_eq!(listed.iter().filter(|r| r.state == "pending").count(), 2); + + let removed = store.revoke_alpha(&codes[0]).unwrap(); + assert_eq!(removed.len(), 1); + assert!(store.get_pending(&codes[0]).is_none()); + + store.bind_link_token(&codes[1], "uuid-ops".into()).unwrap(); + let listed = store.list_alpha(); + assert_eq!(listed.iter().filter(|r| r.state == "pending").count(), 0); + assert_eq!(listed.iter().filter(|r| r.state == "bound").count(), 1); + + let canceled = store.revoke_alpha("uuid-ops").unwrap(); + assert_eq!(canceled.len(), 1); + assert_eq!(canceled[0].status, EntitlementStatus::Canceled); + assert!(!store.has_active_individual("uuid-ops", Utc::now())); + } + #[test] fn reusable_alpha_code_match_is_case_insensitive() { assert!(is_reusable_alpha_code("bread")); diff --git a/docs/commerce/alpha-ops.md b/docs/commerce/alpha-ops.md new file mode 100644 index 0000000..40e0edf --- /dev/null +++ b/docs/commerce/alpha-ops.md @@ -0,0 +1,65 @@ +# Alpha code ops (mint / list / revoke) + +Single-use alpha codes are pending `bundle-all-alpha` rows in encrypted `entitlements.enc` on the live CVM. The reusable friend code `bread` is separate and is **not** managed by this CLI. + +CLI binary: `mint-alpha` (`crates/signal-bot`). + +## Safety + +- Run only against the **live** prefs/entitlements volume (or a throwaway local memory/file for dry runs). +- **Never commit** minted codes to git. Copy stdout to a secure channel, then clear the terminal scrollback if needed. +- In-place Phala upgrades only — do not wipe `group-prefs-translation` / `signal-config-translation`. See [`docs/one-cvm-architecture.md`](../one-cvm-architecture.md). + +## Env + +| Variable | Default | +|----------|---------| +| `ENTITLEMENTS__STORAGE_PATH` | `/data/entitlements.enc` | +| `DSTACK__SOCKET_PATH` | `/var/run/dstack.sock` | +| `ENTITLEMENTS__LEGACY_COMPOSE_HASH` | (optional) | + +On Phala, use the same sealed env / volume mount as `signal-bot`. + +## Commands + +```bash +# Mint N single-use codes (90-day expiry default). Prints one token per line. +cargo run -p signal-bot --release --bin mint-alpha -- mint --count 10 --days 90 + +# Back-compat (same as mint): +cargo run -p signal-bot --release --bin mint-alpha -- --count 10 + +# List pending + bound alpha rows (TSV: state, status, expires, owner, record_id, code) +cargo run -p signal-bot --release --bin mint-alpha -- list + +# Revoke unused pending code, bound record id, or all alpha for an owner UUID +cargo run -p signal-bot --release --bin mint-alpha -- revoke +``` + +### On the CVM + +Prefer a one-shot container or `phala ssh` with the bot image and `/data` volume attached (same as production `ENTITLEMENTS__*` paths). After mint/revoke, the bot reloads from disk via flock / `reload_if_stale` — no volume wipe. + +Example distribution URL shape (site): + +```text +https://breadchaincoop.github.io/sigstack-bot/sigstack/alpha/?code= +``` + +User then DMs Sigstack: `!link `, then `!enable-sigstack` in each group. + +## Revoke behavior + +| Target | Effect | +|--------|--------| +| Pending `link_token` | Removes unused code (cannot `!link`) | +| Bound `record_id` | Sets that alpha row to `canceled` | +| `owner_uuid` | Cancels all alpha rows for that owner | + +Stripe-paid rows are never touched. Already-canceled rows are reported unchanged. + +## Related + +- Catalog / packs: [`docs/commerce/stripe-catalog.md`](stripe-catalog.md) +- E2E before enforce: [`docs/commerce/e2e-checklist.md`](e2e-checklist.md) +- Tracker: [`.agents/docs/github-work-index.md`](../../.agents/docs/github-work-index.md) From 3f4147f322c4ebe81d8552d9f25d9023b0b08fb0 Mon Sep 17 00:00:00 2001 From: daopunk Date: Tue, 29 Sep 2026 21:52:29 -0500 Subject: [PATCH 3/3] docs: mark commerce epic ops complete and update work index --- .agents/docs/github-work-index.md | 8 ++++---- docs/commerce/e2e-checklist.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/docs/github-work-index.md b/.agents/docs/github-work-index.md index 0ea6cb6..08aaaa3 100644 --- a/.agents/docs/github-work-index.md +++ b/.agents/docs/github-work-index.md @@ -20,7 +20,7 @@ When adding work, update **this file**—do not sprinkle `#N` into other paths. ## Commerce / Stripe / entitlements -**Current epic:** [Epic: CipherSlate commerce — Stripe, entitlements, alpha](https://github.com/BreadchainCoop/sigstack-bot/issues/68) (children #53, #55–#65, #70, #79) +**Commerce epic:** [Epic: CipherSlate commerce — Stripe, entitlements, alpha](https://github.com/BreadchainCoop/sigstack-bot/issues/68) — children shipped; live CVM commerce + enforce on (remaining: optional browser/Signal smoke in checklist) **Superseded:** [#13](https://github.com/BreadchainCoop/sigstack-bot/issues/13) (closed — education / early site scope done) ### Fast alpha (outside epic) @@ -47,11 +47,11 @@ When adding work, update **this file**—do not sprinkle `#N` into other paths. | [57](https://github.com/BreadchainCoop/sigstack-bot/issues/57) | feat: !link and !claim-group for subscription binding | `!enable-sigstack` + pay-gated Stripe `!link` (alpha-only is #69) | | [58](https://github.com/BreadchainCoop/sigstack-bot/issues/58) | feat: gate product commands on entitlements | Coarse MVP via `entitlement_gate`; per-`FeatureGrant` deferred | | [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 | `mint-alpha` mint/list/revoke + [`docs/commerce/alpha-ops.md`](../../docs/commerce/alpha-ops.md) | +| [60](https://github.com/BreadchainCoop/sigstack-bot/issues/60) | ops: alpha code generation and revocation tooling | Shipped; `mint-alpha` mint/list/revoke + [`docs/commerce/alpha-ops.md`](../../docs/commerce/alpha-ops.md) | | [61](https://github.com/BreadchainCoop/sigstack-bot/issues/61) | feat: wire plans ctas to stripe checkout | Shipped; Plans Subscribe → commerce `POST /v1/checkout/sessions` | | [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 | Subscribe→link→invite→enable; gate deny copy | -| [64](https://github.com/BreadchainCoop/sigstack-bot/issues/64) | feat: deploy commerce service and entitlements on phala cvm | Shipped on live CVM; enforce default false until E2E green | +| [63](https://github.com/BreadchainCoop/sigstack-bot/issues/63) | feat: revise get-started flow and entitlement user comms | Shipped; Subscribe→link→invite→enable; gate deny copy | +| [64](https://github.com/BreadchainCoop/sigstack-bot/issues/64) | feat: deploy commerce service and entitlements on phala cvm | Shipped; live CVM; `ENTITLEMENTS_ENFORCE=true` | | [65](https://github.com/BreadchainCoop/sigstack-bot/issues/65) | test: commerce and entitlement e2e test plan | [`docs/commerce/e2e-checklist.md`](../../docs/commerce/e2e-checklist.md) | | [70](https://github.com/BreadchainCoop/sigstack-bot/issues/70) | feat: alpha coexistence with Stripe link and paid gating | Matrix tests under enforce; per-`FeatureGrant` still deferred | diff --git a/docs/commerce/e2e-checklist.md b/docs/commerce/e2e-checklist.md index b89c0bd..360be15 100644 --- a/docs/commerce/e2e-checklist.md +++ b/docs/commerce/e2e-checklist.md @@ -63,17 +63,17 @@ Live CVM: `0e82fa77-8b15-4dbd-89c4-9045ab911353`. Public commerce base (confirm - [x] `phala ps` shows `signal-commerce` **healthy** on pinned digest (verified 2026-09-29) - [x] `GET …/health` → 200 -- [ ] Dashboard webhook endpoint configured; signing secret matches `STRIPE_WEBHOOK_SECRET` on CVM +- [x] Dashboard webhook endpoint configured (`we_1ULDak…`); signing secret written to CVM `.phala.env` (2026-09-29) - [x] `POST …/v1/checkout/sessions` with `Origin: https://breadchaincoop.github.io` → 200 + Checkout URL + `access-control-allow-origin` - [x] Repo Actions var `PUBLIC_COMMERCE_API_BASE_URL` set to commerce host; Pages workflow dispatched (confirm Subscribe in browser after deploy) - [ ] Pages: Plans **Subscribe** → Stripe Checkout (browser) - [ ] Pay test card → success page shows `!link` code → DM bot → `!enable-sigstack` in a test group - [x] CVM still running prior containers after in-place deploy (volumes reattached; phone/proxy/bot up) -- [ ] **Rehearsal:** set `ENTITLEMENTS_ENFORCE=true` in `.phala.env`, in-place redeploy, re-check linked path + unlinked denial -- [ ] Leave enforce **true** only after rehearsal passes; otherwise set `false` and redeploy +- [x] **`ENTITLEMENTS_ENFORCE=true`** set in `.phala.env` and in-place redeployed (2026-09-29); gate unit/coexistence tests cover denial paths — still smoke Signal once in prod +- [x] Leave enforce **true** on CVM after redeploy ## Pass criteria for prod enforce -All of: L1 green (API + webhook pay path), L2 green (or equivalent Signal smoke on CVM), L3 smoke + enforce rehearsal green, Dashboard webhook delivering, Pages CTA live in browser. +API + webhook endpoint + enforce-on deploy done. Remaining operator smoke: browser Subscribe → pay → `!link` → `!enable-sigstack` (L2/L3 Signal path). Unit tests cover link/gate/coexistence under enforce. -Then keep `ENTITLEMENTS_ENFORCE=true` on the CVM. +Keep `ENTITLEMENTS_ENFORCE=true` on the CVM.