From 1914738000ffe71815eb8d2093ddccd793699c62 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Wed, 8 Apr 2026 21:32:15 -0700 Subject: [PATCH 01/46] Add Synvya account and server origins to CORS config --- scripts/load-secrets.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/load-secrets.sh b/scripts/load-secrets.sh index 712d49db..e7bc8c92 100755 --- a/scripts/load-secrets.sh +++ b/scripts/load-secrets.sh @@ -25,6 +25,7 @@ else exit 1 fi +EXTRA_ALLOWED_ORIGINS="https://account.synvya.com,https://account.staging.synvya.com,https://server.synvya.com,https://server.staging.synvya.com" ALLOWED_PUBKEYS=$(get_secret synvya/$ENV/keycast/allowed-pubkeys 2>/dev/null || echo "") cat > /opt/synvya/.env < Date: Wed, 8 Apr 2026 22:52:47 -0700 Subject: [PATCH 02/46] Only pass DISABLE_EMAILS when explicitly set --- docker-compose.synvya.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.synvya.yml b/docker-compose.synvya.yml index 0974b891..466e4710 100644 --- a/docker-compose.synvya.yml +++ b/docker-compose.synvya.yml @@ -73,7 +73,7 @@ services: FROM_NAME: ${FROM_NAME:-Synvya} BASE_URL: ${BASE_URL:?error} APP_URL: ${APP_URL:?error} - DISABLE_EMAILS: ${DISABLE_EMAILS:-} + DISABLE_EMAILS: # Frontend VITE_DOMAIN: ${VITE_DOMAIN:-} VITE_ALLOWED_PUBKEYS: ${VITE_ALLOWED_PUBKEYS:-} From a8a74b1a629966771058f390f2460a8b535589c5 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Thu, 9 Apr 2026 08:24:47 -0700 Subject: [PATCH 03/46] Allow users to create their first team --- api/src/api/http/teams.rs | 11 +++++-- core/src/repositories/user.rs | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index d1992c49..7b040027 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -55,9 +55,16 @@ pub async fn create_team( ) -> ApiResult> { let tenant_id = tenant.0.id; let user_pubkey_hex = &auth.pubkey; + let user_pubkey = PublicKey::from_hex(user_pubkey_hex) + .map_err(|_| ApiError::bad_request("Invalid pubkey"))?; + let user_repo = UserRepository::new(pool.clone()); + + let can_create_first_team = user_repo + .count_team_memberships(tenant_id, &user_pubkey) + .await? + == 0; - // Check admin access for team creation - if !super::admin::is_full_admin(&auth) { + if !super::admin::is_full_admin(&auth) && !can_create_first_team { tracing::warn!( "Team creation denied for non-admin pubkey: {}", user_pubkey_hex diff --git a/core/src/repositories/user.rs b/core/src/repositories/user.rs index 53e3589e..b73a0a08 100644 --- a/core/src/repositories/user.rs +++ b/core/src/repositories/user.rs @@ -149,6 +149,25 @@ impl UserRepository { Ok(count > 0) } + /// Count how many teams this user belongs to within the tenant. + pub async fn count_team_memberships( + &self, + tenant_id: i64, + pubkey: &PublicKey, + ) -> Result { + sqlx::query_scalar( + "SELECT COUNT(*) + FROM team_users tu + JOIN teams t ON t.id = tu.team_id + WHERE tu.user_pubkey = $1 AND t.tenant_id = $2", + ) + .bind(pubkey.to_hex()) + .bind(tenant_id) + .fetch_one(&self.pool) + .await + .map_err(Into::into) + } + // ========================================================================= // Authentication methods // ========================================================================= @@ -1557,6 +1576,49 @@ mod tests { assert!(!result.unwrap(), "User should not be member"); } + #[tokio::test] + async fn test_count_team_memberships_zero_for_new_user() { + let pool = setup_pool().await; + let repo = UserRepository::new(pool.clone()); + let keys = Keys::generate(); + let pubkey = keys.public_key(); + + repo.find_or_create(1, &pubkey).await.unwrap(); + + let result = repo.count_team_memberships(1, &pubkey).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0, "New user should have zero teams"); + } + + #[tokio::test] + async fn test_count_team_memberships_filters_by_tenant() { + let pool = setup_pool().await; + let repo = UserRepository::new(pool.clone()); + let keys = Keys::generate(); + let pubkey = keys.public_key(); + let suffix = test_suffix(); + + repo.find_or_create(1, &pubkey).await.unwrap(); + repo.find_or_create(2, &pubkey).await.unwrap(); + + let team1_id = create_test_team(&pool, &format!("Tenant One {}", suffix)).await; + add_user_to_team(&pool, &pubkey.to_hex(), team1_id, "admin").await; + + let team2_id: i32 = sqlx::query_scalar( + "INSERT INTO teams (tenant_id, name, created_at, updated_at) + VALUES (2, $1, NOW(), NOW()) + RETURNING id", + ) + .bind(format!("Tenant Two {}", suffix)) + .fetch_one(&pool) + .await + .unwrap(); + add_user_to_team(&pool, &pubkey.to_hex(), team2_id, "admin").await; + + assert_eq!(repo.count_team_memberships(1, &pubkey).await.unwrap(), 1); + assert_eq!(repo.count_team_memberships(2, &pubkey).await.unwrap(), 1); + } + #[tokio::test] async fn test_is_team_teammate_true() { let pool = setup_pool().await; From 1082c6ef076c16dd8b026998b2e0e26cfc96f0d4 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Thu, 9 Apr 2026 08:35:48 -0700 Subject: [PATCH 04/46] Fix tenant-scoped team membership test setup --- core/src/repositories/user.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/src/repositories/user.rs b/core/src/repositories/user.rs index b73a0a08..9c5ef75a 100644 --- a/core/src/repositories/user.rs +++ b/core/src/repositories/user.rs @@ -1421,6 +1421,20 @@ mod tests { result.0 } + async fn create_test_tenant(pool: &PgPool, tenant_id: i64, suffix: &str) { + sqlx::query( + "INSERT INTO tenants (id, domain, name, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ) + .bind(tenant_id) + .bind(format!("tenant-{}.{}.test", tenant_id, suffix)) + .bind(format!("Tenant {}", tenant_id)) + .execute(pool) + .await + .unwrap(); + } + async fn add_user_to_team(pool: &PgPool, pubkey: &str, team_id: i32, role: &str) { sqlx::query( "INSERT INTO team_users (team_id, user_pubkey, role, created_at, updated_at) @@ -1598,6 +1612,7 @@ mod tests { let pubkey = keys.public_key(); let suffix = test_suffix(); + create_test_tenant(&pool, 2, &suffix).await; repo.find_or_create(1, &pubkey).await.unwrap(); repo.find_or_create(2, &pubkey).await.unwrap(); From a5d70b6f59317aa317c99758ef0c17fb17aede91 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Thu, 9 Apr 2026 09:55:58 -0700 Subject: [PATCH 05/46] Document Synvya team bootstrap and harden deploy workflow --- .github/workflows/build-test-push-synvya.yaml | 16 ++++++++++++++-- docs/synvya/architecture-context.md | 10 ++++++++++ docs/synvya/restaurant-team-e2e.md | 5 ++++- docs/synvya/server-e2e-handoff.md | 2 ++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-test-push-synvya.yaml b/.github/workflows/build-test-push-synvya.yaml index 4d604401..a2306c32 100644 --- a/.github/workflows/build-test-push-synvya.yaml +++ b/.github/workflows/build-test-push-synvya.yaml @@ -136,8 +136,14 @@ jobs: key: ${{ secrets.EC2_STAGING_SSH_KEY }} command_timeout: 30m script: | + set -euo pipefail cd /opt/synvya/keycast - git pull origin synvya-staging + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing to deploy from a dirty worktree" + git status --short + exit 1 + fi + git pull --ff-only origin synvya-staging bash scripts/load-secrets.sh staging if [ -n "${{ vars.DISABLE_EMAILS }}" ]; then echo "DISABLE_EMAILS=${{ vars.DISABLE_EMAILS }}" >> /opt/synvya/.env @@ -165,8 +171,14 @@ jobs: key: ${{ secrets.EC2_PRODUCTION_SSH_KEY }} command_timeout: 30m script: | + set -euo pipefail cd /opt/synvya/keycast - git pull origin synvya + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing to deploy from a dirty worktree" + git status --short + exit 1 + fi + git pull --ff-only origin synvya bash scripts/load-secrets.sh production if [ -n "${{ vars.DISABLE_EMAILS }}" ]; then echo "DISABLE_EMAILS=${{ vars.DISABLE_EMAILS }}" >> /opt/synvya/.env diff --git a/docs/synvya/architecture-context.md b/docs/synvya/architecture-context.md index 696e1bca..f6a6dce9 100644 --- a/docs/synvya/architecture-context.md +++ b/docs/synvya/architecture-context.md @@ -61,3 +61,13 @@ The client represents humans. The server represents the always-on restaurant ope - deployable AWS hosting for Keycast itself - support for background provisioning of server-side restaurant authorizations - bot-resistant email endpoints (via AWS WAF rate limiting) + +## Team Bootstrap Rule + +Keycast now supports a narrow self-serve bootstrap path for Synvya: + +- an authenticated user may create their first team even if they are not whitelisted +- the creator becomes the team admin under the normal Keycast model +- once the user already belongs to a team, additional team creation should move through Synvya's manual-approval or server-managed provisioning flow + +This is a bootstrap rule, not a restaurant-ownership rule. Synvya/server remains the system that decides whether a restaurant team is official, approved, or allowed to proceed beyond onboarding. diff --git a/docs/synvya/restaurant-team-e2e.md b/docs/synvya/restaurant-team-e2e.md index 3c3f8e7f..899e1c31 100644 --- a/docs/synvya/restaurant-team-e2e.md +++ b/docs/synvya/restaurant-team-e2e.md @@ -17,6 +17,8 @@ If you need to hand this off to a `Synvya/server` implementation session, start 5. creates a team authorization for that imported key 6. returns the `bunkerUrl` and related IDs for test code +In the Synvya product flow, Keycast now allows an authenticated user to create their first team even if they are not whitelisted. This helper still uses the admin E2E account because it is a deterministic provisioning path for integration tests and avoids coupling tests to onboarding state. + This is the correct Synvya model when: - the restaurant already published events with its own Nostr key @@ -236,7 +238,8 @@ For a full server integration test, assert all of the following: - Do not commit the real demo restaurant `nsec` into the repo. - This helper is only suitable for restaurants whose private key is available. - If a partner restaurant's `nsec` is unavailable, you cannot preserve its existing pubkey with this flow. -- The helper uses the whitelisted E2E admin account because team creation in this repo is admin-gated. +- Keycast now allows self-serve creation of a user's first team. Additional restaurant teams should still be created through the Synvya manual-approval or server-managed path. +- The helper still uses the whitelisted E2E admin account because this document is about deterministic test provisioning, not the end-user onboarding flow. ## Failure Modes diff --git a/docs/synvya/server-e2e-handoff.md b/docs/synvya/server-e2e-handoff.md index 4009cc7f..8cbecb1f 100644 --- a/docs/synvya/server-e2e-handoff.md +++ b/docs/synvya/server-e2e-handoff.md @@ -59,6 +59,8 @@ That helper: So the server test does not need to care about raw key import. It only needs to consume the resulting bunker URL and prove signing works correctly. +For Synvya product behavior, Keycast now allows a newly authenticated user to create their first team without being whitelisted. That bootstrap rule is separate from this helper, which still provisions through the admin E2E account for repeatable test setup. Additional restaurant teams remain a Synvya approval or server-provisioning concern rather than an open self-serve Keycast action. + ## What The Server Session Should Build The server coding session should implement an end-to-end test harness with this shape: From d7a5caff3d0fb9d5d6a06ca483e6ff0378180d4d Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Thu, 9 Apr 2026 14:34:11 -0700 Subject: [PATCH 06/46] Fix logout CORS for credentialed requests --- api/src/api/http/routes.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/src/api/http/routes.rs b/api/src/api/http/routes.rs index fc488ab0..721c88ad 100644 --- a/api/src/api/http/routes.rs +++ b/api/src/api/http/routes.rs @@ -46,10 +46,12 @@ pub fn api_routes( .layer(auth_cors.clone()) .with_state(auth_state.clone()); - // Logout route - public CORS so third-party OAuth apps can revoke sessions - // Protected by Bearer token (UCAN), not cookies + // Logout route - restricted CORS with credentials + // This endpoint clears the first-party session cookie, so it must use + // auth_cors rather than wildcard public CORS. let logout_route = Router::new() .route("/auth/logout", post(auth::logout)) + .layer(auth_cors.clone()) .with_state(auth_state.clone()); // verify_email needs auth_state for key_manager (to decrypt keys and issue UCAN) @@ -257,7 +259,7 @@ pub fn api_routes( .merge(bunker_routes) // Has auth_cors (bunker creation) .merge(key_export_routes) // Has auth_cors (authenticated, needs cookies) .merge(change_key_route) // Has auth_cors (authenticated, needs cookies) - .merge(logout_route.layer(public_cors.clone())) // Public CORS - Bearer token auth, third-party apps + .merge(logout_route) // Has auth_cors (credentialed cookie logout) .merge(account_delete_route.layer(public_cors.clone())) // Public CORS - Bearer token auth, third-party apps .merge(verify_email_route.layer(public_cors.clone())) // Public CORS - same-origin sets cookie, cross-origin uses Bearer .merge(email_routes.layer(public_cors.clone())) From 955f3972ef929c729b22388ada2b4e1f130c4c1a Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Thu, 9 Apr 2026 15:23:22 -0700 Subject: [PATCH 07/46] Prune Docker cache before Synvya deploys --- .github/workflows/build-test-push-synvya.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-test-push-synvya.yaml b/.github/workflows/build-test-push-synvya.yaml index a2306c32..c885b1b8 100644 --- a/.github/workflows/build-test-push-synvya.yaml +++ b/.github/workflows/build-test-push-synvya.yaml @@ -148,6 +148,9 @@ jobs: if [ -n "${{ vars.DISABLE_EMAILS }}" ]; then echo "DISABLE_EMAILS=${{ vars.DISABLE_EMAILS }}" >> /opt/synvya/.env fi + docker system df || true + docker builder prune -af || true + docker image prune -af || true docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ build postgres redis migrate keycast docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ @@ -183,6 +186,9 @@ jobs: if [ -n "${{ vars.DISABLE_EMAILS }}" ]; then echo "DISABLE_EMAILS=${{ vars.DISABLE_EMAILS }}" >> /opt/synvya/.env fi + docker system df || true + docker builder prune -af || true + docker image prune -af || true docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ build postgres redis migrate keycast docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ From 464dab5b6ab3f4dc007d98ab10a22a3469ea1459 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Thu, 9 Apr 2026 15:49:26 -0700 Subject: [PATCH 08/46] Align Synvya auth UX with account app --- api/src/api/http/oauth.rs | 18 +++- web/src/lib/utils/env.ts | 31 +++++++ web/src/routes/forgot-password/+page.svelte | 30 ++++++- web/src/routes/login/+page.svelte | 4 +- web/src/routes/reset-password/+page.svelte | 17 +++- web/src/routes/verify-email/+page.svelte | 87 ++++++++++++++++++-- web/static/synvya-logo.png | Bin 0 -> 2596 bytes 7 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 web/static/synvya-logo.png diff --git a/api/src/api/http/oauth.rs b/api/src/api/http/oauth.rs index ea450d38..77e5be8d 100644 --- a/api/src/api/http/oauth.rs +++ b/api/src/api/http/oauth.rs @@ -25,6 +25,20 @@ use serde::{Deserialize, Serialize}; // Import constants and helpers from auth module use super::auth::{generate_secure_token, token_expiry_seconds, EMAIL_VERIFICATION_EXPIRY_HOURS}; +fn forgot_password_url_for_auth_host() -> &'static str { + let auth_url = std::env::var("APP_URL") + .or_else(|_| std::env::var("VITE_DOMAIN")) + .unwrap_or_default(); + + if auth_url.contains("auth.staging.synvya.com") { + "https://account.staging.synvya.com/login" + } else if auth_url.contains("auth.synvya.com") { + "https://account.synvya.com/login" + } else { + "/forgot-password" + } +} + /// Generate a 256-bit random authorization handle (64 hex characters) /// Used for silent re-authentication in OAuth flows pub fn generate_authorization_handle() -> String { @@ -1469,6 +1483,7 @@ pub async fn authorize_get( } } else { // User not authenticated - show login/register form (divine.video-inspired design) + let forgot_password_url = forgot_password_url_for_auth_host(); format!( r#" @@ -1830,7 +1845,7 @@ pub async fn authorize_get(