Skip to content

Commit 3f62e39

Browse files
committed
feat: add SaaS recovery and blueprint community footers
Make Founding Customer certificates unlimited and document the production deliverables.
1 parent c015515 commit 3f62e39

31 files changed

Lines changed: 1618 additions & 92 deletions

.github/workflows/deploy-saas-production.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ on:
1111
description: 'Full Git commit SHA of the image currently accepted in SaaS staging'
1212
required: true
1313
type: string
14+
password_reset_mode:
15+
description: 'Enable only after the production Resend domain and protected key are ready'
16+
required: true
17+
default: disabled
18+
type: choice
19+
options:
20+
- disabled
21+
- resend
1422

1523
concurrency:
1624
group: saas-production
@@ -42,13 +50,18 @@ jobs:
4250
NEXUS_USERNAME: ${{ secrets.SAAS_PRODUCTION_NEXUS_USERNAME }}
4351
NEXUS_PASSWORD: ${{ secrets.SAAS_PRODUCTION_NEXUS_PASSWORD }}
4452
STAGING_COMMIT: ${{ inputs.staging_commit }}
53+
PASSWORD_RESET_MODE: ${{ inputs.password_reset_mode }}
54+
RESEND_API_KEY: ${{ secrets.SAAS_PRODUCTION_RESEND_API_KEY }}
4555
run: |
4656
[[ "$STAGING_COMMIT" =~ ^[0-9a-f]{40}$ ]]
4757
test "${DATABASE_URL#postgres}" != "$DATABASE_URL"
4858
test "${DATABASE_URL#*sslmode=require}" != "$DATABASE_URL"
4959
test "${#APP_KEY}" -ge 32
5060
test -n "$NEXUS_USERNAME"
5161
test "${#NEXUS_PASSWORD}" -ge 16
62+
if [ "$PASSWORD_RESET_MODE" = 'resend' ]; then
63+
test "${RESEND_API_KEY#re_}" != "$RESEND_API_KEY"
64+
fi
5265
5366
- name: Log in to Azure with a short-lived production OIDC token
5467
uses: azure/login@v3
@@ -116,6 +129,9 @@ jobs:
116129
DEPLOYMENT_TIER=production \
117130
NEXUS_TRUSTED_TLS_TERMINATION=azure-container-apps \
118131
MIGRATE_ON_STARTUP=true \
132+
PASSWORD_RESET_MODE=disabled \
133+
ACCOUNT_PUBLIC_BASE_URL=https://saas.rullst.win \
134+
ACCOUNT_MAIL_FROM=security@mail.rullst.win \
119135
PAYMENTS_MODE=disabled \
120136
BILLING_PROVIDER=stripe \
121137
BILLING_EXPECTED_CURRENCY=BRL \
@@ -132,6 +148,21 @@ jobs:
132148
--transport auto \
133149
--only-show-errors --output none
134150
151+
- name: Enable production password recovery
152+
if: ${{ inputs.password_reset_mode == 'resend' }}
153+
shell: bash
154+
env:
155+
RESEND_API_KEY: ${{ secrets.SAAS_PRODUCTION_RESEND_API_KEY }}
156+
run: |
157+
az containerapp secret set --name "$PRODUCTION_CONTAINER_APP" --resource-group "$RESOURCE_GROUP" \
158+
--secrets resend-api-key="$RESEND_API_KEY" \
159+
--only-show-errors --output none
160+
az containerapp update --name "$PRODUCTION_CONTAINER_APP" --resource-group "$RESOURCE_GROUP" \
161+
--set-env-vars \
162+
PASSWORD_RESET_MODE=resend \
163+
RESEND_API_KEY=secretref:resend-api-key \
164+
--only-show-errors --output none
165+
135166
- name: Verify the immutable revision and production database
136167
shell: bash
137168
env:

