Conversation
Anchored on closing the application-status loop for Plata Mia: > She gets accepted by an admin, receives an email, and sees the badge flip > from "submitted" to "accepted" without manual reload. Phase 1 spec §7 named this the #1 deferred Phase 2 item. Six issues across five blocks: identity-layer (wallet_contacts), dispatch backbone (notifications + Resend), trigger wiring on three admin controllers, builder UX (email entry + focus-refetch), end-to-end rehearsal. Authored on a provisional baseline — the Plata Mia Phase 1 rehearsal hasn't run yet. Rehearsal-notes file gets a header making this explicit so the gap is visible. If the real rehearsal later surfaces friction that conflicts with Phase 2 priorities, this spec gets reopened, not silently extended. Schema additions: wallet_contacts (wallet_address PK, optional email, notifications_enabled flag) and notifications (full audit + idempotency log with a unique index on recipient/event/source_id). Deliberate non-goals: Telegram/Discord/Farcaster, in-app inbox, per-event opt-out granularity, admin-side reminders/escalations (#17 territory), real-time push, retry queue, Pitch Off, mentor entities, m2_* renames. Verification cost note: every Phase 2 write path is automatable thanks to the SIWS test-wallet harness from PR #62. Phase 1 had six SIWS-blocked flows; Phase 2 has zero.
docs(revamp): Phase 2 spec — close the application-status loop
Adds the wallet_contacts table (migration), repository, service, controller,
and routes. GET /:address is public and returns only { email_set, notifications_enabled }.
PUT /:address is guarded by requireOwnWallet (new SIWS middleware). validateEmail
added to server/api/utils/validation.js. 85 tests passing.
feat(phase-2): wallet_contacts — data model + API (#67)
Add the notifications table (audit + idempotency) and a notify() service that decides send/skip and writes one row per trigger. Status is queued; the actual Resend send lands in P2-03 and controller wiring lands in P2-04. Closes #68
…-p2-02 feat(phase-2): notifications log + dispatcher service (#68)
Extend notify() so a queued notification row triggers a transactional email via Resend, then flips the row to sent or failed. Transport is env-driven (mock under NODE_ENV=test, graceful provider_not_configured when RESEND_API_KEY is unset) so a future provider swap is one file. Send failures are fully isolated — they never throw to the caller, which keeps the P2-04 admin-controller trigger points safe. Closes #69
feat(phase-2): Resend integration — send queued notifications (#69)
After a successful DB write, three admin actions now fan out a notification to every team-member wallet of the affected project: application accept/reject, M2 approve, and M2 request-changes. The notify call runs after the HTTP response is sent and is fully isolated — the prior-status lookup, the team fan-out, and the send are each guarded so a failure logs an error but never changes the response or rolls back the underlying admin action. Closes #70
feat(phase-2): wire notify() into admin controllers (#70)
Found during a full user-journey test pass — the team-member list keys its cards by wallet address, which collides when two members share an address (the redacted mock address triggers a React duplicate-key warning on the Team & Payments tab).
Adds the builder-facing UX that closes the Phase 2 loop: - NotificationsCard on the project Overview tab (team-gated) — a team member sets their email + global opt-out, SIWS-signed, PUT to /api/wallet-contacts/:address. Renders a confirmation state when an email is already on file (the GET never returns the raw email). - Focus-refetch + a Refresh button on the two application-status surfaces (ProjectProgramsSection, ProgramDetailPage) so a left-open tab updates itself when refocused. - Client email validator mirroring the server's, used inline before any SIWS prompt. Closes #71
Provisional template for the Phase 2 end-to-end rehearsal — Plata Mia's opt-in → admin-accept → email → focus-refetch → opt-out journey. Mirrors the Phase 1 rehearsal-notes structure; Observed/Friction/retro fields are left empty for the human rehearsal driver to fill verbatim. The rehearsal itself is operational and human-gated (production deploy, configured Resend, a real inbox) — this commit ships only the template. Refs #72
Adds a "Create Project" button + modal on /admin so an admin can onboard a project in-app instead of running an offline seed/migration script. The modal collects name (required), optional id, description, repo/demo URLs, hackathon, categories, and team members; saves through a SIWS-signed POST to the existing /api/m2-program/ endpoint. Hardens the endpoint: createProject previously wrote req.body with no validation — it now runs a new validateProject() and returns 400 on missing/invalid fields. Closes #80
Extends EditProjectDetailsModal so an admin/team member can set fields previously writable only via offline scripts: final-submission details, hackathon assignment, and bounty prizes (liveUrl was already editable). Also fixes a server gap: the repository's updateProject never wrote the bounty_prizes table on a PATCH, so bounty edits silently dropped. It now delete-and-re-inserts bounty_prizes when bountyPrize is in the payload, mirroring the teamMembers block. finalSubmission and hackathon already round-trip via toSupabaseProject. Closes #81
…ion-key chore(backlog): log TeamPaymentSection duplicate-key bug
feat(phase-2): Notifications card + focus-refetch (#71)
docs(phase-2): alpha-rehearsal-notes template for Block E (#72)
feat: admin Create Project UI (#80)
feat: widen the project edit form (#81)
Phase A — server verifier abstraction: - Add server/api/auth/ with a per-chain verifier registry, shared statement validation, payload parsing, and address normalization. - Refactor auth.middleware.js to one chain-aware pipeline. The x-siws-auth payload gains an optional `chain` field defaulting to 'substrate', so existing SIWS clients are unaffected. Phase B — Ethereum (SIWE / EIP-4361): - Add an Ethereum verifier (viem parseSiweMessage + offline recoverMessageAddress); rejects expired / not-yet-valid messages. - Chain-tagged AUTHORIZED_SIGNERS via authorizedSigners.js (`chain:address` or bare => substrate); admins authorized per chain. - Client: client/src/lib/auth/ wallet-provider abstraction (Substrate + Ethereum/EIP-6963), useWalletAuth hook, and a ChainPicker component. - AdminPage and ProjectDetailsPage migrated to useWalletAuth; WalletConnectionBanner gains the chain picker. - addressUtils / constants made chain-aware (backward compatible). Server: 153 tests pass. Client build + lint green.
- Add a Solana (Sign-In With Solana) verifier: ed25519 signature verification via tweetnacl, base58 address handling via bs58, and a parser for the fixed SIWS-style message format. Rejects expired messages. - normalize.js gains a solana case (canonical base58 of the 32-byte public key); the verifier registry now includes solana. - Client: solanaProvider (window.solana / Phantom) builds and signs the matching message format; registry + ChainPicker now offer Solana. - addressUtils normalizes solana addresses for chain-aware comparison. Server: 158 tests pass. Client build + lint green.
Add a `chain` discriminator to every address-bearing table so team members and payout addresses can be Substrate, Ethereum or Solana. - Migration 20260520000000: team_members.wallet_chain, projects.donation_chain (both with a CHECK constraint), and a composite (wallet_chain, wallet_address) primary key on wallet_contacts. Existing rows backfill to 'substrate' via the column default before any constraint/PK is applied. - validation.js: validateAddress(chain, address) with per-chain format rules; validateTeamMember / validateProject validate the (chain, address) pair. - project.repository: transform + insert walletChain / donationChain; findByTeamWallet filters on (wallet_chain, wallet_address). - updatePayoutAddress accepts donationChain and validates per chain. - wallet_contacts repo/service/controller are chain-aware; requireOwnWallet exposes the signer's chain on req.user. Server: 161 tests pass.
Wire the client API layer to the multi-chain server endpoints: - updateTeam / updatePayoutAddress forward donationChain and per-member walletChain (both default to 'substrate'). - getWalletContact accepts an optional chain and sends ?chain=. Client build + lint green.
- TeamPaymentSection: a chain selector on each team-member wallet and on the payout address; read-only views show a chain badge. handleTeamPaymentSave forwards walletChain / donationChain through api.updateTeam. - ProjectDetailsPage: ApiProject / ApiTeamMember carry the chain fields; donationChain is passed to TeamPaymentSection. - NotificationsCard: signs via useWalletAuth (multi-chain) instead of an inline substrate-only SIWS block; getWalletContact passes the connected chain. Client build + lint green.
feat: multi-chain sign-in — Substrate + Ethereum + Solana
…ProjectModal ApplyToProgramModal: DialogTitle becomes the poster font-display in all-caps, body description keeps text-body; Labels become label-hw-dim captions; the counter swaps between label-hw-dim / label-hw destructive depending on overflow; Cancel and Submit buttons become rack-styled ghost / display-fill respectively, with the same SUBMITTING… / ▸ idiom the rest of the v3 chrome uses. ProjectUpdatesTab: Card → panel for the empty state; per-update Cards become panels with label-hw-dim meta row (mono-cased address + date), text-body for the prose, mono link styling for u.linkUrl. Loading + error states get label-hw chrome. Post button is rack-styled with display fill. ApplicationCard: shadcn Card/Badge/Button dropped in favor of a panel with hairline-separated header. Status badge maps to a bordered mono span — accepted gets display fill, withdrawn/rejected get hairline + label-mid, submitted gets the bordered display-on-deep treatment. Accept/Reject buttons mirror the apply-modal pattern (display fill + hairline outline). ShareProjectModal: title gets font-display + uppercase, Labels → label-hw-dim, share buttons become rack-styled outlines with mono caps, copy-affordance is a 36px square outlined button, the check icon flips to text-led when copied. Drops the unused 'error' parameter on the clipboard catch block.
ProjectProgramsSection: Card/Header/Content/Badge/Button → panel + label-hw header + bordered mono status badge. Refresh button becomes an outlined square sized to match the badge, with text-label-mid → display on hover. FilterSidebar: shadcn Card and Separator dropped in favor of panel with hairline-subtle dividers. Category and Winners rows become uniform 'rack rows' — mono uppercase with hairline borders, display fill for the active winners pill, panel-deep fill for active categories. Clear-filters becomes a centered ghost row. Yellow trophy treatment is gone; the pulse animation on Trophy stays as the only motion.
…, PostUpdateModal M2SubmissionTimeline: Card/Header/Badge/Alert/Button dropped. The shell is now a panel with label-hw header + LED-tagged status pill (display fill for completed, panel-deep for under_review, hairline for building). Checklist rows extracted into a small ChecklistRow helper rendering each state as an lcd block with text-led for done items and label-hw-dim for the trailing date/payout meta. The submitted-/-approved confirmation is a border-led lcd; gated states (not connected / not team) become lcd captions. Submit button is a wide rack action with display fill. EditProjectDetailsModal: just the chrome — DialogTitle gets font-display + all-caps, footer Cancel/Save become the rack outline + display-fill button pattern. The form fields themselves still use shadcn Input/Textarea which already render in the new tokens. UpdateTeamModal: per-member 'border-subtle/bg-muted/30' wrappers → lcd panels; section headings → label-hw text-display; remove-member icon button → small bordered square that goes hover-destructive; Add Member + Cancel + Save Changes adopt the rack button pattern. All Labels become label-hw-dim mono captions and Inputs get font-mono for the address feel. PostUpdateModal: same treatment — DialogTitle font-display, label-hw-dim labels, mono Textarea/Input, label-hw error + count row, rack-styled footer buttons with the POSTING… / ▸ idiom.
…n payment modals EditFundingSignalModal: full restyle — DialogTitle gets font-display, the 'Actively seeking funding' wrapper becomes an lcd block with label-hw chrome + label-hw-dim help text, Switch unchanged. Inner Labels/Inputs/ Textarea/Select all swap to label-hw-dim labels + font-mono inputs. Error + counter row uses the label-hw / label-hw-dim split. Footer buttons are the rack outline + display-fill pattern. FinalSubmissionModal: same treatment. The yellow Alert becomes an lcd panel with the AlertTriangle icon + display-toned label-hw warning. The checkbox confirmation sits inside an lcd panel so it reads as 'arming the action.' Drops the unused Alert/Button shadcn imports. SubmitM2DeliverablesModal: full restyle. Both Alerts (warning at top, success-tone tip at bottom) become lcd panels with display-toned AlertTriangle + LED-toned Lightbulb. Field labels gain their leading lucide icon at h-3 in the label-hw line; inputs get font-mono. Error + counter rows match the EditFundingSignalModal pattern. Footer buttons are rack-styled. Replaces a console.error with import.meta.env.DEV guard. Drops the discarded 'confirmed' from form data via void. CreateProjectModal / ConfirmM1PayoutModal / ConfirmPaymentModal / TestPaymentModal: chrome-only polish — DialogTitle to font-display uppercase, footer Cancel/primary Buttons to rack outline + display-fill, payment-action buttons in TestPaymentModal get matching rack styling with led / destructive fills for the multi-step approve/cancel flow. The form bodies (heavy admin workflows with recipient editors, multisig state, etc.) are left untouched in this pass.
ProgramsTable: full restyle. Card/Header/Title/Badge/Button dropped. Outer shell is a panel; header label-hw + rack-styled Create program button. Status badges become bordered mono pills (display fill = open, panel-deep = draft, hairline = closed/completed). Table head row uses label-hw-dim captions, rows hover panel-deep, dates formatted in mono caps with → separator, Edit action is a small rack outline button. M2ProjectsTable: same treatment. Status pill extracted into a helper returning a bordered mono span with the appropriate lucide icon at h-2.5; completed gets led fill. Team-address chips use bordered panel-deep with hairline-subtle border. Payment cells show check-led when paid, View TX as a label-hw-dim link. Action icons (Eye, DollarSign) become 28px outlined squares. Empty state is a panel with label-hw-dim caption. WinnersTable: chrome-only polish — filter row uses rack-styled all/main-track/bounty buttons (display fill for active, hairline for inactive), Mark All as Paid becomes a rack outline. Outer Card → panel with overflow hidden; TableHead row uses label-hw-dim caps. The getM2StatusDisplay helper now returns bordered mono pills matching the M2 table's pattern (led-fill for completed, panel-deep for under_review/ active, hairline for the catch-all). The deep manage-project dialog and the per-row action buttons are left for a focused follow-up — the file is 976 lines and a full restyle would dwarf this commit.
Promotes `programs` to the single source of truth for every event and continuation track. Adds a nullable `program_id` FK on `projects`, backfilled from `hackathon_id`, and surfaces a `program` sub-object on the project API response alongside the legacy `hackathon` view. Seeded program rows: Symbiosis 2025, Symmetry 2024, Synergy 2025 (historical hackathons); Bitrefill 2026 (upcoming, June 17 Berlin); M2 Incubator and Dogfooding (singleton continuation tracks). Legacy `hackathon_*` flat columns are untouched — removal is Phase 4.
feat: Stadium v3 — rack/poster redesign + brightness dial + WebZero palette
feat(programs): backfill canonical event/track entity (Phase 1)
Repositions the app from "the WebZero hackathon site" to a multi-event showcase platform where past events, upcoming events (Bitrefill 2026), and continuation tracks (M2, Dogfooding) all read naturally. - HomePage: hero subtitle reframed; event filter driven by `programs` (program_type='hackathon') rather than aggregated from project rows so upcoming events surface even when they have zero projects yet. - WinnersPage: title parameterized from the canonical program name, falls back to legacy `hackathon.name` then to a slug pretty-print. - ProjectDetailsPage: project-of-origin label prefers `program.name`, legacy `hackathon.eventStartedAt` special-cases stay as fallback. - M2ProgramPage: header reframed — M2 is the post-event continuation track, not the headline product. - ProgramsPage: copy broader than "past WebZero winners". - ProgramCard: M2 cards link to `/m2-program`; everything else still routes to the generic `/programs/:slug` detail.
feat(copy): reframe Stadium as multi-event builder showcase (Phase 2)
Adds a `program_admins` allow-list so each event/track can have its own admins distinct from the global `ADMIN_WALLETS` env. The global list stays as the superadmin escape hatch. Server - Migration `20260520400000_program_admins.sql`: composite PK (program_id, wallet_chain, wallet). Wallet is stored normalized so lookups are direct equality checks. - New `program-admin.repository.js` (list/add/remove/isAdmin) normalizes addresses on write and compare. - New `requireProgramAdmin(slugParam)` middleware: passes for global signers or for entries in program_admins for the URL-identified program. Application-management routes now use this scoped check so per-event admins can review their event's applications. - New routes: GET/POST/DELETE on /programs/:slug/admins. Read is gated on requireProgramAdmin; mutate on requireAdmin (global only). - 6 new middleware tests covering the global / per-program / unauthorized / 404 paths. Client - New `ProgramAdminsSection` component (mounted on AdminProgramPage) lists current admins; global admins get add/remove controls, per-program admins see the list only. - AdminProgramPage's connect-wallet flow now also accepts per-program admins via a SIWS probe against the program-scoped admins endpoint; the `isGlobalAdmin` flag governs whether add/remove controls render.
feat(auth): per-event admin permissions (Phase 3)
ProgramFormModal: full restyle. DialogTitle → font-display all-caps, DialogDescription → text-body. Every Label converted to label-hw-dim caption, every Input/Textarea/Select trigger gets font-mono. Field errors → label-hw text-destructive (also for the editing-locked slug note). Footer Cancel/Submit are the rack outline + display-fill pattern, with SAVING… / SAVE / CREATE PROGRAM ▸. Drops the unused Button import. WinnersTable manage-project Dialog: DialogTitle picks up font-display all-caps, project name in strong text-display. The four tab save buttons (Status, Agreement, Deliverables, Confirm Payment) become the rack display-fill action with leading Save / Loader2 at h-3, all caps. The bulk-mark-paid dialog gets the same: font-display title, lcd panel for the project list with mono right-aligned amounts, rack-styled Cancel/Mark All as Paid footer buttons. WinnersTable per-row actions: ghost Eye becomes a 28px outlined square matching the M2 table; primary Manage becomes a small rack action with display fill + Settings icon. Drops Card/CardContent/Badge/Button shadcn imports — none used anymore in this file.
- BrightnessRack collapses to a single-line strip after the user first changes anything (slider / AUTO / palette). The strip shows the live brightness %, the current phase / mode, and the active palette label; clicking it (or the explicit chevron in the expanded view) toggles between collapsed and expanded. - The collapsed/expanded state now persists in the same 'stadium-brightness' localStorage entry as mode / manualValue / paletteKey, so it survives page navigations and reloads. Mode, manual value, and palette were already persisted; this just extends the same StoredState. - Adds tick marks at clock hours 0, 4, 8, 12, 16, 20 along the slider track, plotted at the solar-brightness % each hour would land on according to lib/solar.ts. Tiny mono labels (00h, 04h, …) sit just under the track at the same horizontal position; the existing NIGHT / DAWN / DAY / NOON copy stays as the bottom band. - Auto-collapse fires once per mount via a ref guard so subsequent hydration of the persisted state (e.g. an already-collapsed user reloading the page) does not re-trigger it.
polish(v3): ProgramFormModal + WinnersTable manage-dialog chrome
scripts/seed-dogfooding-program.js already exists with final Phase 1 copy and the correct Berlin / 13–19 June 2026 dates, but had no corresponding 'npm run' entry — operators had to remember the full node path. Wires it up next to seed:dev so it's discoverable from 'npm run' tab-complete. Dry-run smoke-tested via 'npm run seed:dogfooding -- --dry-run' from server/ — Supabase client constructed, planned row printed, no write. Part of #48 (alpha readiness).
…t-48 scripts: add seed:dogfooding npm shortcut (#48)
…n't break The dev allowlist was a hardcoded set of ports (3000, 5173, 8080). When Vite finds its preferred port taken it auto-bumps (8080 → 8081, 5173 → 5174, …) and the resulting origin isn't in the list, so preflight fails with 'No Access-Control-Allow-Origin header' — surfaces as e.g. POST /api/programs failing on a fresh dev machine. Replaced the dev port list with a regex match on http://localhost:* / http://127.0.0.1:* that fires only when NODE_ENV !== 'production'. Production keeps its explicit allowlist of real domains + the *.vercel.app regex unchanged. Verified: - server tests still pass (177 / 177). - No localhost match in production paths — isProd is computed once and the localhost branch is gated on !isProd. Caught while the user was trying to create a new program from local client at :8081 against local server at :2000.
Adds program-level event metadata + a per-program sponsor sub-resource so
each event (e.g. Bitrefill 2026) can carry its own apply-here block and
internal post-event follow-up notes without overloading the description.
## Schema (20260520500000_program_event_meta_and_sponsors.sql)
- programs.event_url TEXT — Luma / sign-up page link
- program_sponsors (id, program_id FK, name, submission_target,
target_profiles TEXT[], application_instructions, follow_up_notes,
apply_url, created_at, updated_at) + idx on program_id
## Server
- program.repository: round-trip event_url through transform/toSnakeCase
- program-sponsor.{repository,service}: list/find/create/update/delete
- program.controller: listSponsors / createSponsor / updateSponsor /
deleteSponsor; the update/delete handlers cross-check programId so a
sponsor from program A can't be mutated via program B's slug
- program.routes: GET public, POST/PATCH/DELETE behind requireProgramAdmin
('slug') — same gate used by admins/applications, so per-event admins
can manage their own sponsors without needing global admin
- validation: eventUrl (program) + full validateSponsor (name 1–200,
submissionTarget non-negative int <= 100000, targetProfiles array of
strings <= 60 chars, instructions/notes <= 4000, applyUrl http/https)
- statements: 'Update program sponsors on Stadium' added so SIWS msgs
for sponsor mutations pass validateStatement
- Tests: 11 new cases covering list 404 + sponsor 422 / 404 / partial
patch / cross-program 404 / delete; full suite 188 / 188
## Client
- ApiProgram gains eventUrl; new ApiProgramSponsor type
- api.ts: listProgramSponsors / createProgramSponsor / updateProgramSponsor
/ deleteProgramSponsor with full mock-mode parity via mockProgramSponsors
keyed by slug (Bitrefill 2026 sponsor seeded for previews)
- siwsUtils: 'update-program-sponsors' action → matching server statement
- ProgramFormModal: new EVENT URL field (validates http/https), wired
into the create/update payload
- ProgramDetailPage: SIGN UP row in Key Dates when eventUrl is set;
new SPONSORS & HOW TO APPLY panel rendering each sponsor's name +
submission-target chip + profile chips + instructions + apply link
- ProgramSponsorsSection: admin editor with inline add/edit/remove,
profile chip multi-select, separate field for post-event follow-ups
(admin-only); each mutation signs with the new statement. Confirms
before delete.
- AdminProgramPage: mounts the new section right after ProgramAdminsSection
Build + lint clean; server vitest 188 / 188.
fix(cors): allow any localhost:* origin in dev (Vite port-bump)
feat(programs): event_url + per-program sponsors with goals & follow-ups
B of the three-part admin UX request. Lets per-event admins upload a Luma
event-registrations CSV and store who signed up, distinct from project-
level program_applications. Headcount + a simple Q&A list, with the raw
CSV row preserved so we never silently drop fields.
- program_signups (id, program_id FK on delete cascade, email NOT NULL,
name, wallet, registered_at, source DEFAULT 'luma', raw_row JSONB,
created_at) + UNIQUE (program_id, email) so re-importing the same CSV
is idempotent + idx on program_id
- luma-csv.parser: tolerant header-synonym mapping (email / fullname /
walletaddress / registrationdate / etc.), lowercased dedup email,
skipped-row count for rows missing a valid email, raw_row preserves
everything else. Streams via csv-parser (already in deps).
- program-signup.{repository,service}: list, count, findById, insertMany,
delete. planImport() classifies parsed rows as new vs duplicate
without writing; commitImport() writes only the non-duplicates.
- program.controller: listSignups, importSignups, deleteSignup. Import
accepts EITHER text/csv raw body OR JSON {csv:'...'} for compatibility
with both curl pipelines and the admin form. ?dry_run=true plans,
otherwise commits. Cross-checks program scoping on delete (signup
from program A can't be deleted via program B).
- routes: GET / POST import / DELETE all behind requireProgramAdmin,
so per-event admins manage their own program's signups. CSV body is
parsed by route-scoped express.text({limit:'5mb'}) + express.json
({limit:'5mb'}) middleware stack so the server-wide 100kb json default
isn't bumped. Stacks with the existing SIWS auth header.
- statements: 'Import program signups on Stadium' for validateStatement
- Tests: 14 new (6 parser, 8 controller — list 404, import 422 on empty
/ missing csv, import dry_run routes to planImport without inserts,
import commit calls commitImport and returns inserted, delete 404 on
cross-program, delete 204 happy)
- Full server suite 191 / 191 (was 177; +14)
- ApiProgramSignup + ProgramSignupImportSummary types
- api.ts: listProgramSignups / importProgramSignups (dryRun param) /
deleteProgramSignup with full mock-mode parity. mockPrograms exposes
importMockSignups() that runs a tiny csv parser locally so previews
can demo the flow without a backend.
- siwsUtils: 'import-program-signups' action -> matching server statement
- ProgramSignupsSection: file picker + preview-then-import flow. Preview
shows parsed / new / duplicates / skipped counters with the first 5
new rows as a sample. Import button only enables when preview shows
newCount > 0 so accidental dupe re-imports are a no-op. Per-row
delete with confirm. Header chip shows live count.
- AdminProgramPage mounts the new section right after the admins panel.
Build + lint clean.
Both ProgramAdminsSection (already on develop) and the new ProgramSignupsSection (this PR) include `signAuthHeader` in their useEffect dependency chain. AdminProgramPage was passing a fresh `() => auth.signAction(...)` arrow on every render, which made `signAuthHeader` a new reference each time → child's `load` callback recomputed → effect re-fired → another wallet popup → setState during load triggers another render → loop forever. The bug shipped on develop already (admins section had it), but you only really see it when there's a second section doing the same thing on the same page — two simultaneous wallet popups makes the cycle visually obvious. Fix: bind the per-action signers once via useCallback against the stable, account-memoized `signAction` from useWalletAuth. The sections receive a stable function reference and `load` runs exactly once per mount. `loadApplications` (page-level handler) now uses the memoized `signAdminAction` for consistency; `connectWallet` keeps the inline `auth.signAction(...)` because it's deliberately invoked just after `auth.selectAccount(candidate)` and reads from `auth` directly. Verified: - build + lint clean - one signature request on mount per section (not infinite) - Polish #102 inherits the same fix when it lands — it will need the same useCallback for the sponsors section, but ProgramSponsorsSection doesn't sign on read (public route) so it's not affected.
feat(programs): Luma CSV signup import with dry-run preview
Last of the three-part admin UX request (after #102 sponsors, #103 signups). The user reported popping the wallet for every admin click — load list, edit a sponsor, accept an application, import a CSV — even though they already authenticated this session. Per-action SIWS was deliberate (nonce-replay protection from #88 prevents reuse) but the UX cost is real, especially during a rehearsal with dozens of admin actions. ## Solution After one SIWS sign, exchange it via POST /api/admin/session for a short-lived HMAC-signed bearer token (default 15 min TTL). Subsequent admin requests send Authorization: Bearer <token>; the auth middleware accepts either path. The nonce store still consumes each SIWS message once, so this doesn't widen the replay surface — it just amortises the sign cost across a session. ## Server - server/api/auth/sessionToken.js — issue/verify HMAC-SHA256 tokens signed against ADMIN_SESSION_SECRET. Payload = base64url JSON { address, chain, iat, exp }. Signature constant-time-compared via timingSafeEqual. Cached secret reset hook for tests. TTL from ADMIN_SESSION_TTL_SECONDS env (default 900s, clamped to 24h). - server/api/controllers/admin-session.controller.js — POST /admin/session takes one fresh SIWS sign through the existing authenticateRequest path (consuming the nonce), then issues a token. The route deliberately does NOT accept bearer auth itself — tokens can't mint new tokens. - server/api/routes/admin-session.routes.js + server.js mount under /api/admin/session. - server/api/middleware/auth.middleware.js: * authenticateRequest is now exported (used by the controller above). * new resolveAuthIdentity(req, { checkDomain }) — single entry point that prefers bearer when present, falls back to SIWS, returns the same shape on success/failure. Dev-bypass surfaced as a flag. * requireAdmin / requireTeamMemberOrAdmin / requireOwnWallet / requireProgramAdmin all now call resolveAuthIdentity instead of hand-rolling the same header/auth dance. The authorization checks below (isAuthorizedSigner / team-member match / program_admins lookup) are untouched — bearer just brings the verified identity to the gate. - server/.env.example — documents ADMIN_SESSION_SECRET (32+ chars required) + ADMIN_SESSION_TTL_SECONDS. ## Server tests - server/api/auth/__tests__/sessionToken.test.js (8 cases): round-trip; tampered payload rejected; malformed inputs rejected; wrong-secret rejected (constant-time signature compare); expired rejected; missing/short secret throws on issue; TTL env honoured; extractBearerToken happy + null paths. - server/api/controllers/__tests__/admin-session.test.js (3 cases): 401 with no SIWS header; forwards authenticateRequest failure verbatim; issues a verifiable token on success. - Full server suite: 214 / 214 passing (was 202; +12). ## Client - src/lib/auth/adminSession.ts — sessionStorage-backed token cache keyed by (chain, address). REFRESH_MARGIN_MS (30s) margin so callers don't race expiry. clearAdminToken on disconnect. - src/lib/auth/useWalletAuth.ts — new getAdminBearerHeaders(): Promise<AdminAuthHeaders>. Cache hit returns {Authorization: Bearer …} immediately. Cache miss signs admin-action once, POSTs to /api/admin/session, caches the token, returns headers. If the session exchange fails (network / server), falls back to one-shot SIWS so the caller still completes. Disconnect drops the cached token. - src/lib/api.ts — new AdminAuthArg = string | Record<string,string>; adminAuthHeaders(a) maps to header entries. Every admin-mutating method widened from authHeader?: string to authHeader?: AdminAuthArg. Strict widening — existing string callers unaffected. - AdminProgramPage — single getAdminAuth callback wired to the new bearer helper; ProgramAdminsSection / ProgramSponsorsSection / ProgramSignupsSection / ApplicationCard all share it. connectWallet probe now uses getAdminBearerHeaders, primes the cache on first admin connect. Removes the per-action signAdminAction / signUpdateSponsors / signImportSignups callbacks. - AdminPage — handleConfirmM1Payout / handleConfirmPayment use bearer headers. WinnersTable signAdminAction type widened to return AdminAuthArg; all five row actions (status / agreement / deliverables / payment / bulk mark-paid) share the cache via auth.getAdminBearerHeaders. - ApplicationCard — inline SIWS dance removed; accepts signAuthHeader prop and uses the shared cache. Drops the Polkadot-direct imports (web3Enable, web3Accounts, web3FromSource, SiwsMessage, generateSiwsStatement) from this component. ## Verification - npm run build (client): clean (tsc + vite) - npm run lint --max-warnings 0 (client): clean - npm test (server): 214 / 214 ## Test scenarios - On /admin/programs/bitrefill-2026 as a global admin, click CONNECT ADMIN WALLET — exactly one wallet popup. Subsequent actions in the same session (LOAD APPLICATIONS, ADD SPONSOR, SAVE CHANGES, IMPORT CSV, ACCEPT/REJECT row, MARK ALL AS PAID) trigger zero popups. - After 15 minutes (or whatever ADMIN_SESSION_TTL_SECONDS is set to), the next admin action signs once to mint a fresh token, then continues without popups. - Set ADMIN_SESSION_SECRET to a string < 32 chars → POST /admin/session returns 500 (issueSessionToken throws). Set it long enough → tokens work. - Tamper a token's payload (swap address) → /admin/session-gated routes return 401 'Session token invalid or expired'. - Production: bearer-only flow needs the server-side secret deployed. Without ADMIN_SESSION_SECRET set, the /admin/session endpoint 500s but EXISTING SIWS flow keeps working — clients silently fall back to one-shot SIWS. Targets develop. Draft for review.
feat(auth): admin session tokens — one sign per session, not per action
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes everything that's accumulated on
developsince the lastmainbump. Trigger to merge: deploy of admin session tokens + sponsors + Luma CSV signups + the v3 design system to prod. Railway auto-deploys on merge intomain.What lands on prod
Auth / security
requireTeamMemberOrAdmindomain check (consistency withrequireAdmin)expirationTime(anti-replay)program_adminstable +requireProgramAdmin)POST /api/admin/session— one wallet sign per session, thenAuthorization: Bearer …for subsequent admin actions. Cuts admin popup count from N-per-action to 1-per-15-minData model / features
programsas canonical events table; backfill of historical hackathons + Bitrefill 2026 + Dogfooding 2026 seed;projects.program_idFKprograms.event_url(Luma link) + newprogram_sponsorstable (name, submission_target, target_profiles[], application_instructions, follow_up_notes, apply_url); admin editor + public renderprogram_signupstable + tolerant Luma CSV importer (POST /programs/:slug/signups/importwith?dry_runpreview); admin UI sectionAdmin UX
npm run seed:dogfoodingshortcutDesign system
Ops / docs
Dev-only (no prod impact)
http://localhost:*so Vite port-bump (8080→8081 when held) doesn't break preflight. Production allowlist unchanged.Schema migrations (apply in order to prod Supabase before/with the merge)
Per
supabase/migrations/, these are additive and idempotent (IF NOT EXISTS, default values):If any haven't been applied yet, apply via Supabase dashboard or
supabase db pushagainst the prod branch before merging this PR — otherwise the new routes will 500 on schema mismatches.Env vars (set on Railway prod BEFORE merging)
ADMIN_SESSION_SECRET— already set during this PR's prep. Required by feat(auth): admin session tokens — one sign per session, not per action #104; without it, the new session endpoint 500s and clients fall back to one-shot SIWS (degraded UX but no breakage).ADMIN_SESSION_TTL_SECONDS— defaults to 900 (15 min). Set if you want a different TTL.Verification
servertests: 214 / 214 ondevelopclientbuild (tsc + vite): cleanclientlint (--max-warnings 0): cleanPost-merge smoke tests
Then on
/admin/programs/bitrefill-2026in prod: one wallet popup on connect → silent admin actions for 15 min.Risks
x-siws-auth); existing flows that worked before continue to work.Draft for review.