From 6d5a47f78dbb8e8fd0a21fcc7f5d5171cd81e2f7 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Fri, 22 May 2026 03:06:29 +0200 Subject: [PATCH 01/24] chore(deploy): remove stale deploy-all.js, fix stale prod URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys are automatic on merge to main (Railway + Vercel GitHub integrations), so the manual one-command path is obsolete — and dangerous. - Delete server/scripts/deploy-all.js: it re-ran stale one-off Symbiosis/ Plata-Mia prod data mutations (whose db:* npm scripts no longer exist), did supabase db push, and auto-committed/pushed main. - Remove the deploy:all script from server/package.json. - Rewrite the PRODUCTION_DEPLOYMENT.md top section to document the auto-deploy-on-merge flow + the manual Supabase data step. - Fix stale defaults: verify scripts' FRONTEND_URL and client/.env.example's prod API host now point at stadium.joinwebzero.com / stadium-production-996a. - CLAUDE.md: drop deploy:all from the operational list; note auto-deploy. --- CLAUDE.md | 2 +- client/.env.example | 2 +- docs/PRODUCTION_DEPLOYMENT.md | 51 ++--- server/package.json | 1 - server/scripts/deploy-all.js | 302 ------------------------- server/scripts/verify-main-deployed.js | 2 +- server/scripts/verify-production.js | 4 +- 7 files changed, 23 insertions(+), 341 deletions(-) delete mode 100644 server/scripts/deploy-all.js diff --git a/CLAUDE.md b/CLAUDE.md index b2d3a69..390f4e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Always run from the subdir, not the repo root. - `npm test` — `vitest run` - `npm start` — `node server.js` - `npm run seed:dev`, `npm run db:migrate`, `npm run db:reset` — local Mongo tooling (destructive; run only when asked) -- `npm run verify:production`, `npm run verify:main-deployed`, `npm run deploy:all` — operational +- `npm run verify:production`, `npm run verify:main-deployed` — operational (read-only prod health checks). Deploys are automatic: merging to `main` triggers Railway (server) + Vercel (client) via their GitHub integrations. --- diff --git a/client/.env.example b/client/.env.example index 843d457..3b749c9 100644 --- a/client/.env.example +++ b/client/.env.example @@ -2,7 +2,7 @@ # For local development: VITE_API_BASE_URL=http://localhost:2000/api # For production: -# VITE_API_BASE_URL=https://hw4os4c00wg4k80o.apps.joinwebzero.com/api +# VITE_API_BASE_URL=https://stadium-production-996a.up.railway.app/api # Admin wallet addresses (comma-separated). Each entry is either `chain:address` # or a bare address (bare ⇒ substrate). Supported chains: substrate, ethereum. diff --git a/docs/PRODUCTION_DEPLOYMENT.md b/docs/PRODUCTION_DEPLOYMENT.md index 007d2a1..29ac743 100644 --- a/docs/PRODUCTION_DEPLOYMENT.md +++ b/docs/PRODUCTION_DEPLOYMENT.md @@ -4,48 +4,33 @@ Instructions for a **code agent** or automation to update production after code --- -## 🚀 One-command deploy (recommended) +## 🚀 How deploys happen (automatic on merge to `main`) -From the **repo root**, run: +**Deploys are automatic.** Railway (server) and Vercel (client) are connected to +this GitHub repo and deploy on every push to **`main`**. The normal flow is: -```bash -node server/scripts/deploy-all.js -``` - -Or from **server/**: - -```bash -cd server -npm run deploy:all -``` - -This single command: +1. Open a PR into `develop`, get it reviewed/merged. +2. Promote `develop → main` (PR). Merging to `main` **auto-deploys both** the + Railway server and the Vercel client — no manual deploy command needed. +3. **Data changes are NOT automatic.** Supabase schema migrations and any data + backfills must be applied separately (see "Database (Supabase)" below). A + merge does not run migrations or seed data. -1. ✅ Checks Git (on main, clean, up to date) — auto-commits/pushes if needed -2. ✅ Applies Supabase migrations (`supabase db push` if any pending) -3. ✅ Updates Symbiosis M2 status: - - Sets main-track winners to `m2_status='building'` (for Program Overview) - - Sets completed projects to `m2_status='completed'` (for Recently Shipped) -4. ✅ Applies Symbiosis payouts: - - Updates `payments` table with M1/M2/BOUNTY payments - - Updates project `m2_status` and `completion_date` based on payouts -5. ✅ Deploys Railway server (`railway up --detach`) -6. ✅ Deploys Vercel client (`vercel --prod`) -7. ✅ Quick verification (API/frontend reachable) +> The old `server/scripts/deploy-all.js` "one-command deploy" was **removed** — +> it re-ran stale one-off data mutations and required local Railway/Vercel CLI +> auth. Use the auto-deploy flow above. If you ever need a manual deploy (e.g. +> the GitHub integration is down), the CLI fallback is in +> "Deploy server / client" below. -**Note:** Steps 3-4 require `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` in your environment. If not set, they will skip with a warning (projects may already be updated). - -**After it finishes**, wait 1-2 minutes for Railway build, then run: +**After a deploy**, verify with (read-only): ```bash cd server -npm run verify:production +API_BASE_URL=https://stadium-production-996a.up.railway.app/api \ +FRONTEND_URL=https://stadium.joinwebzero.com \ +npm run verify:main-deployed ``` -For full verification that everything matches main and works correctly. - ---- - --- ## Do I need to commit everything for deploy? diff --git a/server/package.json b/server/package.json index 4645be8..efa23f9 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,6 @@ "test": "vitest run", "verify:production": "node scripts/verify-production.js", "verify:main-deployed": "node scripts/verify-main-deployed.js", - "deploy:all": "node scripts/deploy-all.js", "start": "node server.js", "dev": "nodemon server.js", "seed:dev": "node scripts/seed-dev.js", diff --git a/server/scripts/deploy-all.js b/server/scripts/deploy-all.js deleted file mode 100644 index 1886adb..0000000 --- a/server/scripts/deploy-all.js +++ /dev/null @@ -1,302 +0,0 @@ -/** - * One-command deploy: ensure main is up to date, migrations applied, Railway + Vercel deployed. - * - * Usage (from repo root): - * node server/scripts/deploy-all.js - * - * Or from server/: - * npm run deploy:all - * - * This script: - * 1. Checks git (on main, clean, up to date) - * 2. Applies Supabase migrations if needed - * 3. Updates Symbiosis M2 status (sets main-track winners to 'building' and 'completed') - * 4. Applies Symbiosis payouts (updates payments table and project status) - * 5. Updates project data (Plata Mia team/plan, final submissions for completed projects) - * 6. Deploys Railway server - * 7. Deploys Vercel client - * 8. Verifies everything works - */ - -import { execSync } from 'child_process'; -import dotenv from 'dotenv'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import fs from 'fs'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const scriptRepoRoot = path.resolve(__dirname, '../..'); -const cwd = process.cwd(); -const repoRoot = (() => { - try { - fs.accessSync(path.join(cwd, '.git')); - return cwd; - } catch { - return scriptRepoRoot; - } -})(); - -dotenv.config({ path: path.join(scriptRepoRoot, 'server', '.env') }); - -const API_BASE = process.env.API_BASE_URL || 'https://stadium-production-996a.up.railway.app/api'; -const FRONTEND_URL = process.env.FRONTEND_URL || 'https://client-n7lu4qlus-sachalanskys-projects.vercel.app'; - -let step = 0; -const steps = []; - -function logStep(name) { - step++; - console.log(`\n[${step}] ${name}`); - console.log('─'.repeat(60)); - steps.push({ step, name, status: 'running' }); -} - -function logSuccess(msg) { - console.log(`✅ ${msg}`); - if (steps.length > 0) steps[steps.length - 1].status = 'success'; -} - -function logError(msg) { - console.error(`❌ ${msg}`); - if (steps.length > 0) steps[steps.length - 1].status = 'failed'; -} - -function logWarning(msg) { - console.log(`⚠️ ${msg}`); - if (steps.length > 0) steps[steps.length - 1].status = 'warning'; -} - -// Not used - using execSync directly for simplicity - -async function checkGit() { - logStep('Checking Git status'); - - const branch = execSync('git branch --show-current', { cwd: repoRoot, encoding: 'utf-8' }).trim(); - if (branch !== 'main') { - throw new Error(`Not on main branch (current: ${branch}). Switch to main first.`); - } - logSuccess(`On main branch`); - - const status = execSync('git status --porcelain', { cwd: repoRoot, encoding: 'utf-8' }).trim(); - if (status) { - logWarning('Uncommitted changes detected. Committing them...'); - try { - execSync('git add -A', { cwd: repoRoot }); - execSync('git commit -m "chore: auto-commit before deploy"', { cwd: repoRoot }); - logSuccess('Committed changes'); - } catch (err) { - throw new Error('Could not auto-commit. Commit manually or stash changes.'); - } - } else { - logSuccess('Working tree clean'); - } - - try { - execSync('git fetch origin main', { cwd: repoRoot, stdio: 'ignore' }); - const local = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim(); - const remote = execSync('git rev-parse origin/main', { cwd: repoRoot, encoding: 'utf-8' }).trim(); - if (local !== remote) { - logWarning(`Local (${local.slice(0, 7)}) != remote (${remote.slice(0, 7)}). Pushing...`); - execSync('git push origin main', { cwd: repoRoot, stdio: 'inherit' }); - logSuccess('Pushed to origin/main'); - } else { - logSuccess('Up to date with origin/main'); - } - } catch (err) { - throw new Error(`Git sync failed: ${err.message}`); - } -} - -async function checkSupabaseMigrations() { - logStep('Checking Supabase migrations'); - - try { - const output = execSync('supabase migration list', { cwd: repoRoot, encoding: 'utf-8', stdio: 'pipe' }); - const lines = output.split('\n').filter(l => l.includes('|')); - if (lines.length < 2) { - logWarning('Could not parse migration list. Skipping migration check.'); - return; - } - - const mismatches = lines.slice(1).filter(l => { - const parts = l.split('|').map(p => p.trim()); - return parts.length >= 2 && parts[0] !== parts[1]; - }); - - if (mismatches.length > 0) { - logWarning(`${mismatches.length} migration(s) not applied. Applying...`); - execSync('supabase db push', { cwd: repoRoot, stdio: 'inherit' }); - logSuccess('Migrations applied'); - } else { - logSuccess('All migrations applied'); - } - } catch (err) { - logWarning(`Supabase CLI check failed: ${err.message}. Skipping migration check.`); - } -} - -async function updateSymbiosisM2Status() { - logStep('Updating Symbiosis M2 status'); - - const serverDir = path.join(repoRoot, 'server'); - try { - // Check if env vars are set - if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) { - logWarning('SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not set. Skipping Symbiosis M2 update.'); - return; - } - - // First set main-track winners to 'building' - execSync('npm run db:symbiosis-m2-building', { cwd: serverDir, stdio: 'inherit' }); - logSuccess('Symbiosis M2 building status updated'); - - // Then set completed projects to 'completed' (for Recently Shipped) - execSync('npm run db:symbiosis-completed', { cwd: serverDir, stdio: 'inherit' }); - logSuccess('Symbiosis M2 completed status updated'); - } catch (err) { - logWarning(`Symbiosis M2 update failed: ${err.message}. This is optional - projects may already be updated.`); - } -} - -async function applySymbiosisPayouts() { - logStep('Applying Symbiosis payouts'); - - const serverDir = path.join(repoRoot, 'server'); - try { - // Check if env vars are set - if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) { - logWarning('SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not set. Skipping payout updates.'); - return; - } - - execSync('npm run db:symbiosis-payouts', { cwd: serverDir, stdio: 'inherit' }); - logSuccess('Symbiosis payouts applied'); - } catch (err) { - logWarning(`Symbiosis payout update failed: ${err.message}. This is optional - payouts may already be applied.`); - } -} - -async function updateProjectData() { - logStep('Updating project data (Plata Mia, final submissions)'); - - const serverDir = path.join(repoRoot, 'server'); - try { - // Check if env vars are set - if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) { - logWarning('SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not set. Skipping project data updates.'); - return; - } - - // Update Plata Mia with team members and M2 plan - execSync('npm run db:plata-mia-update', { cwd: serverDir, stdio: 'inherit' }); - logSuccess('Plata Mia data updated'); - - // Sync final submissions for completed projects - execSync('npm run db:sync-final-submissions', { cwd: serverDir, stdio: 'inherit' }); - logSuccess('Final submissions synced'); - } catch (err) { - logWarning(`Project data update failed: ${err.message}. This is optional - data may already be updated.`); - } -} - -async function deployRailway() { - logStep('Deploying Railway server'); - - const serverDir = path.join(repoRoot, 'server'); - try { - execSync('railway up --detach', { cwd: serverDir, stdio: 'inherit' }); - logSuccess('Railway deployment queued'); - console.log(' (Wait 1-2 minutes for build to complete)'); - } catch (err) { - throw new Error(`Railway deploy failed: ${err.message}`); - } -} - -async function deployVercel() { - logStep('Deploying Vercel client'); - - const clientDir = path.join(repoRoot, 'client'); - try { - execSync('vercel --prod', { cwd: clientDir, stdio: 'inherit' }); - logSuccess('Vercel deployment completed'); - } catch (err) { - throw new Error(`Vercel deploy failed: ${err.message}`); - } -} - -async function verify() { - logStep('Quick verification'); - - console.log('Waiting 10 seconds for deployments to start...'); - await new Promise(resolve => setTimeout(resolve, 10000)); - - // Run basic checks - try { - const res = await fetch(`${API_BASE}/health`); - if (res.ok) { - logSuccess('Railway API is reachable'); - } else { - logWarning(`Railway API returned ${res.status} (may still be building)`); - } - } catch (err) { - logWarning(`Railway API not reachable yet: ${err.message} (build may still be running)`); - } - - try { - const res = await fetch(FRONTEND_URL, { method: 'HEAD' }); - if (res.ok || res.status === 405) { - logSuccess('Vercel frontend is reachable'); - } else { - logWarning(`Vercel returned ${res.status}`); - } - } catch (err) { - logWarning(`Vercel not reachable: ${err.message}`); - } - - console.log('\n💡 For full verification, wait 1-2 minutes then run:'); - console.log(' cd server && npm run verify:production'); -} - -async function main() { - console.log('🚀 Deploy All: Main → Migrations → Railway → Vercel\n'); - console.log(`Repo: ${repoRoot}`); - console.log(`API: ${API_BASE}`); - console.log(`Frontend: ${FRONTEND_URL}\n`); - - try { - await checkGit(); - await checkSupabaseMigrations(); - await updateSymbiosisM2Status(); - await applySymbiosisPayouts(); - await updateProjectData(); - await deployRailway(); - await deployVercel(); - await verify(); - - console.log('\n' + '='.repeat(60)); - console.log('🎉 DEPLOYMENT COMPLETE'); - console.log('='.repeat(60)); - console.log('\nNext steps:'); - console.log(' • Wait 1-2 minutes for Railway build to finish'); - console.log(' • Run: npm run verify:production (in server/) to verify everything'); - console.log(' • Check Railway/Vercel dashboards for deployment status'); - - } catch (err) { - console.error('\n' + '='.repeat(60)); - console.error('❌ DEPLOYMENT FAILED'); - console.error('='.repeat(60)); - console.error(`\nError: ${err.message}`); - console.error('\nSteps completed:'); - steps.forEach(({ step, name, status }) => { - const icon = status === 'success' ? '✅' : status === 'failed' ? '❌' : '⚠️'; - console.error(` ${icon} [${step}] ${name}`); - }); - process.exit(1); - } -} - -main().catch((err) => { - console.error('Fatal error:', err); - process.exit(1); -}); diff --git a/server/scripts/verify-main-deployed.js b/server/scripts/verify-main-deployed.js index 1ee307d..b02d9a7 100644 --- a/server/scripts/verify-main-deployed.js +++ b/server/scripts/verify-main-deployed.js @@ -39,7 +39,7 @@ const actualRepoRoot = (() => { dotenv.config({ path: path.join(scriptRepoRoot, 'server', '.env') }); const API_BASE = process.env.API_BASE_URL || 'https://stadium-production-996a.up.railway.app/api'; -const FRONTEND_URL = process.env.FRONTEND_URL || 'https://client-n7lu4qlus-sachalanskys-projects.vercel.app'; +const FRONTEND_URL = process.env.FRONTEND_URL || 'https://stadium.joinwebzero.com'; const results = { passed: 0, failed: 0, warnings: 0 }; const errors = []; diff --git a/server/scripts/verify-production.js b/server/scripts/verify-production.js index 59f89c8..1945c7c 100644 --- a/server/scripts/verify-production.js +++ b/server/scripts/verify-production.js @@ -3,7 +3,7 @@ * * Usage (from server/): * API_BASE_URL=https://stadium-production-996a.up.railway.app/api \ - * FRONTEND_URL=https://client-xxx.sachalanskys-projects.vercel.app \ + * FRONTEND_URL=https://stadium.joinwebzero.com \ * node scripts/verify-production.js * * Or set in .env: @@ -15,7 +15,7 @@ import dotenv from 'dotenv'; dotenv.config(); const API_BASE = process.env.API_BASE_URL || 'https://stadium-production-996a.up.railway.app/api'; -const FRONTEND_URL = process.env.FRONTEND_URL || 'https://client-n7lu4qlus-sachalanskys-projects.vercel.app'; +const FRONTEND_URL = process.env.FRONTEND_URL || 'https://stadium.joinwebzero.com'; const results = { passed: 0, failed: 0, warnings: 0 }; const errors = []; From 0f8e0b690b5245f0aa76c70a2cd5ac254cb90ff8 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Fri, 22 May 2026 03:47:48 +0200 Subject: [PATCH 02/24] copy: rename user-facing 'UNIT' label to 'ENTRY' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Units' read oddly; projects/entries are now labelled ENTRY / ENTRIES. User-facing strings only — internal identifiers (UnitCard, unit-card.tsx, toUnit, unitNumber, UnitForCard, UnitDetailModal) are unchanged. Covers card labels, the detail modal header, 'NOW SHOWING / ENTRY NNN', M2 'ENTRY / TEAM' + 'NO ENTRIES MATCH' + row labels, project-detail breadcrumb, admin card label, the 'N ENTRIES' counts, the 'Total Entries' stat, search placeholders, and the 404 message. --- client/src/components/unit-card.tsx | 2 +- client/src/components/unit-detail-modal.tsx | 2 +- client/src/pages/AdminPage.tsx | 2 +- client/src/pages/HomePage.tsx | 8 ++++---- client/src/pages/M2ProgramPage.tsx | 8 ++++---- client/src/pages/NotFound.tsx | 2 +- client/src/pages/ProjectDetailsPage.tsx | 2 +- client/src/pages/WinnersPage.tsx | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/client/src/components/unit-card.tsx b/client/src/components/unit-card.tsx index 85f6cd3..590f674 100644 --- a/client/src/components/unit-card.tsx +++ b/client/src/components/unit-card.tsx @@ -62,7 +62,7 @@ export function UnitCard({
- UNIT {unitNumber}{date && ` · ${date}`} + ENTRY {unitNumber}{date && ` · ${date}`}