.github/workflows/deploy-saas-staging.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ on:
2020
options:
2121
- disabled
2222
- test
23+
password_reset_mode:
24+
description: 'Enable only after the staging Resend domain and protected key are ready'
25+
required: true
26+
default: disabled
27+
type: choice
28+
options:
29+
- disabled
30+
- resend
2331

2432
concurrency:
2533
group: saas-staging
@@ -103,13 +111,18 @@ jobs:
103111
NEXUS_PASSWORD: ${{ secrets.SAAS_STAGING_NEXUS_PASSWORD }}
104112
STRIPE_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
105113
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_TEST_WEBHOOK_SECRET }}
114+
PASSWORD_RESET_MODE: ${{ inputs.password_reset_mode }}
115+
RESEND_API_KEY: ${{ secrets.SAAS_STAGING_RESEND_API_KEY }}
106116
run: |
107117
test "${DATABASE_URL#postgres}" != "$DATABASE_URL"
108118
test "${#APP_KEY}" -ge 32
109119
test -n "$NEXUS_USERNAME"
110120
test "${#NEXUS_PASSWORD}" -ge 16
111121
test "${STRIPE_KEY#sk_test_}" != "$STRIPE_KEY"
112122
test "${STRIPE_WEBHOOK_SECRET#whsec_}" != "$STRIPE_WEBHOOK_SECRET"
123+
if [ "$PASSWORD_RESET_MODE" = 'resend' ]; then
124+
test "${RESEND_API_KEY#re_}" != "$RESEND_API_KEY"
125+
fi
113126
114127
- name: Log in to Azure with a short-lived GitHub OIDC token
115128
uses: azure/login@v3
@@ -156,6 +169,9 @@ jobs:
156169
DEPLOYMENT_TIER=staging \
157170
NEXUS_TRUSTED_TLS_TERMINATION=azure-container-apps \
158171
MIGRATE_ON_STARTUP=true \
172+
PASSWORD_RESET_MODE=disabled \
173+
ACCOUNT_PUBLIC_BASE_URL=https://saas-staging.rullst.win \
174+
ACCOUNT_MAIL_FROM=security@staging-mail.rullst.win \
159175
PAYMENTS_MODE="$PAYMENTS_MODE" \
160176
BILLING_PROVIDER=stripe \
161177
BILLING_PRICE_ID="$STRIPE_TEST_PRICE_ID" \
@@ -170,6 +186,21 @@ jobs:
170186
STRIPE_WEBHOOK_SECRET=secretref:stripe-test-webhook-secret \
171187
--only-show-errors --output none
172188
189+
- name: Enable staging password recovery
190+
if: ${{ inputs.password_reset_mode == 'resend' }}
191+
shell: bash
192+
env:
193+
RESEND_API_KEY: ${{ secrets.SAAS_STAGING_RESEND_API_KEY }}
194+
run: |
195+
az containerapp secret set --name "$CONTAINER_APP" --resource-group "$RESOURCE_GROUP" \
196+
--secrets resend-api-key="$RESEND_API_KEY" \
197+
--only-show-errors --output none
198+
az containerapp update --name "$CONTAINER_APP" --resource-group "$RESOURCE_GROUP" \
199+
--set-env-vars \
200+
PASSWORD_RESET_MODE=resend \
201+
RESEND_API_KEY=secretref:resend-api-key \
202+
--only-show-errors --output none
203+
173204
- name: Verify immutable revision and database readiness
174205
shell: bash
175206
env:

.github/workflows/enable-saas-live.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,6 @@ jobs:
173173
MERCHANT_COUNTRY=BR \
174174
SUPPORT_EMAIL=officialrullst@gmail.com \
175175
REFUND_WINDOW_DAYS=14 \
176-
FOUNDING_CUSTOMER_LIMIT=100 \
177176
--only-show-errors --output none
178177
179178
- name: Verify live revision health without creating a payment

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,14 @@ example is also governed by the engineering
2929

3030
Welcome to the official showcase and blueprints repository for the **Rullst Framework (`12.0.0`)**. This repository demonstrates how to architect, build, and deploy production-grade, sovereign web applications in pure Rust without JavaScript framework lock-in.
3131

