From 1827b38171ab1699ed416554e5cabaf1dbb6fcbd Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Fri, 26 Jun 2026 02:46:26 +0200 Subject: [PATCH 01/25] feat(voting): attendee talk voting via guild.host OAuth (wip) --- .env.example | 7 + DEVELOPMENT.md | 26 ++ HANDOVER.md | 110 +++++++++ TODO.md | 48 ++++ resources/schema.sql | 22 ++ src/server/configs/oauth.ts | 16 ++ src/server/configs/vote.ts | 6 + src/server/helpers/oauth.ts | 87 +++++++ src/server/helpers/session.ts | 96 ++++++++ src/server/index.ts | 14 +- src/server/repositories/attendees.ts | 96 ++++++++ src/server/repositories/oauth-token.ts | 74 ++++++ src/server/repositories/vote.ts | 61 +++++ src/server/routes.ts | 6 + src/server/routes/auth.ts | 100 ++++++++ src/server/routes/vote.ts | 92 +++++++ src/server/types.ts | 7 + src/website/pages/vote/index.tsx | 126 ++++++++++ test/server/routes/vote.test.ts | 319 +++++++++++++++++++++++++ 19 files changed, 1311 insertions(+), 2 deletions(-) create mode 100644 HANDOVER.md create mode 100644 TODO.md create mode 100644 src/server/configs/oauth.ts create mode 100644 src/server/configs/vote.ts create mode 100644 src/server/helpers/oauth.ts create mode 100644 src/server/helpers/session.ts create mode 100644 src/server/repositories/attendees.ts create mode 100644 src/server/repositories/oauth-token.ts create mode 100644 src/server/repositories/vote.ts create mode 100644 src/server/routes/auth.ts create mode 100644 src/server/routes/vote.ts create mode 100644 src/website/pages/vote/index.tsx create mode 100644 test/server/routes/vote.test.ts diff --git a/.env.example b/.env.example index f85f7c3..44bb4dc 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,10 @@ WORKER_DOMAIN='' ALLOWED_ORIGIN='' STRIPE_SECRET_KEY='' STRIPE_WEBHOOK_SECRET='' +# Voting / guild.host OAuth (worker secrets) +GUILD_OAUTH_CLIENT_ID='' +GUILD_OAUTH_CLIENT_SECRET='' +GUILD_OAUTH_REDIRECT_URI='' +# Organizer refresh token (seeds oauth_tokens on first use; rotated copies live in D1) +GUILD_ORG_REFRESH_TOKEN='' +SESSION_SECRET='' diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f7c34e1..de7c640 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,3 +84,29 @@ npm run typecheck # Verificação de tipos TypeScript ```sh npm run lint # Verificação de linting ``` + +--- + +## Votação de palestras + +Quem comprou ingresso vota nas palestras do C4P. A pessoa entra com a conta do **guild.host**, e quantos votos ela tem depende do **tier** do ingresso. + +Como funciona: + +1. A pessoa abre `/vote` e entra com o guild.host (`GET /api/vote/login`). O **Worker** guarda a identidade num cookie de sessão assinado. +2. Na hora do voto, o Worker descobre o tier da pessoa na lista de participantes do guild.host (usando um token de organizador) e cruza com a tabela `ticket_tiers` pra saber quantos votos ela tem. +3. Os votos vão pra tabela `c4p_votes` (`POST /api/vote`), até o limite do tier e até a data de fechamento (`VOTE_CLOSES_AT` em `src/server/configs/vote.ts`). + +Tabelas novas (`resources/schema.sql`): `ticket_tiers` (nome do tier e número de votos), `c4p_votes` (um voto por pessoa e palestra) e `oauth_tokens` (token de organizador, que o Worker renova sozinho). + +Secrets do Worker: `GUILD_OAUTH_CLIENT_ID`, `GUILD_OAUTH_CLIENT_SECRET`, `GUILD_OAUTH_REDIRECT_URI`, `GUILD_ORG_REFRESH_TOKEN`, `SESSION_SECRET`. O `ALLOWED_ORIGIN` precisa ser a origem do site (não pode ser `*`, senão o cookie de sessão não vai). + +Rodando local: `npm run db:init` cria as tabelas. Fora de produção dá pra simular um usuário pelo header `X-Dev-User` (em produção isso é ignorado, só vale a sessão do OAuth): + +```sh +curl -H 'X-Dev-User: user-1' localhost:8787/api/vote +``` + +> [!TIP] +> +> O passo a passo pra subir em produção (registrar o app OAuth, popular `ticket_tiers`, pegar o refresh token de organizador) está no `TODO.md`. diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..83c94d2 --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,110 @@ +# Handover — JSConf BR attendee voting system + +Context for a fresh Claude session picking up this work. Branch: `voting-system`. + +## What this is + +JSConf BR landing page. Static Docusaurus site on GitHub Pages (`jsconf.com.br`), a Cloudflare +Worker API (`api.jsconf.com.br`), Cloudflare D1 (SQLite) database. Tickets sell through +guild.host (Stripe behind it). CD: push to `main` → `.github/workflows/cd_deploy.yml` builds +both, deploys worker via wrangler + site to the `website` branch. + +The feature added on this branch: **ticket holders vote on C4P talks.** Each attendee votes +individually; vote allowance depends on ticket tier. + +## Decisions made (with the user) and why + +- **Identity = per-attendee guild.host OAuth login**, NOT tokenized email links. Reason: the + guild.host attendees API exposes no email field, so magic-link-by-email may be impossible. + OAuth removes the email pipeline and that feasibility risk, and gives one-account-one-voter + integrity. +- **Budget resolved on the fly, NO cron** (user vetoed crons). At vote time the worker fetches + the attendee list with an organizer token, caches `userId→tier` in memory (`lru.min`, 5-min + TTL), and joins a `ticket_tiers(name, budget)` table for the vote count. There is no per-user + attendee endpoint in the guild API, so the full list is fetched + cached. +- **Stripe unused for voting.** It knows only the buyer, and votes are per individual attendee. +- **Voting runs for months**, so the organizer token (24h access / 30d rotating refresh) is + auto-refreshed and persisted in D1 — see `oauth_tokens` + `getOrgAccessToken`. +- **Vote model:** approval voting — pick up to `budget` distinct talks, toggle on/off until + `VOTE_CLOSES_AT`. All C4P submissions are votable (no `talks.status` filter). +- **Session:** HMAC-signed cookie (`vote_session`), `SameSite=None; Secure` so it survives the + cross-subdomain fetch from the website to the API. No DB session store. + +## Architecture / flow + +``` +Website /vote page (src/website/pages/vote/index.tsx) + └─ no session → "Login with guild.host" → GET /api/vote/login + └─ session → GET /api/vote (talks + budget + my votes), POST /api/vote (toggle) + +Worker (src/server/index.ts switch router) + GET /api/vote/login routes/auth.ts authLogin → redirect to guild authorize (state cookie) + GET /api/vote/callback routes/auth.ts authCallback → exchange code, userinfo, set session cookie + GET /api/vote routes/vote.ts voteGet → { budget, used, talks[], myVotes[], closesAt } + POST /api/vote routes/vote.ts voteSubmit → cast/remove a vote + +Identity: attendee's own OAuth token (scope profile:read) → userinfo → user id +Budget: organizer token → /events/{slug}/attendees → userId→tier → ticket_tiers → budget +Org token: oauth_tokens row, auto-refreshed + rotated (repositories/oauth-token.ts) +``` + +## Files (all new/changed on this branch) + +Server: +- `src/server/configs/oauth.ts` — guild endpoint URLs, scope, EVENT_SLUG, cookie names/TTL. +- `src/server/configs/vote.ts` — `VOTE_CLOSES_AT`, `isVotingOpen()`. +- `src/server/helpers/session.ts` — HMAC `signSession`/`verifySession`, async `getUserId` + (cookie in prod; `X-Dev-User` header fallback only when `ENVIRONMENT !== 'production'`). +- `src/server/helpers/oauth.ts` — `buildAuthorizeUrl`, `exchangeCode`, `refreshToken`, + `fetchUserInfo`. +- `src/server/repositories/oauth-token.ts` — `getOrgAccessToken`: valid-token passthrough, + else refresh + persist rotated token; seeds from `GUILD_ORG_REFRESH_TOKEN`. +- `src/server/repositories/attendees.ts` — `resolveBudget(env, db, userId)`: paginated + attendee fetch (org token), `lru.min` cache, joins `ticket_tiers`. null = not an attendee. +- `src/server/repositories/vote.ts` — `listTalks`, `listUserVotes`, `castVote` (budget + + idempotent), `removeVote`. +- `src/server/routes/vote.ts`, `src/server/routes/auth.ts` — the four endpoints. +- `src/server/routes.ts`, `src/server/index.ts` — wiring + credentialed CORS. +- `src/server/types.ts` — `Env` additions (5 secrets + `ENVIRONMENT`). +- `resources/schema.sql` — `ticket_tiers`, `c4p_votes`, `oauth_tokens`. + +Site: +- `src/website/pages/vote/index.tsx` — voting UI (login state, checkbox approval list, + optimistic toggle, remaining-votes counter). + +Tests: +- `test/server/routes/vote.test.ts` — session crypto, token refresh/rotation, route auth + (401/403), budget limit, idempotent re-vote, unvote, 415/422, prod no-bypass. + +Config: `.env.example` updated. `TODO.md` has the deploy checklist. + +## State of play + +- `npm run typecheck`, `npm run lint`, `npm test` (10 files) all pass. +- Nothing is committed — review the diff and commit when ready (conventional commits, no + Claude attribution per user preference; always branch, never push to main). + +## What's NOT done — see TODO.md + +1. Operational: register OAuth app, set 5 secrets, set `ALLOWED_ORIGIN`, seed `ticket_tiers`, + `npm run db:init`, obtain the organizer refresh token, set the real `VOTE_CLOSES_AT`. +2. **One live verification** (cannot be done without a registered app + real login): the + userinfo id field that joins the attendees list. `fetchUserInfo` tries + `sub ?? id ?? slugId ?? rowId` — confirm which is correct. If none join, the + identity→tier mapping breaks and needs rethinking. +3. Optional: logout, vote-tally admin read endpoint, refund→revoke. + +## Gotchas for the next session + +- Deps may not be installed — run `npm ci` first (typecheck/test need it). +- `X-Dev-User` is a dev-only auth shortcut; it returns null in production by design. Do not + "fix" it to work in prod. +- The attendee tier map and the rate limiter are per-isolate in-memory caches (Cloudflare runs + many isolates) — intentional, marked with `ponytail:` comments. Don't treat them as global. +- guild.host OAuth: authorize `https://guild.host/oauth/authorize`, token + `https://guild.host/api/oauth/token`, userinfo `https://guild.host/api/oauth/userinfo`, + attendees `https://guild.host/api/next/events/{slug}/attendees` (needs organizer bearer + + manage permission). Swagger: `~/Downloads/api-1.json`. OAuth docs: + `https://guild.host/docs/developers/oauth`. +- User runs caveman + ponytail modes (terse output, laziest-correct solution). Keep new code + minimal; mark deliberate shortcuts with `ponytail:` comments. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2cf5f73 --- /dev/null +++ b/TODO.md @@ -0,0 +1,48 @@ +# Voting system — remaining work + +Code is complete, typechecked, linted, tested (`npm test` → 10 files pass). What's left is +operational + one live verification. + +## Before deploy + +- [ ] **Register an OAuth app on guild.host** (client id + secret). Redirect URI = + `https://api.jsconf.com.br/api/vote/callback`. +- [ ] **Obtain the organizer refresh token** via a one-time OAuth consent with the + `event_attendees:read` scope (the organizer account must be able to manage the event). + This seeds `oauth_tokens`; the worker rotates it from then on. +- [ ] **Set worker secrets:** `GUILD_OAUTH_CLIENT_ID`, `GUILD_OAUTH_CLIENT_SECRET`, + `GUILD_OAUTH_REDIRECT_URI`, `GUILD_ORG_REFRESH_TOKEN`, `SESSION_SECRET` + (`wrangler secret put `). +- [ ] **Set `ALLOWED_ORIGIN`** to the website origin (e.g. `https://jsconf.com.br`). + Must NOT be `*` — credentialed cookies require an explicit origin. +- [ ] **Run `npm run db:init`** to create the new tables (`ticket_tiers`, `c4p_votes`, + `oauth_tokens`). +- [ ] **Seed `ticket_tiers`** with tier→votes rows. Names must match guild.host tier names + exactly, e.g. `INSERT INTO ticket_tiers (name, budget) VALUES ('Standard', 2), ('VIP', 5);` +- [ ] **Confirm `EVENT_SLUG`** in `src/server/configs/oauth.ts` (currently `vdc8dh`). +- [ ] **Adjust `VOTE_CLOSES_AT`** in `src/server/configs/vote.ts` to the real deadline. + +## Live verification (needs a real login — can't be done headless) + +- [ ] **userinfo id join.** `fetchUserInfo` (`src/server/helpers/oauth.ts`) reads the id as + `sub ?? id ?? slugId ?? rowId`. Do one test login and confirm the returned id matches a + `user.id`/`rowId`/`slugId` in `GET /events/{slug}/attendees`. If it's a different field, + it's a one-line fix. If the id never joins, the OAuth-identity → tier mapping breaks and + needs rethinking. + +## Smoke test (local) + +``` +npm run db:init +# seed a tier + (for dev) a session via the X-Dev-User header +wrangler dev +curl -H 'X-Dev-User: user-1' localhost:8787/api/vote # GET session (dev only) +``` +Note: `X-Dev-User` works only when `ENVIRONMENT !== 'production'`; production requires the +real signed-cookie session from the OAuth flow. + +## Nice-to-have (not blocking) + +- [ ] Logout endpoint / button (clear `vote_session` cookie). +- [ ] Admin read endpoint for vote tallies (none exists yet — votes are write-only via the API). +- [ ] `charge.refunded` → revoke votes (Stripe webhook stub already receives the event). diff --git a/resources/schema.sql b/resources/schema.sql index c226e04..3ee7543 100644 --- a/resources/schema.sql +++ b/resources/schema.sql @@ -57,3 +57,25 @@ CREATE TABLE IF NOT EXISTS speaker_diversity ( ); CREATE INDEX IF NOT EXISTS idx_speaker_diversity_speaker_id ON speaker_diversity(speaker_id); + +CREATE TABLE IF NOT EXISTS ticket_tiers ( + name VARCHAR(200) PRIMARY KEY, -- guild.host tier name, matched against attendee tier + budget INTEGER NOT NULL CHECK(budget >= 0) +); + +CREATE TABLE IF NOT EXISTS c4p_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, -- guild.host user id (from OAuth) + talk_id INTEGER NOT NULL REFERENCES talks(id), + created_at VARCHAR(20) NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, talk_id) +); + +CREATE INDEX IF NOT EXISTS idx_c4p_votes_user ON c4p_votes(user_id); + +CREATE TABLE IF NOT EXISTS oauth_tokens ( + name TEXT PRIMARY KEY, -- 'organizer' + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL -- unix seconds +); diff --git a/src/server/configs/oauth.ts b/src/server/configs/oauth.ts new file mode 100644 index 0000000..ed7b541 --- /dev/null +++ b/src/server/configs/oauth.ts @@ -0,0 +1,16 @@ +// guild.host OAuth endpoints (docs: https://guild.host/docs/developers/oauth) +export const AUTHORIZE_URL = 'https://guild.host/oauth/authorize'; +export const TOKEN_URL = 'https://guild.host/api/oauth/token'; +export const USERINFO_URL = 'https://guild.host/api/oauth/userinfo'; +export const ATTENDEES_BASE = 'https://guild.host/api/next/events'; + +// Scope that grants the authenticated user's identity via the userinfo endpoint. +export const IDENTITY_SCOPE = 'profile:read'; + +// ponytail: event slug hardcoded (same one the ticket widget uses, TicketSelection.tsx). +// Move to an Env var if it ever changes per environment. +export const EVENT_SLUG = 'vdc8dh'; + +export const SESSION_COOKIE = 'vote_session'; +export const STATE_COOKIE = 'vote_oauth_state'; +export const SESSION_TTL_SECONDS = 86400; diff --git a/src/server/configs/vote.ts b/src/server/configs/vote.ts new file mode 100644 index 0000000..8e8ac2c --- /dev/null +++ b/src/server/configs/vote.ts @@ -0,0 +1,6 @@ +// ponytail: hardcoded voting window. Move to an Env var only if the date needs changing +// without a deploy. +export const VOTE_CLOSES_AT = '2026-09-01T00:00:00Z'; + +export const isVotingOpen = (now: Date = new Date()): boolean => + now < new Date(VOTE_CLOSES_AT); diff --git a/src/server/helpers/oauth.ts b/src/server/helpers/oauth.ts new file mode 100644 index 0000000..d038dca --- /dev/null +++ b/src/server/helpers/oauth.ts @@ -0,0 +1,87 @@ +import { + AUTHORIZE_URL, + IDENTITY_SCOPE, + TOKEN_URL, + USERINFO_URL, +} from '../configs/oauth.js'; + +export const buildAuthorizeUrl = ( + clientId: string, + redirectUri: string, + state: string +): string => { + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: IDENTITY_SCOPE, + state, + }); + return `${AUTHORIZE_URL}?${params.toString()}`; +}; + +export const exchangeCode = async ( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string +): Promise<{ access_token: string } | null> => { + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }), + }).catch(() => null); + + if (!res || !res.ok) return null; + return (await res.json()) as { access_token: string }; +}; + +export const refreshToken = async ( + refresh: string, + clientId: string, + clientSecret: string +): Promise<{ + access_token: string; + refresh_token?: string; + expires_in: number; +} | null> => { + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refresh, + client_id: clientId, + client_secret: clientSecret, + }), + }).catch(() => null); + + if (!res || !res.ok) return null; + return (await res.json()) as { + access_token: string; + refresh_token?: string; + expires_in: number; + }; +}; + +// Returns the authenticated user's stable id. +// ponytail: VERIFY LIVE — the userinfo id field that joins the attendees list +// (user.id / rowId / slugId) is unconfirmed. We try the common keys; adjust once verified. +export const fetchUserInfo = async ( + accessToken: string +): Promise => { + const res = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).catch(() => null); + + if (!res || !res.ok) return null; + const data = (await res.json()) as Record; + const id = data['sub'] ?? data['id'] ?? data['slugId'] ?? data['rowId']; + return typeof id === 'string' ? id : null; +}; diff --git a/src/server/helpers/session.ts b/src/server/helpers/session.ts new file mode 100644 index 0000000..4e48636 --- /dev/null +++ b/src/server/helpers/session.ts @@ -0,0 +1,96 @@ +import type { Env } from '../types.js'; +import { SESSION_COOKIE, SESSION_TTL_SECONDS } from '../configs/oauth.js'; + +const encoder = new TextEncoder(); + +const toBase64Url = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +const fromBase64Url = (text: string): Uint8Array => { + const b64 = text.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(b64); + return Uint8Array.from(bin, (char) => char.charCodeAt(0)); +}; + +const key = (secret: string): Promise => + crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'] + ); + +export const signSession = async ( + userId: string, + secret: string, + ttlSeconds: number = SESSION_TTL_SECONDS +): Promise => { + const exp = Math.floor(Date.now() / 1000) + ttlSeconds; + const payload = toBase64Url( + encoder.encode(JSON.stringify({ sub: userId, exp })) + ); + const sig = await crypto.subtle.sign( + 'HMAC', + await key(secret), + encoder.encode(payload) + ); + return `${payload}.${toBase64Url(new Uint8Array(sig))}`; +}; + +export const verifySession = async ( + token: string, + secret: string +): Promise => { + const [payload, sig] = token.split('.'); + if (!payload || !sig) return null; + + const valid = await crypto.subtle.verify( + 'HMAC', + await key(secret), + fromBase64Url(sig) as BufferSource, + encoder.encode(payload) + ); + if (!valid) return null; + + try { + const { sub, exp } = JSON.parse( + new TextDecoder().decode(fromBase64Url(payload)) + ); + if (typeof sub !== 'string' || typeof exp !== 'number') return null; + if (exp < Math.floor(Date.now() / 1000)) return null; + return sub; + } catch { + return null; + } +}; + +const readCookie = (request: Request, name: string): string | null => { + const header = request.headers.get('Cookie'); + if (!header) return null; + for (const part of header.split(';')) { + const [k, ...v] = part.trim().split('='); + if (k === name) return v.join('='); + } + return null; +}; + +// Resolves the voter's id from the signed session cookie. In non-production an X-Dev-User +// header is accepted as a fallback so the endpoints are testable without the OAuth round-trip. +export const getUserId = async ( + request: Request, + env: Env +): Promise => { + const cookie = readCookie(request, SESSION_COOKIE); + if (cookie && env.SESSION_SECRET) { + const userId = await verifySession(cookie, env.SESSION_SECRET); + if (userId) return userId; + } + // ponytail: dev-only header bypass. Never honored in production. + if (env.ENVIRONMENT !== 'production') + return request.headers.get('X-Dev-User'); + return null; +}; diff --git a/src/server/index.ts b/src/server/index.ts index 8bd8143..82319e5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,12 +7,14 @@ import { routes } from './routes.js'; export default { async fetch(request: Request, env: Env): Promise { const origin = env.ALLOWED_ORIGIN || '*'; - const cors = { + const cors: Record = { 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', Vary: 'Origin', }; + // Credentialed requests (vote session cookie) need an explicit origin, never '*'. + if (origin !== '*') cors['Access-Control-Allow-Credentials'] = 'true'; const { method } = request; if (method === 'OPTIONS') @@ -36,6 +38,14 @@ export default { switch (`${method} ${pathname}`) { case 'POST /api/c4p': return routes.c4p({ request, cors, database: env.DB, ip }); + case 'GET /api/vote/login': + return routes.authLogin({ request, env }); + case 'GET /api/vote/callback': + return routes.authCallback({ request, env }); + case 'GET /api/vote': + return routes.voteGet({ request, cors, database: env.DB, env }); + case 'POST /api/vote': + return routes.voteSubmit({ request, cors, database: env.DB, env }); case 'POST /api/waitlist': return routes.waitlist({ request, cors, database: env.DB, ip }); default: diff --git a/src/server/repositories/attendees.ts b/src/server/repositories/attendees.ts new file mode 100644 index 0000000..e3939fc --- /dev/null +++ b/src/server/repositories/attendees.ts @@ -0,0 +1,96 @@ +import type { Database, Env } from '../types.js'; +import { createLRU } from 'lru.min'; +import { ATTENDEES_BASE, EVENT_SLUG } from '../configs/oauth.js'; +import { getOrgAccessToken } from './oauth-token.js'; + +// Cached userId -> tier name map, keyed by event slug. Refetched after TTL. +// ponytail: per-isolate in-memory cache, same tradeoff as the rate limiter. Many isolates +// each refetch on a cold miss; fine for a vote window with a few hundred attendees. +const TTL_MS = 5 * 60_000; +const cache = createLRU }>({ + max: 8, +}); + +type Attendee = { + status?: string; + paymentStatus?: string | null; + user?: { id?: string; rowId?: string; slugId?: string } | null; + ticketOrder?: { + eventTicketOrderItems?: { + nodes?: { eventTicketingTier?: { name?: string } | null }[]; + }; + } | null; +}; + +const isConfirmed = (a: Attendee): boolean => + a.status !== 'CANCELLED' && a.paymentStatus !== 'FAILED'; + +const tierOf = (a: Attendee): string | undefined => + a.ticketOrder?.eventTicketOrderItems?.nodes?.[0]?.eventTicketingTier?.name; + +const fetchTierMap = async (token: string): Promise> => { + const map: Record = {}; + let after: string | null = null; + + do { + const url = new URL(`${ATTENDEES_BASE}/${EVENT_SLUG}/attendees`); + url.searchParams.set('first', '100'); + if (after) url.searchParams.set('after', after); + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => null); + if (!res || !res.ok) break; + + const data = (await res.json()) as { + edges?: { node?: Attendee }[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + }; + + for (const edge of data.edges ?? []) { + const node = edge.node; + if (!node || !isConfirmed(node)) continue; + const tier = tierOf(node); + if (!tier) continue; + // Map every id variant so the lookup matches whatever userinfo returns. + for (const id of [node.user?.id, node.user?.rowId, node.user?.slugId]) + if (id) map[id] = tier; + } + + after = data.pageInfo?.hasNextPage + ? (data.pageInfo.endCursor ?? null) + : null; + } while (after); + + return map; +}; + +const getTierMap = async (token: string): Promise> => { + const hit = cache.get(EVENT_SLUG); + if (hit && Date.now() - hit.at < TTL_MS) return hit.map; + const map = await fetchTierMap(token); + cache.set(EVENT_SLUG, { at: Date.now(), map }); + return map; +}; + +// Returns the vote budget for a user, or null when they are not a confirmed attendee. +// An attendee on a tier missing from ticket_tiers gets 0 (can view, cannot vote). +export const resolveBudget = async ( + env: Env, + database: Database, + userId: string +): Promise => { + const token = await getOrgAccessToken(env, database); + if (!token) return null; + + const map = await getTierMap(token); + const tier = map[userId]; + if (!tier) return null; + + const { results } = await database + .prepare('SELECT budget FROM ticket_tiers WHERE name = ?') + .bind(tier) + .all<{ budget: number }>(); + + return results[0]?.budget ?? 0; +}; diff --git a/src/server/repositories/oauth-token.ts b/src/server/repositories/oauth-token.ts new file mode 100644 index 0000000..592d9c5 --- /dev/null +++ b/src/server/repositories/oauth-token.ts @@ -0,0 +1,74 @@ +import type { Database, Env } from '../types.js'; +import { refreshToken } from '../helpers/oauth.js'; + +const NAME = 'organizer'; +const BUFFER_SECONDS = 60; + +type Row = { access_token: string; refresh_token: string; expires_at: number }; + +const readRow = async (database: Database): Promise => { + const { results } = await database + .prepare( + 'SELECT access_token, refresh_token, expires_at FROM oauth_tokens WHERE name = ?' + ) + .bind(NAME) + .all(); + return results[0] ?? null; +}; + +const writeRow = async ( + database: Database, + access: string, + refresh: string, + expiresAt: number +): Promise => { + await database + .prepare( + `INSERT INTO oauth_tokens (name, access_token, refresh_token, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at` + ) + .bind(NAME, access, refresh, expiresAt) + .run(); +}; + +// Returns a valid organizer access token, refreshing (and persisting the rotated refresh +// token) when the stored one is near expiry. Seeds from GUILD_ORG_REFRESH_TOKEN on first use. +export const getOrgAccessToken = async ( + env: Env, + database: Database +): Promise => { + const now = Math.floor(Date.now() / 1000); + const row = await readRow(database); + if (row && row.expires_at > now + BUFFER_SECONDS) return row.access_token; + + const refresh = row?.refresh_token ?? env.GUILD_ORG_REFRESH_TOKEN; + if (!refresh || !env.GUILD_OAUTH_CLIENT_ID || !env.GUILD_OAUTH_CLIENT_SECRET) + return null; + + const tokens = await refreshToken( + refresh, + env.GUILD_OAUTH_CLIENT_ID, + env.GUILD_OAUTH_CLIENT_SECRET + ); + + if (!tokens) { + // ponytail: refresh tokens rotate, so a concurrent isolate may have just consumed ours. + // Re-read — if another isolate wrote a fresh token, use it; otherwise give up. + const fresh = await readRow(database); + if (fresh && fresh.expires_at > now + BUFFER_SECONDS) + return fresh.access_token; + return null; + } + + await writeRow( + database, + tokens.access_token, + tokens.refresh_token ?? refresh, + now + tokens.expires_in + ); + return tokens.access_token; +}; diff --git a/src/server/repositories/vote.ts b/src/server/repositories/vote.ts new file mode 100644 index 0000000..ad198fc --- /dev/null +++ b/src/server/repositories/vote.ts @@ -0,0 +1,61 @@ +import type { Database } from '../types.js'; + +export type TalkRow = { + id: number; + title: string; + description: string; + speaker_name: string; +}; + +type CastResult = { success: true } | { success: false; reason: 'budget' }; + +export const vote = (database: Database) => { + const listTalks = async (): Promise => { + const { results } = await database + .prepare( + `SELECT t.id, t.title, t.description, s.name AS speaker_name + FROM talks t + JOIN speakers s ON s.id = t.speaker_id + ORDER BY t.id` + ) + .bind() + .all(); + return results; + }; + + const listUserVotes = async (userId: string): Promise => { + const { results } = await database + .prepare('SELECT talk_id FROM c4p_votes WHERE user_id = ?') + .bind(userId) + .all<{ talk_id: number }>(); + return results.map((row) => row.talk_id); + }; + + const castVote = async ( + userId: string, + talkId: number, + budget: number + ): Promise => { + const current = await listUserVotes(userId); + if (current.includes(talkId)) return { success: true }; + if (current.length >= budget) return { success: false, reason: 'budget' }; + + await database + .prepare( + 'INSERT OR IGNORE INTO c4p_votes (user_id, talk_id) VALUES (?, ?)' + ) + .bind(userId, talkId) + .run(); + + return { success: true }; + }; + + const removeVote = async (userId: string, talkId: number): Promise => { + await database + .prepare('DELETE FROM c4p_votes WHERE user_id = ? AND talk_id = ?') + .bind(userId, talkId) + .run(); + }; + + return { listTalks, listUserVotes, castVote, removeVote }; +}; diff --git a/src/server/routes.ts b/src/server/routes.ts index e17ec77..b6bd95d 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,9 +1,15 @@ +import { authCallback, authLogin } from './routes/auth.js'; import { c4p } from './routes/c4p.js'; import { stripeWebhook } from './routes/stripe-webhook.js'; +import { voteGet, voteSubmit } from './routes/vote.js'; import { waitlist } from './routes/waitlist.js'; export const routes = { c4p, waitlist, stripeWebhook, + voteGet, + voteSubmit, + authLogin, + authCallback, }; diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts new file mode 100644 index 0000000..113f257 --- /dev/null +++ b/src/server/routes/auth.ts @@ -0,0 +1,100 @@ +import type { Env } from '../types.js'; +import { + SESSION_COOKIE, + SESSION_TTL_SECONDS, + STATE_COOKIE, +} from '../configs/oauth.js'; +import { + buildAuthorizeUrl, + exchangeCode, + fetchUserInfo, +} from '../helpers/oauth.js'; +import { signSession } from '../helpers/session.js'; + +type Options = { request: Request; env: Env }; + +const randomState = (): string => { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +}; + +const redirectUri = (request: Request, env: Env): string => + env.GUILD_OAUTH_REDIRECT_URI ?? + `${new URL(request.url).origin}/api/vote/callback`; + +const websiteOrigin = (request: Request, env: Env): string => + env.ALLOWED_ORIGIN ?? new URL(request.url).origin; + +const readCookie = (request: Request, name: string): string | null => { + for (const part of (request.headers.get('Cookie') ?? '').split(';')) { + const [k, ...v] = part.trim().split('='); + if (k === name) return v.join('='); + } + return null; +}; + +const redirect = (location: string, cookies: string[] = []): Response => { + const headers = new Headers({ Location: location }); + for (const cookie of cookies) headers.append('Set-Cookie', cookie); + return new Response(null, { status: 302, headers }); +}; + +export const authLogin = async ({ + request, + env, +}: Options): Promise => { + if (!env.GUILD_OAUTH_CLIENT_ID) + return new Response('OAuth not configured.', { status: 500 }); + + const state = randomState(); + const url = buildAuthorizeUrl( + env.GUILD_OAUTH_CLIENT_ID, + redirectUri(request, env), + state + ); + // ponytail: state cookie is Lax — it rides the top-level redirect back to /api/vote/callback. + return redirect(url, [ + `${STATE_COOKIE}=${state}; HttpOnly; Secure; SameSite=Lax; Path=/api/vote; Max-Age=600`, + ]); +}; + +export const authCallback = async ({ + request, + env, +}: Options): Promise => { + const params = new URL(request.url).searchParams; + const site = websiteOrigin(request, env); + + if (params.get('error')) return redirect(`${site}/vote?error=denied`); + + const code = params.get('code'); + const state = params.get('state'); + if (!code || !state || state !== readCookie(request, STATE_COOKIE)) + return redirect(`${site}/vote?error=state`); + + if ( + !env.GUILD_OAUTH_CLIENT_ID || + !env.GUILD_OAUTH_CLIENT_SECRET || + !env.SESSION_SECRET + ) + return new Response('OAuth not configured.', { status: 500 }); + + const tokens = await exchangeCode( + code, + env.GUILD_OAUTH_CLIENT_ID, + env.GUILD_OAUTH_CLIENT_SECRET, + redirectUri(request, env) + ); + if (!tokens) return redirect(`${site}/vote?error=token`); + + const userId = await fetchUserInfo(tokens.access_token); + if (!userId) return redirect(`${site}/vote?error=identity`); + + const session = await signSession(userId, env.SESSION_SECRET); + // ponytail: session cookie is SameSite=None;Secure so it's sent on the website's + // cross-origin fetch to the API subdomain. Both must be HTTPS. + return redirect(`${site}/vote`, [ + `${SESSION_COOKIE}=${session}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${SESSION_TTL_SECONDS}`, + `${STATE_COOKIE}=; Max-Age=0; Path=/api/vote`, + ]); +}; diff --git a/src/server/routes/vote.ts b/src/server/routes/vote.ts new file mode 100644 index 0000000..af6e146 --- /dev/null +++ b/src/server/routes/vote.ts @@ -0,0 +1,92 @@ +import type { Database, Env } from '../types.js'; +import { z } from 'zod'; +import { isVotingOpen, VOTE_CLOSES_AT } from '../configs/vote.js'; +import { + isJsonContentType, + isWithinSize, + parseBody, +} from '../helpers/request.js'; +import { response } from '../helpers/response.js'; +import { getUserId } from '../helpers/session.js'; +import { resolveBudget } from '../repositories/attendees.js'; +import { vote as repository } from '../repositories/vote.js'; + +type Options = { + request: Request; + cors: Record; + database: Database; + env: Env; +}; + +const submitSchema = z.object({ + talkId: z.coerce.number().int().positive(), + action: z.enum(['add', 'remove']).default('add'), +}); + +export const voteGet = async ({ + request, + cors, + database, + env, +}: Options): Promise => { + const userId = await getUserId(request, env); + if (!userId) return response({ error: 'Unauthorized.' }, 401, cors); + + const budget = await resolveBudget(env, database, userId); + if (budget === null) + return response({ error: 'Not an attendee.' }, 403, cors); + + const repo = repository(database); + const [talks, myVotes] = await Promise.all([ + repo.listTalks(), + repo.listUserVotes(userId), + ]); + + return response( + { budget, used: myVotes.length, talks, myVotes, closesAt: VOTE_CLOSES_AT }, + 200, + cors + ); +}; + +export const voteSubmit = async ({ + request, + cors, + database, + env, +}: Options): Promise => { + if (!isJsonContentType(request)) + return response({ error: 'Unsupported content type.' }, 415, cors); + + const userId = await getUserId(request, env); + if (!userId) return response({ error: 'Unauthorized.' }, 401, cors); + + if (!isVotingOpen()) return response({ error: 'Voting closed.' }, 403, cors); + + const text = await request.text(); + if (!isWithinSize(text, 1024)) + return response({ error: 'Payload too large.' }, 413, cors); + + const body = parseBody(text); + if (!body) return response({ error: 'Invalid JSON.' }, 400, cors); + + const parsed = submitSchema.safeParse(body); + if (!parsed.success) return response({ error: 'Invalid input.' }, 422, cors); + + const budget = await resolveBudget(env, database, userId); + if (budget === null) + return response({ error: 'Not an attendee.' }, 403, cors); + + const repo = repository(database); + + if (parsed.data.action === 'remove') { + await repo.removeVote(userId, parsed.data.talkId); + return response({ success: true }, 200, cors); + } + + const result = await repo.castVote(userId, parsed.data.talkId, budget); + if (!result.success) + return response({ error: 'Vote limit reached.' }, 422, cors); + + return response({ success: true }, 200, cors); +}; diff --git a/src/server/types.ts b/src/server/types.ts index 164824e..baa61d7 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -10,6 +10,13 @@ export type Database = { export type Env = { DB: Database; ALLOWED_ORIGIN?: string; + ENVIRONMENT?: string; STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string; + // Voting / guild.host OAuth + GUILD_OAUTH_CLIENT_ID?: string; + GUILD_OAUTH_CLIENT_SECRET?: string; + GUILD_OAUTH_REDIRECT_URI?: string; + GUILD_ORG_REFRESH_TOKEN?: string; + SESSION_SECRET?: string; }; diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx new file mode 100644 index 0000000..5863eb1 --- /dev/null +++ b/src/website/pages/vote/index.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { toast } from 'sonner'; +import { Page } from '@site/src/website/components/shared/Page'; + +type Talk = { + id: number; + title: string; + description: string; + speaker_name: string; +}; + +type Session = { + budget: number; + used: number; + talks: Talk[]; + myVotes: number[]; + closesAt: string; +}; + +const Vote = () => { + const { siteConfig } = useDocusaurusContext(); + const workerDomain = siteConfig.customFields?.['workerDomain'] as + | string + | undefined; + + const [session, setSession] = useState(null); + const [status, setStatus] = useState< + 'loading' | 'ready' | 'unauth' | 'error' + >('loading'); + const [votes, setVotes] = useState>(new Set()); + + useEffect(() => { + if (!workerDomain) return setStatus('error'); + fetch(`${workerDomain}/api/vote`, { credentials: 'include' }) + .then(async (res) => { + if (res.status === 401 || res.status === 403) + return setStatus('unauth'); + if (!res.ok) return setStatus('error'); + const data = (await res.json()) as Session; + setSession(data); + setVotes(new Set(data.myVotes)); + setStatus('ready'); + }) + .catch(() => setStatus('error')); + }, [workerDomain]); + + const toggle = useCallback( + async (talkId: number) => { + if (!session) return; + const has = votes.has(talkId); + if (!has && votes.size >= session.budget) { + toast.error(`Você já usou seus ${session.budget} votos.`); + return; + } + + // ponytail: optimistic toggle; revert on failure. No queue/debounce — one vote per click. + const next = new Set(votes); + if (has) next.delete(talkId); + if (!has) next.add(talkId); + setVotes(next); + + const res = await fetch(`${workerDomain}/api/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ talkId, action: has ? 'remove' : 'add' }), + }).catch(() => null); + + if (!res || !res.ok) { + setVotes(votes); + toast.error('Erro ao registrar voto. Tente novamente.'); + } + }, + [session, votes, workerDomain] + ); + + return ( + +
+