{title} diff --git a/client/src/components/unit-detail-modal.tsx b/client/src/components/unit-detail-modal.tsx index 9796e6b..ae6ef94 100644 --- a/client/src/components/unit-detail-modal.tsx +++ b/client/src/components/unit-detail-modal.tsx @@ -76,7 +76,7 @@ export function UnitDetailModal({ open, onOpenChange, unit }: UnitDetailModalPro
- UNIT {unit.unitNumber}{unit.date && ` · ${unit.date}`} + ENTRY {unit.unitNumber}{unit.date && ` · ${unit.date}`}
{unit.isWinner && ( diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 1dce20c..763ec4d 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -443,7 +443,7 @@ const AdminPage = () => { {projectsUnderReview.map((project) => (
-
UNIT · SUBMITTED {formatDate(project.finalSubmission?.submittedDate || project.submittedDate)}
+
ENTRY · SUBMITTED {formatDate(project.finalSubmission?.submittedDate || project.submittedDate)}

{project.projectName}

diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 2bc0fa6..ce498bd 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -284,7 +284,7 @@ const HomePage = () => {
·INDEX / LIVE
- + @@ -298,7 +298,7 @@ const HomePage = () => {
{featuredUnit.date &&
{featuredUnit.date}
}
@@ -314,7 +314,7 @@ const HomePage = () => { {hackathons.length > 0 && ( @@ -369,7 +369,7 @@ const HomePage = () => {
·DIRECTORY / GRID
-
{filteredProjects.length} {filteredProjects.length === 1 ? "UNIT" : "UNITS"}
+
{filteredProjects.length} {filteredProjects.length === 1 ? "ENTRY" : "ENTRIES"}
{loading ? ( diff --git a/client/src/pages/M2ProgramPage.tsx b/client/src/pages/M2ProgramPage.tsx index e238c91..0a2338d 100644 --- a/client/src/pages/M2ProgramPage.tsx +++ b/client/src/pages/M2ProgramPage.tsx @@ -189,7 +189,7 @@ const M2ProgramPage = () => { @@ -245,7 +245,7 @@ const M2ProgramPage = () => { {filtered.length === 0 && (
-
·NO UNITS MATCH
+
·NO ENTRIES MATCH

{view === "mine" && !connectedAddress ? "Connect a wallet to see your teams." @@ -288,7 +288,7 @@ function M2Rack({

-
UNIT / TEAM
+
ENTRY / TEAM
MILESTONES
PRIZE
@@ -316,7 +316,7 @@ function M2Row({ team, onClick }: { team: Team; onClick: () => void }) {
-
UNIT {team.unitNumber}
+
ENTRY {team.unitNumber}
{team.title}
diff --git a/client/src/pages/NotFound.tsx b/client/src/pages/NotFound.tsx index b850391..8c03716 100644 --- a/client/src/pages/NotFound.tsx +++ b/client/src/pages/NotFound.tsx @@ -10,7 +10,7 @@ const NotFound = () => {

404

-

No unit found at this address.

+

No entry found at this address.

{location.pathname}

{
{/* Header Section — rack chrome */}
-
·UNIT / {project.id?.toUpperCase()}
+
·ENTRY / {project.id?.toUpperCase()}
{project.m2Status === 'completed' && ( diff --git a/client/src/pages/WinnersPage.tsx b/client/src/pages/WinnersPage.tsx index 71f9960..8ba223b 100644 --- a/client/src/pages/WinnersPage.tsx +++ b/client/src/pages/WinnersPage.tsx @@ -158,7 +158,7 @@ const WinnersPage = () => {
- {loading ? "…" : `${winners.length} ${winners.length === 1 ? "UNIT" : "UNITS"}`} + {loading ? "…" : `${winners.length} ${winners.length === 1 ? "ENTRY" : "ENTRIES"}`}
From b458df4e68a1486c0b99e3cbb2743f0ec2287ce3 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Fri, 22 May 2026 04:12:39 +0200 Subject: [PATCH 03/24] feat(landing): program-type spaces entry point + per-program projects view Makes the landing page an entry point to all WebZero programs and lets a program show its projects. - New ProgramSpaces component: 4 evergreen type 'spaces' (Hackathons, M2 Incubator, Dogfooding, PitchOff) with icon + blurb + per-type event count + a culture blurb, in the existing rack/LCD aesthetic. Rendered on HomePage after the stats panel; reuses the programs already fetched there. - ProgramsPage: optional ?type= filter (useSearchParams) so the spaces are real category links; M2 keeps its dedicated /m2-program page. - ProgramDetailPage: fetch + render the program's Stadium project entries via getProjects({hackathonId: slug}) as a UnitCard grid linking to /m2-program/:id; falls back to the existing signup-derived project cards (PitchOff/Dogfooding). Client-only; no server/schema/data changes. --- client/src/components/program-spaces.tsx | 96 ++++++++++++++++++++++++ client/src/pages/HomePage.tsx | 8 +- client/src/pages/ProgramDetailPage.tsx | 54 ++++++++++++- client/src/pages/ProgramsPage.tsx | 20 ++++- 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 client/src/components/program-spaces.tsx diff --git a/client/src/components/program-spaces.tsx b/client/src/components/program-spaces.tsx new file mode 100644 index 0000000..7ca4184 --- /dev/null +++ b/client/src/components/program-spaces.tsx @@ -0,0 +1,96 @@ +import { Link } from "react-router-dom"; +import { Zap, Lightbulb, Eye, Mic, type LucideIcon } from "lucide-react"; +import type { ApiProgram } from "@/lib/api"; + +type Space = { + type: ApiProgram["programType"]; + label: string; + blurb: string; + href: string; + Icon: LucideIcon; +}; + +// The four evergreen program types. M2 has its own dedicated page; the others +// deep-link into the /programs directory filtered by type. +const SPACES: Space[] = [ + { + type: "hackathon", + label: "HACKATHONS", + blurb: "Weekend build sprints on Polkadot — ship something new, win bounties.", + href: "/programs?type=hackathon", + Icon: Zap, + }, + { + type: "m2_incubator", + label: "M2 INCUBATOR", + blurb: "Keep building what you shipped, with a mentor and a defined Milestone 2.", + href: "/m2-program", + Icon: Lightbulb, + }, + { + type: "dogfooding", + label: "DOGFOODING", + blurb: "Hands-on sessions where builders trade real, structured feedback.", + href: "/programs?type=dogfooding", + Icon: Eye, + }, + { + type: "pitch_off", + label: "PITCHOFF", + blurb: "Builders pitch live to the room — the crowd picks what's next.", + href: "/programs?type=pitch_off", + Icon: Mic, + }, +]; + +/** + * Landing-page entry point to all WebZero programs: one "space" per program + * type, with a culture blurb. Counts are derived from the programs already + * loaded by HomePage (no extra fetch). + */ +export function ProgramSpaces({ programs }: { programs: ApiProgram[] }) { + const countFor = (t: ApiProgram["programType"]) => + programs.filter((p) => p.programType === t).length; + + return ( +
+
+
+
+
+

+ WebZero is where builders ship — then keep going. Start at a hackathon, go deeper in M2, + get real feedback at a dogfooding, and put it in front of the room at PitchOff. Find your lane. +

+
+ {SPACES.map(({ type, label, blurb, href, Icon }) => { + const count = countFor(type); + return ( + +
+
+ + ); + })} +
+
+ ); +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index ce498bd..aee5492 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -10,7 +10,8 @@ import { InputBus } from "@/components/input-bus"; import { HardwareToggle } from "@/components/hardware-toggle"; import { ProjectCardSkeleton } from "@/components/ProjectCardSkeleton"; import { NoProjectsFound } from "@/components/EmptyState"; -import { api, type ApiProject, API_BASE_URL } from "@/lib/api"; +import { ProgramSpaces } from "@/components/program-spaces"; +import { api, type ApiProject, type ApiProgram, API_BASE_URL } from "@/lib/api"; import { isMainTrackWinner } from "@/lib/projectUtils"; import { useToast } from "@/hooks/use-toast"; @@ -58,6 +59,7 @@ const HomePage = () => { const [projects, setProjects] = useState([]); const [hackathons, setHackathons] = useState<{ id: string; name: string }[]>([]); const [allProjects, setAllProjects] = useState([]); + const [allPrograms, setAllPrograms] = useState([]); const [statsLoading, setStatsLoading] = useState(true); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); @@ -78,6 +80,7 @@ const HomePage = () => { const apiProjects: ApiProject[] = Array.isArray(projectsResp?.data) ? projectsResp.data : []; setAllProjects(apiProjects); setStatsLoading(false); + setAllPrograms(programsResp?.data ?? []); const eventPrograms = (programsResp?.data ?? []).filter( (p) => p.programType === "hackathon", ); @@ -292,6 +295,9 @@ const HomePage = () => {
+ {/* Programs — entry point to all WebZero program types */} + + {/* Now Showing — single featured unit */} {featuredUnit && (
diff --git a/client/src/pages/ProgramDetailPage.tsx b/client/src/pages/ProgramDetailPage.tsx index 59061cd..f6dd3ab 100644 --- a/client/src/pages/ProgramDetailPage.tsx +++ b/client/src/pages/ProgramDetailPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useParams, Link, useNavigate } from "react-router-dom"; import { Navigation } from "@/components/Navigation"; +import { UnitCard } from "@/components/unit-card"; import { RotateCw } from "lucide-react"; import { ChainPicker } from "@/components/auth/ChainPicker"; import { getProvider } from "@/lib/auth/registry"; @@ -47,7 +48,9 @@ const ProgramDetailPage = () => { const [myApplications, setMyApplications] = useState([]); const [sponsors, setSponsors] = useState([]); const [projects, setProjects] = useState([]); + const [entries, setEntries] = useState([]); const [modalOpen, setModalOpen] = useState(false); + const navigate = useNavigate(); // Admins can apply on behalf of any project (server-side `requireTeamMemberOrAdminByBodyProject` // accepts admins). Without this branch, the page would dead-end at "You need to be a team member" @@ -131,6 +134,20 @@ const ProgramDetailPage = () => { return () => { active = false; }; }, [slug, program]); + // Stadium project entries belonging to this program (hackathons etc.). + // Server filters on hackathon_id, backfilled to match program slugs. + useEffect(() => { + if (!slug || !program) { setEntries([]); return; } + let active = true; + api + .getProjects({ hackathonId: slug, limit: 500 }) + .then((r: { data?: ApiProject[] }) => { + if (active) setEntries(Array.isArray(r?.data) ? r.data : []); + }) + .catch(() => { if (active) setEntries([]); }); + return () => { active = false; }; + }, [slug, program]); + const refetchApplications = useCallback(() => { if (!program || myProjects.length === 0) { setMyApplications([]); @@ -421,7 +438,40 @@ const ProgramDetailPage = () => {
)} - {projects.length > 0 && ( + {entries.length > 0 && ( +
+
·PROJECTS
+
+ {entries.map((p, i) => { + const isWinner = Array.isArray(p.bountyPrize) && p.bountyPrize.length > 0; + const dateStr = p.completionDate || p.submittedDate || p.hackathon?.endDate; + const date = dateStr + ? new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "2-digit" }).toUpperCase() + : undefined; + return ( + navigate(`/m2-program/${p.id}`)} + /> + ); + })} +
+
+ )} + + {entries.length === 0 && projects.length > 0 && (
·PROJECTS
diff --git a/client/src/pages/ProgramsPage.tsx b/client/src/pages/ProgramsPage.tsx index 8579fbb..70ac729 100644 --- a/client/src/pages/ProgramsPage.tsx +++ b/client/src/pages/ProgramsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { Navigation } from "@/components/Navigation"; import { api, type ApiProgram } from "@/lib/api"; @@ -26,9 +26,16 @@ const ProgramsPage = () => { }; }, []); - const openPrograms = programs.filter((p) => p.status === "open"); + const [searchParams] = useSearchParams(); + const typeFilter = searchParams.get("type"); + const typeLabel = typeFilter + ? PROGRAM_TYPE_LABEL[typeFilter as ApiProgram["programType"]] ?? typeFilter.toUpperCase() + : null; + const matchesType = (p: ApiProgram) => !typeFilter || p.programType === typeFilter; + + const openPrograms = programs.filter((p) => p.status === "open" && matchesType(p)); const pastPrograms = programs.filter( - (p) => p.status === "completed" || p.status === "closed", + (p) => (p.status === "completed" || p.status === "closed") && matchesType(p), ); return ( @@ -37,7 +44,12 @@ const ProgramsPage = () => {
-
·PROGRAMS / OPEN
+
+ ·PROGRAMS / {typeLabel ?? "OPEN"} + {typeFilter && ( + · ALL ▸ + )} +

Programs

From 0175d386c8f9f665dbe93aed80b30f139c05d20a Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Fri, 22 May 2026 04:21:57 +0200 Subject: [PATCH 04/24] security: add helmet headers + express-rate-limit (#127, #128) - helmet for X-Frame-Options / X-Content-Type-Options / etc. CSP disabled (JSON API, no HTML) and crossOriginResourcePolicy set to 'cross-origin' so the separately-hosted SPA can still read responses. - express-rate-limit: app-wide 200/min/IP default + a tight 10/min/IP on the unauthenticated, signature-verifying /api/admin/session endpoint. - trust proxy = 1 so client IPs are correct behind Railway's proxy. helmet + express-rate-limit added to deps. --- server/package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ server/package.json | 2 ++ server/server.js | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/server/package-lock.json b/server/package-lock.json index c8ee10c..8559c6f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,6 +18,8 @@ "csv-parser": "^3.0.0", "dotenv": "^17.2.1", "express": "^5.1.0", + "express-rate-limit": "^8.5.2", + "helmet": "^8.1.0", "mongoose": "^8.16.1", "polkadot-api": "^1.13.1", "resend": "^6.12.3", @@ -5310,6 +5312,7 @@ "node_modules/express": { "version": "5.2.1", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5348,6 +5351,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", @@ -5593,6 +5614,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/hmac-drbg": { "version": "1.0.1", "license": "MIT", @@ -5686,6 +5716,15 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", diff --git a/server/package.json b/server/package.json index efa23f9..30b8da1 100644 --- a/server/package.json +++ b/server/package.json @@ -34,6 +34,8 @@ "csv-parser": "^3.0.0", "dotenv": "^17.2.1", "express": "^5.1.0", + "express-rate-limit": "^8.5.2", + "helmet": "^8.1.0", "mongoose": "^8.16.1", "polkadot-api": "^1.13.1", "resend": "^6.12.3", diff --git a/server/server.js b/server/server.js index 5576a5a..c8874f9 100644 --- a/server/server.js +++ b/server/server.js @@ -1,5 +1,7 @@ import express from "express"; import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; import connectToSupabase, { supabase } from "./db.js"; import m2ProgramRoutes from './api/routes/m2-program.routes.js'; import programRoutes from './api/routes/program.routes.js'; @@ -12,6 +14,35 @@ import { getAuthorizedAddresses, NETWORK_CONFIG } from './config/polkadot-config const app = express(); const PORT = process.env.PORT || 2000; +// Railway terminates TLS at a proxy, so trust the first hop for correct client +// IPs (used by rate limiting). A specific value avoids express-rate-limit's +// permissive-trust-proxy validation error. +app.set('trust proxy', 1); + +// Security headers. This is a JSON API (no HTML), so CSP is unnecessary; allow +// the separately-hosted SPA to read responses cross-origin (#128). +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginResourcePolicy: { policy: 'cross-origin' }, +})); + +// Rate limiting (#127): a generous app-wide default plus a tight limit on the +// unauthenticated, signature-verifying admin session endpoint. +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 200, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many requests, slow down.' }, +}); +const sessionLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 10, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many sign-in attempts, try again shortly.' }, +}); + // CORS Configuration: allow explicit list + any Vercel deployment (*.vercel.app) // In non-production, also allow any http://localhost:* or http://127.0.0.1:* // origin so Vite's auto-port-bump (5173 → 5174, 8080 → 8081, …) doesn't break @@ -43,12 +74,13 @@ app.use(cors({ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(requestLogger); +app.use(apiLimiter); // API Routes app.use('/api/m2-program', m2ProgramRoutes); app.use('/api/programs', programRoutes); app.use('/api/wallet-contacts', walletContactRoutes); -app.use('/api/admin/session', adminSessionRoutes); +app.use('/api/admin/session', sessionLimiter, adminSessionRoutes); app.use('/api/admin', adminTiersRoutes); // Backward compatibility: Keep old /api/projects route as alias From aa78ca1fa5ed8c89879448ff128eac50d8e1c217 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Fri, 22 May 2026 04:28:23 +0200 Subject: [PATCH 05/24] =?UTF-8?q?feat(programs):=20non-member=20apply=20fl?= =?UTF-8?q?ow=20=E2=80=94=20emails=20the=20team=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets someone without a Stadium project apply to a program. A public form emails the team; they approve manually and reply (matching the original ask). - POST /api/programs/:slug/applications/non-member (public, no auth): validates {name, email, walletAddress?, pitch}, honeypot field, then emails the team. - non-member-application.service: sends ONE email to info@joinwebzero.com with sacha@joinwebzero.com cc'd and applicant details in the body. Fixed recipients only (applicant address never used as a recipient) so it can't be abused as an open relay. HTML-escaped body. Returns 503 if Resend isn't configured. - email-transport: forward cc to Resend. - Client: NonMemberApplyModal + a 'Don't have a Stadium project yet?' CTA on open program detail pages; api.submitNonMemberApplication. - Tests: validation + the email to/cc/body + HTML-escaping. Note: the original confirmation-to-applicant was intentionally dropped — it'd let the endpoint email arbitrary addresses from our verified domain. --- .../program/NonMemberApplyModal.tsx | 165 ++++++++++++++++++ client/src/lib/api.ts | 18 ++ client/src/pages/ProgramDetailPage.tsx | 19 ++ server/api/controllers/program.controller.js | 36 ++++ server/api/routes/program.routes.js | 2 + .../non-member-application.service.test.js | 64 +++++++ server/api/services/email-transport.js | 4 +- .../non-member-application.service.js | 74 ++++++++ 8 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 client/src/components/program/NonMemberApplyModal.tsx create mode 100644 server/api/services/__tests__/non-member-application.service.test.js create mode 100644 server/api/services/non-member-application.service.js diff --git a/client/src/components/program/NonMemberApplyModal.tsx b/client/src/components/program/NonMemberApplyModal.tsx new file mode 100644 index 0000000..6736f21 --- /dev/null +++ b/client/src/components/program/NonMemberApplyModal.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2 } from "lucide-react"; +import { api, type ApiProgram } from "@/lib/api"; +import { useToast } from "@/hooks/use-toast"; + +const PITCH_MAX = 1000; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function NonMemberApplyModal({ + open, + onOpenChange, + program, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + program: ApiProgram; +}) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [wallet, setWallet] = useState(""); + const [pitch, setPitch] = useState(""); + const [company, setCompany] = useState(""); // honeypot + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const { toast } = useToast(); + + useEffect(() => { + if (open) { + setName(""); + setEmail(""); + setWallet(""); + setPitch(""); + setCompany(""); + setError(null); + } + }, [open]); + + const validate = (): string | null => { + if (!name.trim()) return "Your name is required."; + if (!EMAIL_RE.test(email.trim())) return "A valid email is required."; + if (!pitch.trim()) return "Tell us briefly what you're building."; + if (pitch.trim().length > PITCH_MAX) return `Pitch must be ${PITCH_MAX} characters or fewer.`; + return null; + }; + + const handleSubmit = async () => { + const err = validate(); + if (err) { + setError(err); + return; + } + setError(null); + setSubmitting(true); + try { + await api.submitNonMemberApplication(program.slug, { + name: name.trim(), + email: email.trim(), + walletAddress: wallet.trim() || undefined, + pitch: pitch.trim(), + company, + }); + toast({ title: "Application sent", description: "We'll be in touch by email." }); + onOpenChange(false); + } catch (e) { + toast({ + title: "Couldn't send application", + description: (e as Error)?.message || "Unknown error", + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + return ( + (submitting ? null : onOpenChange(v))}> + + + + APPLY TO {program.name.toUpperCase()} + + + Don't have a Stadium project yet? Send your details and we'll get you set up. + + +
+
+ + setName(e.target.value)} className="font-mono text-sm" /> +
+
+ + setEmail(e.target.value)} className="font-mono text-sm" /> +
+
+ + setWallet(e.target.value)} className="font-mono text-sm" /> +
+
+ +