Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1827b38
feat(voting): attendee talk voting via guild.host OAuth (wip)
khaosdoctor Jun 26, 2026
c08efbe
docs(voting): votable status filter, handover + todo notes (wip)
khaosdoctor Jun 26, 2026
61245f9
docs: add CLAUDE.md
khaosdoctor Jun 26, 2026
44f7b9b
docs(voting): guild login pending + group-ticket resolved (wip)
khaosdoctor Jun 26, 2026
410c9fc
refactor(voting): guild-only, jose sessions, parseRequest + richer ta…
khaosdoctor Jul 7, 2026
52a5e23
feat(voting): wire guild OAuth app scopes + dev.vars template
khaosdoctor Jul 7, 2026
a0fc1df
docs(voting): record /ticket refactor plan + live-login verification …
khaosdoctor Jul 7, 2026
452a17f
refactor(voting): budget from per-user /ticket endpoint, drop org-tok…
khaosdoctor Jul 7, 2026
87116a8
feat(voting): add logout endpoint
khaosdoctor Jul 7, 2026
4c70374
feat(voting): add Sair (logout) button to the vote page
khaosdoctor Jul 7, 2026
92f1afa
feat(voting): surface OAuth login errors on the vote page
khaosdoctor Jul 7, 2026
84524d1
feat(account): add account page + /api/vote/me, salvaged from diverge…
khaosdoctor Jul 7, 2026
1a3c5ad
chore: salvage .serena gitignore + headroom notes from divergent branch
khaosdoctor Jul 7, 2026
7184981
feat(voting): handle closed voting + show deadline on the vote page
khaosdoctor Jul 7, 2026
0cbb1a5
i18n(voting): translate the vote and account pages
khaosdoctor Jul 7, 2026
27bf16e
i18n(c4p): translate the entire C4P page tree
khaosdoctor Jul 8, 2026
0dd3774
docs(env): split .env deploy vars from .dev.vars runtime secrets
khaosdoctor Jul 9, 2026
736cac8
feat: voting system
khaosdoctor Jul 10, 2026
0e35939
chore(server): drop ponytail comment markers, remove leftover debug l…
khaosdoctor Jul 11, 2026
2647620
docs(voting): rewrite technical docs with diagrams, remove stale HAND…
khaosdoctor Jul 11, 2026
5cbabba
docs: instructions
khaosdoctor Jul 11, 2026
17248e8
Merge remote-tracking branch 'origin/main' into voting-system
khaosdoctor Jul 11, 2026
e792138
fix(voting): reject only unparseable JSON and guard missing worker do…
khaosdoctor Jul 11, 2026
b9dd426
fix(voting): fail-closed dev gates, atomic budget check, translate lo…
khaosdoctor Jul 12, 2026
bf39af6
chore(deps): drop unused stripe dependency
khaosdoctor Jul 12, 2026
c54c730
fix(voting): harden manager-capture response, match cookie-clear attr…
khaosdoctor Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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

# 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
Comment thread
khaosdoctor marked this conversation as resolved.

# 'production' disables the X-Dev-User bypass and the dev session shortcut.
ENVIRONMENT=development
5 changes: 2 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Build / deploy vars (read by tools/prepare-worker.mts and the CD workflow).
# Worker runtime secrets (guild.host OAuth, session, ALLOWED_ORIGIN) live in .dev.vars, not here.
CLOUDFLARE_ACCOUNT_ID=''
CLOUDFLARE_API_TOKEN=''
WORKER_D1=''
WORKER_DOMAIN=''
ALLOWED_ORIGIN=''
STRIPE_SECRET_KEY=''
STRIPE_WEBHOOK_SECRET=''
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
.env
.dev.vars
.npmrc
/.serena
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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 `<Translate>` / `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<Enum, T>`.
- **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,
manager-token-based ticket-tier lookup, D1-backed vote casting with budget limits) and is
built and verified end-to-end against a real guild.host login. See `DEVELOPMENT.md` for the
technical deep-dive (architecture, OAuth flow, DB schema) and `TODO.md` for the remaining
deploy steps and known caveats.

<!-- headroom:learn:start -->

## 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/<pkg>/package.json`. Do NOT use `node -e "require('<pkg>/package.json')"` — packages like `jose` define `exports` and throw `ERR_PACKAGE_PATH_NOT_EXPORTED`.

<!-- headroom:learn:end -->
179 changes: 179 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,182 @@ 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 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 |

### Duas identidades OAuth diferentes

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