Vote nas palestras

+ + {status === 'loading' &&

Carregando…

} + {status === 'error' && ( +

Não foi possível carregar a votação. Tente mais tarde.

+ )} + {status === 'unauth' && ( + + Entrar com guild.host + + )} + + {status === 'ready' && session && ( + <> +

+ Votos restantes: {session.budget - votes.size} de {session.budget} +

+
    + {session.talks.map((talk) => ( +
  • + +
  • + ))} +
+ + )} +
+
+ ); +}; + +export default Vote; diff --git a/test/server/routes/vote.test.ts b/test/server/routes/vote.test.ts new file mode 100644 index 0000000..217e00c --- /dev/null +++ b/test/server/routes/vote.test.ts @@ -0,0 +1,319 @@ +import type { Database, Env } from '../../../src/server/types.js'; +import { assert, describe, it } from 'poku'; +import { isVotingOpen } from '../../../src/server/configs/vote.js'; +import { + signSession, + verifySession, +} from '../../../src/server/helpers/session.js'; +import { getOrgAccessToken } from '../../../src/server/repositories/oauth-token.js'; +import { routes } from '../../../src/server/routes.js'; + +const cors = { 'Access-Control-Allow-Origin': '*' }; + +const talks = [ + { id: 1, title: 'A', description: 'da', speaker_name: 'Ada' }, + { id: 2, title: 'B', description: 'db', speaker_name: 'Bob' }, +]; + +const FUTURE = Math.floor(Date.now() / 1000) + 100_000; + +// Stub guild.host: the attendees list (user-1 = confirmed 'Standard') and the token refresh. +globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes('/attendees')) + return new Response( + JSON.stringify({ + edges: [ + { + node: { + status: 'CONFIRMED', + paymentStatus: 'PAID', + user: { id: 'user-1' }, + ticketOrder: { + eventTicketOrderItems: { + nodes: [{ eventTicketingTier: { name: 'Standard' } }], + }, + }, + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + if (url.includes('/oauth/token')) + return new Response( + JSON.stringify({ + access_token: 'refreshed-access', + refresh_token: 'r2', + expires_in: 86400, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + return new Response('{}', { status: 404 }); +}) as typeof fetch; + +type Mock = { + database: Database; + votes: Set; + tokenWrites: unknown[][]; +}; + +const makeMock = ( + seed: number[] = [], + tokenRow: { refresh_token: string; expires_at: number } | null = { + refresh_token: 'r1', + expires_at: FUTURE, + } +): Mock => { + const votes = new Set(seed); + const tokenWrites: unknown[][] = []; + return { + votes, + tokenWrites, + database: { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => { + if (sql.includes('INTO c4p_votes')) votes.add(values[1] as number); + else if (sql.startsWith('DELETE')) + votes.delete(values[1] as number); + else if (sql.includes('INTO oauth_tokens')) + tokenWrites.push(values); + }, + all: async () => { + if (sql.includes('oauth_tokens')) + return { + results: (tokenRow + ? [{ access_token: 'org-access', ...tokenRow }] + : []) as T[], + }; + if (sql.includes('ticket_tiers')) + return { results: [{ budget: 3 }] as T[] }; + if (sql.includes('FROM talks')) return { results: talks as T[] }; + if (sql.includes('FROM c4p_votes')) + return { + results: [...votes].map((id) => ({ talk_id: id })) as T[], + }; + return { results: [] as T[] }; + }, + }), + }), + }, + }; +}; + +const makeEnv = (environment?: string): Env => + ({ + ENVIRONMENT: environment, + GUILD_OAUTH_CLIENT_ID: 'cid', + GUILD_OAUTH_CLIENT_SECRET: 'secret', + }) as Env; + +const getReq = (userId?: string): Request => + new Request('http://localhost/api/vote', { + method: 'GET', + headers: userId ? { 'X-Dev-User': userId } : {}, + }); + +const postReq = ( + body: unknown, + init: { userId?: string; contentType?: string | null } = {} +): Request => { + const headers: Record = {}; + if (init.contentType !== null) + headers['Content-Type'] = init.contentType ?? 'application/json'; + if (init.userId) headers['X-Dev-User'] = init.userId; + return new Request('http://localhost/api/vote', { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +}; + +describe('configs.vote', async () => { + await it('isVotingOpen is true before the deadline, false after', () => { + assert.equal(isVotingOpen(new Date('2026-01-01T00:00:00Z')), true); + assert.equal(isVotingOpen(new Date('2099-01-01T00:00:00Z')), false); + }); +}); + +describe('helpers.session', async () => { + await it('round-trips a signed session', async () => { + const token = await signSession('user-1', 'secret'); + assert.equal(await verifySession(token, 'secret'), 'user-1'); + }); + + await it('rejects a wrong secret and a tampered token', async () => { + const token = await signSession('user-1', 'secret'); + assert.equal(await verifySession(token, 'other'), null); + assert.equal(await verifySession(`${token}x`, 'secret'), null); + }); + + await it('rejects an expired session', async () => { + const token = await signSession('user-1', 'secret', -1); + assert.equal(await verifySession(token, 'secret'), null); + }); +}); + +describe('repositories.oauth-token', async () => { + await it('returns the stored access token when still valid', async () => { + const mock = makeMock(); + const token = await getOrgAccessToken(makeEnv(), mock.database); + assert.equal(token, 'org-access'); + assert.equal(mock.tokenWrites.length, 0); + }); + + await it('refreshes and persists the rotated token when expired', async () => { + const mock = makeMock([], { refresh_token: 'r1', expires_at: 0 }); + const token = await getOrgAccessToken(makeEnv(), mock.database); + assert.equal(token, 'refreshed-access'); + assert.equal(mock.tokenWrites.length, 1); + // [name, access_token, refresh_token, expires_at] + assert.equal(mock.tokenWrites[0]?.[2], 'r2'); + }); + + await it('seeds from the env refresh token when no row exists', async () => { + const mock = makeMock([], null); + const env = { ...makeEnv(), GUILD_ORG_REFRESH_TOKEN: 'seed' } as Env; + const token = await getOrgAccessToken(env, mock.database); + assert.equal(token, 'refreshed-access'); + }); +}); + +describe('routes.voteGet', async () => { + await it('returns 401 without a session', async () => { + const res = await routes.voteGet({ + request: getReq(), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 401); + }); + + await it('ignores the dev header in production (no bypass)', async () => { + const res = await routes.voteGet({ + request: getReq('user-1'), + cors, + database: makeMock().database, + env: makeEnv('production'), + }); + assert.equal(res.status, 401); + }); + + await it('returns 403 for a non-attendee', async () => { + const res = await routes.voteGet({ + request: getReq('ghost'), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 403); + }); + + await it('returns budget, used, talks and current votes', async () => { + const res = await routes.voteGet({ + request: getReq('user-1'), + cors, + database: makeMock([1]).database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as { + budget: number; + used: number; + talks: unknown[]; + myVotes: number[]; + }; + assert.equal(body.budget, 3); + assert.equal(body.used, 1); + assert.equal(body.talks.length, 2); + assert.deepEqual(body.myVotes, [1]); + }); +}); + +describe('routes.voteSubmit', async () => { + await it('records a vote', async () => { + const mock = makeMock(); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 2 }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { success: true }); + assert.equal(mock.votes.has(2), true); + }); + + await it('rejects with 422 when budget is exhausted', async () => { + const mock = makeMock([1, 2, 3]); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 4 }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 422); + assert.deepEqual(await res.json(), { error: 'Vote limit reached.' }); + assert.equal(mock.votes.has(4), false); + }); + + await it('is idempotent when re-voting an existing talk at the limit', async () => { + const mock = makeMock([1, 2, 3]); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 1 }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.equal(mock.votes.size, 3); + }); + + await it('removes a vote', async () => { + const mock = makeMock([1, 2]); + const res = await routes.voteSubmit({ + request: postReq({ talkId: 1, action: 'remove' }, { userId: 'user-1' }), + cors, + database: mock.database, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.equal(mock.votes.has(1), false); + }); + + await it('returns 401 without a session', async () => { + const res = await routes.voteSubmit({ + request: postReq({ talkId: 1 }), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 401); + }); + + await it('returns 415 for non-JSON content type', async () => { + const res = await routes.voteSubmit({ + request: postReq( + { talkId: 1 }, + { userId: 'user-1', contentType: 'text/plain' } + ), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 415); + }); + + await it('returns 422 for an invalid body', async () => { + const res = await routes.voteSubmit({ + request: postReq({ nope: true }, { userId: 'user-1' }), + cors, + database: makeMock().database, + env: makeEnv(), + }); + assert.equal(res.status, 422); + }); +}); From c08efbeb6fb372d567424a466bd8eebb4dc40f9f Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Fri, 26 Jun 2026 03:05:46 +0200 Subject: [PATCH 02/25] docs(voting): votable status filter, handover + todo notes (wip) --- HANDOVER.md | 64 +++++++++++++++++++++++++++++++++ TODO.md | 40 +++++++++++++++++++++ src/server/configs/vote.ts | 2 ++ src/server/repositories/vote.ts | 4 ++- 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/HANDOVER.md b/HANDOVER.md index 83c94d2..9bcbb1a 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -94,6 +94,70 @@ Config: `.env.example` updated. `TODO.md` has the deploy checklist. identity→tier mapping breaks and needs rethinking. 3. Optional: logout, vote-tally admin read endpoint, refund→revoke. +## Why guild.host (not Stripe) for attendees + +Earlier the guild.host attendee list wasn't available, so the only way to know buyers was +Stripe — which sees the **buyer only**, not the guests on a multi-ticket order. PR #40 was +built under that limitation (Stripe webhook → buyer email → votes). Guild **now exposes the +full attendee list**, and each attendee carries their own `ticketOrder` tier, so the +"who bought what" cross-check is already per-attendee in guild data. That is why we use guild +OAuth + the attendees API, and treat #40's Stripe approach as a workaround for a gone +limitation. + +## Multi-ticket / group purchases — decided + BLOCKER + +Policy (user decision): **each guest votes individually** with their own tier budget. Our +model supports this *if* guild turns each guest into their own attendee record. + +BLOCKER, unverified, schema allows the opposite: `EventAttendee` has one `user` but a +`ticketOrder.eventTicketOrderItems` connection with `totalCount` + N nodes — i.e. one attendee +can own N tickets. Must run `GET /events/vdc8dh/attendees` against the real event: +- N attendee edges, each a distinct `user` → guests are real attendees, model works. +- 1 edge with `eventTicketOrderItems.totalCount = N` → buyer holds all; guests have no + identity → per-individual impossible without a guild claim setting or a manual admin + fallback. See `TODO.md`. Do not build further on the per-individual assumption until known. + +## Stealing from PR #40 (branch feat/c4p-voting-quantity-tiers) + +His model: email + 4-digit code login (JWT 15m + rotating refresh, `jose`), budget from Stripe +`checkout.session.completed` (quantity × tier weight), `users-batch` admin endpoint. + +Taken so far: +- **`status = 2` = votable.** Concrete meaning for the undocumented `talks.status`. Now + filtered in `listTalks` via `VOTABLE_TALK_STATUS` (`configs/vote.ts`). + +Rejected (conflicts with our guild model — kept here so the option isn't lost): +- **Stripe-webhook-derived budget** (`checkout.session.completed` → buyer email + + quantity×tier-weight → upsert `users`). Would kill our org-token + OAuth-refresh + + attendees-fetch + lru machinery AND the userinfo→attendee id-join risk. We don't take it + because it is **buyer-only** (no group guests) and guild now gives the full per-attendee + list. BUT: if the group-ticket verification shows "buyer holds all", this Stripe model + becomes the fallback for per-individual being impossible. Tie it to that blocker. +- **Repurchase accumulation** (`ON CONFLICT(email) DO UPDATE SET votes_allowed = + votes_allowed + excluded`). N/A in our model: budget is a live tier lookup, not a persisted + per-purchase sum, so there's nothing to accumulate. Only relevant if we adopt the Stripe + fallback above. + +Worth taking later (model-compatible, not yet done): +- His `getEligibleTalks` computes `has_voted` per talk in one query — cleaner than our + two-query `voteGet`. +- Richer voting payload: he returns `duration` + `audience_level` per talk and splits + `talks` / `votedTalkIds`. Ours returns only title/desc/speaker. Better talk cards. +- Explicit vote status codes: `403` no votes left, `409` already voted, `404` retract-not-found. + Ours collapses to `422` + idempotent re-vote. His is clearer for the UI; a semantics choice. +- `parseRequest(request, schema, cors)` helper + shared `RouteContext` type — DRYs the + 415/413/400/422 dance our c4p + vote routes repeat. +- Dev seed script (his `scripts/seed.ts`): seed talks + `ticket_tiers` so local voting is + testable without the `X-Dev-User` curl dance. We have none. +- `schemas.ts` centralizing typed response shapes. Minor. +- His richer frontend: `src/website/components/voting/*`, `pages/voting/*`, `hooks/voting/*`, + full i18n. Ours is one page. +- `charge.refunded` → revoke votes. Awkward in our model (Stripe gives buyer email, our votes + key on guild user_id — no clean map). Note, don't force it. + +Do NOT take: 4-digit `Math.random()` code (weak, 10k space, not crypto-random); hardcoded +early-bird date for tier detection (we read the real tier from guild). + ## Gotchas for the next session - Deps may not be installed — run `npm ci` first (typecheck/test need it). diff --git a/TODO.md b/TODO.md index 2cf5f73..3430ae8 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,21 @@ operational + one live verification. - [ ] **Confirm `EVENT_SLUG`** in `src/server/configs/oauth.ts` (currently `vdc8dh`). - [ ] **Adjust `VOTE_CLOSES_AT`** in `src/server/configs/vote.ts` to the real deadline. +## BLOCKER — verify group-ticket behavior before trusting per-individual voting + +Policy chosen: each guest votes individually with their own tier budget. This only works if +guild.host turns each guest into their own attendee. Unverified, and the API schema allows the +opposite (one attendee owning N ticket items). + +- [ ] Run `GET https://guild.host/api/next/events/vdc8dh/attendees` (organizer token) for a + known multi-ticket purchase: + - N attendee edges, each a distinct `user` → guests are real attendees, our model works. + - 1 attendee edge with `ticketOrder.eventTicketOrderItems.totalCount = N` → buyer holds + all; guests have no identity → per-individual voting impossible without a guild setting + that forces per-ticket claiming, or a manual admin-create fallback. +- [ ] Confirm in the guild dashboard that the event requires each guest to claim/assign their + ticket to their own account. + ## Live verification (needs a real login — can't be done headless) - [ ] **userinfo id join.** `fetchUserInfo` (`src/server/helpers/oauth.ts`) reads the id as @@ -41,6 +56,31 @@ curl -H 'X-Dev-User: user-1' localhost:8787/api/vote # GET session (dev only) Note: `X-Dev-User` works only when `ENVIRONMENT !== 'production'`; production requires the real signed-cookie session from the OAuth flow. +## Simplify + +- [ ] **Lean on auth libraries — but no external auth service.** Hard constraint: NO hosted + identity service or external dashboard (no Auth0, Clerk, WorkOS, hosted Supabase-auth). + Self-hosted libraries that run inside our worker with tokens/sessions stored in OUR D1 + are fine. We keep our data; we just stop writing the crypto. + + Right now we hand-roll the whole surface: OAuth authorize/exchange/refresh + state/CSRF + (`helpers/oauth.ts`, `routes/auth.ts`), HMAC session sign/verify + base64url + (`helpers/session.ts`), and cookie parse/serialize (duplicated `readCookie` in both + files). All of it is a security boundary. Replace with a lib, owning as little as possible: + + - **Own least (preferred if it fits):** a self-hosted auth lib handling OAuth + session + + cookies, persisting to our D1. `@auth/core` (Auth.js, D1 adapter) or `better-auth` (D1 + via Drizzle/Kysely). Both are libraries, not services — no external dashboard. Catch: + guild.host is a **non-standard** provider (custom `userinfo`, unverified id-join field); + confirm the lib's generic-OAuth provider can model it before committing, or it fights us. + - **Own a bit more (safe fallback):** `arctic` generic `OAuth2Client` (authorize / code + exchange / refresh + state/PKCE) + `jose` for the session JWT + the `cookie` package for + parse/serialize. Lighter, no adapter, fits the custom provider more flexibly. Still all + in-process, tokens in our D1. + + Try `@auth/core` first; drop to arctic+jose if guild's non-standard shape doesn't map. + Either way tokens stay in our D1 and `resolveBudget` / the votes flow are unchanged. + ## Nice-to-have (not blocking) - [ ] Logout endpoint / button (clear `vote_session` cookie). diff --git a/src/server/configs/vote.ts b/src/server/configs/vote.ts index 8e8ac2c..5db4533 100644 --- a/src/server/configs/vote.ts +++ b/src/server/configs/vote.ts @@ -4,3 +4,5 @@ export const VOTE_CLOSES_AT = '2026-09-01T00:00:00Z'; export const isVotingOpen = (now: Date = new Date()): boolean => now < new Date(VOTE_CLOSES_AT); + +export const VOTABLE_TALK_STATUS = 2; diff --git a/src/server/repositories/vote.ts b/src/server/repositories/vote.ts index ad198fc..c1a3250 100644 --- a/src/server/repositories/vote.ts +++ b/src/server/repositories/vote.ts @@ -1,4 +1,5 @@ import type { Database } from '../types.js'; +import { VOTABLE_TALK_STATUS } from '../configs/vote.js'; export type TalkRow = { id: number; @@ -16,9 +17,10 @@ export const vote = (database: Database) => { `SELECT t.id, t.title, t.description, s.name AS speaker_name FROM talks t JOIN speakers s ON s.id = t.speaker_id + WHERE t.status = ? ORDER BY t.id` ) - .bind() + .bind(VOTABLE_TALK_STATUS) .all(); return results; }; From 61245f9bca9ee338a8ced07ed061aec53628e24a Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Fri, 26 Jun 2026 03:10:00 +0200 Subject: [PATCH 03/25] docs: add CLAUDE.md --- CLAUDE.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..03b7024 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +Guidance for Claude when working in this repo (JSConf Brasil landing page). + +## What this is + +Three pieces, one repo: + +- **Website** — Docusaurus 3 + React 19, static, deployed to GitHub Pages (`jsconf.com.br`). + Source in `src/website/`. File-based routes under `src/website/pages/`. i18n in `i18n/` + (pt-BR default, en-US, es-419) via Docusaurus `` / `code.json`. +- **Server** — a single Cloudflare Worker (`src/server/index.ts`), custom domain + `api.jsconf.com.br`. No framework: a `fetch` handler with a manual `switch` router. Helpers, + routes, repositories, configs split under `src/server/`. +- **Database** — Cloudflare D1 (SQLite), bound as `DB`. Schema in `resources/schema.sql` + (idempotent `CREATE TABLE IF NOT EXISTS`). Apply with `npm run db:init` (local) / + `db:init:remote`. + +Deploy: push to `main` → `.github/workflows/cd_deploy.yml` builds both, deploys the worker +via wrangler and the site to the `website` branch. + +## Commands + +```sh +npm start # site (pt-BR) + worker via wrangler dev, concurrently +npm run server # worker only (wrangler dev) +npm run db:init # apply resources/schema.sql to local D1 +npm test # poku (-r=compact) +npm run typecheck # tsc +npm run lint # prettier --check (lint:fix to write) +npm run build # worker + docusaurus +``` + +Run `npm ci` first if `node_modules` is missing (typecheck/test need deps). + +## Conventions + +- **Style:** no `else` (early-return + guard clauses), avoid `let`, prefer `for..of` over + `.forEach`, keep it simple over clever. TS: `switch` default `x satisfies never`, + `Record`. +- **Server shape:** routes in `src/server/routes/`, registered in `routes.ts` + the `index.ts` + switch (`METHOD /path`). DB access in `repositories/`, validated with `zod`. Responses via + `helpers/response.ts`. Reuse the existing helpers (`request.ts`, `session.ts`, etc.). +- **Validation is the trust boundary** server-side; the frontend schemas are UX only. +- **Tests:** `poku`, files `*.test.ts` under `test/` mirroring `src/`. Mock D1 with a plain + object implementing `prepare().bind().run()/all()` (see `test/server/routes/c4p/__utils__.ts` + and `test/server/routes/vote.test.ts`). +- **Secrets:** `wrangler secret put` (see `npm run secret`); never commit them. `.env.example` + lists what exists. + +## Git / PRs + +- Conventional commits, single `-m`, no Claude attribution. Never push to `main` — branch + first. No gitmoji. +- `git` here needs a clean env to avoid shell-hook hangs: + `env -i HOME=$HOME PATH=/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin /usr/bin/git --no-pager …` + +## In-flight work + +The `voting-system` branch adds attendee voting on C4P talks (guild.host OAuth identity, +on-the-fly tier budget, votes in D1). See `HANDOVER.md` for full context and `TODO.md` for the +remaining (mostly operational) work and a verification blocker. From 44f7b9b80a93d04457b318570c81771210bfc97a Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Fri, 26 Jun 2026 15:30:00 +0200 Subject: [PATCH 04/25] docs(voting): guild login pending + group-ticket resolved (wip) --- DEVELOPMENT.md | 4 ++++ HANDOVER.md | 57 +++++++++++++++++++++++++++++++++----------------- README.md | 2 +- TODO.md | 48 +++++++++++++++++++++++++++++------------- 4 files changed, 76 insertions(+), 35 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index de7c640..f78ab7b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -91,6 +91,10 @@ npm run lint # Verificação de linting Quem comprou ingresso vota nas palestras do C4P. A pessoa entra com a conta do **guild.host**, e quantos votos ela tem depende do **tier** do ingresso. +> [!WARNING] +> +> **Em construção (2026-06-26).** O login "Entrar com guild.host" **ainda não existe** no guild: o OAuth da documentação deles é só pra acesso de API privada, não pra login de terceiros. O Taz (dono do guild) vai adicionar esse login, previsão **segunda 2026-06-29**. Até lá o login no site é um **stub** e o resto (votos, tiers, tabelas) roda pelo header `X-Dev-User` em dev. Quem compra vários ingressos pra outras pessoas: cada uma cria a própria conta no guild ao resgatar o ingresso, então cada uma vira um participante separado e vota individualmente. Não usamos Stripe nem sincronizamos nada. + Como funciona: 1. A pessoa abre `/vote` e entra com o guild.host (`GET /api/vote/login`). O **Worker** guarda a identidade num cookie de sessão assinado. diff --git a/HANDOVER.md b/HANDOVER.md index 9bcbb1a..68e2d48 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -2,6 +2,16 @@ Context for a fresh Claude session picking up this work. Branch: `voting-system`. +> **Status update 2026-06-26 (from the guild.host owner, Taz):** +> - Group-ticket question **RESOLVED** — each guest claims their own Guild account/email, so +> every ticket holder is a separate attendee. Per-individual voting works out of the box. +> - **"Log in with Guild" 3rd-party sign-in does NOT exist yet.** The guild OAuth docs are +> private-API access only. Taz will add the sign-in flow, est. **Monday 2026-06-29**, shape +> TBD. Our OAuth code in `helpers/oauth.ts` / `routes/auth.ts` assumes a standard flow and is +> **stubbed/on hold** until he ships and documents the real one. Everything else works via the +> dev `X-Dev-User` header. +> - No Stripe, no syncing. Still need Guild + Stripe **admin access** (requested from the team). + ## What this is JSConf BR landing page. Static Docusaurus site on GitHub Pages (`jsconf.com.br`), a Cloudflare @@ -26,7 +36,7 @@ individually; vote allowance depends on ticket tier. - **Voting runs for months**, so the organizer token (24h access / 30d rotating refresh) is auto-refreshed and persisted in D1 — see `oauth_tokens` + `getOrgAccessToken`. - **Vote model:** approval voting — pick up to `budget` distinct talks, toggle on/off until - `VOTE_CLOSES_AT`. All C4P submissions are votable (no `talks.status` filter). + `VOTE_CLOSES_AT`. Votable talks = `talks.status = 2` (`VOTABLE_TALK_STATUS`, adopted from PR #40). - **Session:** HMAC-signed cookie (`vote_session`), `SameSite=None; Secure` so it survives the cross-subdomain fetch from the website to the API. No DB session store. @@ -86,13 +96,15 @@ Config: `.env.example` updated. `TODO.md` has the deploy checklist. ## What's NOT done — see TODO.md -1. Operational: register OAuth app, set 5 secrets, set `ALLOWED_ORIGIN`, seed `ticket_tiers`, +1. **Blocked on guild:** wait for Taz's "Log in with Guild" sign-in (~Mon 2026-06-29), then + align `helpers/oauth.ts` + `routes/auth.ts` to the real flow (currently a stub). +2. **Blocked on access:** Guild + Stripe admin (API key + manager OAuth) to read attendees; + requested from the team. Fallback: collect emails in the purchase form. +3. Operational: register OAuth app, set 5 secrets, set `ALLOWED_ORIGIN`, seed `ticket_tiers`, `npm run db:init`, obtain the organizer refresh token, set the real `VOTE_CLOSES_AT`. -2. **One live verification** (cannot be done without a registered app + real login): the - userinfo id field that joins the attendees list. `fetchUserInfo` tries - `sub ?? id ?? slugId ?? rowId` — confirm which is correct. If none join, the - identity→tier mapping breaks and needs rethinking. -3. Optional: logout, vote-tally admin read endpoint, refund→revoke. +4. **Live verification once login exists:** the userinfo id field that joins the attendees list. + `fetchUserInfo` tries `sub ?? id ?? slugId ?? rowId` — confirm which is correct. +5. Optional: logout, vote-tally admin read endpoint, refund→revoke. ## Why guild.host (not Stripe) for attendees @@ -104,18 +116,24 @@ full attendee list**, and each attendee carries their own `ticketOrder` tier, so OAuth + the attendees API, and treat #40's Stripe approach as a workaround for a gone limitation. -## Multi-ticket / group purchases — decided + BLOCKER +## Multi-ticket / group purchases — RESOLVED (2026-06-26) + +Policy: **each guest votes individually** with their own tier budget — and it works. Taz (guild +owner) confirmed: the buyer enters each guest's email, and **each guest creates their own Guild +account to claim** their ticket (and may use a different email than the buyer entered). So every +ticket holder becomes a separate attendee with their own email/data. No buyer-holds-all problem, +no manual fallback needed. (Earlier worry: the schema lets one attendee own N ticket items via +`ticketOrder.eventTicketOrderItems.totalCount`, but the claim flow makes each guest distinct.) -Policy (user decision): **each guest votes individually** with their own tier budget. Our -model supports this *if* guild turns each guest into their own attendee record. +## Login depends on guild.host (BLOCKER) -BLOCKER, unverified, schema allows the opposite: `EventAttendee` has one `user` but a -`ticketOrder.eventTicketOrderItems` connection with `totalCount` + N nodes — i.e. one attendee -can own N tickets. Must run `GET /events/vdc8dh/attendees` against the real event: -- N attendee edges, each a distinct `user` → guests are real attendees, model works. -- 1 edge with `eventTicketOrderItems.totalCount = N` → buyer holds all; guests have no - identity → per-individual impossible without a guild claim setting or a manual admin - fallback. See `TODO.md`. Do not build further on the per-individual assumption until known. +"Log in with Guild" 3rd-party sign-in **does not exist yet** — the OAuth at +`guild.host/docs/developers/oauth` is private-API access only (per Taz). Taz will add it, est. +**Monday 2026-06-29**, shape TBD. Our `helpers/oauth.ts` (`buildAuthorizeUrl`/`exchangeCode`/ +`fetchUserInfo`) + `routes/auth.ts` assume a standard authorization-code flow; they are a +**stub** until the real flow ships — revisit then. Locally, auth is exercised via the +`X-Dev-User` header. Also still need Guild + Stripe admin access (Guild API key + manager OAuth) +to read attendees; requested from the team, fallback is collecting emails in the purchase form. ## Stealing from PR #40 (branch feat/c4p-voting-quantity-tiers) @@ -131,8 +149,9 @@ Rejected (conflicts with our guild model — kept here so the option isn't lost) quantity×tier-weight → upsert `users`). Would kill our org-token + OAuth-refresh + attendees-fetch + lru machinery AND the userinfo→attendee id-join risk. We don't take it because it is **buyer-only** (no group guests) and guild now gives the full per-attendee - list. BUT: if the group-ticket verification shows "buyer holds all", this Stripe model - becomes the fallback for per-individual being impossible. Tie it to that blocker. + list. The group-ticket question is now RESOLVED (each guest claims their own account), so the + "buyer holds all" condition that would have made this Stripe model the fallback no longer + applies — keep it only as a last resort if Guild access never materializes. - **Repurchase accumulation** (`ON CONFLICT(email) DO UPDATE SET votes_allowed = votes_allowed + excluded`). N/A in our model: budget is a live tier lookup, not a persisted per-purchase sum, so there's nothing to accumulate. Only relevant if we adopt the Stripe diff --git a/README.md b/README.md index 7c11fb8..2f28d67 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A **JSConf Brasil** é um **braço da [NodeBR](https://nodebr.org)** — a assoc ## Site e repositório -O site público está em **[jsconf.com.br](https://jsconf.com.br)**. Este repositório é o código do site e dos serviços que o sustentam (por exemplo, formulários e integrações). +O site público está em **[jsconf.com.br](https://jsconf.com.br)**. Este repositório é o código do site e dos serviços que o sustentam (por exemplo, formulários, integrações e a votação de palestras pela comunidade). Quer contribuir com código, traduções ou correções? Veja **[DEVELOPMENT.md](./DEVELOPMENT.md)**. diff --git a/TODO.md b/TODO.md index 3430ae8..bc32c1f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,15 @@ # Voting system — remaining work Code is complete, typechecked, linted, tested (`npm test` → 10 files pass). What's left is -operational + one live verification. +operational + blocked on guild.host shipping "Log in with Guild" (see blockers). + +> **Update 2026-06-26 (guild.host owner, Taz):** +> - Group-ticket question **RESOLVED** — each guest claims their own Guild account/email, so +> every ticket holder is a separate attendee. Per-individual voting works out of the box. +> - **"Log in with Guild" 3rd-party sign-in does NOT exist yet.** The OAuth docs are +> private-API access only. Taz will build the sign-in flow, est. **Monday 2026-06-29**, shape +> TBD. The website login is **stubbed** until then — revisit `helpers/oauth.ts` once it ships. +> - No Stripe, no syncing. Still need Guild + Stripe **admin access** (requested from the team). ## Before deploy @@ -22,28 +30,38 @@ operational + one live verification. - [ ] **Confirm `EVENT_SLUG`** in `src/server/configs/oauth.ts` (currently `vdc8dh`). - [ ] **Adjust `VOTE_CLOSES_AT`** in `src/server/configs/vote.ts` to the real deadline. -## BLOCKER — verify group-ticket behavior before trusting per-individual voting +## RESOLVED — group-ticket behavior + +Each guest votes individually with their own tier budget — and this works. Taz (guild owner) +confirmed: the buyer enters each guest's email, each guest **creates their own Guild account to +claim** their ticket (and may use a different email). So every ticket holder is a separate +attendee with their own email/data. No buyer-holds-all problem; no manual fallback needed. + +## BLOCKER — "Log in with Guild" doesn't exist yet + +The website login flow depends on guild.host adding 3rd-party sign-in. The OAuth at +`guild.host/docs/developers/oauth` is **private API access only**, not sign-in (per Taz). + +- [ ] Wait for Taz to ship "Log in with Guild" (est. Monday 2026-06-29) and document the real + flow. Then revisit `src/server/helpers/oauth.ts` (`buildAuthorizeUrl` / `exchangeCode` / + `fetchUserInfo`) and `routes/auth.ts` against the actual endpoints/shape. +- [ ] Until then the login is **stubbed**; everything else (votes, budget, tables, tests) works + via the dev `X-Dev-User` header. -Policy chosen: each guest votes individually with their own tier budget. This only works if -guild.host turns each guest into their own attendee. Unverified, and the API schema allows the -opposite (one attendee owning N ticket items). +## BLOCKER — Guild / Stripe admin access -- [ ] Run `GET https://guild.host/api/next/events/vdc8dh/attendees` (organizer token) for a - known multi-ticket purchase: - - N attendee edges, each a distinct `user` → guests are real attendees, our model works. - - 1 attendee edge with `ticketOrder.eventTicketOrderItems.totalCount = N` → buyer holds - all; guests have no identity → per-individual voting impossible without a guild setting - that forces per-ticket claiming, or a manual admin-create fallback. -- [ ] Confirm in the guild dashboard that the event requires each guest to claim/assign their - ticket to their own account. +- [ ] Get **admin access to Guild and Stripe** (Guild API key + manager-flow OAuth to read all + attendees). Requested from the JSConf team (Erick Wendel / Ana Beatriz) on 2026-06-26. +- [ ] Fallback if access never comes: collect attendee emails in the purchase form instead of + the attendees API. -## Live verification (needs a real login — can't be done headless) +## Live verification (once login exists — can't be done headless) - [ ] **userinfo id join.** `fetchUserInfo` (`src/server/helpers/oauth.ts`) reads the id as `sub ?? id ?? slugId ?? rowId`. Do one test login and confirm the returned id matches a `user.id`/`rowId`/`slugId` in `GET /events/{slug}/attendees`. If it's a different field, it's a one-line fix. If the id never joins, the OAuth-identity → tier mapping breaks and - needs rethinking. + needs rethinking. (Blocked on the "Log in with Guild" flow above.) ## Smoke test (local) From 410c9fcda5c2eda2aadac2f2f6947fc86f2ef48f Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 12:23:45 +0200 Subject: [PATCH 05/25] refactor(voting): guild-only, jose sessions, parseRequest + richer talk cards Drop Stripe entirely (webhook route, client, STRIPE_* secrets, test) - voting is guild-only; the attendees API is the sole tier source. Simplify auth: session cookie is now a jose HS256 JWT (alg-pinned, lib-enforced exp) instead of hand-rolled HMAC+base64url; cookie parsing deduped into helpers/cookies.ts. The OAuth flow (helpers/oauth.ts) stays hand-rolled until the real Log in with Guild flow ships. Steal from #40: parseRequest() DRYs the 415/413/400/422 gauntlet across the c4p and vote routes; the /api/vote payload and /vote page now surface duration and audience_level. --- .env.example | 2 - HANDOVER.md | 58 ++++++--- TODO.md | 62 ++++++---- package-lock.json | 32 ++--- package.json | 2 +- src/server/configs/stripe.ts | 9 -- src/server/helpers/cookies.ts | 7 ++ src/server/helpers/request.ts | 26 ++++ src/server/helpers/session.ts | 74 ++--------- src/server/index.ts | 3 - src/server/repositories/vote.ts | 5 +- src/server/routes.ts | 2 - src/server/routes/auth.ts | 9 +- src/server/routes/c4p.ts | 21 +--- src/server/routes/stripe-webhook.ts | 54 -------- src/server/routes/vote.ts | 21 +--- src/server/types.ts | 2 - src/website/pages/vote/index.tsx | 11 ++ test/server/routes/stripe-webhook.test.ts | 144 ---------------------- 19 files changed, 156 insertions(+), 388 deletions(-) delete mode 100644 src/server/configs/stripe.ts create mode 100644 src/server/helpers/cookies.ts delete mode 100644 src/server/routes/stripe-webhook.ts delete mode 100644 test/server/routes/stripe-webhook.test.ts diff --git a/.env.example b/.env.example index 44bb4dc..ada4319 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,6 @@ CLOUDFLARE_API_TOKEN='' WORKER_D1='' WORKER_DOMAIN='' ALLOWED_ORIGIN='' -STRIPE_SECRET_KEY='' -STRIPE_WEBHOOK_SECRET='' # Voting / guild.host OAuth (worker secrets) GUILD_OAUTH_CLIENT_ID='' GUILD_OAUTH_CLIENT_SECRET='' diff --git a/HANDOVER.md b/HANDOVER.md index 68e2d48..2e2a8e0 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -3,6 +3,7 @@ Context for a fresh Claude session picking up this work. Branch: `voting-system`. > **Status update 2026-06-26 (from the guild.host owner, Taz):** +> > - Group-ticket question **RESOLVED** — each guest claims their own Guild account/email, so > every ticket holder is a separate attendee. Per-individual voting works out of the box. > - **"Log in with Guild" 3rd-party sign-in does NOT exist yet.** The guild OAuth docs are @@ -32,13 +33,16 @@ individually; vote allowance depends on ticket tier. the attendee list with an organizer token, caches `userId→tier` in memory (`lru.min`, 5-min TTL), and joins a `ticket_tiers(name, budget)` table for the vote count. There is no per-user attendee endpoint in the guild API, so the full list is fetched + cached. -- **Stripe unused for voting.** It knows only the buyer, and votes are per individual attendee. +- **Stripe removed — voting is guild-only.** Stripe knows only the buyer, and votes are per + individual attendee. The Stripe webhook route + client + `STRIPE_*` secrets were deleted; the + sole attendee/tier source is the guild.host attendees API. - **Voting runs for months**, so the organizer token (24h access / 30d rotating refresh) is auto-refreshed and persisted in D1 — see `oauth_tokens` + `getOrgAccessToken`. - **Vote model:** approval voting — pick up to `budget` distinct talks, toggle on/off until `VOTE_CLOSES_AT`. Votable talks = `talks.status = 2` (`VOTABLE_TALK_STATUS`, adopted from PR #40). -- **Session:** HMAC-signed cookie (`vote_session`), `SameSite=None; Secure` so it survives the - cross-subdomain fetch from the website to the API. No DB session store. +- **Session:** signed JWT cookie (`vote_session`) via `jose` (HS256, alg-pinned), `SameSite=None; +Secure` so it survives the cross-subdomain fetch from the website to the API. No DB session + store. (Was hand-rolled HMAC; swapped to `jose` in the auth-simplify pass.) ## Architecture / flow @@ -61,12 +65,17 @@ Org token: oauth_tokens row, auto-refreshed + rotated (repositories/oauth-token ## Files (all new/changed on this branch) Server: + - `src/server/configs/oauth.ts` — guild endpoint URLs, scope, EVENT_SLUG, cookie names/TTL. - `src/server/configs/vote.ts` — `VOTE_CLOSES_AT`, `isVotingOpen()`. -- `src/server/helpers/session.ts` — HMAC `signSession`/`verifySession`, async `getUserId` - (cookie in prod; `X-Dev-User` header fallback only when `ENVIRONMENT !== 'production'`). +- `src/server/helpers/session.ts` — `jose` HS256 `signSession`/`verifySession`, async + `getUserId` (cookie in prod; `X-Dev-User` header fallback only when + `ENVIRONMENT !== 'production'`). +- `src/server/helpers/cookies.ts` — shared `readCookie` (deduped from session.ts + auth.ts). +- `src/server/helpers/request.ts` — `parseRequest` (415/413/400/422 gauntlet, shared by c4p + + vote) alongside the primitive `isJsonContentType`/`isWithinSize`/`parseBody`. - `src/server/helpers/oauth.ts` — `buildAuthorizeUrl`, `exchangeCode`, `refreshToken`, - `fetchUserInfo`. + `fetchUserInfo`. Still hand-rolled + stubbed; slated for `arctic` once the real guild flow lands. - `src/server/repositories/oauth-token.ts` — `getOrgAccessToken`: valid-token passthrough, else refresh + persist rotated token; seeds from `GUILD_ORG_REFRESH_TOKEN`. - `src/server/repositories/attendees.ts` — `resolveBudget(env, db, userId)`: paginated @@ -79,10 +88,12 @@ Server: - `resources/schema.sql` — `ticket_tiers`, `c4p_votes`, `oauth_tokens`. Site: + - `src/website/pages/vote/index.tsx` — voting UI (login state, checkbox approval list, optimistic toggle, remaining-votes counter). Tests: + - `test/server/routes/vote.test.ts` — session crypto, token refresh/rotation, route auth (401/403), budget limit, idempotent re-vote, unvote, 415/422, prod no-bypass. @@ -90,7 +101,7 @@ Config: `.env.example` updated. `TODO.md` has the deploy checklist. ## State of play -- `npm run typecheck`, `npm run lint`, `npm test` (10 files) all pass. +- `npm run typecheck`, `npm run lint`, `npm test` (9 files) all pass. - Nothing is committed — review the diff and commit when ready (conventional commits, no Claude attribution per user preference; always branch, never push to main). @@ -98,13 +109,13 @@ Config: `.env.example` updated. `TODO.md` has the deploy checklist. 1. **Blocked on guild:** wait for Taz's "Log in with Guild" sign-in (~Mon 2026-06-29), then align `helpers/oauth.ts` + `routes/auth.ts` to the real flow (currently a stub). -2. **Blocked on access:** Guild + Stripe admin (API key + manager OAuth) to read attendees; - requested from the team. Fallback: collect emails in the purchase form. +2. **Blocked on access:** Guild admin (API key + manager OAuth) to read attendees; requested + from the team. Fallback: collect emails in the purchase form. 3. Operational: register OAuth app, set 5 secrets, set `ALLOWED_ORIGIN`, seed `ticket_tiers`, `npm run db:init`, obtain the organizer refresh token, set the real `VOTE_CLOSES_AT`. 4. **Live verification once login exists:** the userinfo id field that joins the attendees list. `fetchUserInfo` tries `sub ?? id ?? slugId ?? rowId` — confirm which is correct. -5. Optional: logout, vote-tally admin read endpoint, refund→revoke. +5. Optional: logout, vote-tally admin read endpoint. ## Why guild.host (not Stripe) for attendees @@ -141,10 +152,19 @@ His model: email + 4-digit code login (JWT 15m + rotating refresh, `jose`), budg `checkout.session.completed` (quantity × tier weight), `users-batch` admin endpoint. Taken so far: + - **`status = 2` = votable.** Concrete meaning for the undocumented `talks.status`. Now filtered in `listTalks` via `VOTABLE_TALK_STATUS` (`configs/vote.ts`). +- **`parseRequest` helper** (`helpers/request.ts`) — DRYs the 415/413/400/422 gauntlet the c4p + + vote routes repeated. (Dropped his `RouteContext`/`cors` coupling — the helper just returns + `{ data }` or `{ error, status }` and the route builds the response.) +- **Richer talk cards** — `listTalks` + the `/api/vote` payload now carry `duration` + + `audience_level`; the `/vote` page labels them via the existing `durationOptions` / + `audienceLevels` arrays. (Kept our `talks` + `myVotes` shape; did not adopt his + `talks`/`votedTalkIds` split.) Rejected (conflicts with our guild model — kept here so the option isn't lost): + - **Stripe-webhook-derived budget** (`checkout.session.completed` → buyer email + quantity×tier-weight → upsert `users`). Would kill our org-token + OAuth-refresh + attendees-fetch + lru machinery AND the userinfo→attendee id-join risk. We don't take it @@ -153,26 +173,24 @@ Rejected (conflicts with our guild model — kept here so the option isn't lost) "buyer holds all" condition that would have made this Stripe model the fallback no longer applies — keep it only as a last resort if Guild access never materializes. - **Repurchase accumulation** (`ON CONFLICT(email) DO UPDATE SET votes_allowed = - votes_allowed + excluded`). N/A in our model: budget is a live tier lookup, not a persisted +votes_allowed + excluded`). N/A in our model: budget is a live tier lookup, not a persisted per-purchase sum, so there's nothing to accumulate. Only relevant if we adopt the Stripe fallback above. Worth taking later (model-compatible, not yet done): + - His `getEligibleTalks` computes `has_voted` per talk in one query — cleaner than our - two-query `voteGet`. -- Richer voting payload: he returns `duration` + `audience_level` per talk and splits - `talks` / `votedTalkIds`. Ours returns only title/desc/speaker. Better talk cards. + two-query `voteGet`. Skipped for now: it churns the response shape + test mock for a micro-opt. - Explicit vote status codes: `403` no votes left, `409` already voted, `404` retract-not-found. - Ours collapses to `422` + idempotent re-vote. His is clearer for the UI; a semantics choice. -- `parseRequest(request, schema, cors)` helper + shared `RouteContext` type — DRYs the - 415/413/400/422 dance our c4p + vote routes repeat. + Ours collapses to `422` + idempotent re-vote. His is clearer for the UI; a semantics choice + (skipped — would break current tests, no sign-off). - Dev seed script (his `scripts/seed.ts`): seed talks + `ticket_tiers` so local voting is - testable without the `X-Dev-User` curl dance. We have none. + testable without the `X-Dev-User` curl dance. We have none. (Skipped — net-new wrangler + plumbing, not a quick win.) - `schemas.ts` centralizing typed response shapes. Minor. - His richer frontend: `src/website/components/voting/*`, `pages/voting/*`, `hooks/voting/*`, full i18n. Ours is one page. -- `charge.refunded` → revoke votes. Awkward in our model (Stripe gives buyer email, our votes - key on guild user_id — no clean map). Note, don't force it. +- `charge.refunded` → revoke votes. N/A — Stripe is removed; the webhook stub is deleted. Do NOT take: 4-digit `Math.random()` code (weak, 10k space, not crypto-random); hardcoded early-bird date for tier detection (we read the real tier from guild). diff --git a/TODO.md b/TODO.md index bc32c1f..db31dfe 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,10 @@ # Voting system — remaining work -Code is complete, typechecked, linted, tested (`npm test` → 10 files pass). What's left is +Code is complete, typechecked, linted, tested (`npm test` → 9 files pass). What's left is operational + blocked on guild.host shipping "Log in with Guild" (see blockers). > **Update 2026-06-26 (guild.host owner, Taz):** +> > - Group-ticket question **RESOLVED** — each guest claims their own Guild account/email, so > every ticket holder is a separate attendee. Per-individual voting works out of the box. > - **"Log in with Guild" 3rd-party sign-in does NOT exist yet.** The OAuth docs are @@ -48,10 +49,13 @@ The website login flow depends on guild.host adding 3rd-party sign-in. The OAuth - [ ] Until then the login is **stubbed**; everything else (votes, budget, tables, tests) works via the dev `X-Dev-User` header. -## BLOCKER — Guild / Stripe admin access +## BLOCKER — Guild admin access -- [ ] Get **admin access to Guild and Stripe** (Guild API key + manager-flow OAuth to read all - attendees). Requested from the JSConf team (Erick Wendel / Ana Beatriz) on 2026-06-26. +Stripe is out — voting is guild-only. The Stripe webhook path (buyer-derived budget) was +removed; the only attendee/tier source is the guild.host attendees API. + +- [ ] Get **admin access to Guild** (Guild API key + manager-flow OAuth to read all attendees). + Requested from the JSConf team (Erick Wendel / Ana Beatriz) on 2026-06-26. - [ ] Fallback if access never comes: collect attendee emails in the purchase form instead of the attendees API. @@ -71,36 +75,40 @@ npm run db:init wrangler dev curl -H 'X-Dev-User: user-1' localhost:8787/api/vote # GET session (dev only) ``` + Note: `X-Dev-User` works only when `ENVIRONMENT !== 'production'`; production requires the real signed-cookie session from the OAuth flow. ## Simplify -- [ ] **Lean on auth libraries — but no external auth service.** Hard constraint: NO hosted - identity service or external dashboard (no Auth0, Clerk, WorkOS, hosted Supabase-auth). - Self-hosted libraries that run inside our worker with tokens/sessions stored in OUR D1 - are fine. We keep our data; we just stop writing the crypto. - - Right now we hand-roll the whole surface: OAuth authorize/exchange/refresh + state/CSRF - (`helpers/oauth.ts`, `routes/auth.ts`), HMAC session sign/verify + base64url - (`helpers/session.ts`), and cookie parse/serialize (duplicated `readCookie` in both - files). All of it is a security boundary. Replace with a lib, owning as little as possible: - - - **Own least (preferred if it fits):** a self-hosted auth lib handling OAuth + session + - cookies, persisting to our D1. `@auth/core` (Auth.js, D1 adapter) or `better-auth` (D1 - via Drizzle/Kysely). Both are libraries, not services — no external dashboard. Catch: - guild.host is a **non-standard** provider (custom `userinfo`, unverified id-join field); - confirm the lib's generic-OAuth provider can model it before committing, or it fights us. - - **Own a bit more (safe fallback):** `arctic` generic `OAuth2Client` (authorize / code - exchange / refresh + state/PKCE) + `jose` for the session JWT + the `cookie` package for - parse/serialize. Lighter, no adapter, fits the custom provider more flexibly. Still all - in-process, tokens in our D1. - - Try `@auth/core` first; drop to arctic+jose if guild's non-standard shape doesn't map. - Either way tokens stay in our D1 and `resolveBudget` / the votes flow are unchanged. +- [x] **Session crypto → `jose`.** `helpers/session.ts` now signs/verifies an HS256 JWT via + `jose` (`SignJWT` / `jwtVerify`) instead of the hand-rolled HMAC + base64url. Alg is pinned + (`algorithms: ['HS256']`), exp is enforced by the lib. Cookie parsing deduped into + `helpers/cookies.ts` (`readCookie`), shared by `session.ts` + `routes/auth.ts`. + +- [ ] **OAuth flow → lib (DEFERRED to the real guild flow).** `helpers/oauth.ts` + (`buildAuthorizeUrl` / `exchangeCode` / `refreshToken` / `fetchUserInfo`) is still + hand-rolled and still stubbed against an unknown flow. Do NOT migrate it yet — the real + "Log in with Guild" endpoints/shape are unconfirmed, so a swap now = double rework. When + the real flow lands, replace this file in one pass with `arctic` (generic `OAuth2Client`: + authorize / code exchange / refresh + state/PKCE). Hard constraint holds: self-hosted lib + only, tokens stay in our D1, NO hosted identity service. `@auth/core` / `better-auth` were + considered and rejected for a raw Worker + non-standard provider (framework-oriented, + custom `userinfo`). Tokens in D1 and `resolveBudget` / votes flow stay unchanged. ## Nice-to-have (not blocking) - [ ] Logout endpoint / button (clear `vote_session` cookie). - [ ] Admin read endpoint for vote tallies (none exists yet — votes are write-only via the API). -- [ ] `charge.refunded` → revoke votes (Stripe webhook stub already receives the event). + +## Stolen from PR #40 (done) + +- [x] **`parseRequest(request, schema, maxSize)`** in `helpers/request.ts` — DRYs the + 415/413/400/422 content-type/size/JSON/schema gauntlet the `c4p` + `vote` routes repeated. +- [x] **Richer talk cards** — `listTalks` + the `/api/vote` payload now include `duration` and + `audience_level`; the `/vote` page renders them via the existing `durationOptions` / + `audienceLevels` label arrays. + +Not taken (deliberate): single-query `has_voted` (churns the response shape + test mock for a +micro-opt), explicit 403/409/404 vote status codes (semantics change, no sign-off), dev seed +script (net-new wrangler plumbing, not a quick win). diff --git a/package-lock.json b/package-lock.json index 2d6fa76..673bfbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "stripe": "^22.0.0" + "jose": "^6.2.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260408.1", @@ -8153,7 +8153,7 @@ "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -13748,6 +13748,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -21570,23 +21579,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.0.tgz", - "integrity": "sha512-q1UgXXpSfZCmkyzZEh3vFEWT7+ajuaFGqaP9Tsi2NMtwlkigIWNr+KBIUQqtNeNEsreDKgdn+BP5HRW9JDj22Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -22077,7 +22069,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unenv": { diff --git a/package.json b/package.json index 4d72d4f..3036945 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,6 @@ }, "private": true, "dependencies": { - "stripe": "^22.0.0" + "jose": "^6.2.3" } } diff --git a/src/server/configs/stripe.ts b/src/server/configs/stripe.ts deleted file mode 100644 index 4cd1d7f..0000000 --- a/src/server/configs/stripe.ts +++ /dev/null @@ -1,9 +0,0 @@ -import Stripe from 'stripe'; - -export const createStripeClient = (apiKey: string): Stripe => - new Stripe(apiKey, { - apiVersion: '2026-03-25.dahlia', - httpClient: Stripe.createFetchHttpClient(), - }); - -export const cryptoProvider = Stripe.createSubtleCryptoProvider(); diff --git a/src/server/helpers/cookies.ts b/src/server/helpers/cookies.ts new file mode 100644 index 0000000..361b632 --- /dev/null +++ b/src/server/helpers/cookies.ts @@ -0,0 +1,7 @@ +export const readCookie = (request: Request, name: string): string | null => { + for (const part of (request.headers.get('Cookie') ?? '').split(';')) { + const [k, ...v] = part.trim().split('='); + if (k === name) return v.join('='); + } + return null; +}; diff --git a/src/server/helpers/request.ts b/src/server/helpers/request.ts index 17af224..30a320b 100644 --- a/src/server/helpers/request.ts +++ b/src/server/helpers/request.ts @@ -11,3 +11,29 @@ export const parseBody = (text: string): unknown | undefined => { return; } }; + +type ParseResult = { data: T } | { error: string; status: number }; + +// Runs the JSON request gauntlet (content type, size, JSON, schema) shared by the +// c4p and vote routes. Returns the validated data or an { error, status } to respond with. +export const parseRequest = async ( + request: Request, + schema: { + safeParse(input: unknown): { success: true; data: T } | { success: false }; + }, + maxSize: number +): Promise> => { + if (!isJsonContentType(request)) + return { error: 'Unsupported content type.', status: 415 }; + + const text = await request.text(); + if (!isWithinSize(text, maxSize)) + return { error: 'Payload too large.', status: 413 }; + + const body = parseBody(text); + if (!body) return { error: 'Invalid JSON.', status: 400 }; + + const parsed = schema.safeParse(body); + if (!parsed.success) return { error: 'Invalid input.', status: 422 }; + return { data: parsed.data }; +}; diff --git a/src/server/helpers/session.ts b/src/server/helpers/session.ts index 4e48636..8f63065 100644 --- a/src/server/helpers/session.ts +++ b/src/server/helpers/session.ts @@ -1,83 +1,35 @@ import type { Env } from '../types.js'; +import { jwtVerify, SignJWT } from 'jose'; import { SESSION_COOKIE, SESSION_TTL_SECONDS } from '../configs/oauth.js'; +import { readCookie } from './cookies.js'; -const encoder = new TextEncoder(); - -const toBase64Url = (bytes: Uint8Array): string => - btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - -const fromBase64Url = (text: string): Uint8Array => { - const b64 = text.replace(/-/g, '+').replace(/_/g, '/'); - const bin = atob(b64); - return Uint8Array.from(bin, (char) => char.charCodeAt(0)); -}; - -const key = (secret: string): Promise => - crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign', 'verify'] - ); +const key = (secret: string): Uint8Array => new TextEncoder().encode(secret); export const signSession = async ( userId: string, secret: string, ttlSeconds: number = SESSION_TTL_SECONDS -): Promise => { - const exp = Math.floor(Date.now() / 1000) + ttlSeconds; - const payload = toBase64Url( - encoder.encode(JSON.stringify({ sub: userId, exp })) - ); - const sig = await crypto.subtle.sign( - 'HMAC', - await key(secret), - encoder.encode(payload) - ); - return `${payload}.${toBase64Url(new Uint8Array(sig))}`; -}; +): Promise => + new SignJWT() + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(userId) + .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds) + .sign(key(secret)); export const verifySession = async ( token: string, secret: string ): Promise => { - const [payload, sig] = token.split('.'); - if (!payload || !sig) return null; - - const valid = await crypto.subtle.verify( - 'HMAC', - await key(secret), - fromBase64Url(sig) as BufferSource, - encoder.encode(payload) - ); - if (!valid) return null; - try { - const { sub, exp } = JSON.parse( - new TextDecoder().decode(fromBase64Url(payload)) - ); - if (typeof sub !== 'string' || typeof exp !== 'number') return null; - if (exp < Math.floor(Date.now() / 1000)) return null; - return sub; + const { payload } = await jwtVerify(token, key(secret), { + algorithms: ['HS256'], + }); + return typeof payload.sub === 'string' ? payload.sub : null; } catch { return null; } }; -const readCookie = (request: Request, name: string): string | null => { - const header = request.headers.get('Cookie'); - if (!header) return null; - for (const part of header.split(';')) { - const [k, ...v] = part.trim().split('='); - if (k === name) return v.join('='); - } - return null; -}; - // Resolves the voter's id from the signed session cookie. In non-production an X-Dev-User // header is accepted as a fallback so the endpoints are testable without the OAuth round-trip. export const getUserId = async ( diff --git a/src/server/index.ts b/src/server/index.ts index 82319e5..dbc47d3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,9 +22,6 @@ export default { const { pathname } = new URL(request.url); - if (`${method} ${pathname}` === 'POST /api/stripe/webhook') - return routes.stripeWebhook({ request, cors, env }); - const rateLimit = checkRateLimit(request); if (!rateLimit.allowed) return response({ error: 'Rate limit exceeded.' }, 429, { diff --git a/src/server/repositories/vote.ts b/src/server/repositories/vote.ts index c1a3250..0ac65aa 100644 --- a/src/server/repositories/vote.ts +++ b/src/server/repositories/vote.ts @@ -6,6 +6,8 @@ export type TalkRow = { title: string; description: string; speaker_name: string; + duration: number; + audience_level: number; }; type CastResult = { success: true } | { success: false; reason: 'budget' }; @@ -14,7 +16,8 @@ export const vote = (database: Database) => { const listTalks = async (): Promise => { const { results } = await database .prepare( - `SELECT t.id, t.title, t.description, s.name AS speaker_name + `SELECT t.id, t.title, t.description, t.duration, t.audience_level, + s.name AS speaker_name FROM talks t JOIN speakers s ON s.id = t.speaker_id WHERE t.status = ? diff --git a/src/server/routes.ts b/src/server/routes.ts index b6bd95d..0d5f6fb 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,13 +1,11 @@ import { authCallback, authLogin } from './routes/auth.js'; import { c4p } from './routes/c4p.js'; -import { stripeWebhook } from './routes/stripe-webhook.js'; import { voteGet, voteSubmit } from './routes/vote.js'; import { waitlist } from './routes/waitlist.js'; export const routes = { c4p, waitlist, - stripeWebhook, voteGet, voteSubmit, authLogin, diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 113f257..150af42 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -4,6 +4,7 @@ import { SESSION_TTL_SECONDS, STATE_COOKIE, } from '../configs/oauth.js'; +import { readCookie } from '../helpers/cookies.js'; import { buildAuthorizeUrl, exchangeCode, @@ -25,14 +26,6 @@ const redirectUri = (request: Request, env: Env): string => const websiteOrigin = (request: Request, env: Env): string => env.ALLOWED_ORIGIN ?? new URL(request.url).origin; -const readCookie = (request: Request, name: string): string | null => { - for (const part of (request.headers.get('Cookie') ?? '').split(';')) { - const [k, ...v] = part.trim().split('='); - if (k === name) return v.join('='); - } - return null; -}; - const redirect = (location: string, cookies: string[] = []): Response => { const headers = new Headers({ Location: location }); for (const cookie of cookies) headers.append('Set-Cookie', cookie); diff --git a/src/server/routes/c4p.ts b/src/server/routes/c4p.ts index a24bb3a..648518c 100644 --- a/src/server/routes/c4p.ts +++ b/src/server/routes/c4p.ts @@ -1,9 +1,5 @@ import type { Database } from '../types.js'; -import { - isJsonContentType, - isWithinSize, - parseBody, -} from '../helpers/request.js'; +import { parseRequest } from '../helpers/request.js'; import { response } from '../helpers/response.js'; import { c4p as repository } from '../repositories/c4p.js'; @@ -20,20 +16,11 @@ export const c4p = async ({ database, ip, }: Options): Promise => { - if (!isJsonContentType(request)) - return response({ error: 'Unsupported content type.' }, 415, cors); - - const text = await request.text(); - if (!isWithinSize(text, 16384)) - return response({ error: 'Payload too large.' }, 413, cors); - - const body = parseBody(text); - if (!body) return response({ error: 'Invalid JSON.' }, 400, cors); - const { schema, submit } = repository(database); - const parsed = schema.safeParse(body); - if (!parsed.success) return response({ error: 'Invalid input.' }, 422, cors); + const parsed = await parseRequest(request, schema, 16384); + if ('error' in parsed) + return response({ error: parsed.error }, parsed.status, cors); // Bot Honeypot if (parsed.data.confirm_email.length > 0) diff --git a/src/server/routes/stripe-webhook.ts b/src/server/routes/stripe-webhook.ts deleted file mode 100644 index 9f761a2..0000000 --- a/src/server/routes/stripe-webhook.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type Stripe from 'stripe'; -import type { Env } from '../types.js'; -import { createStripeClient, cryptoProvider } from '../configs/stripe.js'; -import { response } from '../helpers/response.js'; - -type Options = { - request: Request; - cors: Record; - env: Env; -}; - -export const handleEvent = (event: Stripe.Event): boolean => { - switch (event.type) { - case 'checkout.session.completed': - case 'payment_intent.succeeded': - case 'charge.succeeded': - case 'charge.refunded': - return true; - default: - return false; - } -}; - -export const stripeWebhook = async ({ - request, - cors, - env, -}: Options): Promise => { - const signature = request.headers.get('stripe-signature'); - if (!signature) return response({ error: 'Missing signature.' }, 400, cors); - - const body = await request.text(); - const stripe = createStripeClient(env.STRIPE_SECRET_KEY); - - try { - const event = await stripe.webhooks.constructEventAsync( - body, - signature, - env.STRIPE_WEBHOOK_SECRET, - undefined, - cryptoProvider - ); - - handleEvent(event); - - return response({ received: true }, 200, cors); - } catch { - return response( - { error: 'Webhook signature verification failed.' }, - 400, - cors - ); - } -}; diff --git a/src/server/routes/vote.ts b/src/server/routes/vote.ts index af6e146..3019074 100644 --- a/src/server/routes/vote.ts +++ b/src/server/routes/vote.ts @@ -1,11 +1,7 @@ import type { Database, Env } from '../types.js'; import { z } from 'zod'; import { isVotingOpen, VOTE_CLOSES_AT } from '../configs/vote.js'; -import { - isJsonContentType, - isWithinSize, - parseBody, -} from '../helpers/request.js'; +import { parseRequest } from '../helpers/request.js'; import { response } from '../helpers/response.js'; import { getUserId } from '../helpers/session.js'; import { resolveBudget } from '../repositories/attendees.js'; @@ -55,23 +51,14 @@ export const voteSubmit = async ({ database, env, }: Options): Promise => { - if (!isJsonContentType(request)) - return response({ error: 'Unsupported content type.' }, 415, cors); - const userId = await getUserId(request, env); if (!userId) return response({ error: 'Unauthorized.' }, 401, cors); if (!isVotingOpen()) return response({ error: 'Voting closed.' }, 403, cors); - const text = await request.text(); - if (!isWithinSize(text, 1024)) - return response({ error: 'Payload too large.' }, 413, cors); - - const body = parseBody(text); - if (!body) return response({ error: 'Invalid JSON.' }, 400, cors); - - const parsed = submitSchema.safeParse(body); - if (!parsed.success) return response({ error: 'Invalid input.' }, 422, cors); + const parsed = await parseRequest(request, submitSchema, 1024); + if ('error' in parsed) + return response({ error: parsed.error }, parsed.status, cors); const budget = await resolveBudget(env, database, userId); if (budget === null) diff --git a/src/server/types.ts b/src/server/types.ts index baa61d7..6700320 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -11,8 +11,6 @@ export type Env = { DB: Database; ALLOWED_ORIGIN?: string; ENVIRONMENT?: string; - STRIPE_SECRET_KEY: string; - STRIPE_WEBHOOK_SECRET: string; // Voting / guild.host OAuth GUILD_OAUTH_CLIENT_ID?: string; GUILD_OAUTH_CLIENT_SECRET?: string; diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx index 5863eb1..f722031 100644 --- a/src/website/pages/vote/index.tsx +++ b/src/website/pages/vote/index.tsx @@ -2,12 +2,18 @@ import { useCallback, useEffect, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { toast } from 'sonner'; import { Page } from '@site/src/website/components/shared/Page'; +import { + audienceLevels, + durationOptions, +} from '@site/src/website/contexts/c4p/definitions'; type Talk = { id: number; title: string; description: string; speaker_name: string; + duration: number; + audience_level: number; }; type Session = { @@ -110,6 +116,11 @@ const Vote = () => { {talk.title} — {talk.speaker_name}
+ + {durationOptions[talk.duration]?.label} ·{' '} + {audienceLevels[talk.audience_level]?.label} + +
{talk.description}
diff --git a/test/server/routes/stripe-webhook.test.ts b/test/server/routes/stripe-webhook.test.ts deleted file mode 100644 index 6299150..0000000 --- a/test/server/routes/stripe-webhook.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Env } from '../../../src/server/types.js'; -import { createHmac } from 'node:crypto'; -import { assert, describe, it } from 'poku'; -import { routes } from '../../../src/server/routes.js'; -import { handleEvent } from '../../../src/server/routes/stripe-webhook.js'; - -const WEBHOOK_SECRET = 'whsec_test_secret'; -const API_KEY = 'sk_test_fake'; - -const cors = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - Vary: 'Origin', -}; - -const mockEnv: Env = { - DB: { - prepare: () => ({ - bind: () => ({ - run: async () => {}, - all: async () => ({ results: [] as T[] }), - }), - }), - }, - STRIPE_SECRET_KEY: API_KEY, - STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, -}; - -const makeEventPayload = (type: string, id = 'evt_test_123') => - JSON.stringify({ - id, - object: 'event', - type, - api_version: '2026-03-25.dahlia', - created: Math.floor(Date.now() / 1000), - data: { object: { id: `obj_${id}` } }, - livemode: false, - pending_webhooks: 0, - request: { id: null, idempotency_key: null }, - }); - -const signPayload = (payload: string, secret: string): string => { - const timestamp = Math.floor(Date.now() / 1000); - const sig = createHmac('sha256', secret) - .update(`${timestamp}.${payload}`, 'utf8') - .digest('hex'); - return `t=${timestamp},v1=${sig}`; -}; - -const makeWebhookRequest = (payload: string, signature?: string): Request => - new Request('http://localhost/api/stripe/webhook', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(signature ? { 'stripe-signature': signature } : {}), - }, - body: payload, - }); - -describe('stripe webhook', async () => { - describe('handleEvent', () => { - const makeEvent = (type: string) => - ({ type, data: { object: { id: 'obj_123' } } }) as never; - - it('returns true for checkout.session.completed', () => { - assert.equal(handleEvent(makeEvent('checkout.session.completed')), true); - }); - - it('returns true for payment_intent.succeeded', () => { - assert.equal(handleEvent(makeEvent('payment_intent.succeeded')), true); - }); - - it('returns true for charge.succeeded', () => { - assert.equal(handleEvent(makeEvent('charge.succeeded')), true); - }); - - it('returns true for charge.refunded', () => { - assert.equal(handleEvent(makeEvent('charge.refunded')), true); - }); - - it('returns false for unknown event types', () => { - assert.equal(handleEvent(makeEvent('unknown.event')), false); - }); - }); - - await describe('routes.stripeWebhook', async () => { - await it('returns 400 when stripe-signature header is missing', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const request = makeWebhookRequest(payload); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 400); - assert.deepEqual(await res.json(), { error: 'Missing signature.' }); - }); - - await it('returns 400 for invalid signature', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const request = makeWebhookRequest(payload, 't=123,v1=invalid'); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 400); - assert.deepEqual(await res.json(), { - error: 'Webhook signature verification failed.', - }); - }); - - await it('returns 400 for tampered payload', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const signature = signPayload(payload, WEBHOOK_SECRET); - const tampered = makeEventPayload('charge.succeeded'); - const request = makeWebhookRequest(tampered, signature); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 400); - }); - - await it('returns 200 for a valid signed event', async () => { - const payload = makeEventPayload('payment_intent.succeeded'); - const signature = signPayload(payload, WEBHOOK_SECRET); - const request = makeWebhookRequest(payload, signature); - const res = await routes.stripeWebhook({ - request, - cors, - env: mockEnv, - }); - - assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { received: true }); - }); - }); -}); From 52a5e23d9c657e30893d7bd2863f839d2ed4f1c2 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 12:23:45 +0200 Subject: [PATCH 06/25] feat(voting): wire guild OAuth app scopes + dev.vars template Request profile:read + event_tickets:read + event_attendees:read at authorize time (matches the registered OAuth app). Rename IDENTITY_SCOPE to OAUTH_SCOPE. Add .dev.vars.example documenting the worker secrets for local wrangler dev. --- .dev.vars.example | 23 +++++++++++++++++++++++ src/server/configs/oauth.ts | 6 ++++-- src/server/helpers/oauth.ts | 4 ++-- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 .dev.vars.example diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..966bf95 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,23 @@ +# Copy to `.dev.vars` (gitignored) for `wrangler dev`. Never commit real secrets. +# These are the Worker secrets the voting flow reads at runtime. + +# guild.host OAuth app (https://guild.host/oauth/authorize) +GUILD_OAUTH_CLIENT_ID= +GUILD_OAUTH_CLIENT_SECRET= +# Must exactly match a redirect URI registered on the OAuth app. +# Local: http://localhost:8787/api/vote/callback Prod: https://api.jsconf.com.br/api/vote/callback +GUILD_OAUTH_REDIRECT_URI=http://localhost:8787/api/vote/callback + +# Organizer refresh token (event_attendees:read). Seeds oauth_tokens on first use; +# the worker rotates it after that. Obtain via a one-time organizer OAuth consent. +GUILD_ORG_REFRESH_TOKEN= + +# HMAC/JWT session signing key. Any long random string, e.g. `openssl rand -hex 32`. +SESSION_SECRET= + +# Website origin for credentialed CORS. Must be explicit, never '*'. +# Local: http://localhost:3000 Prod: https://jsconf.com.br +ALLOWED_ORIGIN=http://localhost:3000 + +# 'production' disables the X-Dev-User bypass and the dev session shortcut. +ENVIRONMENT=development diff --git a/src/server/configs/oauth.ts b/src/server/configs/oauth.ts index ed7b541..4614ed4 100644 --- a/src/server/configs/oauth.ts +++ b/src/server/configs/oauth.ts @@ -4,8 +4,10 @@ export const TOKEN_URL = 'https://guild.host/api/oauth/token'; export const USERINFO_URL = 'https://guild.host/api/oauth/userinfo'; export const ATTENDEES_BASE = 'https://guild.host/api/next/events'; -// Scope that grants the authenticated user's identity via the userinfo endpoint. -export const IDENTITY_SCOPE = 'profile:read'; +// Scopes requested at authorize time: identity (userinfo) + ticket/attendee reads. +// Space-separated per OAuth 2.0; URLSearchParams encodes the space as '+'. +export const OAUTH_SCOPE = + 'profile:read event_tickets:read event_attendees:read'; // ponytail: event slug hardcoded (same one the ticket widget uses, TicketSelection.tsx). // Move to an Env var if it ever changes per environment. diff --git a/src/server/helpers/oauth.ts b/src/server/helpers/oauth.ts index d038dca..7a2d049 100644 --- a/src/server/helpers/oauth.ts +++ b/src/server/helpers/oauth.ts @@ -1,6 +1,6 @@ import { AUTHORIZE_URL, - IDENTITY_SCOPE, + OAUTH_SCOPE, TOKEN_URL, USERINFO_URL, } from '../configs/oauth.js'; @@ -14,7 +14,7 @@ export const buildAuthorizeUrl = ( response_type: 'code', client_id: clientId, redirect_uri: redirectUri, - scope: IDENTITY_SCOPE, + scope: OAUTH_SCOPE, state, }); return `${AUTHORIZE_URL}?${params.toString()}`; From a0fc1df5121776899d1fe0c526409858d22001ec Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 12:38:50 +0200 Subject: [PATCH 07/25] docs(voting): record /ticket refactor plan + live-login verification runbook --- HANDOVER.md | 12 +++-- TODO.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/HANDOVER.md b/HANDOVER.md index 2e2a8e0..8648ff4 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -99,11 +99,17 @@ Tests: Config: `.env.example` updated. `TODO.md` has the deploy checklist. -## State of play +## State of play (updated 2026-07-07) - `npm run typecheck`, `npm run lint`, `npm test` (9 files) all pass. -- Nothing is committed — review the diff and commit when ready (conventional commits, no - Claude attribution per user preference; always branch, never push to main). +- **Committed** on `voting-system` (unsigned — 1Password SSH signer wouldn't run in the agent + session; prior wip history is unsigned too, re-sign via `git rebase --exec 'git commit +--amend --no-edit -S'` if wanted): `refactor(voting): guild-only, jose sessions, parseRequest + - richer talk cards`and`feat(voting): wire guild OAuth app scopes + dev.vars template`. +- **NEXT: the `/ticket` refactor — see the "⏭️ RESUME HERE" section at the top of `TODO.md`.** + Approved, not started. It deletes the org-token machinery and bakes budget into the session. +- Real OAuth creds are in `.dev.vars` (gitignored). Rotate the client secret before go-live + (pasted in plaintext chat). ## What's NOT done — see TODO.md diff --git a/TODO.md b/TODO.md index db31dfe..3df548d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,122 @@ # Voting system — remaining work Code is complete, typechecked, linted, tested (`npm test` → 9 files pass). What's left is -operational + blocked on guild.host shipping "Log in with Guild" (see blockers). +operational + the `/ticket` refactor below. + +## ⏭️ RESUME HERE — switch budget to the per-user `/ticket` endpoint (APPROVED, not started) + +Decision made with the user 2026-07-07, checking `guild.host/docs/developers/oauth`. This +**reverses** the locked org-token decision: the docs DO expose a per-user endpoint +`GET https://guild.host/api/next/events/{slug}/ticket` (scope `event_tickets:read`) returning +the authenticated user's OWN ticket. Our OAuth app already requests that scope. So we read the +voter's tier from THEIR token at login instead of fetching the whole attendee list with an +organizer token. + +Why: kills the org-token machinery, removes the userinfo→attendees id-join blocker, and unblocks +local testing (no organizer consent needed). + +**New flow:** callback → `exchangeCode` → access_token → `fetchUserInfo` (user id) + +`fetchTicketTier` (tier via `/ticket`) → join `ticket_tiers` for budget → bake `{ sub, budget }` +into the session JWT. `voteGet`/`voteSubmit` read budget from the signed session — no per-request +guild call, no stored attendee token. + +Checklist: + +- [ ] `helpers/oauth.ts`: add `fetchTicketTier(accessToken, slug)` hitting + `${EVENTS_BASE}/{slug}/ticket`. **Response shape is UNDOCUMENTED** — reuse the tier path + `attendees.ts` parsed (`ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name`) + as the first guess; confirm on the first live login. Delete the now-unused `refreshToken`. +- [ ] `helpers/session.ts`: session carries budget. `signSession(userId, budget, secret, ttl)`; + `verifySession` → `{ userId, budget } | null`; replace `getUserId` with `getSession` → + `{ userId, budget }`. Dev `X-Dev-User` path returns a default budget (const, e.g. 3) so + local testing works without a login. +- [ ] `routes/auth.ts`: `authCallback` needs `database` (add to its Options + wiring in + `index.ts`/`routes.ts`). Resolve tier→budget; no ticket → `?error=notattendee`, no session. +- [ ] `routes/vote.ts`: read `{ userId, budget }` from `getSession`; drop `resolveBudget` + + the `budget === null` 403 (non-attendees never get a session). +- [ ] DELETE `repositories/oauth-token.ts`, `repositories/attendees.ts`, the `oauth_tokens` + table (`resources/schema.sql`), and `GUILD_ORG_REFRESH_TOKEN` (types.ts, .env.example, + .dev.vars.example). Drop `lru.min` if nothing else uses it (rate-limit does — check). +- [ ] `configs/oauth.ts`: rename `ATTENDEES_BASE` → `EVENTS_BASE` (or add a ticket URL helper). +- [ ] Rewrite `test/server/routes/vote.test.ts`: remove oauth-token + attendees mocks; session + tests carry budget; voteGet/voteSubmit budget comes from the session (dev path). + +### Endpoints CONFIRMED from the docs (2026-07-07) + +- authorize `https://guild.host/oauth/authorize` ✓ (matches code) +- token `https://guild.host/api/oauth/token` ✓ +- userinfo `https://guild.host/api/oauth/userinfo` ✓ — but **response fields are NOT documented** +- per-user ticket `https://guild.host/api/next/events/{slug}/ticket` (`event_tickets:read`) +- attendees `https://guild.host/api/next/events/{slug}/attendees?first=20` (`event_attendees:read`, + only when the Guild user can manage the event) — no longer used after the refactor +- scopes: `profile:read`, `event_tickets:read`, `event_attendees:read` ✓ (all requested) +- tokens: access 1d, refresh 30d rotating, auth code 10min single-use + +### Still needs ONE live login to confirm (undocumented shapes) + +1. **userinfo id field** — which of `sub`/`id`/`slugId`/`rowId` is the stable id. Keys `c4p_votes`. +2. **`/ticket` response shape** — where the tier name sits. Adjust `fetchTicketTier` once seen. +3. **"no ticket" shape** — what `/ticket` returns for a non-attendee (404? empty body? null + field?). Needed to branch `?error=notattendee` correctly. +4. **token response** — confirm `exchangeCode` gets `access_token` (+ whether `expires_in` / + `refresh_token` come back). Log the KEYS only, never the token value. + +### Live-login verification runbook (do this ONCE, then apply + delete the temp code) + +Goal: capture the four shapes above from a single real login. `/ticket` need not be wired yet — +the temp probe calls it directly with the fresh access token. + +1. **Have a test Guild account that holds a ticket on event `vdc8dh`.** Also note a second + account with NO ticket if you can, for step 8. +2. **Seed a tier row** so the budget join has something to hit. Use the exact guild tier name if + you know it; otherwise seed a couple and correct after step 6: + `npm run db:init` then + `wrangler d1 execute jsconf-br --command "INSERT OR IGNORE INTO ticket_tiers (name, budget) VALUES ('Standard', 3), ('VIP', 5)"` +3. **Add temp logging** in `routes/auth.ts` `authCallback`, right after the `if (!tokens)` guard + (import `USERINFO_URL`, `EVENT_SLUG`, `EVENTS_BASE`/`ATTENDEES_BASE`): + ```ts + // TEMP DEBUG — remove after verifying (do NOT log the raw token value) + console.log('TOKEN keys', Object.keys(tokens)); + const at = tokens.access_token; + const ui = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${at}` }, + }); + console.log('USERINFO', ui.status, await ui.text()); + const tk = await fetch(`${ATTENDEES_BASE}/${EVENT_SLUG}/ticket`, { + headers: { Authorization: `Bearer ${at}` }, + }); + console.log('TICKET', tk.status, await tk.text()); + ``` +4. **Run** `npm start` (website :3000 + `wrangler dev` :8787). Keep the `wrangler dev` terminal + visible — `console.log` prints there. +5. **Log in** at `http://localhost:8787/api/vote/login`, consent on guild, land back on `/vote`. +6. **Read the terminal:** + - `USERINFO` line → pick the stable id key → set it in `fetchUserInfo` + (`src/server/helpers/oauth.ts`), replacing the `sub ?? id ?? slugId ?? rowId` guess. + - `TICKET` line → find the tier NAME path → that's what `fetchTicketTier` returns. Confirm the + name string equals a `ticket_tiers.name` you seeded (fix the seed in step 2 if it differs). + - `TOKEN keys` → confirm `access_token` present; note if `expires_in`/`refresh_token` exist. +7. **Confirm the join:** the tier name from `/ticket` must match `ticket_tiers.name` exactly + (case-sensitive). If guild returns e.g. `"Ingresso Padrão"`, seed that literal string. +8. **(If you have a no-ticket account) log in with it** → record the `TICKET` status/body for the + non-attendee branch (`?error=notattendee`). +9. **Delete the temp logging.** Then wire `fetchTicketTier` + `fetchUserInfo` with the confirmed + shapes and finish the refactor checklist above. + +Security: the temp probe logs userinfo + ticket bodies (your own account PII) to the local +terminal only — fine for local dev, but don't paste raw output anywhere shared, and never log the +access token value. + +### Local creds (already set) + +`.dev.vars` (gitignored) holds the real client id/secret, redirect URI, a random SESSION_SECRET, +`ALLOWED_ORIGIN=http://localhost:3000`, `ENVIRONMENT=development`. **Rotate the client secret on +guild.host before go-live** — it was pasted in plaintext chat. `.dev.vars.example` is the template. + +Run: `npm start` (website :3000 + `wrangler dev` :8787). Login at +`http://localhost:8787/api/vote/login`. + +--- > **Update 2026-06-26 (guild.host owner, Taz):** > @@ -38,7 +153,10 @@ confirmed: the buyer enters each guest's email, each guest **creates their own G claim** their ticket (and may use a different email). So every ticket holder is a separate attendee with their own email/data. No buyer-holds-all problem; no manual fallback needed. -## BLOCKER — "Log in with Guild" doesn't exist yet +## ~~BLOCKER — "Log in with Guild" doesn't exist yet~~ — RESOLVED 2026-07-07 + +Login now exists. OAuth app registered, real client id/secret + endpoints in hand (see RESUME +HERE). Endpoints confirmed against the docs. Historical note below. The website login flow depends on guild.host adding 3rd-party sign-in. The OAuth at `guild.host/docs/developers/oauth` is **private API access only**, not sign-in (per Taz). @@ -49,15 +167,14 @@ The website login flow depends on guild.host adding 3rd-party sign-in. The OAuth - [ ] Until then the login is **stubbed**; everything else (votes, budget, tables, tests) works via the dev `X-Dev-User` header. -## BLOCKER — Guild admin access +## ~~BLOCKER — Guild admin access~~ — SUPERSEDED by the `/ticket` refactor (2026-07-07) -Stripe is out — voting is guild-only. The Stripe webhook path (buyer-derived budget) was -removed; the only attendee/tier source is the guild.host attendees API. +The org-token / manage-event access was only needed to read the full attendee list. The +per-user `/ticket` endpoint removes that need — each voter's own `event_tickets:read` token +returns their tier. No organizer admin access required. (Historical note kept below.) -- [ ] Get **admin access to Guild** (Guild API key + manager-flow OAuth to read all attendees). - Requested from the JSConf team (Erick Wendel / Ana Beatriz) on 2026-06-26. -- [ ] Fallback if access never comes: collect attendee emails in the purchase form instead of - the attendees API. +- [ ] ~~Get admin access to Guild (API key + manager-flow OAuth to read all attendees).~~ +- [ ] Fallback if `/ticket` doesn't pan out: collect attendee emails in the purchase form. ## Live verification (once login exists — can't be done headless) From 452a17f45e7368e8e5966a967f86802f1042f592 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 14:52:35 +0200 Subject: [PATCH 08/25] refactor(voting): budget from per-user /ticket endpoint, drop org-token machinery Read the voter's tier from their own event_tickets:read token at login and bake {userId, budget} into the session JWT, instead of fetching the full attendee list with an organizer token. Removes the userinfo->attendees id-join blocker and unblocks local testing (no organizer consent needed). Deletes repositories/oauth-token.ts, repositories/attendees.ts, the oauth_tokens table, GUILD_ORG_REFRESH_TOKEN, and the event_attendees:read scope. Adds fetchTicketTier + vote().budgetForTier; getUserId becomes getSession. Ticket response shape is undocumented; the tier path is a first guess pending one live login (see TODO.md runbook). --- .dev.vars.example | 4 - .env.example | 2 - HANDOVER.md | 16 ++-- TODO.md | 59 ++++++------ resources/schema.sql | 7 -- src/server/configs/oauth.ts | 7 +- src/server/helpers/oauth.ts | 44 ++++----- src/server/helpers/session.ts | 35 ++++--- src/server/index.ts | 2 +- src/server/repositories/attendees.ts | 96 ------------------- src/server/repositories/oauth-token.ts | 74 -------------- src/server/repositories/vote.ts | 11 ++- src/server/routes/auth.ts | 16 +++- src/server/routes/vote.ts | 21 ++-- src/server/types.ts | 1 - test/server/routes/vote.test.ts | 127 +++++-------------------- 16 files changed, 146 insertions(+), 376 deletions(-) delete mode 100644 src/server/repositories/attendees.ts delete mode 100644 src/server/repositories/oauth-token.ts diff --git a/.dev.vars.example b/.dev.vars.example index 966bf95..7149d4b 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -8,10 +8,6 @@ GUILD_OAUTH_CLIENT_SECRET= # Local: http://localhost:8787/api/vote/callback Prod: https://api.jsconf.com.br/api/vote/callback GUILD_OAUTH_REDIRECT_URI=http://localhost:8787/api/vote/callback -# Organizer refresh token (event_attendees:read). Seeds oauth_tokens on first use; -# the worker rotates it after that. Obtain via a one-time organizer OAuth consent. -GUILD_ORG_REFRESH_TOKEN= - # HMAC/JWT session signing key. Any long random string, e.g. `openssl rand -hex 32`. SESSION_SECRET= diff --git a/.env.example b/.env.example index ada4319..3716afd 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,4 @@ ALLOWED_ORIGIN='' GUILD_OAUTH_CLIENT_ID='' GUILD_OAUTH_CLIENT_SECRET='' GUILD_OAUTH_REDIRECT_URI='' -# Organizer refresh token (seeds oauth_tokens on first use; rotated copies live in D1) -GUILD_ORG_REFRESH_TOKEN='' SESSION_SECRET='' diff --git a/HANDOVER.md b/HANDOVER.md index 8648ff4..d2a49dc 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -102,12 +102,16 @@ Config: `.env.example` updated. `TODO.md` has the deploy checklist. ## State of play (updated 2026-07-07) - `npm run typecheck`, `npm run lint`, `npm test` (9 files) all pass. -- **Committed** on `voting-system` (unsigned — 1Password SSH signer wouldn't run in the agent - session; prior wip history is unsigned too, re-sign via `git rebase --exec 'git commit ---amend --no-edit -S'` if wanted): `refactor(voting): guild-only, jose sessions, parseRequest - - richer talk cards`and`feat(voting): wire guild OAuth app scopes + dev.vars template`. -- **NEXT: the `/ticket` refactor — see the "⏭️ RESUME HERE" section at the top of `TODO.md`.** - Approved, not started. It deletes the org-token machinery and bakes budget into the session. +- **`/ticket` refactor DONE (code) 2026-07-07** — budget now comes from the voter's OWN token via + `GET /events/{slug}/ticket`, baked into the session JWT. Deleted `repositories/oauth-token.ts`, + `repositories/attendees.ts`, the `oauth_tokens` table, `GUILD_ORG_REFRESH_TOKEN`, the + `event_attendees:read` scope. `session.ts` carries `{ userId, budget }` (`getSession`); + `auth.ts` callback resolves tier→budget via `vote().budgetForTier`. typecheck/lint/test pass. +- **NEXT: run the live-login verification runbook** (TODO.md) once — confirm the four undocumented + shapes (userinfo id field, `/ticket` body, no-ticket response, token response), then adjust + `fetchTicketTier` / `fetchUserInfo` if the guesses are wrong. Then operational deploy. +- Prior commits on `voting-system` are unsigned (1Password SSH signer wouldn't run in the agent + session; re-sign via `git rebase --exec 'git commit --amend --no-edit -S'` if wanted). - Real OAuth creds are in `.dev.vars` (gitignored). Rotate the client secret before go-live (pasted in plaintext chat). diff --git a/TODO.md b/TODO.md index 3df548d..c7c6ea6 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,12 @@ Code is complete, typechecked, linted, tested (`npm test` → 9 files pass). What's left is operational + the `/ticket` refactor below. -## ⏭️ RESUME HERE — switch budget to the per-user `/ticket` endpoint (APPROVED, not started) +## ✅ DONE (code) — switched budget to the per-user `/ticket` endpoint (2026-07-07) + +Refactor below is **code-complete**: `npm run typecheck` / `lint` / `test` (9 files) pass. All +checklist boxes done. Still needs the **live-login verification runbook** (undocumented shapes) +and the operational deploy steps. NEXT ACTUAL STEP: run the live-login runbook once, confirm the +four shapes, adjust `fetchTicketTier` / `fetchUserInfo` if needed. Decision made with the user 2026-07-07, checking `guild.host/docs/developers/oauth`. This **reverses** the locked org-token decision: the docs DO expose a per-user endpoint @@ -22,24 +27,25 @@ guild call, no stored attendee token. Checklist: -- [ ] `helpers/oauth.ts`: add `fetchTicketTier(accessToken, slug)` hitting - `${EVENTS_BASE}/{slug}/ticket`. **Response shape is UNDOCUMENTED** — reuse the tier path - `attendees.ts` parsed (`ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name`) - as the first guess; confirm on the first live login. Delete the now-unused `refreshToken`. -- [ ] `helpers/session.ts`: session carries budget. `signSession(userId, budget, secret, ttl)`; - `verifySession` → `{ userId, budget } | null`; replace `getUserId` with `getSession` → - `{ userId, budget }`. Dev `X-Dev-User` path returns a default budget (const, e.g. 3) so - local testing works without a login. -- [ ] `routes/auth.ts`: `authCallback` needs `database` (add to its Options + wiring in - `index.ts`/`routes.ts`). Resolve tier→budget; no ticket → `?error=notattendee`, no session. -- [ ] `routes/vote.ts`: read `{ userId, budget }` from `getSession`; drop `resolveBudget` + - the `budget === null` 403 (non-attendees never get a session). -- [ ] DELETE `repositories/oauth-token.ts`, `repositories/attendees.ts`, the `oauth_tokens` - table (`resources/schema.sql`), and `GUILD_ORG_REFRESH_TOKEN` (types.ts, .env.example, - .dev.vars.example). Drop `lru.min` if nothing else uses it (rate-limit does — check). -- [ ] `configs/oauth.ts`: rename `ATTENDEES_BASE` → `EVENTS_BASE` (or add a ticket URL helper). -- [ ] Rewrite `test/server/routes/vote.test.ts`: remove oauth-token + attendees mocks; session - tests carry budget; voteGet/voteSubmit budget comes from the session (dev path). +- [x] `helpers/oauth.ts`: added `fetchTicketTier(accessToken, slug)` hitting + `${EVENTS_BASE}/{slug}/ticket`. **Response shape UNDOCUMENTED** — first guess reuses the tier + path (`ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name`); confirm on first + live login. Deleted `refreshToken`. +- [x] `helpers/session.ts`: session carries budget. `signSession(userId, budget, secret, ttl)`; + `verifySession` → `Session | null`; `getUserId` → `getSession` → `Session`. Dev `X-Dev-User` + path returns `DEV_BUDGET = 3` so local testing works without a login. +- [x] `routes/auth.ts`: `authCallback` takes `database` (wired in `index.ts`). Resolves tier→budget + via `vote(database).budgetForTier`; no ticket → `?error=notattendee`, no session. +- [x] `routes/vote.ts`: reads `{ userId, budget }` from `getSession`; dropped `resolveBudget` + the + `budget === null` 403 (non-attendees never get a session). +- [x] DELETED `repositories/oauth-token.ts`, `repositories/attendees.ts`, the `oauth_tokens` table, + and `GUILD_ORG_REFRESH_TOKEN` (types.ts, .env.example, .dev.vars.example). Kept `lru.min` — + the rate limiter still uses it. +- [x] `configs/oauth.ts`: renamed `ATTENDEES_BASE` → `EVENTS_BASE`. Also dropped the now-unused + `event_attendees:read` scope (only `profile:read event_tickets:read` requested now). +- [x] Rewrote `test/server/routes/vote.test.ts`: removed oauth-token + attendees mocks; session + tests carry budget; added `budgetForTier` tests; voteGet/voteSubmit budget from the session + (dev path). Dropped the non-attendee 403 test (no longer reachable). ### Endpoints CONFIRMED from the docs (2026-07-07) @@ -73,7 +79,7 @@ the temp probe calls it directly with the fresh access token. `npm run db:init` then `wrangler d1 execute jsconf-br --command "INSERT OR IGNORE INTO ticket_tiers (name, budget) VALUES ('Standard', 3), ('VIP', 5)"` 3. **Add temp logging** in `routes/auth.ts` `authCallback`, right after the `if (!tokens)` guard - (import `USERINFO_URL`, `EVENT_SLUG`, `EVENTS_BASE`/`ATTENDEES_BASE`): + (import `USERINFO_URL`, `EVENT_SLUG`, `EVENTS_BASE`): ```ts // TEMP DEBUG — remove after verifying (do NOT log the raw token value) console.log('TOKEN keys', Object.keys(tokens)); @@ -82,7 +88,7 @@ the temp probe calls it directly with the fresh access token. headers: { Authorization: `Bearer ${at}` }, }); console.log('USERINFO', ui.status, await ui.text()); - const tk = await fetch(`${ATTENDEES_BASE}/${EVENT_SLUG}/ticket`, { + const tk = await fetch(`${EVENTS_BASE}/${EVENT_SLUG}/ticket`, { headers: { Authorization: `Bearer ${at}` }, }); console.log('TICKET', tk.status, await tk.text()); @@ -130,17 +136,12 @@ Run: `npm start` (website :3000 + `wrangler dev` :8787). Login at ## Before deploy - [ ] **Register an OAuth app on guild.host** (client id + secret). Redirect URI = - `https://api.jsconf.com.br/api/vote/callback`. -- [ ] **Obtain the organizer refresh token** via a one-time OAuth consent with the - `event_attendees:read` scope (the organizer account must be able to manage the event). - This seeds `oauth_tokens`; the worker rotates it from then on. + `https://api.jsconf.com.br/api/vote/callback`. Scopes: `profile:read event_tickets:read`. - [ ] **Set worker secrets:** `GUILD_OAUTH_CLIENT_ID`, `GUILD_OAUTH_CLIENT_SECRET`, - `GUILD_OAUTH_REDIRECT_URI`, `GUILD_ORG_REFRESH_TOKEN`, `SESSION_SECRET` - (`wrangler secret put `). + `GUILD_OAUTH_REDIRECT_URI`, `SESSION_SECRET` (`wrangler secret put `). - [ ] **Set `ALLOWED_ORIGIN`** to the website origin (e.g. `https://jsconf.com.br`). Must NOT be `*` — credentialed cookies require an explicit origin. -- [ ] **Run `npm run db:init`** to create the new tables (`ticket_tiers`, `c4p_votes`, - `oauth_tokens`). +- [ ] **Run `npm run db:init`** to create the tables (`ticket_tiers`, `c4p_votes`). - [ ] **Seed `ticket_tiers`** with tier→votes rows. Names must match guild.host tier names exactly, e.g. `INSERT INTO ticket_tiers (name, budget) VALUES ('Standard', 2), ('VIP', 5);` - [ ] **Confirm `EVENT_SLUG`** in `src/server/configs/oauth.ts` (currently `vdc8dh`). diff --git a/resources/schema.sql b/resources/schema.sql index 3ee7543..8abf111 100644 --- a/resources/schema.sql +++ b/resources/schema.sql @@ -72,10 +72,3 @@ CREATE TABLE IF NOT EXISTS c4p_votes ( ); CREATE INDEX IF NOT EXISTS idx_c4p_votes_user ON c4p_votes(user_id); - -CREATE TABLE IF NOT EXISTS oauth_tokens ( - name TEXT PRIMARY KEY, -- 'organizer' - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expires_at INTEGER NOT NULL -- unix seconds -); diff --git a/src/server/configs/oauth.ts b/src/server/configs/oauth.ts index 4614ed4..e0dea02 100644 --- a/src/server/configs/oauth.ts +++ b/src/server/configs/oauth.ts @@ -2,12 +2,11 @@ export const AUTHORIZE_URL = 'https://guild.host/oauth/authorize'; export const TOKEN_URL = 'https://guild.host/api/oauth/token'; export const USERINFO_URL = 'https://guild.host/api/oauth/userinfo'; -export const ATTENDEES_BASE = 'https://guild.host/api/next/events'; +export const EVENTS_BASE = 'https://guild.host/api/next/events'; -// Scopes requested at authorize time: identity (userinfo) + ticket/attendee reads. +// Scopes requested at authorize time: identity (userinfo) + the voter's own ticket. // Space-separated per OAuth 2.0; URLSearchParams encodes the space as '+'. -export const OAUTH_SCOPE = - 'profile:read event_tickets:read event_attendees:read'; +export const OAUTH_SCOPE = 'profile:read event_tickets:read'; // ponytail: event slug hardcoded (same one the ticket widget uses, TicketSelection.tsx). // Move to an Env var if it ever changes per environment. diff --git a/src/server/helpers/oauth.ts b/src/server/helpers/oauth.ts index 7a2d049..6831e34 100644 --- a/src/server/helpers/oauth.ts +++ b/src/server/helpers/oauth.ts @@ -1,5 +1,6 @@ import { AUTHORIZE_URL, + EVENTS_BASE, OAUTH_SCOPE, TOKEN_URL, USERINFO_URL, @@ -42,32 +43,31 @@ export const exchangeCode = async ( return (await res.json()) as { access_token: string }; }; -export const refreshToken = async ( - refresh: string, - clientId: string, - clientSecret: string -): Promise<{ - access_token: string; - refresh_token?: string; - expires_in: number; -} | null> => { - const res = await fetch(TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refresh, - client_id: clientId, - client_secret: clientSecret, - }), +// Returns the authenticated user's ticket tier NAME, or null when they hold no ticket for +// the event (non-attendee). Uses the per-user /ticket endpoint (scope event_tickets:read). +// ponytail: VERIFY LIVE — the /ticket response shape is undocumented. First guess reuses the +// tier path the attendees list exposed (ticketOrder.eventTicketOrderItems.nodes[0] +// .eventTicketingTier.name); confirm on the first live login and adjust. +export const fetchTicketTier = async ( + accessToken: string, + slug: string +): Promise => { + const res = await fetch(`${EVENTS_BASE}/${slug}/ticket`, { + headers: { Authorization: `Bearer ${accessToken}` }, }).catch(() => null); if (!res || !res.ok) return null; - return (await res.json()) as { - access_token: string; - refresh_token?: string; - expires_in: number; + const data = (await res.json()) as { + ticketOrder?: { + eventTicketOrderItems?: { + nodes?: { eventTicketingTier?: { name?: string } | null }[]; + }; + } | null; }; + const tier = + data.ticketOrder?.eventTicketOrderItems?.nodes?.[0]?.eventTicketingTier + ?.name; + return tier ?? null; }; // Returns the authenticated user's stable id. diff --git a/src/server/helpers/session.ts b/src/server/helpers/session.ts index 8f63065..88fe19d 100644 --- a/src/server/helpers/session.ts +++ b/src/server/helpers/session.ts @@ -3,14 +3,20 @@ import { jwtVerify, SignJWT } from 'jose'; import { SESSION_COOKIE, SESSION_TTL_SECONDS } from '../configs/oauth.js'; import { readCookie } from './cookies.js'; +// ponytail: dev-only budget for the X-Dev-User bypass so local voting works without a login. +const DEV_BUDGET = 3; + +export type Session = { userId: string; budget: number }; + const key = (secret: string): Uint8Array => new TextEncoder().encode(secret); export const signSession = async ( userId: string, + budget: number, secret: string, ttlSeconds: number = SESSION_TTL_SECONDS ): Promise => - new SignJWT() + new SignJWT({ budget }) .setProtectedHeader({ alg: 'HS256' }) .setSubject(userId) .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds) @@ -19,30 +25,37 @@ export const signSession = async ( export const verifySession = async ( token: string, secret: string -): Promise => { +): Promise => { try { const { payload } = await jwtVerify(token, key(secret), { algorithms: ['HS256'], }); - return typeof payload.sub === 'string' ? payload.sub : null; + const budget = payload['budget']; + if (typeof payload.sub !== 'string' || typeof budget !== 'number') + return null; + return { userId: payload.sub, budget }; } catch { return null; } }; -// Resolves the voter's id from the signed session cookie. In non-production an X-Dev-User -// header is accepted as a fallback so the endpoints are testable without the OAuth round-trip. -export const getUserId = async ( +// Resolves the voter's session from the signed cookie. Budget is baked into the JWT at login +// (from their own ticket tier), so no per-request guild call. In non-production an X-Dev-User +// header is accepted as a fallback (with a default budget) so the endpoints are testable +// without the OAuth round-trip. +export const getSession = async ( request: Request, env: Env -): Promise => { +): Promise => { const cookie = readCookie(request, SESSION_COOKIE); if (cookie && env.SESSION_SECRET) { - const userId = await verifySession(cookie, env.SESSION_SECRET); - if (userId) return userId; + const session = await verifySession(cookie, env.SESSION_SECRET); + if (session) return session; } // ponytail: dev-only header bypass. Never honored in production. - if (env.ENVIRONMENT !== 'production') - return request.headers.get('X-Dev-User'); + if (env.ENVIRONMENT !== 'production') { + const devUser = request.headers.get('X-Dev-User'); + if (devUser) return { userId: devUser, budget: DEV_BUDGET }; + } return null; }; diff --git a/src/server/index.ts b/src/server/index.ts index dbc47d3..f84f5a3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -38,7 +38,7 @@ export default { case 'GET /api/vote/login': return routes.authLogin({ request, env }); case 'GET /api/vote/callback': - return routes.authCallback({ request, env }); + return routes.authCallback({ request, env, database: env.DB }); case 'GET /api/vote': return routes.voteGet({ request, cors, database: env.DB, env }); case 'POST /api/vote': diff --git a/src/server/repositories/attendees.ts b/src/server/repositories/attendees.ts deleted file mode 100644 index e3939fc..0000000 --- a/src/server/repositories/attendees.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Database, Env } from '../types.js'; -import { createLRU } from 'lru.min'; -import { ATTENDEES_BASE, EVENT_SLUG } from '../configs/oauth.js'; -import { getOrgAccessToken } from './oauth-token.js'; - -// Cached userId -> tier name map, keyed by event slug. Refetched after TTL. -// ponytail: per-isolate in-memory cache, same tradeoff as the rate limiter. Many isolates -// each refetch on a cold miss; fine for a vote window with a few hundred attendees. -const TTL_MS = 5 * 60_000; -const cache = createLRU }>({ - max: 8, -}); - -type Attendee = { - status?: string; - paymentStatus?: string | null; - user?: { id?: string; rowId?: string; slugId?: string } | null; - ticketOrder?: { - eventTicketOrderItems?: { - nodes?: { eventTicketingTier?: { name?: string } | null }[]; - }; - } | null; -}; - -const isConfirmed = (a: Attendee): boolean => - a.status !== 'CANCELLED' && a.paymentStatus !== 'FAILED'; - -const tierOf = (a: Attendee): string | undefined => - a.ticketOrder?.eventTicketOrderItems?.nodes?.[0]?.eventTicketingTier?.name; - -const fetchTierMap = async (token: string): Promise> => { - const map: Record = {}; - let after: string | null = null; - - do { - const url = new URL(`${ATTENDEES_BASE}/${EVENT_SLUG}/attendees`); - url.searchParams.set('first', '100'); - if (after) url.searchParams.set('after', after); - - const res = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - }).catch(() => null); - if (!res || !res.ok) break; - - const data = (await res.json()) as { - edges?: { node?: Attendee }[]; - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; - }; - - for (const edge of data.edges ?? []) { - const node = edge.node; - if (!node || !isConfirmed(node)) continue; - const tier = tierOf(node); - if (!tier) continue; - // Map every id variant so the lookup matches whatever userinfo returns. - for (const id of [node.user?.id, node.user?.rowId, node.user?.slugId]) - if (id) map[id] = tier; - } - - after = data.pageInfo?.hasNextPage - ? (data.pageInfo.endCursor ?? null) - : null; - } while (after); - - return map; -}; - -const getTierMap = async (token: string): Promise> => { - const hit = cache.get(EVENT_SLUG); - if (hit && Date.now() - hit.at < TTL_MS) return hit.map; - const map = await fetchTierMap(token); - cache.set(EVENT_SLUG, { at: Date.now(), map }); - return map; -}; - -// Returns the vote budget for a user, or null when they are not a confirmed attendee. -// An attendee on a tier missing from ticket_tiers gets 0 (can view, cannot vote). -export const resolveBudget = async ( - env: Env, - database: Database, - userId: string -): Promise => { - const token = await getOrgAccessToken(env, database); - if (!token) return null; - - const map = await getTierMap(token); - const tier = map[userId]; - if (!tier) return null; - - const { results } = await database - .prepare('SELECT budget FROM ticket_tiers WHERE name = ?') - .bind(tier) - .all<{ budget: number }>(); - - return results[0]?.budget ?? 0; -}; diff --git a/src/server/repositories/oauth-token.ts b/src/server/repositories/oauth-token.ts deleted file mode 100644 index 592d9c5..0000000 --- a/src/server/repositories/oauth-token.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Database, Env } from '../types.js'; -import { refreshToken } from '../helpers/oauth.js'; - -const NAME = 'organizer'; -const BUFFER_SECONDS = 60; - -type Row = { access_token: string; refresh_token: string; expires_at: number }; - -const readRow = async (database: Database): Promise => { - const { results } = await database - .prepare( - 'SELECT access_token, refresh_token, expires_at FROM oauth_tokens WHERE name = ?' - ) - .bind(NAME) - .all(); - return results[0] ?? null; -}; - -const writeRow = async ( - database: Database, - access: string, - refresh: string, - expiresAt: number -): Promise => { - await database - .prepare( - `INSERT INTO oauth_tokens (name, access_token, refresh_token, expires_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - access_token = excluded.access_token, - refresh_token = excluded.refresh_token, - expires_at = excluded.expires_at` - ) - .bind(NAME, access, refresh, expiresAt) - .run(); -}; - -// Returns a valid organizer access token, refreshing (and persisting the rotated refresh -// token) when the stored one is near expiry. Seeds from GUILD_ORG_REFRESH_TOKEN on first use. -export const getOrgAccessToken = async ( - env: Env, - database: Database -): Promise => { - const now = Math.floor(Date.now() / 1000); - const row = await readRow(database); - if (row && row.expires_at > now + BUFFER_SECONDS) return row.access_token; - - const refresh = row?.refresh_token ?? env.GUILD_ORG_REFRESH_TOKEN; - if (!refresh || !env.GUILD_OAUTH_CLIENT_ID || !env.GUILD_OAUTH_CLIENT_SECRET) - return null; - - const tokens = await refreshToken( - refresh, - env.GUILD_OAUTH_CLIENT_ID, - env.GUILD_OAUTH_CLIENT_SECRET - ); - - if (!tokens) { - // ponytail: refresh tokens rotate, so a concurrent isolate may have just consumed ours. - // Re-read — if another isolate wrote a fresh token, use it; otherwise give up. - const fresh = await readRow(database); - if (fresh && fresh.expires_at > now + BUFFER_SECONDS) - return fresh.access_token; - return null; - } - - await writeRow( - database, - tokens.access_token, - tokens.refresh_token ?? refresh, - now + tokens.expires_in - ); - return tokens.access_token; -}; diff --git a/src/server/repositories/vote.ts b/src/server/repositories/vote.ts index 0ac65aa..6e81f4f 100644 --- a/src/server/repositories/vote.ts +++ b/src/server/repositories/vote.ts @@ -62,5 +62,14 @@ export const vote = (database: Database) => { .run(); }; - return { listTalks, listUserVotes, castVote, removeVote }; + // Vote budget for a guild.host tier name. Unknown tier → 0 (can view, cannot vote). + const budgetForTier = async (tier: string): Promise => { + const { results } = await database + .prepare('SELECT budget FROM ticket_tiers WHERE name = ?') + .bind(tier) + .all<{ budget: number }>(); + return results[0]?.budget ?? 0; + }; + + return { listTalks, listUserVotes, castVote, removeVote, budgetForTier }; }; diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 150af42..1a54e62 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -1,5 +1,6 @@ -import type { Env } from '../types.js'; +import type { Database, Env } from '../types.js'; import { + EVENT_SLUG, SESSION_COOKIE, SESSION_TTL_SECONDS, STATE_COOKIE, @@ -8,11 +9,14 @@ import { readCookie } from '../helpers/cookies.js'; import { buildAuthorizeUrl, exchangeCode, + fetchTicketTier, fetchUserInfo, } from '../helpers/oauth.js'; import { signSession } from '../helpers/session.js'; +import { vote } from '../repositories/vote.js'; type Options = { request: Request; env: Env }; +type CallbackOptions = Options & { database: Database }; const randomState = (): string => { const bytes = crypto.getRandomValues(new Uint8Array(16)); @@ -54,7 +58,8 @@ export const authLogin = async ({ export const authCallback = async ({ request, env, -}: Options): Promise => { + database, +}: CallbackOptions): Promise => { const params = new URL(request.url).searchParams; const site = websiteOrigin(request, env); @@ -83,7 +88,12 @@ export const authCallback = async ({ const userId = await fetchUserInfo(tokens.access_token); if (!userId) return redirect(`${site}/vote?error=identity`); - const session = await signSession(userId, env.SESSION_SECRET); + // The voter's own token reads their ticket; no ticket → not an attendee, no session. + const tier = await fetchTicketTier(tokens.access_token, EVENT_SLUG); + if (!tier) return redirect(`${site}/vote?error=notattendee`); + const budget = await vote(database).budgetForTier(tier); + + const session = await signSession(userId, budget, env.SESSION_SECRET); // ponytail: session cookie is SameSite=None;Secure so it's sent on the website's // cross-origin fetch to the API subdomain. Both must be HTTPS. return redirect(`${site}/vote`, [ diff --git a/src/server/routes/vote.ts b/src/server/routes/vote.ts index 3019074..4883509 100644 --- a/src/server/routes/vote.ts +++ b/src/server/routes/vote.ts @@ -3,8 +3,7 @@ import { z } from 'zod'; import { isVotingOpen, VOTE_CLOSES_AT } from '../configs/vote.js'; import { parseRequest } from '../helpers/request.js'; import { response } from '../helpers/response.js'; -import { getUserId } from '../helpers/session.js'; -import { resolveBudget } from '../repositories/attendees.js'; +import { getSession } from '../helpers/session.js'; import { vote as repository } from '../repositories/vote.js'; type Options = { @@ -25,12 +24,9 @@ export const voteGet = async ({ database, env, }: Options): Promise => { - const userId = await getUserId(request, env); - if (!userId) return response({ error: 'Unauthorized.' }, 401, cors); - - const budget = await resolveBudget(env, database, userId); - if (budget === null) - return response({ error: 'Not an attendee.' }, 403, cors); + const session = await getSession(request, env); + if (!session) return response({ error: 'Unauthorized.' }, 401, cors); + const { userId, budget } = session; const repo = repository(database); const [talks, myVotes] = await Promise.all([ @@ -51,8 +47,9 @@ export const voteSubmit = async ({ database, env, }: Options): Promise => { - const userId = await getUserId(request, env); - if (!userId) return response({ error: 'Unauthorized.' }, 401, cors); + const session = await getSession(request, env); + if (!session) return response({ error: 'Unauthorized.' }, 401, cors); + const { userId, budget } = session; if (!isVotingOpen()) return response({ error: 'Voting closed.' }, 403, cors); @@ -60,10 +57,6 @@ export const voteSubmit = async ({ if ('error' in parsed) return response({ error: parsed.error }, parsed.status, cors); - const budget = await resolveBudget(env, database, userId); - if (budget === null) - return response({ error: 'Not an attendee.' }, 403, cors); - const repo = repository(database); if (parsed.data.action === 'remove') { diff --git a/src/server/types.ts b/src/server/types.ts index 6700320..bec5835 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -15,6 +15,5 @@ export type Env = { GUILD_OAUTH_CLIENT_ID?: string; GUILD_OAUTH_CLIENT_SECRET?: string; GUILD_OAUTH_REDIRECT_URI?: string; - GUILD_ORG_REFRESH_TOKEN?: string; SESSION_SECRET?: string; }; diff --git a/test/server/routes/vote.test.ts b/test/server/routes/vote.test.ts index 217e00c..c11a443 100644 --- a/test/server/routes/vote.test.ts +++ b/test/server/routes/vote.test.ts @@ -5,7 +5,7 @@ import { signSession, verifySession, } from '../../../src/server/helpers/session.js'; -import { getOrgAccessToken } from '../../../src/server/repositories/oauth-token.js'; +import { vote } from '../../../src/server/repositories/vote.js'; import { routes } from '../../../src/server/routes.js'; const cors = { 'Access-Control-Allow-Origin': '*' }; @@ -15,62 +15,12 @@ const talks = [ { id: 2, title: 'B', description: 'db', speaker_name: 'Bob' }, ]; -const FUTURE = Math.floor(Date.now() / 1000) + 100_000; +type Mock = { database: Database; votes: Set }; -// Stub guild.host: the attendees list (user-1 = confirmed 'Standard') and the token refresh. -globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = String(input instanceof Request ? input.url : input); - if (url.includes('/attendees')) - return new Response( - JSON.stringify({ - edges: [ - { - node: { - status: 'CONFIRMED', - paymentStatus: 'PAID', - user: { id: 'user-1' }, - ticketOrder: { - eventTicketOrderItems: { - nodes: [{ eventTicketingTier: { name: 'Standard' } }], - }, - }, - }, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); - if (url.includes('/oauth/token')) - return new Response( - JSON.stringify({ - access_token: 'refreshed-access', - refresh_token: 'r2', - expires_in: 86400, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); - return new Response('{}', { status: 404 }); -}) as typeof fetch; - -type Mock = { - database: Database; - votes: Set; - tokenWrites: unknown[][]; -}; - -const makeMock = ( - seed: number[] = [], - tokenRow: { refresh_token: string; expires_at: number } | null = { - refresh_token: 'r1', - expires_at: FUTURE, - } -): Mock => { +const makeMock = (seed: number[] = []): Mock => { const votes = new Set(seed); - const tokenWrites: unknown[][] = []; return { votes, - tokenWrites, database: { prepare: (sql: string) => ({ bind: (...values: unknown[]) => ({ @@ -78,16 +28,8 @@ const makeMock = ( if (sql.includes('INTO c4p_votes')) votes.add(values[1] as number); else if (sql.startsWith('DELETE')) votes.delete(values[1] as number); - else if (sql.includes('INTO oauth_tokens')) - tokenWrites.push(values); }, all: async () => { - if (sql.includes('oauth_tokens')) - return { - results: (tokenRow - ? [{ access_token: 'org-access', ...tokenRow }] - : []) as T[], - }; if (sql.includes('ticket_tiers')) return { results: [{ budget: 3 }] as T[] }; if (sql.includes('FROM talks')) return { results: talks as T[] }; @@ -104,11 +46,7 @@ const makeMock = ( }; const makeEnv = (environment?: string): Env => - ({ - ENVIRONMENT: environment, - GUILD_OAUTH_CLIENT_ID: 'cid', - GUILD_OAUTH_CLIENT_SECRET: 'secret', - }) as Env; + ({ ENVIRONMENT: environment }) as Env; const getReq = (userId?: string): Request => new Request('http://localhost/api/vote', { @@ -139,45 +77,42 @@ describe('configs.vote', async () => { }); describe('helpers.session', async () => { - await it('round-trips a signed session', async () => { - const token = await signSession('user-1', 'secret'); - assert.equal(await verifySession(token, 'secret'), 'user-1'); + await it('round-trips a signed session carrying the budget', async () => { + const token = await signSession('user-1', 5, 'secret'); + assert.deepEqual(await verifySession(token, 'secret'), { + userId: 'user-1', + budget: 5, + }); }); await it('rejects a wrong secret and a tampered token', async () => { - const token = await signSession('user-1', 'secret'); + const token = await signSession('user-1', 5, 'secret'); assert.equal(await verifySession(token, 'other'), null); assert.equal(await verifySession(`${token}x`, 'secret'), null); }); await it('rejects an expired session', async () => { - const token = await signSession('user-1', 'secret', -1); + const token = await signSession('user-1', 5, 'secret', -1); assert.equal(await verifySession(token, 'secret'), null); }); }); -describe('repositories.oauth-token', async () => { - await it('returns the stored access token when still valid', async () => { - const mock = makeMock(); - const token = await getOrgAccessToken(makeEnv(), mock.database); - assert.equal(token, 'org-access'); - assert.equal(mock.tokenWrites.length, 0); - }); - - await it('refreshes and persists the rotated token when expired', async () => { - const mock = makeMock([], { refresh_token: 'r1', expires_at: 0 }); - const token = await getOrgAccessToken(makeEnv(), mock.database); - assert.equal(token, 'refreshed-access'); - assert.equal(mock.tokenWrites.length, 1); - // [name, access_token, refresh_token, expires_at] - assert.equal(mock.tokenWrites[0]?.[2], 'r2'); +describe('repositories.vote.budgetForTier', async () => { + await it('returns the tier budget from ticket_tiers', async () => { + const budget = await vote(makeMock().database).budgetForTier('Standard'); + assert.equal(budget, 3); }); - await it('seeds from the env refresh token when no row exists', async () => { - const mock = makeMock([], null); - const env = { ...makeEnv(), GUILD_ORG_REFRESH_TOKEN: 'seed' } as Env; - const token = await getOrgAccessToken(env, mock.database); - assert.equal(token, 'refreshed-access'); + await it('returns 0 for an unknown tier', async () => { + const db: Database = { + prepare: () => ({ + bind: () => ({ + run: async () => {}, + all: async () => ({ results: [] as T[] }), + }), + }), + }; + assert.equal(await vote(db).budgetForTier('Nope'), 0); }); }); @@ -202,16 +137,6 @@ describe('routes.voteGet', async () => { assert.equal(res.status, 401); }); - await it('returns 403 for a non-attendee', async () => { - const res = await routes.voteGet({ - request: getReq('ghost'), - cors, - database: makeMock().database, - env: makeEnv(), - }); - assert.equal(res.status, 403); - }); - await it('returns budget, used, talks and current votes', async () => { const res = await routes.voteGet({ request: getReq('user-1'), From 87116a8975a51406e7a95c21417b31b66179bedf Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 14:56:46 +0200 Subject: [PATCH 09/25] feat(voting): add logout endpoint GET /api/vote/logout clears the vote_session cookie (matching Path/SameSite/Secure attributes so the browser drops it) and redirects back to /vote. --- TODO.md | 3 ++- src/server/index.ts | 2 ++ src/server/routes.ts | 3 ++- src/server/routes/auth.ts | 11 +++++++++++ test/server/routes/vote.test.ts | 14 ++++++++++++++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index c7c6ea6..d29b97e 100644 --- a/TODO.md +++ b/TODO.md @@ -216,7 +216,8 @@ real signed-cookie session from the OAuth flow. ## Nice-to-have (not blocking) -- [ ] Logout endpoint / button (clear `vote_session` cookie). +- [x] Logout endpoint (`GET /api/vote/logout`, clears `vote_session`, redirects to `/vote`). + Frontend button not added yet — wire one on the `/vote` page when needed. - [ ] Admin read endpoint for vote tallies (none exists yet — votes are write-only via the API). ## Stolen from PR #40 (done) diff --git a/src/server/index.ts b/src/server/index.ts index f84f5a3..c6ab03e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -37,6 +37,8 @@ export default { return routes.c4p({ request, cors, database: env.DB, ip }); case 'GET /api/vote/login': return routes.authLogin({ request, env }); + case 'GET /api/vote/logout': + return routes.authLogout({ request, env }); case 'GET /api/vote/callback': return routes.authCallback({ request, env, database: env.DB }); case 'GET /api/vote': diff --git a/src/server/routes.ts b/src/server/routes.ts index 0d5f6fb..5f1eae8 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,4 +1,4 @@ -import { authCallback, authLogin } from './routes/auth.js'; +import { authCallback, authLogin, authLogout } from './routes/auth.js'; import { c4p } from './routes/c4p.js'; import { voteGet, voteSubmit } from './routes/vote.js'; import { waitlist } from './routes/waitlist.js'; @@ -9,5 +9,6 @@ export const routes = { voteGet, voteSubmit, authLogin, + authLogout, authCallback, }; diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 1a54e62..15231c1 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -55,6 +55,17 @@ export const authLogin = async ({ ]); }; +export const authLogout = async ({ + request, + env, +}: Options): Promise => { + // Clear the session cookie. Attributes must match the ones it was set with (Path=/, + // SameSite=None; Secure) so the browser actually drops it. + return redirect(`${websiteOrigin(request, env)}/vote`, [ + `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=0`, + ]); +}; + export const authCallback = async ({ request, env, diff --git a/test/server/routes/vote.test.ts b/test/server/routes/vote.test.ts index c11a443..08aad51 100644 --- a/test/server/routes/vote.test.ts +++ b/test/server/routes/vote.test.ts @@ -116,6 +116,20 @@ describe('repositories.vote.budgetForTier', async () => { }); }); +describe('routes.authLogout', async () => { + await it('redirects and clears the session cookie', async () => { + const res = await routes.authLogout({ + request: new Request('http://localhost/api/vote/logout'), + env: { ALLOWED_ORIGIN: 'http://site' } as Env, + }); + assert.equal(res.status, 302); + assert.equal(res.headers.get('Location'), 'http://site/vote'); + const cookie = res.headers.get('Set-Cookie') ?? ''; + assert.equal(cookie.includes('vote_session=;'), true); + assert.equal(cookie.includes('Max-Age=0'), true); + }); +}); + describe('routes.voteGet', async () => { await it('returns 401 without a session', async () => { const res = await routes.voteGet({ From 4c703749b76277694c08218a5910e0f43d4633d2 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 14:57:41 +0200 Subject: [PATCH 10/25] feat(voting): add Sair (logout) button to the vote page Links to GET /api/vote/logout in the ready state, next to the remaining-votes count. --- TODO.md | 4 ++-- src/website/pages/vote/index.tsx | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index d29b97e..d6ae898 100644 --- a/TODO.md +++ b/TODO.md @@ -216,8 +216,8 @@ real signed-cookie session from the OAuth flow. ## Nice-to-have (not blocking) -- [x] Logout endpoint (`GET /api/vote/logout`, clears `vote_session`, redirects to `/vote`). - Frontend button not added yet — wire one on the `/vote` page when needed. +- [x] Logout endpoint (`GET /api/vote/logout`, clears `vote_session`, redirects to `/vote`) + + "Sair" button on the `/vote` page. - [ ] Admin read endpoint for vote tallies (none exists yet — votes are write-only via the API). ## Stolen from PR #40 (done) diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx index f722031..e475ec0 100644 --- a/src/website/pages/vote/index.tsx +++ b/src/website/pages/vote/index.tsx @@ -101,9 +101,18 @@ const Vote = () => { {status === 'ready' && session && ( <> -

