Skip to content

release: develop → main (v3 design, multi-chain auth, sponsors/signups, admin session)#105

Merged
sacha-l merged 81 commits into
mainfrom
develop
May 20, 2026
Merged

release: develop → main (v3 design, multi-chain auth, sponsors/signups, admin session)#105
sacha-l merged 81 commits into
mainfrom
develop

Conversation

@sacha-l
Copy link
Copy Markdown
Collaborator

@sacha-l sacha-l commented May 20, 2026

Promotes everything that's accumulated on develop since the last main bump. Trigger to merge: deploy of admin session tokens + sponsors + Luma CSV signups + the v3 design system to prod. Railway auto-deploys on merge into main.

What lands on prod

Auth / security

Data model / features

Admin UX

Design system

Ops / docs

Dev-only (no prod impact)

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):

20260520000000_multichain_addresses.sql            (#84)
20260520100000_m2_entitlement.sql                  (#87)
20260520200000_auth_nonces.sql                     (#89)
20260520300000_programs_as_canonical_events.sql    (#93)
20260520400000_program_admins.sql                  (#95)
20260520500000_program_event_meta_and_sponsors.sql (#102)
20260520600000_program_signups.sql                 (#103)

If any haven't been applied yet, apply via Supabase dashboard or supabase db push against the prod branch before merging this PR — otherwise the new routes will 500 on schema mismatches.

Env vars (set on Railway prod BEFORE merging)

Verification

  • server tests: 214 / 214 on develop
  • client build (tsc + vite): clean
  • client lint (--max-warnings 0): clean

Post-merge smoke tests

# /api/admin/session should now exist (expect 401 not 404)
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://stadium-production-996a.up.railway.app/api/admin/session
# expect: 401

# health still green
curl -s https://stadium-production-996a.up.railway.app/api/health | jq -r .status
# expect: OK

Then on /admin/programs/bitrefill-2026 in prod: one wallet popup on connect → silent admin actions for 15 min.

Risks

  • Single big release. 53 non-merge commits, 7 migrations. Mitigations: every migration is additive (no destructive DDL); the bearer auth path is non-breaking (clients without bearer support still send x-siws-auth); existing flows that worked before continue to work.
  • The v3 design is highly visible. This is the first time prod will look like the rack/poster system. Visual review against the latest Vercel preview before merging is worth it.

Draft for review.

sacha-l added 30 commits April 30, 2026 02:29
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)
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
sacha-l added 26 commits May 20, 2026 14:52
…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
@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stadium Ready Ready Preview, Comment May 20, 2026 9:10pm

@sacha-l sacha-l marked this pull request as ready for review May 20, 2026 21:37
@sacha-l sacha-l merged commit 4ccca1f into main May 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant