Este repositório contém o código-fonte do site oficial da JSConf Brasil (jsconf.com.br): páginas em Docusaurus, conteúdo multilíngue e um Worker (Cloudflare) para formulários e API. Aqui você encontra como rodar o projeto localmente, traduzir textos e validar o build antes de abrir um PR.
npm ci
npm start # Inicia o website e o servidor localmente (pt-BR)npm run start:en # Inglês
npm run start:es # EspanholTip
Use o componente <Image /> ao invés de <img /> para visualizar as imagens corretamente em todos os idiomas durante o desenvolvimento.
- O componente
<Image />utilizaloading="lazy"edecoding="async"por padrão.
Use <Text /> para conteúdo JSX e text() para atributos HTML (aria-label, alt, placeholder, etc.):
<h1>
<Text id='speakers.title' />
</h1><Image alt={text({ id: 'location.venue.imgAlt' })} /><Text /> e text() são abstrações do <Translate /> do Docusaurus:
- IDs tipados com autocomplete a partir de
i18n/pt-BR/code.json - Fallback automático do idioma principal (
pt-BR) — não é necessário passarchildren
Para adicionar um novo texto:
- Crie a chave em
i18n/pt-BR/code.json - Use
<Text id='...' />outext({ id: '...' })no componente - Os tipos são inferidos automaticamente usando
i18n/pt-BR/code.jsoncomo fonte de verdade
npm run lint:fixnpm run build # Compila o website e o workernpm test # Testes unitáriosnpm run typecheck # Verificação de tipos TypeScriptnpm run lint # Verificação de lintingQuem comprou ingresso vota nas palestras do C4P em /vote. A pessoa entra com a conta do guild.host, e quantos votos ela tem depende do tier do ingresso. O fluxo já está implementado de ponta a ponta e foi verificado contra um login real no guild.host.
O site (Docusaurus) e o Worker são dois deploys separados. O navegador nunca fala com o D1 diretamente, só com a API JSON do Worker. As rotas relevantes, registradas no switch de src/server/index.ts e implementadas em src/server/routes/auth.ts e src/server/routes/vote.ts:
| Rota | Handler |
|---|---|
GET /api/vote/login |
authLogin — inicia o OAuth |
GET /api/vote/callback |
authCallback — troca o code, resolve o tier, assina a sessão |
GET /api/vote/me |
authMe — identidade da sessão atual (página de conta) |
GET /api/vote/logout |
authLogout — limpa o cookie de sessão |
GET /api/vote |
voteGet — lista as palestras votáveis + votos da pessoa |
POST /api/vote |
voteSubmit — registra/remove um voto |
Esta é a parte que mais confunde: o login usa dois tokens OAuth com escopos diferentes, um por pessoa (a votante) e um único fixo do lado do servidor (o organizador).
- Token da votante — escopo
profile:readapenas (OAUTH_SCOPEemsrc/server/configs/oauth.ts). Usado uma única vez no login pra chamar/oauth/userinfodo guild.host e pegar{ sub, name, picture }. Nunca é guardado. - Token de organizador — necessário porque o único lugar que expõe o tier do ingresso é
/events/{slug}/attendees, e esse endpoint exige permissão de organizador do evento (escopoevent_attendees:read,MANAGER_SCOPE) — algo que uma votante comum nunca tem. O Worker mantém uma credencial fixa pra isso:GUILD_ORG_REFRESH_TOKENé um secret de bootstrap, capturado uma única vez por um fluxo só-dev: acessar/api/vote/login?manager=1pede oMANAGER_SCOPE, e o callback (gated porenv.ENVIRONMENT !== 'production'+ um cookievote_manager_capture) imprime o refresh token em texto puro no navegador em vez de assinar uma sessão. Esse valor nunca é logado em lugar nenhum.managerAccessToken()(src/server/helpers/oauth.ts) lê a única linha (id = 1) da tabela D1manager_oauth. Se oaccess_tokenem cache ainda não expirou, reusa sem chamar a rede. Senão, faz umPOST grant_type=refresh_tokenno endpoint de token do guild.- Crítico: o guild.host rotaciona o refresh token a cada uso — o antigo é revogado e um novo é emitido na mesma resposta. Por isso todo refresh bem-sucedido grava o novo
refresh_tokende volta na linha demanager_oauth(INSERT ... ON CONFLICT DO UPDATE). A env var só serve de semente; depois do primeiro uso, o D1 é a fonte de verdade e se auto-atualiza para sempre. Sem isso, o primeiro refresh já mataria o token guardado no.dev.vars/secret e quebraria a busca de tier até alguém recapturar o token manualmente.
sequenceDiagram
participant B as Browser
participant W as Worker
participant G as guild.host
participant D as D1
B->>W: GET /api/vote/login
W->>W: gera state, seta cookie vote_oauth_state
W-->>B: 302 -> guild.host /oauth/authorize (scope=profile:read)
B->>G: autoriza o app
G-->>B: 302 -> /api/vote/callback?code&state
B->>W: GET /api/vote/callback?code&state
W->>W: valida state contra o cookie
W->>G: POST /oauth/token (exchangeCode)
G-->>W: access_token, refresh_token (da votante)
W->>G: GET /oauth/userinfo (fetchUserInfo)
G-->>W: sub, name, picture
W->>D: SELECT manager_oauth WHERE id = 1
alt access_token do organizador ainda válido
D-->>W: access_token em cache
else expirado
W->>G: POST /oauth/token (refresh_token do organizador)
G-->>W: novo access_token + refresh_token rotacionado
W->>D: UPSERT manager_oauth (novo refresh_token)
end
W->>G: GET /events/{slug}/attendees?first=100 (paginado, token de organizador)
G-->>W: tier do ingresso (ou nada)
alt sem tier (não é participante)
W-->>B: 302 -> site/?error=notattendee
else tier encontrado
W->>D: SELECT budget FROM ticket_tiers WHERE name = tier
W->>W: assina JWT (sub, budget, name, photo)
W-->>B: 302 -> /vote + cookie vote_session
end
Passo a passo:
GET /api/vote/login(sem?dev/?managerem produção) — o Worker gera umstatealeatório, seta o cookievote_oauth_state(SameSite=Lax, 10min) e redireciona pro/oauth/authorizedo guild.host comclient_id/redirect_uri/scope=profile:read/state.- A pessoa autoriza no guild.host e é redirecionada pra
GET /api/vote/callback?code=...&state=.... - O Worker confere que o
statebate com o cookie, troca ocodepelo par{ access_token, refresh_token }da votante (exchangeCode()). fetchUserInfo(access_token)—{ id (sub), name, picture }do/oauth/userinfodo guild.managerAccessToken(...)— obtém (ou renova) o token de organizador, lendo/gravandomanager_oauthno D1 (ver seção acima).fetchTicketTier(managerToken, EVENT_SLUG, identity.id)— pagina/events/{slug}/attendees?first=100(recursivo, até 30 páginas) procurando o nó cujouserIdé o da votante, e retornanode.ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name, ounullse não houver pedido de ingresso.- Sem tier → redireciona pra home com
?error=notattendee(nenhuma sessão é assinada). Com tier →budgetForTier(tier)consulta a tabelaticket_tiersno D1 (qualquer tier sem linha lá recebe1por padrão). signSession(id, budget, secret, ttl, { name, photo })assina um JWT (HS256, viajose) com o budget já embutido. Seta o cookievote_session(SameSite=None; Secure, 24h) e redireciona pra/vote.- Cuidado: como o budget fica embutido no JWT no momento do login, mudar o budget de um tier no banco não afeta quem já está logado — a pessoa precisa deslogar e logar de novo pra pegar um JWT novo com o budget atualizado. Isso já aconteceu de verdade em teste, não é hipotético.
- Caminhos de erro (todos redirecionam pra home com um
?error=, exibido por um handler global na navbar que lê o query param e limpa ele em seguida):?error=denied(recusou no guild.host),?error=state(state não bate, possível CSRF),?error=token(troca do code falhou),?error=identity(userinfo ou token de organizador falhou),?error=notattendee(sem ingresso pra este evento).
getSession() (src/server/helpers/session.ts) lê o cookie vote_session, verifica o JWT (jose) e retorna { userId, budget, name, photo } direto das claims do token — sem chamada ao D1 nem ao guild a cada request. É por isso que GET /api/vote e GET /api/vote/me são rápidos.
- Bypass só-dev: fora de
production, um headerX-Dev-Userretorna uma sessão fixa sem precisar de cookie — útil pra testar viacurlsem login de verdade:curl -H 'X-Dev-User: user-1' localhost:8787/api/vote - Login-stub só-dev:
GET /api/vote/login?dev=1(mesmo gate de ambiente) assina uma sessão fixa (dev-user) direto, sem passar pelo guild — útil pra testar a UI do/voteno navegador.
GET /api/vote(voteGet) — exige sessão; retorna{ budget, used, talks, myVotes, closesAt }.listTalks()(src/server/repositories/vote.ts) só seleciona palestras comstatus = 2(VOTABLE_TALK_STATUS, emsrc/server/configs/vote.ts) e deliberadamente não faz join comspeakers— a API nunca pode expor quem propôs a palestra, pra evitar viés. O frontend (src/website/pages/vote/index.tsx) gera, por palestra e a cada fetch, um nome fake com cara de nome (tipo "Aabd Cdaes", não é um nome real) e mostra ele borrado via CSS (.speaker-name { filter: blur(...) }) — isso é só estético/cosmético, não é um controle de segurança, já que nada sensível estava sendo escondido (o nome real nunca chegou a ser enviado).POST /api/vote(voteSubmit) — corpo{ talkId, action: 'add' | 'remove' }, validado comzod. ChecaisVotingOpen()(VOTE_CLOSES_ATemsrc/server/configs/vote.ts, uma data ISO fixa no código — mudar a data exige deploy, de propósito, já que muda raramente).castVote()(src/server/repositories/vote.ts) confere o número de votos atual contra o budget (422 se já bateu o limite) e fazINSERT OR IGNOREemc4p_votes.removeVote()só fazDELETE, sem checagem de budget.- UX do frontend: clicar no botão de votar de uma palestra vira o estado local na hora (otimista) e enfileira
{ talkId, action }numa fila com debounce (1s de espera, reseta a cada novo clique), então uma rajada de cliques vira uma única leva de requests em vez de um request por clique. Isso existe porque, em dev local, o rate limiter compartilha um único bucket entre todos os requests quando não há headerCF-Connecting-IP— é uma peculiaridade real de dev local, não acontece em produção (a Cloudflare sempre seta esse header lá). Quando o budget acaba, todo card de palestra ainda não votada fica esmaecido e desabilitado (classe CSSbudget-out), exceto as já votadas, que continuam clicáveis pra desvotar — desvotar reabilita tudo na hora, sem refetch, é tudo derivado do estado local.
erDiagram
speakers ||--o{ talks : "propõe"
talks ||--o{ c4p_votes : "recebe"
ticket_tiers {
VARCHAR name PK
INTEGER budget
}
c4p_votes {
INTEGER id PK
TEXT user_id
INTEGER talk_id FK
VARCHAR created_at
}
manager_oauth {
INTEGER id PK
TEXT refresh_token
TEXT access_token
INTEGER expires_at
VARCHAR updated_at
}
talks {
INTEGER id PK
INTEGER speaker_id FK
VARCHAR title
INTEGER status
}
Ver resources/schema.sql pras colunas exatas. Tabelas relevantes pra votação:
talks— a colunastatuscontrola visibilidade (sóstatus = 2entra na votação); a API de voto nunca faz join comspeakers.ticket_tiers—name(VARCHAR(200) PRIMARY KEY, precisa bater exatamente com o nome do tier no guild.host) ebudget. É uma tabela de overrides:budgetForTier()retorna1por padrão pra qualquer tier sem linha aqui, então só vale adicionar linha pros tiers que têm budget diferente de1.c4p_votes—user_idé osub(UUID) do guild.host,talk_idreferenciatalks, eUNIQUE(user_id, talk_id)impede voto duplicado.manager_oauth— tabela de uma linha só (id INTEGER PRIMARY KEY CHECK(id = 1)) que guarda o refresh token rotativo do organizador, o access token em cache e a expiração.
Rode com npx wrangler d1 execute jsconf-br --file=<arquivo.sql> (adicione --remote pra produção).
-
Adicionar uma palestra votável: insira o speaker, depois a talk com
status = 2.INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) VALUES ('Nome', 'email@exemplo.com', '11999999999', 'São Paulo', 'SP', 0, 1, 'Bio curta.'); INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) VALUES (last_insert_rowid(), 0, 'Título da palestra', 'Descrição.', 1, 'Motivo.', 2);
-
Esconder uma palestra (mantendo os votos):
UPDATE talks SET status = 0 WHERE id = 42;
-
Mudar o budget de um tier:
UPDATE ticket_tiers SET budget = 3 WHERE name = 'Nome exato do tier no guild.host';
Lembrando do aviso acima: quem já logou antes da mudança só pega o novo budget deslogando e logando de novo.
-
Mudar o prazo de votação: editar
VOTE_CLOSES_ATemsrc/server/configs/vote.ts(exige código + deploy).
fetchTicketTier retorna o tier do primeiro nó de participante que bater na lista paginada do guild, não uma agregação/melhor-tier, caso a mesma pessoa apareça mais de uma vez na lista de participantes do evento. Isso ainda não foi testado na prática (só contas com um único ingresso foram usadas nos testes até agora) — é uma limitação conhecida, não um bug observado.
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).
São dois arquivos, cada um com um dono diferente — não misture:
.dev.vars— secrets de runtime do Worker, carregados automaticamente pelowrangler dev. É onde ficamGUILD_OAUTH_*,SESSION_SECRET,ALLOWED_ORIGINeENVIRONMENTno dev local. Modelo:.dev.vars.example. Em produção esses valores vêm dowrangler secret put, não de nenhum.env..env— vars de build/deploy lidas pelos scripts (tools/prepare-worker.mts) e pelo CD:CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN,WORKER_D1,WORKER_DOMAIN. Modelo:.env.example.
Os secrets de OAuth/sessão só entram no .dev.vars — não os coloque no .env, nada os lê de lá. Os dois arquivos são gitignored.
Rodando local: npm run db:init cria as tabelas.
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.