32-
This monorepository contains three live cloud applications:
32+
This monorepository contains four live cloud applications, with the SaaS
33+
blueprint deliberately split into staging and production deployments:
3334
1. **🌐 Rullst Showcase App:** Demonstrates the **5 Frontend Paradigms**, Active Record ORM, WAF security layers, Prompt Injection Shield, and Omni cross-platform capabilities.
3435
2. **🎓 Rullst Academy (LMS Blueprint):** A full-featured, real-world educational platform with 13 SQLite migrations, course catalog, Argon2 authentication, monotonic lesson progress tracking, quizzes, certifications, and integrated **Nexus Admin CMS** + **Studio Dev Cockpit**.
3536
3. **💼 Rullst Portfolio Blueprint:** An ultra-fast, modern developer portfolio featuring dark glassmorphic UI, HTMX SSR, project showcase, experience timeline, skills matrix, and **Nexus Admin CMS**.
37+
4. **Rullst SaaS Blueprint:** An audited one-time Stripe checkout with separate
38+
sandbox and live databases, provider objects, secrets, entitlements,
39+
downloadable artifacts, certificates, refunds and reconciliation.
3640

3741
---
3842

@@ -53,6 +57,8 @@ All applications run in **Microsoft Azure Container Apps** on the **Azure for St
5357
| **🌐 Rullst Showcase** | [showcase.rullst.win](https://showcase.rullst.win) | 5 Web Paradigms, WAF Defense, LiveView, Active Record, Groq AI Copilot | 🛡️ **Nexus Admin:** [/nexus](https://rullst-showcase.redpond-24d9228d.eastus.azurecontainerapps.io/nexus)<br>🚀 **Studio:** [/studio](https://rullst-showcase.redpond-24d9228d.eastus.azurecontainerapps.io/studio)<br>**Public demo login:** `rullst_demo`<br>**Password:** `RullstDemoAccess2026!`<br>🤖 **Groq AI:** GPT-OSS 120B & Prompt Shield<br>📖 [Showcase Guide](docs/showcase-nexus-studio-ai-guide.md) |
5458
| **🎓 LMS Academy** | [lms.rullst.win](https://lms.rullst.win) | Courses, Real Argon2 Auth, Lesson Player, Quizzes, Certificates | 🛡️ **Nexus Admin:** [/nexus](https://rullst-lms.redpond-24d9228d.eastus.azurecontainerapps.io/nexus)<br>🚀 **Studio:** [/studio](https://rullst-lms.redpond-24d9228d.eastus.azurecontainerapps.io/studio)<br>*(Protected by HTTP Basic Auth configured via container environment variables)* |
5559
| **💼 Portfolio** | [portfolio.rullst.win](https://rullst-portfolio.redpond-24d9228d.eastus.azurecontainerapps.io/) | Dark Glassmorphic UI, HTMX SSR, Project Showcase, Experience Timeline | 🛡️ **Nexus Admin:** [/nexus](https://rullst-portfolio.redpond-24d9228d.eastus.azurecontainerapps.io/nexus)<br>🚀 **Studio:** [/studio](https://rullst-portfolio.redpond-24d9228d.eastus.azurecontainerapps.io/studio)<br>**Public demo login:** `rullst_demo`<br>**Password:** `RullstDemoAccess2026!`<br>📖 [Portfolio Guide](docs/BLUEPRINT_PORTFOLIO.md) |
60+
| **SaaS Staging** | [saas-staging.rullst.win](https://saas-staging.rullst.win) | Permanent Stripe sandbox, test report and Sandbox Pioneer certificate; no real charge | Private Nexus credentials; no production Studio process |
61+
| **SaaS Production** | [saas.rullst.win](https://saas.rullst.win) | Real one-time Stripe checkout, private guide, unlimited Founding Customer certificates, refunds and reconciliation | Private Nexus credentials; no production Studio process |
5662

5763
---
5864

@@ -113,6 +119,13 @@ deployments also use separate persistent PostgreSQL databases, webhook secrets,
113119
Price IDs, application keys and private administration credentials. No private
114120
credential is included in this repository.
115121

122+
The sandbox flow grants a downloadable test report and a clearly test-only
123+
`Rullst Sandbox Pioneer` certificate. Every reconciled production purchase
124+
grants the private Stripe implementation guide and a `Rullst Founding Customer`
125+
certificate; the certificate has no quantity limit.
126+
Refunds and disputes revoke the corresponding access after provider
127+
confirmation.
128+
116129
See the [SaaS blueprint guide](blueprints/saas/README.md) for setup and the
117130
[framework improvement report](saas-improvements-needed.md) for the full
118131
provider-by-provider and Rullst Mail account-lifecycle audit.

blueprints/lms/src/migrations/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3024,7 +3024,11 @@ mod tests {
30243024
assert!(rendered_catalog.contains("nonce=\"catalog-csp-nonce\""));
30253025
assert!(rendered_catalog.contains("&lt;script&gt;"));
30263026
assert!(!rendered_catalog.contains("<script>alert(1)</script>"));
3027-
assert!(!rendered_catalog.contains("https://"));
3027+
assert!(!rendered_catalog.contains("src=\"https://"));
3028+
assert!(!rendered_catalog.contains("rel=\"stylesheet\" href=\"https://"));
3029+
assert!(rendered_catalog.contains(
3030+
"href=\"https://discord.gg/2ntKFtsSjw\" target=\"_blank\" rel=\"noopener noreferrer\""
3031+
));
30283032

30293033
let rendered_video = crate::pages::lms::lesson_player_page(
30303034
"Memory safety <essentials>",

blueprints/lms/src/pages/lms.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ use crate::models::category::Category;
44
use crate::models::course::Course;
55
use crate::models::lesson::Lesson;
66

7+
fn render_community_footer() -> String {
8+
html! {
9+
<footer class="community-footer">
10+
<div class="community-footer-mark" aria-hidden="true">"R"</div>
11+
<div class="community-footer-copy">
12+
<p class="community-footer-eyebrow">"Learn, build, contribute"</p>
13+
<h2>"Join our community on Discord"</h2>
14+
<p>"Meet Rullst builders, share what you are learning, and help shape the framework."</p>
15+
</div>
16+
<a href="https://discord.gg/2ntKFtsSjw" target="_blank" rel="noopener noreferrer">"Join the Rullst Discord"</a>
17+
</footer>
18+
}
19+
}
20+
721
pub fn index_page(
822
categories: Vec<Category>,
923
courses: Vec<Course>,
@@ -102,7 +116,15 @@ pub fn index_page(
102116
.card h2 { margin: 0 0 .75rem; font-size: 1.35rem; }
103117
.card p { flex: 1; margin: 0 0 1.25rem; color: #cbd5e1; line-height: 1.6; }
104118
.empty { grid-column: 1 / -1; border: 1px dashed #64748b; border-radius: 1rem; padding: 2rem; text-align: center; }
105-
@media (max-width: 48rem) { header { flex-direction: column; } .search { grid-template-columns: 1fr; } }
119+
.community-footer { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 1.25rem; max-width: 70rem; margin: 3rem auto 0; padding: 1.35rem 1.5rem; border: 1px solid rgba(88, 101, 242, .5); border-radius: 1.25rem; background: linear-gradient(135deg, rgba(88, 101, 242, .2), rgba(15, 23, 42, .96) 58%, rgba(52, 211, 153, .13)); box-shadow: 0 18px 50px rgba(0, 0, 0, .28); }
120+
.community-footer-mark { display: grid; width: 3.25rem; height: 3.25rem; place-items: center; border-radius: 1rem; background: linear-gradient(145deg, #5865f2, #34d399); color: #fff; font-size: 1.4rem; font-weight: 900; box-shadow: 0 10px 28px rgba(88, 101, 242, .35); }
121+
.community-footer h2, .community-footer p { margin: 0; }
122+
.community-footer h2 { margin: .1rem 0 .25rem; font-size: clamp(1.2rem, 3vw, 1.55rem); }
123+
.community-footer-copy > p:not(.community-footer-eyebrow) { color: #cbd5e1; line-height: 1.55; }
124+
.community-footer-eyebrow { color: #a5b4fc; font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
125+
.community-footer a { padding: .8rem 1rem; border: 1px solid rgba(255, 255, 255, .16); border-radius: .8rem; background: #5865f2; color: #fff; font-weight: 800; text-align: center; text-decoration: none; transition: transform 160ms ease, background 160ms ease; }
126+
.community-footer a:hover { background: #4752c4; transform: translateY(-2px); }
127+
@media (max-width: 48rem) { header { flex-direction: column; } .search { grid-template-columns: 1fr; } .community-footer { grid-template-columns: auto minmax(0, 1fr); padding: 1.15rem; } .community-footer a { grid-column: 1 / -1; width: 100%; } }
106128
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
107129
"
108130
</style>
@@ -143,6 +165,7 @@ pub fn index_page(
143165
</div>
144166
</main>
145167
</div>
168+
{rullst::html::RawHtml(render_community_footer())}
146169
{rullst::html::RawHtml(render_lms_ai_widget(csrf_token))}
147170
<script nonce={csp_nonce}>
148171
"if ('serviceWorker' in navigator) { window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(console.error)); }"
@@ -918,3 +941,15 @@ fn render_lms_ai_widget(csrf_token: &str) -> String {
918941
"##.replace("__CSRF_TOKEN__", &rullst::html::escape_str(csrf_token))
919942
}
920943

944+
#[cfg(test)]
945+
mod community_footer_tests {
946+
use super::render_community_footer;
947+
948+
#[test]
949+
fn footer_links_to_discord_safely() {
950+
let footer = render_community_footer();
951+
assert!(footer.contains("Join our community on Discord"));
952+
assert!(footer.contains("https://discord.gg/2ntKFtsSjw"));
953+
assert!(footer.contains("rel=\"noopener noreferrer\""));
954+
}
955+
}

blueprints/portfolio/src/pages/home.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,31 @@ fn cv_styles() -> String {
552552
40% { transform: scale(1.1); opacity: 1; }
553553
}
554554
555+
.community-footer {
556+
display: grid;
557+
grid-template-columns: auto minmax(0, 1fr) auto;
558+
align-items: center;
559+
gap: 1.25rem;
560+
width: min(calc(100% - 2rem), 1350px);
561+
margin: 0 auto 2.5rem;
562+
padding: 1.35rem 1.5rem;
563+
border: 1px solid rgba(88, 101, 242, 0.48);
564+
border-radius: 20px;
565+
background: linear-gradient(135deg, rgba(88, 101, 242, 0.18), rgba(15, 15, 20, 0.94) 58%, rgba(0, 255, 204, 0.1));
566+
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3);
567+
}
568+
.community-footer-mark { display: grid; width: 52px; height: 52px; place-items: center; border-radius: 16px; background: linear-gradient(145deg, #5865f2, #00bfa5); color: #fff; font-size: 1.4rem; font-weight: 900; box-shadow: 0 10px 28px rgba(88, 101, 242, .35); }
569+
.community-footer h2, .community-footer p { margin: 0; }
570+
.community-footer h2 { margin: 2px 0 4px; font-size: clamp(1.2rem, 3vw, 1.55rem); }
571+
.community-footer-copy > p:not(.community-footer-eyebrow) { color: var(--text-muted); }
572+
.community-footer-eyebrow { color: #a5b4fc; font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
573+
.community-footer a { padding: .8rem 1rem; border: 1px solid rgba(255, 255, 255, .16); border-radius: 12px; background: #5865f2; color: #fff; font-weight: 800; text-align: center; text-decoration: none; transition: transform 160ms ease, background 160ms ease; }
574+
.community-footer a:hover { background: #4752c4; transform: translateY(-2px); }
575+
.community-footer a:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
576+
555577
@media (max-width: 640px) {
578+
.community-footer { grid-template-columns: auto minmax(0, 1fr); padding: 1.15rem; }
579+
.community-footer a { grid-column: 1 / -1; width: 100%; }
556580
.ai-crab-launcher {
557581
bottom: max(16px, env(safe-area-inset-bottom));
558582
right: max(16px, env(safe-area-inset-right));
@@ -695,6 +719,20 @@ fn render_content(projects: &[Project], experiences: &[Experience]) -> String {
695719
}
696720
}
697721

722+
fn render_community_footer() -> String {
723+
html! {
724+
<footer class="community-footer">
725+
<div class="community-footer-mark" aria-hidden="true">"R"</div>
726+
<div class="community-footer-copy">
727+
<p class="community-footer-eyebrow">"Connect with the builders"</p>
728+
<h2>"Join our community on Discord"</h2>
729+
<p>"Share projects, discuss Rust architecture, and help shape the Rullst ecosystem."</p>
730+
</div>
731+
<a href="https://discord.gg/2ntKFtsSjw" target="_blank" rel="noopener noreferrer">"Join the Rullst Discord"</a>
732+
</footer>
733+
}
734+
}
735+
698736
fn render_ai_widget(csrf_token: &str) -> String {
699737
r##"
700738
<div id="ai-crab-launcher" class="ai-crab-launcher" onclick="toggleAiDrawer()" role="button" tabindex="0" aria-label="Ask me anything!">
@@ -899,8 +937,22 @@ pub fn render(
899937
{ rullst::html::RawHtml(render_content(projects, experiences)) }
900938
</div>
901939

940+
{ rullst::html::RawHtml(render_community_footer()) }
902941
{ rullst::html::RawHtml(render_ai_widget(csrf_token)) }
903942
</body>
904943
</html>
905944
}
906945
}
946+
947+
#[cfg(test)]
948+
mod community_footer_tests {
949+
use super::render_community_footer;
950+
951+
#[test]
952+
fn footer_links_to_discord_safely() {
953+
let footer = render_community_footer();
954+
assert!(footer.contains("Join our community on Discord"));
955+
assert!(footer.contains("https://discord.gg/2ntKFtsSjw"));
956+
assert!(footer.contains("rel=\"noopener noreferrer\""));
957+
}
958+
}

blueprints/saas/.env.example

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/rullst_saas
1111
NEXUS_ADMIN_USERNAME=
1212
NEXUS_ADMIN_PASSWORD=
1313

14+
# Password recovery is fail-closed until a transactional provider is configured.
15+
# Verify the sending domain with Resend, create a sending-only/domain-scoped API
16+
# key, and use the exact public origin for this deployment. Never share a key
17+
# between staging and production.
18+
PASSWORD_RESET_MODE=disabled
19+
ACCOUNT_PUBLIC_BASE_URL=http://localhost:3000
20+
ACCOUNT_MAIL_FROM=
21+
RESEND_API_KEY=
22+
1423
# Billing mode: disabled, test or live. Keep disabled until the matching
1524
# environment-specific acceptance gate documented in README.md is complete.
1625
PAYMENTS_MODE=disabled
@@ -38,7 +47,6 @@ PAID_ARTIFACT_URL=
3847
PAID_ARTIFACT_STORAGE_ACCOUNT=
3948
PAID_ARTIFACT_SHA256=
4049
PAID_ARTIFACT_FILENAME=rullst-stripe-production-guide-v1.md
41-
FOUNDING_CUSTOMER_LIMIT=100
4250

4351
# Public merchant and refund notice fields required by live mode. Store their
4452
# values only in the deployment secret manager, never in this file or Git.

0 commit comments

Comments
 (0)