diff --git a/Dockerfile b/Dockerfile index a63ca97..c17e7a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,15 +22,17 @@ FROM gcr.io/distroless/cc-debian12 AS runtime WORKDIR /app COPY --from=builder /app/target/release/lgtm-backend /usr/local/bin/server COPY --from=builder /app/target/release/worker /usr/local/bin/worker -COPY --from=builder /app/target/release/seed-demo /usr/local/bin/seed-demo COPY migrations /app/migrations -# Build metadata surfaced by GET /healthz. Set by CI build-args (Spindles derives the -# version from the git tag + passes TANGLED_SHA); defaults keep local builds sane. Only in -# the cheap runtime stage, so a new commit doesn't bust the Rust build cache. +# Build metadata surfaced by GET /healthz. Set by CI build-args (Spindles passes the +# VERSION-file version + TANGLED_SHA); defaults keep local builds sane. Only in the cheap +# runtime stage, so a new commit doesn't bust the Rust build cache. ARG BUILD_VERSION=dev ARG BUILD_COMMIT=unknown ENV LGTM_BUILD_VERSION=$BUILD_VERSION \ LGTM_BUILD_COMMIT=$BUILD_COMMIT EXPOSE 8080 -# server --role all: run_migrations (advisory-locked) then serve HTTP + worker. -ENTRYPOINT ["/usr/local/bin/server", "--role", "all"] +# Default: `server --role all` (migrate → serve HTTP + worker). Override the CMD for other +# roles WITHOUT an entrypoint override, e.g. seed the demo store: +# docker run --role seed +ENTRYPOINT ["/usr/local/bin/server"] +CMD ["--role", "all"] diff --git a/crates/server/src/bin/seed-demo.rs b/crates/server/src/bin/seed-demo.rs index cdfb2fd..8b9d73c 100644 --- a/crates/server/src/bin/seed-demo.rs +++ b/crates/server/src/bin/seed-demo.rs @@ -1,26 +1,14 @@ -//! seed-demo: one-command store seeder for the Phase 1 pilot. +//! seed-demo: one-command store seeder for local dev. //! -//! Connects to the database (same LGTM_* env vars as the server), runs migrations, -//! and idempotently seeds everything a browsable demo needs: -//! -//! 1. tenant row -//! 2. store_signing_key (ES256, sealed with LGTM_MASTER_KEY — MUST match the server) -//! 3. storefront mirror row (what GET /api/storefront reads) -//! 4. product (status = active) + variant (physical, available, $25.00 USD) -//! -//! Run: -//! cargo run -p server --bin seed-demo -//! -//! IMPORTANT: The signing key is sealed with LGTM_MASTER_KEY. Use the SAME value -//! when starting the server, or `load_store_signer` will fail to decrypt. +//! Equivalent to `server --role seed` — both call `server::seed::run_and_report`. Kept as a +//! convenience bin for `cargo run -p server --bin seed-demo`. Uses the same LGTM_* env vars +//! as the server; the signing key it writes is sealed with LGTM_MASTER_KEY, so the server +//! MUST boot with the same value. //! //! Tests live in crates/server/tests/seed_demo.rs (sqlx::test requires the lib crate context). use anyhow::Context; use lgtm_core::Config; -use p256::pkcs8::EncodePrivateKey; -use receipts::signer::{Alg, Signer}; -use uuid::Uuid; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -32,189 +20,9 @@ async fn main() -> anyhow::Result<()> { .init(); let config = Config::load().context("load config (set LGTM_* env vars)")?; - let master_key = config.master_key().context("decode LGTM_MASTER_KEY")?; - let pool = db::connect(&config).await.context("connect db")?; db::run_migrations(&pool).await.context("run migrations")?; - println!("[seed-demo] migrations applied"); - - // ── 1. Tenant ───────────────────────────────────────────────────────────── - let tenant_id = Uuid::new_v4(); - let store_did = &config.store_did; - let result = sqlx::query( - "insert into tenant (id, did, store_did, handle, display_name) \ - values ($1, $2, $3, $4, $5) \ - on conflict (did) do nothing", - ) - .bind(tenant_id) - .bind(store_did) - .bind(store_did) - .bind("demo.lgtm.shop") - .bind("LGTM Demo Store") - .execute(&pool) - .await - .context("seed tenant")?; - - let tenant_id: Uuid = if result.rows_affected() == 0 { - // Already existed — look it up. - sqlx::query_scalar("select id from tenant where did = $1") - .bind(store_did) - .fetch_one(&pool) - .await - .context("fetch existing tenant id")? - } else { - tenant_id - }; - println!("[seed-demo] tenant: {tenant_id} did={store_did}"); - - // ── 2. Store signing key ────────────────────────────────────────────────── - // Generate ES256 keypair, seal PKCS8 DER with the master key. This is the - // EXACT shape load_store_signer reads: alg='ES256', public_multibase (z-base58btc), - // private_enc = seal(master_key, pkcs8_der). - let existing_key: Option = - sqlx::query_as("select * from store_signing_key where tenant_id = $1") - .bind(tenant_id) - .fetch_optional(&pool) - .await - .context("check existing store_signing_key")?; - - if existing_key.is_some() { - println!("[seed-demo] store_signing_key already exists — skipping"); - } else { - let sk = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); - let pkcs8_der = sk - .to_pkcs8_der() - .context("encode ES256 private key as PKCS8 DER")?; - let pkcs8_bytes = pkcs8_der.as_bytes(); - - // Build a temporary Signer just to derive the public_multibase. - let signer = Signer::from_pkcs8_der(Alg::Es256, pkcs8_bytes) - .map_err(|e| anyhow::anyhow!("build Signer: {e}"))?; - let public_multibase = signer.public_multibase(); - - let sealed = db::models::StoreSigningKey::new_sealed( - &master_key, - tenant_id, - "ES256", - &public_multibase, - pkcs8_bytes, - ); - - sqlx::query( - "insert into store_signing_key (tenant_id, alg, public_multibase, private_enc) \ - values ($1, $2, $3, $4) \ - on conflict (tenant_id) do nothing", - ) - .bind(sealed.tenant_id) - .bind(&sealed.alg) - .bind(&sealed.public_multibase) - .bind(&sealed.private_enc) - .execute(&pool) - .await - .context("seed store_signing_key")?; - - println!( - "[seed-demo] store_signing_key seeded alg=ES256 pubkey={}", - &public_multibase[..16] - ); - } - - // ── 3. Storefront mirror row ────────────────────────────────────────────── - // GET /api/storefront reads product where at_uri ends with - // '.../shop.lgtm.commerce.storefront/self'. - let storefront_uri = format!("at://{store_did}/shop.lgtm.commerce.storefront/self"); - sqlx::query( - "insert into product (id, tenant_id, at_uri, title, status) \ - values ($1, $2, $3, $4, 'active') \ - on conflict (tenant_id, at_uri) do nothing", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(&storefront_uri) - .bind("LGTM Demo Store") - .execute(&pool) - .await - .context("seed storefront mirror row")?; - println!("[seed-demo] storefront: {storefront_uri}"); - - // ── 4. Product + variant ────────────────────────────────────────────────── - let product_id = Uuid::new_v4(); - let product_uri = format!("at://{store_did}/shop.lgtm.commerce.product/{product_id}"); - let product_result = sqlx::query( - "insert into product (id, tenant_id, at_uri, title, status) \ - values ($1, $2, $3, 'LGTM Demo Tee', 'active') \ - on conflict (tenant_id, at_uri) do nothing", - ) - .bind(product_id) - .bind(tenant_id) - .bind(&product_uri) - .execute(&pool) - .await - .context("seed product")?; - - // If the product already existed (conflict), look it up. - let actual_product_id: Uuid = if product_result.rows_affected() == 0 { - sqlx::query_scalar( - "select id from product where tenant_id = $1 and title = 'LGTM Demo Tee' limit 1", - ) - .bind(tenant_id) - .fetch_one(&pool) - .await - .context("fetch existing product id")? - } else { - product_id - }; - println!("[seed-demo] product: {actual_product_id}"); - - // variant: no unique constraint on (tenant_id, sku), so check-then-insert. - let existing_variant_id: Option = sqlx::query_scalar( - "select id from variant where tenant_id = $1 and sku = 'DEMO-TEE-001' limit 1", - ) - .bind(tenant_id) - .fetch_optional(&pool) - .await - .context("check existing variant")?; - - let actual_variant_id: Uuid = if let Some(id) = existing_variant_id { - id - } else { - let variant_id = Uuid::new_v4(); - sqlx::query( - "insert into variant (id, product_id, tenant_id, sku, kind, availability, pricing, \ - unit_amount, currency) \ - values ($1, $2, $3, 'DEMO-TEE-001', 'physical', 'available', 'fixed', 2500, 'USD')", - ) - .bind(variant_id) - .bind(actual_product_id) - .bind(tenant_id) - .execute(&pool) - .await - .context("seed variant")?; - variant_id - }; - println!("[seed-demo] variant: {actual_variant_id} sku=DEMO-TEE-001 price=$25.00 USD"); - - // ── Summary ─────────────────────────────────────────────────────────────── - let public_url = config.public_url.trim_end_matches('/'); - println!(); - println!("══════════════════════════════════════════════════════════════"); - println!(" LGTM Demo Store ready"); - println!("══════════════════════════════════════════════════════════════"); - println!(" variant_id : {actual_variant_id}"); - println!(); - println!(" Start the server (fake-payments mode, no Stripe/Printful needed):"); - println!(" cargo run -p server -- --role all --fake-payments"); - println!(); - println!(" Then POST a checkout:"); - println!( - r#" curl -fsS {public_url}/api/checkout \ - -H 'content-type: application/json' \ - -d '{{"buyer":{{"did":{{"did":"did:web:demo.buyer"}}}},"items":[{{"variant_id":"{actual_variant_id}","quantity":1}}]}}'"# - ); - println!(); - println!(" The response includes a /dev/pay URL — open it to simulate payment."); - println!(" Then: GET {public_url}/api/backers"); - println!("══════════════════════════════════════════════════════════════"); + println!("[seed] migrations applied"); - Ok(()) + server::seed::run_and_report(&pool, &config).await } diff --git a/crates/server/src/boot.rs b/crates/server/src/boot.rs index 6853fd7..5875667 100644 --- a/crates/server/src/boot.rs +++ b/crates/server/src/boot.rs @@ -5,6 +5,8 @@ pub enum Role { All, Server, Worker, + /// One-shot: run migrations, seed the demo store, exit. Not a long-running process. + Seed, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -21,7 +23,8 @@ impl FromStr for Role { "all" => Ok(Role::All), "server" => Ok(Role::Server), "worker" => Ok(Role::Worker), - other => Err(format!("unknown role: {other} (expected all|server|worker)")), + "seed" => Ok(Role::Seed), + other => Err(format!("unknown role: {other} (expected all|server|worker|seed)")), } } } @@ -37,6 +40,8 @@ pub fn boot_plan(role: Role) -> Vec { } Role::Server => steps.push(BootStep::Serve), Role::Worker => steps.push(BootStep::Worker), + // Seed migrates then seeds + exits; the seed step is handled in main, not here. + Role::Seed => {} } steps } @@ -86,6 +91,7 @@ mod tests { assert_eq!("all".parse::().unwrap(), Role::All); assert_eq!("server".parse::().unwrap(), Role::Server); assert_eq!("worker".parse::().unwrap(), Role::Worker); + assert_eq!("seed".parse::().unwrap(), Role::Seed); assert!("nonsense".parse::().is_err()); } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 865fa07..2e4e073 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod app; pub mod dev; pub mod routes; +pub mod seed; pub mod state; #[cfg(test)] pub(crate) mod test_support; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index dd3e9ee..dc6706a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -17,6 +17,7 @@ fn parse_role(args: &[String]) -> boot::Role { match it.next().map(String::as_str) { Some("server") => role = boot::Role::Server, Some("worker") => role = boot::Role::Worker, + Some("seed") => role = boot::Role::Seed, Some("all") | None => role = boot::Role::All, Some(other) => { eprintln!("unknown --role '{other}', defaulting to all"); @@ -262,6 +263,13 @@ async fn main() -> anyhow::Result<()> { let pool = db::connect(&config).await.context("connect db")?; db::run_migrations(&pool).await.context("run migrations")?; + // `--role seed` is one-shot: migrate (done above), seed the demo store, exit. This is the + // container-friendly seed path (`docker run --role seed`) — no entrypoint override. + if role == boot::Role::Seed { + server::seed::run_and_report(&pool, &config).await?; + return Ok(()); + } + let master_key = config.master_key().context("decode master_key")?; // Load the store signing key once at startup; shared by the attest service and @@ -408,6 +416,14 @@ mod tests { ); } + #[test] + fn test_parse_role_seed() { + assert_eq!( + parse_role(&["--role".into(), "seed".into()]), + boot::Role::Seed + ); + } + #[test] fn test_parse_role_unknown_defaults_to_all() { assert_eq!(parse_role(&["--role".into(), "bogus".into()]), boot::Role::All); diff --git a/crates/server/src/seed.rs b/crates/server/src/seed.rs new file mode 100644 index 0000000..842dd0a --- /dev/null +++ b/crates/server/src/seed.rs @@ -0,0 +1,197 @@ +//! Demo-store seeder, shared by `server --role seed` and the `seed-demo` bin. +//! +//! Idempotently seeds everything a browsable demo needs: +//! 1. tenant row +//! 2. store_signing_key (ES256, sealed with the master key — MUST match the server's) +//! 3. storefront mirror row (what `GET /api/storefront` reads) +//! 4. product (status = active) + variant (physical, available, $25.00 USD) +//! +//! Assumes migrations have already run on `pool`. + +use anyhow::Context; +use lgtm_core::Config; +use p256::pkcs8::EncodePrivateKey; +use receipts::signer::{Alg, Signer}; +use sqlx::PgPool; +use uuid::Uuid; + +/// Ids produced by a seed run. +pub struct Seeded { + pub tenant_id: Uuid, + pub variant_id: Uuid, +} + +/// Idempotently seed the demo store. `master_key` seals the ES256 signing key and MUST +/// match the value the server boots with (`LGTM_MASTER_KEY`), or `load_store_signer` can't +/// decrypt it. Returns the seeded tenant + variant ids. +pub async fn run(pool: &PgPool, store_did: &str, master_key: &[u8; 32]) -> anyhow::Result { + // ── 1. Tenant ───────────────────────────────────────────────────────────── + let proposed_id = Uuid::new_v4(); + sqlx::query( + "insert into tenant (id, did, store_did, handle, display_name) \ + values ($1, $2, $3, $4, $5) \ + on conflict (did) do nothing", + ) + .bind(proposed_id) + .bind(store_did) + .bind(store_did) + .bind("demo.lgtm.shop") + .bind("LGTM Demo Store") + .execute(pool) + .await + .context("seed tenant")?; + + let tenant_id: Uuid = sqlx::query_scalar("select id from tenant where did = $1") + .bind(store_did) + .fetch_one(pool) + .await + .context("fetch tenant id")?; + println!("[seed] tenant: {tenant_id} did={store_did}"); + + // ── 2. Store signing key ────────────────────────────────────────────────── + let existing_key: Option = + sqlx::query_as("select * from store_signing_key where tenant_id = $1") + .bind(tenant_id) + .fetch_optional(pool) + .await + .context("check existing store_signing_key")?; + + if existing_key.is_some() { + println!("[seed] store_signing_key already exists — skipping"); + } else { + let sk = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); + let pkcs8_der = sk + .to_pkcs8_der() + .context("encode ES256 private key as PKCS8 DER")?; + let pkcs8_bytes = pkcs8_der.as_bytes(); + + let signer = Signer::from_pkcs8_der(Alg::Es256, pkcs8_bytes) + .map_err(|e| anyhow::anyhow!("build Signer: {e}"))?; + let public_multibase = signer.public_multibase(); + + let sealed = db::models::StoreSigningKey::new_sealed( + master_key, + tenant_id, + "ES256", + &public_multibase, + pkcs8_bytes, + ); + + sqlx::query( + "insert into store_signing_key (tenant_id, alg, public_multibase, private_enc) \ + values ($1, $2, $3, $4) \ + on conflict (tenant_id) do nothing", + ) + .bind(sealed.tenant_id) + .bind(&sealed.alg) + .bind(&sealed.public_multibase) + .bind(&sealed.private_enc) + .execute(pool) + .await + .context("seed store_signing_key")?; + + println!("[seed] store_signing_key seeded alg=ES256 pubkey={}", &public_multibase[..16]); + } + + // ── 3. Storefront mirror row ────────────────────────────────────────────── + let storefront_uri = format!("at://{store_did}/shop.lgtm.commerce.storefront/self"); + sqlx::query( + "insert into product (id, tenant_id, at_uri, title, status) \ + values ($1, $2, $3, $4, 'active') \ + on conflict (tenant_id, at_uri) do nothing", + ) + .bind(Uuid::new_v4()) + .bind(tenant_id) + .bind(&storefront_uri) + .bind("LGTM Demo Store") + .execute(pool) + .await + .context("seed storefront mirror row")?; + println!("[seed] storefront: {storefront_uri}"); + + // ── 4. Product + variant ────────────────────────────────────────────────── + let product_id = Uuid::new_v4(); + let product_uri = format!("at://{store_did}/shop.lgtm.commerce.product/{product_id}"); + let product_result = sqlx::query( + "insert into product (id, tenant_id, at_uri, title, status) \ + values ($1, $2, $3, 'LGTM Demo Tee', 'active') \ + on conflict (tenant_id, at_uri) do nothing", + ) + .bind(product_id) + .bind(tenant_id) + .bind(&product_uri) + .execute(pool) + .await + .context("seed product")?; + + let actual_product_id: Uuid = if product_result.rows_affected() == 0 { + sqlx::query_scalar( + "select id from product where tenant_id = $1 and title = 'LGTM Demo Tee' limit 1", + ) + .bind(tenant_id) + .fetch_one(pool) + .await + .context("fetch existing product id")? + } else { + product_id + }; + println!("[seed] product: {actual_product_id}"); + + // variant: no unique constraint on (tenant_id, sku), so check-then-insert. + let existing_variant_id: Option = sqlx::query_scalar( + "select id from variant where tenant_id = $1 and sku = 'DEMO-TEE-001' limit 1", + ) + .bind(tenant_id) + .fetch_optional(pool) + .await + .context("check existing variant")?; + + let variant_id: Uuid = if let Some(id) = existing_variant_id { + id + } else { + let new_id = Uuid::new_v4(); + sqlx::query( + "insert into variant (id, product_id, tenant_id, sku, kind, availability, pricing, \ + unit_amount, currency) \ + values ($1, $2, $3, 'DEMO-TEE-001', 'physical', 'available', 'fixed', 2500, 'USD')", + ) + .bind(new_id) + .bind(actual_product_id) + .bind(tenant_id) + .execute(pool) + .await + .context("seed variant")?; + new_id + }; + println!("[seed] variant: {variant_id} sku=DEMO-TEE-001 price=$25.00 USD"); + + Ok(Seeded { tenant_id, variant_id }) +} + +/// Seed, then print the CLI summary (used by `server --role seed` and the `seed-demo` bin). +pub async fn run_and_report(pool: &PgPool, config: &Config) -> anyhow::Result<()> { + let master_key = config.master_key().context("decode LGTM_MASTER_KEY")?; + let Seeded { variant_id, .. } = run(pool, &config.store_did, &master_key).await?; + + let public_url = config.public_url.trim_end_matches('/'); + println!(); + println!("══════════════════════════════════════════════════════════════"); + println!(" LGTM Demo Store ready"); + println!("══════════════════════════════════════════════════════════════"); + println!(" variant_id : {variant_id}"); + println!(); + println!(" Start the server (fake-payments mode, no Stripe/Printful needed):"); + println!(" cargo run -p server -- --role all --fake-payments"); + println!(); + println!(" Then POST a checkout:"); + println!( + r#" curl -fsS {public_url}/api/checkout \ + -H 'content-type: application/json' \ + -d '{{"buyer":{{"did":{{"did":"did:web:demo.buyer"}}}},"items":[{{"variant_id":"{variant_id}","quantity":1}}]}}'"# + ); + println!(); + println!(" The response includes a /dev/pay URL — open it to simulate payment."); + println!(" Then: GET {public_url}/api/backers"); + println!("══════════════════════════════════════════════════════════════"); + Ok(()) +} diff --git a/crates/server/tests/seed_demo.rs b/crates/server/tests/seed_demo.rs index cf45ebf..1bd4ed5 100644 --- a/crates/server/tests/seed_demo.rs +++ b/crates/server/tests/seed_demo.rs @@ -1,134 +1,24 @@ -//! Tests for the seed-demo seeding logic and the DevPaymentProvider. +//! Tests for the shared seeder (`server::seed`) and its round-trip with `load_store_signer`. //! -//! Kept here (integration test file) rather than in src/bin/seed-demo.rs because -//! `#[sqlx::test]` expands into items that require the library crate context; -//! it does not work inside a binary crate. +//! Kept here (integration test file) rather than in a bin because `#[sqlx::test]` expands +//! into items that require the library crate context; it does not work inside a binary crate. use db::models::StoreSigningKey; -use p256::pkcs8::EncodePrivateKey; use receipts::signer::{Alg, Signer}; use sqlx::PgPool; use uuid::Uuid; -// ── Seed helper ─────────────────────────────────────────────────────────────── - /// Master key for tests — 32 zero bytes (matches the test config in app.rs tests). fn test_master_key() -> [u8; 32] { [0u8; 32] } -/// Replicate the seed-demo logic against a test pool. -/// Returns (tenant_id, variant_id). +/// Seed via the real `server::seed::run`; returns (tenant_id, variant_id). async fn run_seed(pool: &PgPool, store_did: &str) -> (Uuid, Uuid) { - let master_key = test_master_key(); - - // 1. tenant - let proposed_id = Uuid::new_v4(); - sqlx::query( - "insert into tenant (id, did, store_did, handle, display_name) \ - values ($1, $2, $3, $4, $5) \ - on conflict (did) do nothing", - ) - .bind(proposed_id) - .bind(store_did) - .bind(store_did) - .bind("demo.lgtm.shop") - .bind("LGTM Demo Store") - .execute(pool) - .await - .unwrap(); - - let tenant_id: Uuid = sqlx::query_scalar("select id from tenant where did = $1") - .bind(store_did) - .fetch_one(pool) - .await - .unwrap(); - - // 2. store_signing_key - let existing_key: Option = - sqlx::query_as("select * from store_signing_key where tenant_id = $1") - .bind(tenant_id) - .fetch_optional(pool) - .await - .unwrap(); - - if existing_key.is_none() { - let sk = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); - let der = sk.to_pkcs8_der().unwrap(); - let signer = Signer::from_pkcs8_der(Alg::Es256, der.as_bytes()).unwrap(); - let pub_mb = signer.public_multibase(); - let sealed = StoreSigningKey::new_sealed(&master_key, tenant_id, "ES256", &pub_mb, der.as_bytes()); - sqlx::query( - "insert into store_signing_key (tenant_id, alg, public_multibase, private_enc) \ - values ($1, $2, $3, $4) \ - on conflict (tenant_id) do nothing", - ) - .bind(sealed.tenant_id) - .bind(&sealed.alg) - .bind(&sealed.public_multibase) - .bind(&sealed.private_enc) - .execute(pool) - .await - .unwrap(); - } - - // 3. storefront mirror - let storefront_uri = format!("at://{store_did}/shop.lgtm.commerce.storefront/self"); - sqlx::query( - "insert into product (id, tenant_id, at_uri, title, status) \ - values ($1, $2, $3, $4, 'active') \ - on conflict (tenant_id, at_uri) do nothing", - ) - .bind(Uuid::new_v4()) - .bind(tenant_id) - .bind(&storefront_uri) - .bind("LGTM Demo Store") - .execute(pool) - .await - .unwrap(); - - // 4. product - let product_id = Uuid::new_v4(); - let product_uri = format!("at://{store_did}/shop.lgtm.commerce.product/{product_id}"); - sqlx::query( - "insert into product (id, tenant_id, at_uri, title, status) \ - values ($1, $2, $3, 'LGTM Demo Tee', 'active') \ - on conflict (tenant_id, at_uri) do nothing", - ) - .bind(product_id) - .bind(tenant_id) - .bind(&product_uri) - .execute(pool) - .await - .unwrap(); - - // 5. variant — no unique constraint on (tenant_id, sku), check-then-insert - let existing_variant: Option = - sqlx::query_scalar("select id from variant where tenant_id = $1 and sku = 'DEMO-TEE-001' limit 1") - .bind(tenant_id) - .fetch_optional(pool) - .await - .unwrap(); - - let actual_variant_id: Uuid = if let Some(id) = existing_variant { - id - } else { - let variant_id = Uuid::new_v4(); - sqlx::query( - "insert into variant (id, product_id, tenant_id, sku, kind, availability, pricing, \ - unit_amount, currency) \ - values ($1, $2, $3, 'DEMO-TEE-001', 'physical', 'available', 'fixed', 2500, 'USD')", - ) - .bind(variant_id) - .bind(product_id) - .bind(tenant_id) - .execute(pool) + let s = server::seed::run(pool, store_did, &test_master_key()) .await - .unwrap(); - variant_id - }; - - (tenant_id, actual_variant_id) + .expect("seed"); + (s.tenant_id, s.variant_id) } // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 627007d..20cd374 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,13 +2,11 @@ # Copy this into your instance repo (e.g. lgtm-shop/lgtm.shop), add a .env with your # secrets, and pin the image tag. Bump the tag to upgrade. # -# Seed the store once. The image ENTRYPOINT is `server --role all`, so you MUST override -# it — `run app seed-demo` would run `server --role all seed-demo` (a stray server), not -# the seeder: -# docker compose -f docker-compose.prod.yml run --rm --entrypoint /usr/local/bin/seed-demo app +# Seed the store once (a one-shot role of the main binary — migrate, seed, exit): +# docker compose -f docker-compose.prod.yml run --rm app --role seed services: app: - image: lgtmsupplyco/commerce:v0.2.0 + image: lgtmsupplyco/commerce:v0.2.1 restart: unless-stopped env_file: .env environment: diff --git a/docs/deploy/running-an-instance.md b/docs/deploy/running-an-instance.md index b694539..3a743b7 100644 --- a/docs/deploy/running-an-instance.md +++ b/docs/deploy/running-an-instance.md @@ -48,30 +48,29 @@ LGTM_PRINTFUL_WEBHOOK_SECRET=x ## Seed the store (one-off) -The `seed-demo` binary (shipped in the image) creates a tenant, an ES256 store signing -key (sealed with `LGTM_MASTER_KEY` — it MUST match the app's), a storefront record, and a -demo product. Run it once against the same database and env. The image's `ENTRYPOINT` is -`server --role all`, so you must **override the entrypoint** — otherwise the path is passed -as an argument to `server` and you start a stray server instead of the seeder: +`--role seed` runs a one-shot: migrate, create a tenant + an ES256 store signing key +(sealed with `LGTM_MASTER_KEY` — it MUST match the app's) + a storefront record + a demo +product, then exit. Run it once against the same database and env: ```bash -docker run --rm --env-file .env --entrypoint /usr/local/bin/seed-demo lgtmsupplyco/commerce:v0.2.0 +docker run --rm --env-file .env lgtmsupplyco/commerce:v0.2.1 --role seed ``` +*(v0.2.1+: seeding is a role of the main binary — no entrypoint override. On older images +use `--entrypoint /usr/local/bin/seed-demo`.)* + ## Railway quickstart -1. **New Project → Deploy from Docker image** → `lgtmsupplyco/commerce:v0.2.0`. +1. **New Project → Deploy from Docker image** → `lgtmsupplyco/commerce:v0.2.1`. 2. **Add a Postgres plugin.** In the service variables, set `LGTM_DATABASE_URL = ${{Postgres.DATABASE_URL}}` (Railway exposes `DATABASE_URL`; the app reads the `LGTM_`-prefixed name). 3. Set the rest of the env from the block above. Leave `PORT` unset — Railway injects it and the app binds it automatically. -4. **Seed:** run the one-off with the same env, overriding the entrypoint — - `railway run docker run --rm --env-file <(railway variables --json | jq -r '...') --entrypoint /usr/local/bin/seed-demo lgtmsupplyco/commerce:v0.2.0` - — or add a temporary service whose start command is `/usr/local/bin/seed-demo`, let it - run once, then remove it. **After seeding, restart the backend service** (the worker - only starts once a signing key exists; as of v0.2.0 it waits/polls for the key, so a - restart is no longer required — but on older images it is). +4. **Seed:** run the one-off with the same env — override the service's start command to + `--role seed` for one run (or `railway run … lgtmsupplyco/commerce:v0.2.1 --role seed`). + No restart needed afterwards — the worker polls for the signing key and self-starts + (v0.2.0+). 5. Open the Railway-provided URL; the storefront is browsable. 6. Attach your custom domain (`lgtm.shop`) in Railway settings; TLS is automatic.