Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .agents/docs/github-work-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 | Minimal mint in #69; fuller ops later |
| [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 | |
| [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 |
Expand Down
159 changes: 127 additions & 32 deletions crates/signal-bot/src/bin/mint-alpha.rs
Original file line number Diff line number Diff line change
@@ -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 <code|record-id|owner-uuid>
//!
//! # Back-compat (same as `mint`):
//! cargo run -p signal-bot --bin mint-alpha -- --count 5
//!
//! # Env (defaults match compose):
Expand All @@ -28,15 +33,36 @@ fn legacy_hashes(raw: &str) -> Vec<String> {
.collect()
}

#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.init();
fn usage() {
eprintln!(
"Usage:\n\
\tmint-alpha mint [--count N] [--days 90]\n\
\tmint-alpha list\n\
\tmint-alpha revoke <code|record-id|owner-uuid>\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<String> = env::args().skip(1).collect();
async fn open_store() -> Result<Arc<EntitlementsStore>> {
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;
Expand All @@ -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))?;
Expand All @@ -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<String> = 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 <code|record-id|owner-uuid>")?;
if args.len() > 2 {
bail!("revoke takes exactly one argument");
}
cmd_revoke(target).await
}
other => {
usage();
bail!("unknown command: {other}");
}
}
}
47 changes: 40 additions & 7 deletions crates/signal-bot/src/entitlement_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,46 @@ 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 <code> to unlock (checkout code or alpha).";
pub const DENY_NEED_LINK: &str = "Complete linking: DM this bot with !link <code> 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.";

/// 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,
}

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()
}
Expand All @@ -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))
};
}

Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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}"
);
}
Expand Down Expand Up @@ -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<EntitlementsStore>) -> EntitlementGate {
Expand Down Expand Up @@ -442,7 +475,7 @@ mod tests {

assert_eq!(
gate.allow(&dm("!help", "uuid-dead")).await.unwrap_err(),
GateDeny::NeedLink
GateDeny::NeedSubscribe
);
}
}
Loading
Loading