1. **Token da votante** — escopo `profile:read` apenas (`OAUTH_SCOPE` em `src/server/configs/oauth.ts`). Usado uma única vez no login pra chamar `/oauth/userinfo` do guild.host e pegar `{ sub, name, picture }`. Nunca é guardado.
2. **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 (escopo `event_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=1` pede o `MANAGER_SCOPE`, e o callback (gated por `env.ENVIRONMENT !== 'production'` + um cookie `vote_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 D1 `manager_oauth`. Se o `access_token` em cache ainda não expirou, reusa sem chamar a rede. Senão, faz um `POST grant_type=refresh_token` no 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_token` de volta** na linha de `manager_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.

### Fluxo de login completo

```mermaid
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:

1. `GET /api/vote/login` (sem `?dev`/`?manager` em produção) — o Worker gera um `state` aleatório, seta o cookie `vote_oauth_state` (`SameSite=Lax`, 10min) e redireciona pro `/oauth/authorize` do guild.host com `client_id`/`redirect_uri`/`scope=profile:read`/`state`.
2. A pessoa autoriza no guild.host e é redirecionada pra `GET /api/vote/callback?code=...&state=...`.
3. O Worker confere que o `state` bate com o cookie, troca o `code` pelo par `{ access_token, refresh_token }` da votante (`exchangeCode()`).
4. `fetchUserInfo(access_token)` — `{ id (sub), name, picture }` do `/oauth/userinfo` do guild.
5. `managerAccessToken(...)` — obtém (ou renova) o token de organizador, lendo/gravando `manager_oauth` no D1 (ver seção acima).
6. `fetchTicketTier(managerToken, EVENT_SLUG, identity.id)` — pagina `/events/{slug}/attendees?first=100` (recursivo, até 30 páginas) procurando o nó cujo `userId` é o da votante, e retorna `node.ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name`, ou `null` se não houver pedido de ingresso.
7. Sem tier → redireciona pra home com `?error=notattendee` (nenhuma sessão é assinada). Com tier → `budgetForTier(tier)` consulta a tabela `ticket_tiers` no D1 (qualquer tier sem linha lá recebe `1` por padrão).
8. `signSession(id, budget, secret, ttl, { name, photo })` assina um JWT (HS256, via `jose`) com o **budget já embutido**. Seta o cookie `vote_session` (`SameSite=None; Secure`, 24h) e redireciona pra `/vote`.
9. **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.
10. 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).

### Autenticação a cada request (depois do login)

`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 header `X-Dev-User` retorna uma sessão fixa sem precisar de cookie — útil pra testar via `curl` sem login de verdade:
```sh
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 `/vote` no navegador.

### A votação em si

- `GET /api/vote` (`voteGet`) — exige sessão; retorna `{ budget, used, talks, myVotes, closesAt }`. `listTalks()` (`src/server/repositories/vote.ts`) só seleciona palestras com `status = 2` (`VOTABLE_TALK_STATUS`, em `src/server/configs/vote.ts`) e **deliberadamente não faz join** com `speakers` — 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 com `zod`. Checa `isVotingOpen()` (`VOTE_CLOSES_AT` em `src/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 faz `INSERT OR IGNORE` em `c4p_votes`. `removeVote()` só faz `DELETE`, 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á header `CF-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 CSS `budget-out`), exceto as já votadas, que continuam clicáveis pra desvotar — desvotar reabilita tudo na hora, sem refetch, é tudo derivado do estado local.

### Banco de dados

```mermaid
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 coluna `status` controla visibilidade (só `status = 2` entra na votação); a API de voto nunca faz join com `speakers`.
- **`ticket_tiers`** — `name` (`VARCHAR(200) PRIMARY KEY`, precisa bater exatamente com o nome do tier no guild.host) e `budget`. É uma tabela de **overrides**: `budgetForTier()` retorna `1` por padrão pra qualquer tier sem linha aqui, então só vale adicionar linha pros tiers que têm budget diferente de `1`.
- **`c4p_votes`** — `user_id` é o `sub` (UUID) do guild.host, `talk_id` referencia `talks`, e `UNIQUE(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.

### Operações administrativas

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`.

```sql
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):**
```sql
UPDATE talks SET status = 0 WHERE id = 42;
```
- **Mudar o budget de um tier:**
```sql
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_AT` em `src/server/configs/vote.ts` (exige código + deploy).

### Limitação conhecida: ingressos múltiplos

`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).

### Variáveis de ambiente (dois arquivos)

São dois arquivos, cada um com um dono diferente — não misture:

- **`.dev.vars`** — secrets de _runtime_ do Worker, carregados automaticamente pelo `wrangler dev`. É onde ficam `GUILD_OAUTH_*`, `SESSION_SECRET`, `ALLOWED_ORIGIN` e `ENVIRONMENT` no dev local. Modelo: `.dev.vars.example`. Em produção esses valores vêm do `wrangler 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`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**.

Expand Down
Loading