- Votos restantes: {session.budget - votes.size} de {session.budget} -

+
+

+ Votos restantes: {session.budget - votes.size} de{' '} + {session.budget} +

+ + Sair + +
    {session.talks.map((talk) => (
  • From 92f1afa25b500be8cf76574b9eb0ce83d9e4d07c Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 14:59:41 +0200 Subject: [PATCH 11/25] feat(voting): surface OAuth login errors on the vote page Read the ?error= param the callback bounces back (denied/state/token/identity/ notattendee), toast a pt-BR message, and strip it from the URL. Guards run inside useEffect so SSR stays window-free. --- src/website/pages/vote/index.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx index e475ec0..73faeb5 100644 --- a/src/website/pages/vote/index.tsx +++ b/src/website/pages/vote/index.tsx @@ -24,6 +24,15 @@ type Session = { closesAt: string; }; +// Login failure messages surfaced from the OAuth callback's ?error= redirect. +const LOGIN_ERRORS: Record = { + denied: 'Login cancelado.', + state: 'Sessão de login expirada. Tente novamente.', + token: 'Falha ao autenticar com guild.host. Tente novamente.', + identity: 'Não foi possível identificar sua conta guild.host.', + notattendee: 'Você não possui um ingresso para o evento.', +}; + const Vote = () => { const { siteConfig } = useDocusaurusContext(); const workerDomain = siteConfig.customFields?.['workerDomain'] as @@ -36,6 +45,21 @@ const Vote = () => { >('loading'); const [votes, setVotes] = useState>(new Set()); + // Surface a login failure bounced back from the callback, then strip it from the URL. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const error = params.get('error'); + if (!error) return; + toast.error(LOGIN_ERRORS[error] ?? 'Erro ao entrar. Tente novamente.'); + params.delete('error'); + const query = params.toString(); + window.history.replaceState( + {}, + '', + window.location.pathname + (query ? `?${query}` : '') + ); + }, []); + useEffect(() => { if (!workerDomain) return setStatus('error'); fetch(`${workerDomain}/api/vote`, { credentials: 'include' }) From 84524d1ff1fd8c2d85bec623d21406a3e525a442 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 15:22:23 +0200 Subject: [PATCH 12/25] feat(account): add account page + /api/vote/me, salvaged from divergent branch The remote voting-system had backed the voting feature out in favor of an account page; this keeps the voting feature and folds in the still-useful pieces: a /account page showing the logged-in guild.host user id (GET /api/vote/me, from getSession), the navbar link, and the i18n strings for all three locales. --- i18n/en-US/code.json | 3 ++ i18n/es-419/code.json | 3 ++ i18n/pt-BR/code.json | 3 ++ src/server/index.ts | 2 + src/server/routes.ts | 3 +- src/server/routes/auth.ts | 15 +++++- src/website/components/_partial/Navbar.tsx | 6 +++ src/website/pages/account/index.tsx | 57 ++++++++++++++++++++++ test/server/routes/vote.test.ts | 23 +++++++++ 9 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/website/pages/account/index.tsx diff --git a/i18n/en-US/code.json b/i18n/en-US/code.json index fe60042..8bcd8c6 100644 --- a/i18n/en-US/code.json +++ b/i18n/en-US/code.json @@ -14,6 +14,9 @@ "navbar.section.team": { "message": "Our Team" }, + "navbar.section.account": { + "message": "Account" + }, "navbar.section.sponsors": { "message": "Become a Sponsor" }, diff --git a/i18n/es-419/code.json b/i18n/es-419/code.json index a7d3864..9a7381f 100644 --- a/i18n/es-419/code.json +++ b/i18n/es-419/code.json @@ -14,6 +14,9 @@ "navbar.section.team": { "message": "Nuestro Equipo" }, + "navbar.section.account": { + "message": "Cuenta" + }, "navbar.section.sponsors": { "message": "Sé un Patrocinador" }, diff --git a/i18n/pt-BR/code.json b/i18n/pt-BR/code.json index 34a21cf..c7e679b 100644 --- a/i18n/pt-BR/code.json +++ b/i18n/pt-BR/code.json @@ -14,6 +14,9 @@ "navbar.section.team": { "message": "Nosso Time" }, + "navbar.section.account": { + "message": "Conta" + }, "navbar.section.sponsors": { "message": "Seja um Patrocinador" }, diff --git a/src/server/index.ts b/src/server/index.ts index c6ab03e..823ec4d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -37,6 +37,8 @@ export default { return routes.c4p({ request, cors, database: env.DB, ip }); case 'GET /api/vote/login': return routes.authLogin({ request, env }); + case 'GET /api/vote/me': + return routes.authMe({ request, cors, env }); case 'GET /api/vote/logout': return routes.authLogout({ request, env }); case 'GET /api/vote/callback': diff --git a/src/server/routes.ts b/src/server/routes.ts index 5f1eae8..fa10d43 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,4 +1,4 @@ -import { authCallback, authLogin, authLogout } from './routes/auth.js'; +import { authCallback, authLogin, authLogout, authMe } from './routes/auth.js'; import { c4p } from './routes/c4p.js'; import { voteGet, voteSubmit } from './routes/vote.js'; import { waitlist } from './routes/waitlist.js'; @@ -10,5 +10,6 @@ export const routes = { voteSubmit, authLogin, authLogout, + authMe, authCallback, }; diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 15231c1..81b7ee6 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -12,11 +12,13 @@ import { fetchTicketTier, fetchUserInfo, } from '../helpers/oauth.js'; -import { signSession } from '../helpers/session.js'; +import { response } from '../helpers/response.js'; +import { getSession, signSession } from '../helpers/session.js'; import { vote } from '../repositories/vote.js'; type Options = { request: Request; env: Env }; type CallbackOptions = Options & { database: Database }; +type MeOptions = Options & { cors: Record }; const randomState = (): string => { const bytes = crypto.getRandomValues(new Uint8Array(16)); @@ -55,6 +57,17 @@ export const authLogin = async ({ ]); }; +// Returns the logged-in voter's guild.host user id, for the account page. 401 when unauthenticated. +export const authMe = async ({ + request, + env, + cors, +}: MeOptions): Promise => { + const session = await getSession(request, env); + if (!session) return response({ error: 'Unauthorized.' }, 401, cors); + return response({ userId: session.userId }, 200, cors); +}; + export const authLogout = async ({ request, env, diff --git a/src/website/components/_partial/Navbar.tsx b/src/website/components/_partial/Navbar.tsx index ad696e3..035e537 100644 --- a/src/website/components/_partial/Navbar.tsx +++ b/src/website/components/_partial/Navbar.tsx @@ -188,6 +188,9 @@ export const Navbar = () => { + + +
    {otherLocales.length > 0 && ( @@ -274,6 +277,9 @@ export const Navbar = () => { + + + diff --git a/src/website/pages/account/index.tsx b/src/website/pages/account/index.tsx new file mode 100644 index 0000000..9ae91c3 --- /dev/null +++ b/src/website/pages/account/index.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { Page } from '@site/src/website/components/shared/Page'; + +const Account = () => { + const { siteConfig } = useDocusaurusContext(); + const workerDomain = siteConfig.customFields?.['workerDomain'] as + | string + | undefined; + + const [userId, setUserId] = useState(null); + const [status, setStatus] = useState<'loading' | 'in' | 'out' | 'error'>( + 'loading' + ); + + useEffect(() => { + if (!workerDomain) return setStatus('error'); + fetch(`${workerDomain}/api/vote/me`, { credentials: 'include' }) + .then(async (res) => { + if (res.status === 401) return setStatus('out'); + if (!res.ok) return setStatus('error'); + const data = (await res.json()) as { userId: string }; + setUserId(data.userId); + setStatus('in'); + }) + .catch(() => setStatus('error')); + }, [workerDomain]); + + return ( + +
    +

    Conta

    + + {status === 'loading' &&

    Carregando…

    } + {status === 'error' && ( +

    Não foi possível carregar. Tente mais tarde.

    + )} + {status === 'out' && ( + + Entrar com guild.host + + )} + {status === 'in' && userId && ( +
    + {userId} + Sair +
    + )} +
    +
    + ); +}; + +export default Account; diff --git a/test/server/routes/vote.test.ts b/test/server/routes/vote.test.ts index 08aad51..d141821 100644 --- a/test/server/routes/vote.test.ts +++ b/test/server/routes/vote.test.ts @@ -116,6 +116,29 @@ describe('repositories.vote.budgetForTier', async () => { }); }); +describe('routes.authMe', async () => { + await it('returns 401 without a session', async () => { + const res = await routes.authMe({ + request: new Request('http://localhost/api/vote/me'), + cors, + env: makeEnv(), + }); + assert.equal(res.status, 401); + }); + + await it('returns the user id from the session (dev header)', async () => { + const res = await routes.authMe({ + request: new Request('http://localhost/api/vote/me', { + headers: { 'X-Dev-User': 'user-1' }, + }), + cors, + env: makeEnv(), + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { userId: 'user-1' }); + }); +}); + describe('routes.authLogout', async () => { await it('redirects and clears the session cookie', async () => { const res = await routes.authLogout({ From 1a3c5adf3451427a05e527376302df47a5c28980 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 15:24:09 +0200 Subject: [PATCH 13/25] chore: salvage .serena gitignore + headroom notes from divergent branch --- .gitignore | 1 + CLAUDE.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index 6dddee8..0d32e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .env .dev.vars .npmrc +/.serena diff --git a/CLAUDE.md b/CLAUDE.md index 03b7024..a3c5350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,3 +60,18 @@ Run `npm ci` first if `node_modules` is missing (typecheck/test need deps). The `voting-system` branch adds attendee voting on C4P talks (guild.host OAuth identity, on-the-fly tier budget, votes in D1). See `HANDOVER.md` for full context and `TODO.md` for the remaining (mostly operational) work and a verification blocker. + + + +## Headroom Learned Patterns + +_Auto-generated by `headroom learn` on 2026-06-26 — do not edit manually_ + +### Dependencies + +_~1,500 tokens/session saved_ + +- Server session/JWT uses `jose` (^6.x). The `cookie` package was tried and removed — it is NOT a dependency; don't re-add it. +- To check an installed package version, `grep -E '"version"' node_modules//package.json`. Do NOT use `node -e "require('/package.json')"` — packages like `jose` define `exports` and throw `ERR_PACKAGE_PATH_NOT_EXPORTED`. + + From 7184981edc70b0cbdf2a8286b33bf35068f69e4e Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 17:39:49 +0200 Subject: [PATCH 14/25] feat(voting): handle closed voting + show deadline on the vote page closesAt was fetched but never used. Now render the deadline while open, show a closed notice and disable the checkboxes once past closesAt, and no-op the toggle. --- src/website/pages/vote/index.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx index 73faeb5..4856c12 100644 --- a/src/website/pages/vote/index.tsx +++ b/src/website/pages/vote/index.tsx @@ -75,9 +75,13 @@ const Vote = () => { .catch(() => setStatus('error')); }, [workerDomain]); + const closed = session + ? new Date(session.closesAt).getTime() < Date.now() + : false; + const toggle = useCallback( async (talkId: number) => { - if (!session) return; + if (!session || closed) return; const has = votes.has(talkId); if (!has && votes.size >= session.budget) { toast.error(`Você já usou seus ${session.budget} votos.`); @@ -102,7 +106,7 @@ const Vote = () => { toast.error('Erro ao registrar voto. Tente novamente.'); } }, - [session, votes, workerDomain] + [session, votes, workerDomain, closed] ); return ( @@ -126,10 +130,19 @@ const Vote = () => { {status === 'ready' && session && ( <>
    -

    - Votos restantes: {session.budget - votes.size} de{' '} - {session.budget} -

    + {closed ? ( +

    A votação foi encerrada.

    + ) : ( +

    + Votos restantes: {session.budget - votes.size} de{' '} + {session.budget} +
    + + Aberta até{' '} + {new Date(session.closesAt).toLocaleString('pt-BR')} + +

    + )} { toggle(talk.id)} /> From 0cbb1a53842c98a8f837b784ceba7256cb4e9a4e Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Tue, 7 Jul 2026 17:55:31 +0200 Subject: [PATCH 15/25] i18n(voting): translate the vote and account pages Both pages were hardcoded pt-BR while the site is trilingual. Route all strings through the shared Text/text helpers, add en-US and es-419 catalog entries, and format the deadline with the current Docusaurus locale. --- i18n/en-US/code.json | 60 +++++++++++++++++++++++ i18n/es-419/code.json | 60 +++++++++++++++++++++++ i18n/pt-BR/code.json | 60 +++++++++++++++++++++++ src/website/pages/account/index.tsx | 23 ++++++--- src/website/pages/vote/index.tsx | 74 +++++++++++++++++++---------- 5 files changed, 247 insertions(+), 30 deletions(-) diff --git a/i18n/en-US/code.json b/i18n/en-US/code.json index 8bcd8c6..c478d16 100644 --- a/i18n/en-US/code.json +++ b/i18n/en-US/code.json @@ -367,5 +367,65 @@ }, "supporters.subtitle": { "message": "We are grateful for the support of our partners who share our vision of strengthening the JavaScript community in Brazil." + }, + "common.loading": { + "message": "Loading…" + }, + "auth.login": { + "message": "Log in with guild.host" + }, + "auth.logout": { + "message": "Log out" + }, + "vote.title": { + "message": "Voting - JSConf Brasil 2026" + }, + "vote.heading": { + "message": "Vote for talks" + }, + "vote.loadError": { + "message": "Could not load voting. Try again later." + }, + "vote.remaining": { + "message": "Votes left: {remaining} of {budget}" + }, + "vote.openUntil": { + "message": "Open until {date}" + }, + "vote.closed": { + "message": "Voting has closed." + }, + "vote.limitReached": { + "message": "You have already used all {budget} of your votes." + }, + "vote.submitError": { + "message": "Error saving your vote. Try again." + }, + "login.error.denied": { + "message": "Login cancelled." + }, + "login.error.state": { + "message": "Login session expired. Try again." + }, + "login.error.token": { + "message": "Failed to authenticate with guild.host. Try again." + }, + "login.error.identity": { + "message": "Could not identify your guild.host account." + }, + "login.error.notattendee": { + "message": "You do not have a ticket for the event." + }, + "login.error.generic": { + "message": "Login error. Try again." + }, + "account.title": { + "message": "Account - JSConf Brasil 2026" + }, + "account.heading": { + "message": "Account" + }, + "account.loadError": { + "message": "Could not load. Try again later." } } diff --git a/i18n/es-419/code.json b/i18n/es-419/code.json index 9a7381f..be6d7f2 100644 --- a/i18n/es-419/code.json +++ b/i18n/es-419/code.json @@ -367,5 +367,65 @@ }, "supporters.subtitle": { "message": "Estamos agradecidos por el apoyo de nuestros socios que comparten nuestra visión de fortalecer la comunidad JavaScript en Brasil." + }, + "common.loading": { + "message": "Cargando…" + }, + "auth.login": { + "message": "Entrar con guild.host" + }, + "auth.logout": { + "message": "Salir" + }, + "vote.title": { + "message": "Votación - JSConf Brasil 2026" + }, + "vote.heading": { + "message": "Vota por las charlas" + }, + "vote.loadError": { + "message": "No se pudo cargar la votación. Inténtalo más tarde." + }, + "vote.remaining": { + "message": "Votos restantes: {remaining} de {budget}" + }, + "vote.openUntil": { + "message": "Abierta hasta {date}" + }, + "vote.closed": { + "message": "La votación ha cerrado." + }, + "vote.limitReached": { + "message": "Ya usaste tus {budget} votos." + }, + "vote.submitError": { + "message": "Error al registrar el voto. Inténtalo de nuevo." + }, + "login.error.denied": { + "message": "Inicio de sesión cancelado." + }, + "login.error.state": { + "message": "La sesión de inicio expiró. Inténtalo de nuevo." + }, + "login.error.token": { + "message": "Error al autenticar con guild.host. Inténtalo de nuevo." + }, + "login.error.identity": { + "message": "No se pudo identificar tu cuenta de guild.host." + }, + "login.error.notattendee": { + "message": "No tienes una entrada para el evento." + }, + "login.error.generic": { + "message": "Error al entrar. Inténtalo de nuevo." + }, + "account.title": { + "message": "Cuenta - JSConf Brasil 2026" + }, + "account.heading": { + "message": "Cuenta" + }, + "account.loadError": { + "message": "No se pudo cargar. Inténtalo más tarde." } } diff --git a/i18n/pt-BR/code.json b/i18n/pt-BR/code.json index c7e679b..c07e361 100644 --- a/i18n/pt-BR/code.json +++ b/i18n/pt-BR/code.json @@ -367,5 +367,65 @@ }, "supporters.subtitle": { "message": "Somos gratos pelo apoio de nossos parceiros que compartilham nossa visão de fortalecer a comunidade JavaScript no Brasil." + }, + "common.loading": { + "message": "Carregando…" + }, + "auth.login": { + "message": "Entrar com guild.host" + }, + "auth.logout": { + "message": "Sair" + }, + "vote.title": { + "message": "Votação - JSConf Brasil 2026" + }, + "vote.heading": { + "message": "Vote nas palestras" + }, + "vote.loadError": { + "message": "Não foi possível carregar a votação. Tente mais tarde." + }, + "vote.remaining": { + "message": "Votos restantes: {remaining} de {budget}" + }, + "vote.openUntil": { + "message": "Aberta até {date}" + }, + "vote.closed": { + "message": "A votação foi encerrada." + }, + "vote.limitReached": { + "message": "Você já usou seus {budget} votos." + }, + "vote.submitError": { + "message": "Erro ao registrar voto. Tente novamente." + }, + "login.error.denied": { + "message": "Login cancelado." + }, + "login.error.state": { + "message": "Sessão de login expirada. Tente novamente." + }, + "login.error.token": { + "message": "Falha ao autenticar com guild.host. Tente novamente." + }, + "login.error.identity": { + "message": "Não foi possível identificar sua conta guild.host." + }, + "login.error.notattendee": { + "message": "Você não possui um ingresso para o evento." + }, + "login.error.generic": { + "message": "Erro ao entrar. Tente novamente." + }, + "account.title": { + "message": "Conta - JSConf Brasil 2026" + }, + "account.heading": { + "message": "Conta" + }, + "account.loadError": { + "message": "Não foi possível carregar. Tente mais tarde." } } diff --git a/src/website/pages/account/index.tsx b/src/website/pages/account/index.tsx index 9ae91c3..89bde0b 100644 --- a/src/website/pages/account/index.tsx +++ b/src/website/pages/account/index.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import { Page } from '@site/src/website/components/shared/Page'; const Account = () => { @@ -27,26 +28,36 @@ const Account = () => { }, [workerDomain]); return ( - + diff --git a/src/website/pages/vote/index.tsx b/src/website/pages/vote/index.tsx index 4856c12..645abda 100644 --- a/src/website/pages/vote/index.tsx +++ b/src/website/pages/vote/index.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { toast } from 'sonner'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import { Page } from '@site/src/website/components/shared/Page'; import { audienceLevels, @@ -24,17 +25,18 @@ type Session = { closesAt: string; }; -// Login failure messages surfaced from the OAuth callback's ?error= redirect. -const LOGIN_ERRORS: Record = { - denied: 'Login cancelado.', - state: 'Sessão de login expirada. Tente novamente.', - token: 'Falha ao autenticar com guild.host. Tente novamente.', - identity: 'Não foi possível identificar sua conta guild.host.', - notattendee: 'Você não possui um ingresso para o evento.', -}; +// Callback ?error= code -> translation id for the message surfaced on the vote page. +const LOGIN_ERROR_IDS = { + denied: 'login.error.denied', + state: 'login.error.state', + token: 'login.error.token', + identity: 'login.error.identity', + notattendee: 'login.error.notattendee', +} as const; const Vote = () => { - const { siteConfig } = useDocusaurusContext(); + const { siteConfig, i18n } = useDocusaurusContext(); + const locale = i18n.currentLocale; const workerDomain = siteConfig.customFields?.['workerDomain'] as | string | undefined; @@ -50,7 +52,10 @@ const Vote = () => { const params = new URLSearchParams(window.location.search); const error = params.get('error'); if (!error) return; - toast.error(LOGIN_ERRORS[error] ?? 'Erro ao entrar. Tente novamente.'); + const id = + LOGIN_ERROR_IDS[error as keyof typeof LOGIN_ERROR_IDS] ?? + 'login.error.generic'; + toast.error(text({ id })); params.delete('error'); const query = params.toString(); window.history.replaceState( @@ -84,7 +89,9 @@ const Vote = () => { if (!session || closed) return; const has = votes.has(talkId); if (!has && votes.size >= session.budget) { - toast.error(`Você já usou seus ${session.budget} votos.`); + toast.error( + text({ id: 'vote.limitReached' }, { budget: session.budget }) + ); return; } @@ -103,27 +110,35 @@ const Vote = () => { if (!res || !res.ok) { setVotes(votes); - toast.error('Erro ao registrar voto. Tente novamente.'); + toast.error(text({ id: 'vote.submitError' })); } }, [session, votes, workerDomain, closed] ); return ( - +
    -

    Vote nas palestras

    - - {status === 'loading' &&

    Carregando…

    } +

    + +

    + + {status === 'loading' && ( +

    + +

    + )} {status === 'error' && ( -

    Não foi possível carregar a votação. Tente mais tarde.

    +

    + +

    )} {status === 'unauth' && ( - Entrar com guild.host + )} @@ -131,15 +146,26 @@ const Vote = () => { <>
    {closed ? ( -

    A votação foi encerrada.

    +

    + +

    ) : (

    - Votos restantes: {session.budget - votes.size} de{' '} - {session.budget} +
    - Aberta até{' '} - {new Date(session.closesAt).toLocaleString('pt-BR')} +

    )} @@ -147,7 +173,7 @@ const Vote = () => { className='button button--secondary button--sm' href={`${workerDomain}/api/vote/logout`} > - Sair +
      From 27bf16e1773f174138438c5a4a680e2f32d53b97 Mon Sep 17 00:00:00 2001 From: Lucas Santos Date: Wed, 8 Jul 2026 14:12:17 +0200 Subject: [PATCH 16/25] i18n(c4p): translate the entire C4P page tree Route every user-facing string in the C4P flow through Text/text helpers and add en-US + es-419 catalog entries (117 keys per locale). definitions.ts options carry a labelId translation key instead of a literal label (value unchanged), rendered via Text at each consumer including the vote page. schema.ts zod messages became translation keys resolved lazily in validateStep to follow the current locale. Export TranslationId for that cast. --- i18n/en-US/code.json | 351 ++++++++++++++++++ i18n/es-419/code.json | 351 ++++++++++++++++++ i18n/pt-BR/code.json | 351 ++++++++++++++++++ src/website/components/shared/i18n.tsx | 2 +- src/website/contexts/c4p/definitions.ts | 99 +++-- src/website/contexts/c4p/index.tsx | 3 +- src/website/contexts/c4p/schema.ts | 34 +- src/website/hooks/c4p/useSubmit.tsx | 9 +- src/website/pages/c4p/_components/about.tsx | 67 ++-- .../pages/c4p/_components/diversity.tsx | 57 +-- .../pages/c4p/_components/field-status.tsx | 15 +- .../pages/c4p/_components/introduction.tsx | 77 ++-- src/website/pages/c4p/_components/success.tsx | 38 +- src/website/pages/c4p/_components/talk.tsx | 47 ++- src/website/pages/c4p/index.tsx | 19 +- src/website/pages/vote/index.tsx | 48 +-- 16 files changed, 1347 insertions(+), 221 deletions(-) diff --git a/i18n/en-US/code.json b/i18n/en-US/code.json index c478d16..bc452c2 100644 --- a/i18n/en-US/code.json +++ b/i18n/en-US/code.json @@ -427,5 +427,356 @@ }, "account.loadError": { "message": "Could not load. Try again later." + }, + "c4p.pageTitle": { + "message": "Call4Papers - JSConf Brasil 2026" + }, + "c4p.action.back": { + "message": "Back" + }, + "c4p.action.continue": { + "message": "Continue" + }, + "c4p.action.finish": { + "message": "Submit" + }, + "c4p.action.submitAnother": { + "message": "Submit another talk" + }, + "c4p.status.invalid": { + "message": "Invalid" + }, + "c4p.status.valid": { + "message": "Valid" + }, + "c4p.step.about": { + "message": "About you" + }, + "c4p.step.diversity": { + "message": "Diversity and inclusion (optional)" + }, + "c4p.step.talk": { + "message": "About the talk" + }, + "c4p.step.done": { + "message": "Proposal submitted!" + }, + "c4p.progress": { + "message": "Page {current} of 4" + }, + "c4p.error.name": { + "message": "Enter your name" + }, + "c4p.error.email": { + "message": "Enter a valid email" + }, + "c4p.error.emailTooLong": { + "message": "Email is too long" + }, + "c4p.error.phone": { + "message": "Enter a valid number" + }, + "c4p.error.city": { + "message": "Enter your city" + }, + "c4p.error.state": { + "message": "Select a state" + }, + "c4p.error.selectOption": { + "message": "Select an option" + }, + "c4p.error.bio": { + "message": "Enter your bio" + }, + "c4p.error.bioTooLong": { + "message": "Bio is too long" + }, + "c4p.error.duration": { + "message": "Select the duration" + }, + "c4p.error.talkTitle": { + "message": "Enter the title" + }, + "c4p.error.talkDescription": { + "message": "Enter the description" + }, + "c4p.error.audienceLevel": { + "message": "Select the level" + }, + "c4p.error.talkReason": { + "message": "Enter the reason" + }, + "c4p.error.requiredFields": { + "message": "Fill in the required fields" + }, + "c4p.submit.configError": { + "message": "Configuration unavailable. Try again later." + }, + "c4p.submit.limit": { + "message": "You have reached the limit of 3 talks." + }, + "c4p.submit.error": { + "message": "Error submitting your proposal. Try again." + }, + "c4p.intro.toastTitle": { + "message": "Your data is saved in your browser." + }, + "c4p.intro.toastDesc": { + "message": "You can pick up where you left off anytime." + }, + "c4p.intro.heading1": { + "message": "Want to speak at this year's JSConf Brasil?" + }, + "c4p.intro.p1": { + "message": "This is your chance to share the stage with some of the most amazing professionals in tech! Fill out our Call4Papers and our team will review and select the submitted proposals." + }, + "c4p.intro.p2": { + "message": "{brand} exists to share knowledge, bring the community together, and strengthen a diverse, inclusive, and collaborative event." + }, + "c4p.intro.dateHeading": { + "message": "JSConf Brasil - November 28, 2026" + }, + "c4p.intro.deadline": { + "message": "We will accept submissions until {date}." + }, + "c4p.intro.deadlineDate": { + "message": "July 30" + }, + "c4p.intro.maxTalks": { + "message": "You can submit up to {count} talks." + }, + "c4p.intro.eventInfo": { + "message": "The event takes place on {date}, at {venue}. There will be talks, panels, interactive activities, an exhibitor fair, and much more." + }, + "c4p.intro.eventDate": { + "message": "November 28, 2026" + }, + "c4p.intro.topicsIntro": { + "message": "These are some of the topics we suggest (starred ones are preferred):" + }, + "c4p.topic.devTooling": { + "message": "Dev Tooling" + }, + "c4p.topic.devEx": { + "message": "Developer Experience" + }, + "c4p.topic.aiDev": { + "message": "AI in Development" + }, + "c4p.topic.architecture": { + "message": "Architecture" + }, + "c4p.topic.devops": { + "message": "DevOps" + }, + "c4p.topic.observability": { + "message": "Observability" + }, + "c4p.topic.performance": { + "message": "Performance" + }, + "c4p.topic.realCases": { + "message": "Real-World Problem Solving with Code" + }, + "c4p.topic.a11yCode": { + "message": "Code for Accessibility" + }, + "c4p.topic.openSource": { + "message": "Open Source" + }, + "c4p.topic.designSystem": { + "message": "Design System" + }, + "c4p.topic.security": { + "message": "Security in Development" + }, + "c4p.exp.0": { + "message": "0 - 1 year" + }, + "c4p.exp.1": { + "message": "2 - 4 years" + }, + "c4p.exp.2": { + "message": "5 - 9 years" + }, + "c4p.exp.3": { + "message": "Over 10 years" + }, + "c4p.duration.0": { + "message": "15 minutes" + }, + "c4p.duration.1": { + "message": "25 minutes" + }, + "c4p.travel.0": { + "message": "I would like the organization to cover my travel and accommodation" + }, + "c4p.travel.1": { + "message": "I can cover the costs myself" + }, + "c4p.gender.0": { + "message": "Man" + }, + "c4p.gender.1": { + "message": "Woman" + }, + "c4p.gender.2": { + "message": "Non-binary" + }, + "c4p.gender.3": { + "message": "Prefer not to say" + }, + "c4p.race.0": { + "message": "White" + }, + "c4p.race.1": { + "message": "Mixed" + }, + "c4p.race.2": { + "message": "Black" + }, + "c4p.race.3": { + "message": "Indigenous" + }, + "c4p.race.4": { + "message": "I don't know" + }, + "c4p.race.5": { + "message": "Other" + }, + "c4p.race.6": { + "message": "Prefer not to say" + }, + "c4p.disability.0": { + "message": "I am blind / have low vision" + }, + "c4p.disability.1": { + "message": "I am deaf / have a hearing impairment" + }, + "c4p.disability.2": { + "message": "I cannot / have difficulty walking or standing without assistance" + }, + "c4p.disability.3": { + "message": "I cannot / have difficulty typing" + }, + "c4p.disability.4": { + "message": "Not applicable" + }, + "c4p.disability.5": { + "message": "Prefer not to say" + }, + "c4p.audience.0": { + "message": "All levels" + }, + "c4p.audience.1": { + "message": "Junior" + }, + "c4p.audience.2": { + "message": "Mid-level" + }, + "c4p.audience.3": { + "message": "Senior" + }, + "c4p.about.name": { + "message": "Your name" + }, + "c4p.about.email": { + "message": "Email" + }, + "c4p.about.phone": { + "message": "Mobile" + }, + "c4p.about.city": { + "message": "City" + }, + "c4p.about.uf": { + "message": "State" + }, + "c4p.about.travelHeading": { + "message": "Travel and accommodation" + }, + "c4p.about.travelDesc": { + "message": "The city where you live may affect the selection, since travel can create additional costs for the event. Depending on our budget, it may not be possible to cover travel and accommodation for every speaker." + }, + "c4p.about.social": { + "message": "Social media" + }, + "c4p.about.website": { + "message": "Personal website" + }, + "c4p.about.experience": { + "message": "Years of experience" + }, + "c4p.about.bio": { + "message": "Mini bio" + }, + "c4p.about.bioDesc": { + "message": "In a single paragraph of up to 280 characters, include information about your background, certifications, current and past roles, research, published articles, or any other professional detail you consider relevant." + }, + "c4p.talk.contentType": { + "message": "Content type" + }, + "c4p.talk.contentTypeDesc": { + "message": "Talks will be 15 or 25 minutes long. Choose the duration you consider most suitable for your proposal." + }, + "c4p.talk.duration": { + "message": "Duration" + }, + "c4p.talk.title": { + "message": "Title" + }, + "c4p.talk.description": { + "message": "Description" + }, + "c4p.talk.descriptionDesc": { + "message": "Provide a summary of your content. This information will be used on our website to promote your talk." + }, + "c4p.talk.audience": { + "message": "Who is this content for?" + }, + "c4p.talk.reason": { + "message": "Why should we consider this content at JSConf Brasil?" + }, + "c4p.success.thanks": { + "message": "Thank you for submitting to the Call4Papers of {brand}. Your proposal was received successfully." + }, + "c4p.success.heading": { + "message": "How will proposals be reviewed?" + }, + "c4p.success.p1": { + "message": "Proposals will be reviewed in two stages." + }, + "c4p.success.p2": { + "message": "First, a co-curation group will anonymously review the title, description, and the indicated knowledge level for each talk." + }, + "c4p.success.p3": { + "message": "Next, the JSConf Brasil team will do a complementary review to check how the content fits the schedule, considering budget, strategy, and topic diversity." + }, + "c4p.success.p4": { + "message": "There is no fixed number of people selected; the choice will depend on the quality and fit of the submitted proposals." + }, + "c4p.success.p5": { + "message": "After submissions close, the entire review process will be completed within two weeks." + }, + "c4p.success.p6": { + "message": "Selected speakers will receive an email to confirm their participation, and if there is no response, we will reach out to the next people on the list. Once all confirmations are finalized, we will also notify those who were not approved." + }, + "c4p.div.intro": { + "message": "JSConf Brasil always strives for diversity, inclusion, and accessibility. If you feel comfortable answering, we would like to know:" + }, + "c4p.div.sensitive": { + "message": "This data is sensitive and optional. It will be used solely to (a) build a diverse speaker line-up, (b) plan event accessibility (sign-language interpreters, physical accessibility, etc.), and (c) generate aggregate statistics about diversity in the C4P. You can revoke this consent at any time by writing to one of {link}." + }, + "c4p.div.volunteers": { + "message": "our volunteers" + }, + "c4p.div.gender": { + "message": "Gender identity" + }, + "c4p.div.race": { + "message": "Which color/race do you identify with?" + }, + "c4p.div.disability": { + "message": "Disability status: which of the options below describes you, if any?" } } diff --git a/i18n/es-419/code.json b/i18n/es-419/code.json index be6d7f2..fee09a2 100644 --- a/i18n/es-419/code.json +++ b/i18n/es-419/code.json @@ -427,5 +427,356 @@ }, "account.loadError": { "message": "No se pudo cargar. Inténtalo más tarde." + }, + "c4p.pageTitle": { + "message": "Call4Papers - JSConf Brasil 2026" + }, + "c4p.action.back": { + "message": "Volver" + }, + "c4p.action.continue": { + "message": "Continuar" + }, + "c4p.action.finish": { + "message": "Finalizar" + }, + "c4p.action.submitAnother": { + "message": "Enviar otra charla" + }, + "c4p.status.invalid": { + "message": "Inválido" + }, + "c4p.status.valid": { + "message": "Válido" + }, + "c4p.step.about": { + "message": "Sobre ti" + }, + "c4p.step.diversity": { + "message": "Diversidad e inclusión (opcional)" + }, + "c4p.step.talk": { + "message": "Sobre la charla" + }, + "c4p.step.done": { + "message": "¡Propuesta enviada!" + }, + "c4p.progress": { + "message": "Página {current} de 4" + }, + "c4p.error.name": { + "message": "Ingresa tu nombre" + }, + "c4p.error.email": { + "message": "Ingresa un correo válido" + }, + "c4p.error.emailTooLong": { + "message": "El correo es demasiado largo" + }, + "c4p.error.phone": { + "message": "Ingresa un número válido" + }, + "c4p.error.city": { + "message": "Ingresa tu ciudad" + }, + "c4p.error.state": { + "message": "Selecciona un estado" + }, + "c4p.error.selectOption": { + "message": "Selecciona una opción" + }, + "c4p.error.bio": { + "message": "Ingresa tu biografía" + }, + "c4p.error.bioTooLong": { + "message": "La biografía es demasiado larga" + }, + "c4p.error.duration": { + "message": "Selecciona la duración" + }, + "c4p.error.talkTitle": { + "message": "Ingresa el título" + }, + "c4p.error.talkDescription": { + "message": "Ingresa la descripción" + }, + "c4p.error.audienceLevel": { + "message": "Selecciona el nivel" + }, + "c4p.error.talkReason": { + "message": "Ingresa el motivo" + }, + "c4p.error.requiredFields": { + "message": "Completa los campos obligatorios" + }, + "c4p.submit.configError": { + "message": "Configuración no disponible. Inténtalo más tarde." + }, + "c4p.submit.limit": { + "message": "Ya alcanzaste el límite de 3 charlas." + }, + "c4p.submit.error": { + "message": "Error al enviar la propuesta. Inténtalo de nuevo." + }, + "c4p.intro.toastTitle": { + "message": "Tus datos se guardan en el navegador." + }, + "c4p.intro.toastDesc": { + "message": "Puedes continuar donde lo dejaste en cualquier momento." + }, + "c4p.intro.heading1": { + "message": "¿Quieres dar una charla en la JSConf Brasil de este año?" + }, + "c4p.intro.p1": { + "message": "¡Esta es tu oportunidad de compartir el escenario con algunos de los profesionales más increíbles del área de tecnología! Completa nuestro Call4Papers y nuestro equipo analizará y seleccionará las propuestas enviadas." + }, + "c4p.intro.p2": { + "message": "{brand} existe para compartir conocimiento, acercar a la comunidad y fortalecer un evento diverso, inclusivo y colaborativo." + }, + "c4p.intro.dateHeading": { + "message": "JSConf Brasil - 28 de noviembre de 2026" + }, + "c4p.intro.deadline": { + "message": "Aceptaremos inscripciones hasta el {date}." + }, + "c4p.intro.deadlineDate": { + "message": "30 de julio" + }, + "c4p.intro.maxTalks": { + "message": "Puedes enviar hasta {count} charlas." + }, + "c4p.intro.eventInfo": { + "message": "El evento se realizará el {date}, en la {venue}. Habrá charlas, paneles, actividades interactivas, feria de expositores y mucho más." + }, + "c4p.intro.eventDate": { + "message": "28 de noviembre de 2026" + }, + "c4p.intro.topicsIntro": { + "message": "Estos son algunos de los temas que sugerimos (los que tienen estrella son los preferidos):" + }, + "c4p.topic.devTooling": { + "message": "Dev Tooling" + }, + "c4p.topic.devEx": { + "message": "Developer Experience" + }, + "c4p.topic.aiDev": { + "message": "IA en el Desarrollo" + }, + "c4p.topic.architecture": { + "message": "Arquitectura" + }, + "c4p.topic.devops": { + "message": "DevOps" + }, + "c4p.topic.observability": { + "message": "Observabilidad" + }, + "c4p.topic.performance": { + "message": "Performance" + }, + "c4p.topic.realCases": { + "message": "Casos Reales de Resolución de Problemas con código" + }, + "c4p.topic.a11yCode": { + "message": "Código para Accesibilidad" + }, + "c4p.topic.openSource": { + "message": "Open Source" + }, + "c4p.topic.designSystem": { + "message": "Design System" + }, + "c4p.topic.security": { + "message": "Seguridad en el Desarrollo" + }, + "c4p.exp.0": { + "message": "0 - 1 año" + }, + "c4p.exp.1": { + "message": "2 - 4 años" + }, + "c4p.exp.2": { + "message": "5 - 9 años" + }, + "c4p.exp.3": { + "message": "Más de 10 años" + }, + "c4p.duration.0": { + "message": "15 minutos" + }, + "c4p.duration.1": { + "message": "25 minutos" + }, + "c4p.travel.0": { + "message": "Me gustaría que la organización pagara mi viaje y hospedaje" + }, + "c4p.travel.1": { + "message": "Puedo cubrir los costos" + }, + "c4p.gender.0": { + "message": "Hombre" + }, + "c4p.gender.1": { + "message": "Mujer" + }, + "c4p.gender.2": { + "message": "No binario" + }, + "c4p.gender.3": { + "message": "Prefiero no decir" + }, + "c4p.race.0": { + "message": "Blanca" + }, + "c4p.race.1": { + "message": "Parda" + }, + "c4p.race.2": { + "message": "Negra" + }, + "c4p.race.3": { + "message": "Indígena" + }, + "c4p.race.4": { + "message": "No sé" + }, + "c4p.race.5": { + "message": "Otro" + }, + "c4p.race.6": { + "message": "Prefiero no decir" + }, + "c4p.disability.0": { + "message": "Soy ciego(a) / tengo baja visión" + }, + "c4p.disability.1": { + "message": "Soy sordo(a) / tengo discapacidad auditiva" + }, + "c4p.disability.2": { + "message": "No puedo / tengo dificultad para caminar o mantenerme de pie sin ayuda" + }, + "c4p.disability.3": { + "message": "No puedo / tengo dificultad para escribir" + }, + "c4p.disability.4": { + "message": "No aplica" + }, + "c4p.disability.5": { + "message": "Prefiero no decir" + }, + "c4p.audience.0": { + "message": "Todos los niveles" + }, + "c4p.audience.1": { + "message": "Junior" + }, + "c4p.audience.2": { + "message": "Semi-senior" + }, + "c4p.audience.3": { + "message": "Senior" + }, + "c4p.about.name": { + "message": "Tu nombre" + }, + "c4p.about.email": { + "message": "Correo electrónico" + }, + "c4p.about.phone": { + "message": "Celular" + }, + "c4p.about.city": { + "message": "Ciudad" + }, + "c4p.about.uf": { + "message": "Estado" + }, + "c4p.about.travelHeading": { + "message": "Sobre el viaje y el hospedaje" + }, + "c4p.about.travelDesc": { + "message": "La ciudad donde vives puede influir en la selección, ya que el desplazamiento puede generar costos adicionales para el evento. Según nuestro presupuesto, quizás no sea posible cubrir el viaje y el hospedaje de todos los ponentes." + }, + "c4p.about.social": { + "message": "Redes sociales" + }, + "c4p.about.website": { + "message": "Sitio personal" + }, + "c4p.about.experience": { + "message": "Tiempo de experiencia" + }, + "c4p.about.bio": { + "message": "Mini biografía" + }, + "c4p.about.bioDesc": { + "message": "En un solo párrafo, de hasta 280 caracteres, incluye información sobre tu formación, certificaciones, cargo actual y anteriores, investigaciones, artículos publicados o cualquier otro dato profesional que consideres relevante." + }, + "c4p.talk.contentType": { + "message": "Tipo de contenido" + }, + "c4p.talk.contentTypeDesc": { + "message": "Las charlas serán de 15 o 25 minutos. Elige la duración que consideres más adecuada para tu propuesta." + }, + "c4p.talk.duration": { + "message": "Duración" + }, + "c4p.talk.title": { + "message": "Título" + }, + "c4p.talk.description": { + "message": "Descripción" + }, + "c4p.talk.descriptionDesc": { + "message": "Proporciona un resumen de tu contenido. Esta información se usará en nuestro sitio para promocionar tu charla." + }, + "c4p.talk.audience": { + "message": "¿Para quién es este contenido?" + }, + "c4p.talk.reason": { + "message": "¿Por qué deberíamos considerar este contenido en la JSConf Brasil?" + }, + "c4p.success.thanks": { + "message": "Gracias por inscribirte en el Call4Papers de {brand}. Tu propuesta se recibió con éxito." + }, + "c4p.success.heading": { + "message": "¿Cómo se evaluarán las propuestas?" + }, + "c4p.success.p1": { + "message": "La evaluación de las propuestas se realizará en dos etapas." + }, + "c4p.success.p2": { + "message": "Primero, un grupo de co-curaduría analizará de forma anónima el título, la descripción y el nivel de conocimiento indicado para cada charla." + }, + "c4p.success.p3": { + "message": "Luego, el equipo de la JSConf Brasil hará un análisis complementario para verificar el encaje del contenido en la programación, considerando presupuesto, estrategia y diversidad temática." + }, + "c4p.success.p4": { + "message": "No existe un número fijo de personas seleccionadas; la elección dependerá de la calidad y adecuación de las propuestas enviadas." + }, + "c4p.success.p5": { + "message": "Tras el cierre de las inscripciones, todo el proceso de evaluación se completará en un plazo de dos semanas." + }, + "c4p.success.p6": { + "message": "Las personas seleccionadas recibirán un correo para confirmar su participación y, si no hay respuesta, contactaremos a las siguientes de la lista. Una vez finalizadas todas las confirmaciones, también informaremos a quienes no fueron aprobadas." + }, + "c4p.div.intro": { + "message": "La JSConf Brasil siempre busca la diversidad, la inclusión y la accesibilidad. Si te sientes cómodo(a) respondiendo, nos gustaría saber:" + }, + "c4p.div.sensitive": { + "message": "Estos datos son sensibles y opcionales. Se usarán exclusivamente para (a) formar un line-up diverso de ponentes, (b) planificar la accesibilidad del evento (intérpretes de lengua de señas, accesibilidad física, etc.) y (c) generar estadísticas agregadas sobre diversidad en el C4P. Puedes revocar este consentimiento en cualquier momento escribiendo a uno de {link}." + }, + "c4p.div.volunteers": { + "message": "nuestros voluntarios" + }, + "c4p.div.gender": { + "message": "Identidad de género" + }, + "c4p.div.race": { + "message": "¿Con qué color/raza te identificas?" + }, + "c4p.div.disability": { + "message": "Situación de discapacidad: ¿cuál de las opciones describe tu caso, si aplica?" } } diff --git a/i18n/pt-BR/code.json b/i18n/pt-BR/code.json index c07e361..995d265 100644 --- a/i18n/pt-BR/code.json +++ b/i18n/pt-BR/code.json @@ -427,5 +427,356 @@ }, "account.loadError": { "message": "Não foi possível carregar. Tente mais tarde." + }, + "c4p.pageTitle": { + "message": "Call4Papers - JSConf Brasil 2026" + }, + "c4p.action.back": { + "message": "Voltar" + }, + "c4p.action.continue": { + "message": "Continuar" + }, + "c4p.action.finish": { + "message": "Finalizar" + }, + "c4p.action.submitAnother": { + "message": "Enviar outra palestra" + }, + "c4p.status.invalid": { + "message": "Inválido" + }, + "c4p.status.valid": { + "message": "Válido" + }, + "c4p.step.about": { + "message": "Sobre você" + }, + "c4p.step.diversity": { + "message": "Diversidade e inclusão (opcional)" + }, + "c4p.step.talk": { + "message": "Sobre a Palestra" + }, + "c4p.step.done": { + "message": "Proposta enviada!" + }, + "c4p.progress": { + "message": "Página {current} de 4" + }, + "c4p.error.name": { + "message": "Informe seu nome" + }, + "c4p.error.email": { + "message": "Informe um e-mail válido" + }, + "c4p.error.emailTooLong": { + "message": "E-mail muito longo" + }, + "c4p.error.phone": { + "message": "Informe um número válido" + }, + "c4p.error.city": { + "message": "Informe sua cidade" + }, + "c4p.error.state": { + "message": "Selecione um estado" + }, + "c4p.error.selectOption": { + "message": "Selecione uma opção" + }, + "c4p.error.bio": { + "message": "Informe sua biografia" + }, + "c4p.error.bioTooLong": { + "message": "Biografia muito longa" + }, + "c4p.error.duration": { + "message": "Selecione a duração" + }, + "c4p.error.talkTitle": { + "message": "Informe o título" + }, + "c4p.error.talkDescription": { + "message": "Informe a descrição" + }, + "c4p.error.audienceLevel": { + "message": "Selecione o nível" + }, + "c4p.error.talkReason": { + "message": "Informe o motivo" + }, + "c4p.error.requiredFields": { + "message": "Preencha os campos obrigatórios" + }, + "c4p.submit.configError": { + "message": "Configuração indisponível. Tente novamente mais tarde." + }, + "c4p.submit.limit": { + "message": "Você já atingiu o limite de 3 palestras." + }, + "c4p.submit.error": { + "message": "Erro ao enviar proposta. Tente novamente." + }, + "c4p.intro.toastTitle": { + "message": "Seus dados ficam salvos no navegador." + }, + "c4p.intro.toastDesc": { + "message": "Você pode continuar de onde parou a qualquer momento." + }, + "c4p.intro.heading1": { + "message": "Quer palestrar na JSConf Brasil deste ano?" + }, + "c4p.intro.p1": { + "message": "Essa é a sua chance de dividir o palco com alguns dos profissionais mais incríveis da área de tecnologia! Preencha o nosso Call4Papers, nossa equipe fará a análise e seleção das propostas enviadas." + }, + "c4p.intro.p2": { + "message": "A {brand} existe para compartilhar conhecimento, aproximar a comunidade e fortalecer um evento diverso, inclusivo e colaborativo." + }, + "c4p.intro.dateHeading": { + "message": "JSConf Brasil - 28 de novembro de 2026" + }, + "c4p.intro.deadline": { + "message": "Aceitaremos inscrições até {date}." + }, + "c4p.intro.deadlineDate": { + "message": "30 de julho" + }, + "c4p.intro.maxTalks": { + "message": "Você pode enviar até {count} palestras." + }, + "c4p.intro.eventInfo": { + "message": "O evento acontecerá no dia {date}, na {venue}. Serão palestras, painéis, atividades interativas, feira de expositores e muito mais." + }, + "c4p.intro.eventDate": { + "message": "28 de novembro de 2026" + }, + "c4p.intro.topicsIntro": { + "message": "Esses são alguns dos tópicos que sugerimos (os que têm estrelinha são os preferidos):" + }, + "c4p.topic.devTooling": { + "message": "Dev Tooling" + }, + "c4p.topic.devEx": { + "message": "Developer Experience" + }, + "c4p.topic.aiDev": { + "message": "IA no Desenvolvimento" + }, + "c4p.topic.architecture": { + "message": "Arquitetura" + }, + "c4p.topic.devops": { + "message": "DevOps" + }, + "c4p.topic.observability": { + "message": "Observabilidade" + }, + "c4p.topic.performance": { + "message": "Performance" + }, + "c4p.topic.realCases": { + "message": "Cases Reais de Resolução de Problemas usando código" + }, + "c4p.topic.a11yCode": { + "message": "Código para Acessibilidade" + }, + "c4p.topic.openSource": { + "message": "Open Source" + }, + "c4p.topic.designSystem": { + "message": "Design System" + }, + "c4p.topic.security": { + "message": "Segurança no Desenvolvimento" + }, + "c4p.exp.0": { + "message": "0 - 1 ano" + }, + "c4p.exp.1": { + "message": "2 - 4 anos" + }, + "c4p.exp.2": { + "message": "5 - 9 anos" + }, + "c4p.exp.3": { + "message": "Acima de 10 anos" + }, + "c4p.duration.0": { + "message": "15 minutos" + }, + "c4p.duration.1": { + "message": "25 minutos" + }, + "c4p.travel.0": { + "message": "Gostaria que a organização pagasse minha viagem e hospedagem" + }, + "c4p.travel.1": { + "message": "Posso arcar com os custos" + }, + "c4p.gender.0": { + "message": "Homem" + }, + "c4p.gender.1": { + "message": "Mulher" + }, + "c4p.gender.2": { + "message": "Não-binário" + }, + "c4p.gender.3": { + "message": "Prefiro não dizer" + }, + "c4p.race.0": { + "message": "Branca" + }, + "c4p.race.1": { + "message": "Parda" + }, + "c4p.race.2": { + "message": "Preta" + }, + "c4p.race.3": { + "message": "Indígena" + }, + "c4p.race.4": { + "message": "Não sei" + }, + "c4p.race.5": { + "message": "Outro" + }, + "c4p.race.6": { + "message": "Prefiro não dizer" + }, + "c4p.disability.0": { + "message": "Sou cego(a) / tenho baixa visão" + }, + "c4p.disability.1": { + "message": "Sou surdo(a) / tenho deficiência auditiva" + }, + "c4p.disability.2": { + "message": "Eu não consigo / tenho dificuldade de andar ou ficar em pé sem assistência" + }, + "c4p.disability.3": { + "message": "Eu não consigo / tenho dificuldade de digitar" + }, + "c4p.disability.4": { + "message": "Não se aplica" + }, + "c4p.disability.5": { + "message": "Prefiro não dizer" + }, + "c4p.audience.0": { + "message": "Todos os níveis" + }, + "c4p.audience.1": { + "message": "Júnior" + }, + "c4p.audience.2": { + "message": "Pleno" + }, + "c4p.audience.3": { + "message": "Sênior" + }, + "c4p.about.name": { + "message": "Seu nome" + }, + "c4p.about.email": { + "message": "E-mail" + }, + "c4p.about.phone": { + "message": "Celular" + }, + "c4p.about.city": { + "message": "Cidade" + }, + "c4p.about.uf": { + "message": "UF" + }, + "c4p.about.travelHeading": { + "message": "Sobre a viagem e hospedagem" + }, + "c4p.about.travelDesc": { + "message": "A cidade onde você mora pode influenciar na seleção, pois o deslocamento pode gerar custos adicionais para o evento. Dependendo do nosso orçamento, talvez não seja possível custear passagem e hospedagem para todos os palestrantes." + }, + "c4p.about.social": { + "message": "Redes sociais" + }, + "c4p.about.website": { + "message": "Site Pessoal" + }, + "c4p.about.experience": { + "message": "Tempo de experiência" + }, + "c4p.about.bio": { + "message": "Mini biografia" + }, + "c4p.about.bioDesc": { + "message": "Em um único parágrafo, com até 280 caracteres, inclua informações sobre sua formação, certificações, cargo atual e anteriores, pesquisas desenvolvidas, artigos publicados ou qualquer outro dado profissional que considere relevante." + }, + "c4p.talk.contentType": { + "message": "Tipo de conteúdo" + }, + "c4p.talk.contentTypeDesc": { + "message": "O formato dos conteúdos será em palestras de 15 ou 25 minutos. Escolha o tempo de duração que você considera mais adequado para a sua proposta." + }, + "c4p.talk.duration": { + "message": "Tempo de duração" + }, + "c4p.talk.title": { + "message": "Título" + }, + "c4p.talk.description": { + "message": "Descrição" + }, + "c4p.talk.descriptionDesc": { + "message": "Forneça um resumo do seu conteúdo. Essa informação será usada em nosso site para divulgação da sua palestra." + }, + "c4p.talk.audience": { + "message": "Para quem é este conteúdo?" + }, + "c4p.talk.reason": { + "message": "Por que deveríamos considerar este conteúdo na JSConf Brasil?" + }, + "c4p.success.thanks": { + "message": "Obrigado por se inscrever no Call4Papers da {brand}. Sua proposta foi recebida com sucesso." + }, + "c4p.success.heading": { + "message": "Como os conteúdos serão avaliados?" + }, + "c4p.success.p1": { + "message": "A avaliação das propostas acontecerá em duas etapas." + }, + "c4p.success.p2": { + "message": "Primeiro, um grupo de co-curadoria analisará anonimamente o título, a descrição e o nível de conhecimento indicado para cada palestra." + }, + "c4p.success.p3": { + "message": "Em seguida, a equipe da JSConf Brasil fará uma análise complementar para verificar o encaixe do conteúdo na programação, considerando orçamento, estratégia e diversidade temática." + }, + "c4p.success.p4": { + "message": "Não existe um número fixo de pessoas selecionadas; a escolha dependerá da qualidade e adequação das propostas enviadas." + }, + "c4p.success.p5": { + "message": "Após o fim das inscrições, todo o processo de avaliação será concluído em até duas semanas." + }, + "c4p.success.p6": { + "message": "As pessoas selecionadas receberão um e-mail para confirmar sua participação e, caso não haja resposta, entraremos em contato com as próximas da lista. Assim que todas as confirmações forem finalizadas, informaremos também as pessoas que não forem aprovadas." + }, + "c4p.div.intro": { + "message": "A JSConf Brasil busca sempre pela diversidade, inclusão e acessibilidade. Caso se sinta confortável em responder, gostaríamos de saber:" + }, + "c4p.div.sensitive": { + "message": "Estes dados são sensíveis e opcionais. Serão usados exclusivamente para (a) compor um line-up diverso de palestrantes, (b) planejar a acessibilidade do evento (intérpretes de Libras, acessibilidade física, etc.) e (c) gerar estatísticas agregadas sobre diversidade no C4P. Você pode revogar esse consentimento a qualquer momento escrevendo para um dos {link}." + }, + "c4p.div.volunteers": { + "message": "nossos voluntários" + }, + "c4p.div.gender": { + "message": "Identidade de Gênero" + }, + "c4p.div.race": { + "message": "Qual cor/raça você se identifica?" + }, + "c4p.div.disability": { + "message": "Situação de deficiência: qual das opções abaixo descreve você, se tiver alguma?" } } diff --git a/src/website/components/shared/i18n.tsx b/src/website/components/shared/i18n.tsx index b380a76..bcb02f2 100644 --- a/src/website/components/shared/i18n.tsx +++ b/src/website/components/shared/i18n.tsx @@ -5,7 +5,7 @@ import OriginalTranslate, { translate as originalTranslate, } from '@docusaurus/Translate'; -type TranslationId = keyof typeof Translation; +export type TranslationId = keyof typeof Translation; type TextProps = { id: TranslationId; values?: InterpolateValues; diff --git a/src/website/contexts/c4p/definitions.ts b/src/website/contexts/c4p/definitions.ts index 1767052..0141846 100644 --- a/src/website/contexts/c4p/definitions.ts +++ b/src/website/contexts/c4p/definitions.ts @@ -2,82 +2,71 @@ import type { FormData } from './types'; export const STORAGE_KEY = 'c4p-form-v2'; +// labelId is a translation key resolved via / text() at render time (see i18n.tsx). +// value is the stored/submitted value and must never change. export const topics = [ - { name: 'Dev Tooling', preferred: true }, - { name: 'Developer Experience', preferred: true }, - { name: 'IA no Desenvolvimento', preferred: true }, - { name: 'Arquitetura', preferred: true }, - { name: 'DevOps', preferred: true }, - { name: 'Observabilidade', preferred: true }, - { name: 'Performance', preferred: true }, - { - name: 'Cases Reais de Resolução de Problemas usando código', - preferred: true, - }, - { name: 'Código para Acessibilidade', preferred: false }, - { name: 'Open Source', preferred: false }, - { name: 'Design System', preferred: false }, - { name: 'Segurança no Desenvolvimento', preferred: false }, -]; + { labelId: 'c4p.topic.devTooling', preferred: true }, + { labelId: 'c4p.topic.devEx', preferred: true }, + { labelId: 'c4p.topic.aiDev', preferred: true }, + { labelId: 'c4p.topic.architecture', preferred: true }, + { labelId: 'c4p.topic.devops', preferred: true }, + { labelId: 'c4p.topic.observability', preferred: true }, + { labelId: 'c4p.topic.performance', preferred: true }, + { labelId: 'c4p.topic.realCases', preferred: true }, + { labelId: 'c4p.topic.a11yCode', preferred: false }, + { labelId: 'c4p.topic.openSource', preferred: false }, + { labelId: 'c4p.topic.designSystem', preferred: false }, + { labelId: 'c4p.topic.security', preferred: false }, +] as const; export const experienceOptions = [ - { value: '0', label: '0 - 1 ano' }, - { value: '1', label: '2 - 4 anos' }, - { value: '2', label: '5 - 9 anos' }, - { value: '3', label: 'Acima de 10 anos' }, + { value: '0', labelId: 'c4p.exp.0' }, + { value: '1', labelId: 'c4p.exp.1' }, + { value: '2', labelId: 'c4p.exp.2' }, + { value: '3', labelId: 'c4p.exp.3' }, ] as const; export const durationOptions = [ - { value: '0', label: '15 minutos' }, - { value: '1', label: '25 minutos' }, + { value: '0', labelId: 'c4p.duration.0' }, + { value: '1', labelId: 'c4p.duration.1' }, ] as const; export const travelOptions = [ - { - value: '0', - label: 'Gostaria que a organização pagasse minha viagem e hospedagem', - }, - { value: '1', label: 'Posso arcar com os custos' }, + { value: '0', labelId: 'c4p.travel.0' }, + { value: '1', labelId: 'c4p.travel.1' }, ] as const; export const genderOptions = [ - { value: '0', label: 'Homem' }, - { value: '1', label: 'Mulher' }, - { value: '2', label: 'Não-binário' }, - { value: '3', label: 'Prefiro não dizer' }, + { value: '0', labelId: 'c4p.gender.0' }, + { value: '1', labelId: 'c4p.gender.1' }, + { value: '2', labelId: 'c4p.gender.2' }, + { value: '3', labelId: 'c4p.gender.3' }, ] as const; export const raceOptions = [ - { value: '0', label: 'Branca' }, - { value: '1', label: 'Parda' }, - { value: '2', label: 'Preta' }, - { value: '3', label: 'Indígena' }, - { value: '4', label: 'Não sei' }, - { value: '5', label: 'Outro' }, - { value: '6', label: 'Prefiro não dizer' }, + { value: '0', labelId: 'c4p.race.0' }, + { value: '1', labelId: 'c4p.race.1' }, + { value: '2', labelId: 'c4p.race.2' }, + { value: '3', labelId: 'c4p.race.3' }, + { value: '4', labelId: 'c4p.race.4' }, + { value: '5', labelId: 'c4p.race.5' }, + { value: '6', labelId: 'c4p.race.6' }, ] as const; export const disabilityOptions = [ - { value: '0', label: 'Sou cego(a) / tenho baixa visão' }, - { value: '1', label: 'Sou surdo(a) / tenho deficiência auditiva' }, - { - value: '2', - label: - 'Eu não consigo / tenho dificuldade de andar ou ficar em pé sem assistência', - }, - { - value: '3', - label: 'Eu não consigo / tenho dificuldade de digitar', - }, - { value: '4', label: 'Não se aplica' }, - { value: '5', label: 'Prefiro não dizer' }, + { value: '0', labelId: 'c4p.disability.0' }, + { value: '1', labelId: 'c4p.disability.1' }, + { value: '2', labelId: 'c4p.disability.2' }, + { value: '3', labelId: 'c4p.disability.3' }, + { value: '4', labelId: 'c4p.disability.4' }, + { value: '5', labelId: 'c4p.disability.5' }, ] as const; export const audienceLevels = [ - { value: '0', label: 'Todos os níveis' }, - { value: '1', label: 'Júnior' }, - { value: '2', label: 'Pleno' }, - { value: '3', label: 'Sênior' }, + { value: '0', labelId: 'c4p.audience.0' }, + { value: '1', labelId: 'c4p.audience.1' }, + { value: '2', labelId: 'c4p.audience.2' }, + { value: '3', labelId: 'c4p.audience.3' }, ] as const; export const brazilianStates = [ diff --git a/src/website/contexts/c4p/index.tsx b/src/website/contexts/c4p/index.tsx index e68b010..1240175 100644 --- a/src/website/contexts/c4p/index.tsx +++ b/src/website/contexts/c4p/index.tsx @@ -10,6 +10,7 @@ import { useState, } from 'react'; import { toast } from 'sonner'; +import { text } from '@site/src/website/components/shared/i18n'; import { loadFromStorage, saveToStorage } from './helpers'; import { validateStep } from './schema'; @@ -68,7 +69,7 @@ export const C4PProvider: FC<{ children: ReactNode }> = ({ children }) => { setErrors(result); const keys = Object.keys(result); if (keys.length > 0) { - toast.error('Preencha os campos obrigatórios'); + toast.error(text({ id: 'c4p.error.requiredFields' })); return false; } return true; diff --git a/src/website/contexts/c4p/schema.ts b/src/website/contexts/c4p/schema.ts index c04dbd7..7ca090c 100644 --- a/src/website/contexts/c4p/schema.ts +++ b/src/website/contexts/c4p/schema.ts @@ -1,14 +1,19 @@ +import type { TranslationId } from '@site/src/website/components/shared/i18n'; import { z } from 'zod'; +import { text } from '@site/src/website/components/shared/i18n'; +// zod messages are translation KEYS, not final strings — validateStep resolves them with text() +// at validation time (client-side, current locale). Defining them as literal translations here +// would lock the message to the module-load locale. export const aboutSchema = z.object({ - name: z.string().min(1, 'Informe seu nome'), - email: z.email('Informe um e-mail válido').max(254), - phone: z.string().min(8, 'Informe um número válido'), - city: z.string().min(1, 'Informe sua cidade'), - state: z.string().min(2, 'Selecione um estado'), - travelPreference: z.string().min(1, 'Selecione uma opção'), - experienceLevel: z.string().min(1, 'Selecione uma opção'), - bio: z.string().min(1, 'Informe sua biografia').max(280), + name: z.string().min(1, 'c4p.error.name'), + email: z.email('c4p.error.email').max(254, 'c4p.error.emailTooLong'), + phone: z.string().min(8, 'c4p.error.phone'), + city: z.string().min(1, 'c4p.error.city'), + state: z.string().min(2, 'c4p.error.state'), + travelPreference: z.string().min(1, 'c4p.error.selectOption'), + experienceLevel: z.string().min(1, 'c4p.error.selectOption'), + bio: z.string().min(1, 'c4p.error.bio').max(280, 'c4p.error.bioTooLong'), }); export const diversitySchema = z.object({ @@ -18,11 +23,11 @@ export const diversitySchema = z.object({ }); export const talkSchema = z.object({ - duration: z.string().min(1, 'Selecione a duração'), - talkTitle: z.string().min(1, 'Informe o título'), - talkDescription: z.string().min(1, 'Informe a descrição'), - audienceLevel: z.string().min(1, 'Selecione o nível'), - talkReason: z.string().min(1, 'Informe o motivo'), + duration: z.string().min(1, 'c4p.error.duration'), + talkTitle: z.string().min(1, 'c4p.error.talkTitle'), + talkDescription: z.string().min(1, 'c4p.error.talkDescription'), + audienceLevel: z.string().min(1, 'c4p.error.audienceLevel'), + talkReason: z.string().min(1, 'c4p.error.talkReason'), }); const stepSchemas = { @@ -47,7 +52,8 @@ export const validateStep = ( for (const issue of result.error.issues) { const field = issue.path[0]; - if (field && !errors[String(field)]) errors[String(field)] = issue.message; + if (field && !errors[String(field)]) + errors[String(field)] = text({ id: issue.message as TranslationId }); } return errors; diff --git a/src/website/hooks/c4p/useSubmit.tsx b/src/website/hooks/c4p/useSubmit.tsx index 3fd720d..b5f3de9 100644 --- a/src/website/hooks/c4p/useSubmit.tsx +++ b/src/website/hooks/c4p/useSubmit.tsx @@ -2,6 +2,7 @@ import type { SubmitEvent } from 'react'; import { useRef, useState } from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { toast } from 'sonner'; +import { text } from '@site/src/website/components/shared/i18n'; import { useC4P } from '../../contexts/c4p'; export const useSubmit = () => { @@ -16,7 +17,7 @@ export const useSubmit = () => { if (!validate() || submitting.current) return; if (typeof workerDomain !== 'string') { - toast.error('Configuração indisponível. Tente novamente mais tarde.'); + toast.error(text({ id: 'c4p.submit.configError' })); return; } @@ -35,15 +36,15 @@ export const useSubmit = () => { } | null; const message = data?.error === 'Talk limit exceeded.' - ? 'Você já atingiu o limite de 3 palestras.' - : 'Erro ao enviar proposta. Tente novamente.'; + ? text({ id: 'c4p.submit.limit' }) + : text({ id: 'c4p.submit.error' }); toast.error(message); return; } goToStep(5); } catch { - toast.error('Erro ao enviar proposta. Tente novamente.'); + toast.error(text({ id: 'c4p.submit.error' })); } finally { submitting.current = false; } diff --git a/src/website/pages/c4p/_components/about.tsx b/src/website/pages/c4p/_components/about.tsx index 9a5e083..fc77208 100644 --- a/src/website/pages/c4p/_components/about.tsx +++ b/src/website/pages/c4p/_components/about.tsx @@ -12,6 +12,7 @@ import { FaUser, FaYoutube, } from 'react-icons/fa6'; +import { Text, text } from '@site/src/website/components/shared/i18n'; import * as styles from '../_styles'; import { brazilianStates, @@ -55,7 +56,7 @@ export const About = () => {

      - Seu nome +

      { /> {

      - E-mail +

      { /> {

      - Celular +

      { /> {

      - Cidade +

      { /> {

      - UF +

      {

      - Tempo de experiência + {' '} +

      {experienceOptions.map((option, index) => ( @@ -302,7 +308,9 @@ export const About = () => { checked={formData.experienceLevel === option.value} onChange={handleRadioChange('experienceLevel', option.value)} /> - {option.label} + + + ))}
      @@ -312,16 +320,13 @@ export const About = () => {

      - Mini biografia +

      - Em um único parágrafo, com até 280 caracteres, inclua informações - sobre sua formação, certificações, cargo atual e anteriores, - pesquisas desenvolvidas, artigos publicados ou qualquer outro dado - profissional que considere relevante. +