Skip to content

Add rotatable proxy tokens and a proxy-only key for internal API routes - #378

Merged
Brooooooklyn merged 12 commits into
security/1a-quick-fixesfrom
security/1b-proxy-token-v2
Oct 8, 2026
Merged

Brooooooklyn merged 12 commits into
security/1a-quick-fixesfrom
security/1b-proxy-token-v2

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Attack surface before this PR

Every tenant Worker holds a project token, __VOID_PROXY_TOKEN. With it, the Worker uses AI, email, D1, KV and R2 through the platform proxy.

token = HMAC(JWT_SECRET, projectId)          ← fixed forever, same key as login JWTs

1. Shared key: anyone could make a token

login JWT  =  header.body . HMAC(JWT_SECRET, "header.body")
                  │                 │
                  ▼                 ▼
proxy call:   projectId = "header.body",  token = signature   ──► accepted ✗
                                                                 free Workers AI, metered to nobody

The previous PR blocks this by checking the project id format. This PR gives new tokens their own key, so a signature from any other feature can never be a token.

2. A token never expires

collaborator deploys code that reads env.__VOID_PROXY_TOKEN
        │
        ▼
owner removes the collaborator ──► token still works ✗  (D1 SQL, KV, R2, AI, email — forever)

There was no way to change a token without breaking the project.

3. Internal routes opened by a tenant token

internet ──► public API host ──► /internal/resolve-worker          (Worker name, deployment id)
             x-void-internal:     /internal/resolve-resource        (D1/KV/R2/queue Cloudflare ids)
             <project token>      /internal/verify-prerender-build  (prerender credential check)

These routes are meant for the proxy only. They accepted the same token every tenant Worker holds.

What this PR adds

This PR lays the groundwork. It does not change behavior for any current project, and the next PR does the rotation.

Numbered tokens and a floor

original token (generation 0):  HMAC(JWT_SECRET, projectId)          43 chars, unchanged
new token (generation N ≥ 1):   <N>.HMAC(K_token, "void-proxy-token:<projectId>:<N>")
                                K_token = HKDF(JWT_SECRET, "void-proxy-token", "proxy-token")

KV  proxy-token:<projectId> = {"gen": F}        accept when  MAC ok  AND  N ≥ F
                                                no key → F = 0 → everything accepted, as today

Check order (a forged token never costs a KV read)

project id format ──► token syntax ──► MAC ──► read floor ──► gen ≥ floor?
      ✗ 403              ✗ 403         ✗ 403    ✗ 503          ✗ 403

Proxy-only key on internal routes

proxy ──► API /internal/*
          x-void-proxy-internal: HMAC(K_internal, route + projectId)    ✓ new, proxy only
          x-void-internal:       <project token>                        ✓ still accepted, but floor-checked
          K_internal = HKDF(JWT_SECRET, "void-proxy-internal", "proxy-internal")  never leaves the platform

One token per call, never replayed

proxy / cron / queue ──► Worker with one token ──► ok, or 403 (no second try)

The Worker runs user middleware before its own token check, so a replay could run app code twice. Senders therefore never retry with another token. The next PR makes sure they send the token each Worker holds.

Decisions

Choice Why
Floor in KV, not a token ledger One small value per project. No new tables.
No new secret Both keys come from JWT_SECRET through HKDF. Operators have nothing to set up.
A bad floor returns 503 A floor that can't be read or parsed fails closed, not open.
A raised floor takes about 105 s to reach everywhere KV propagation about 60 s + KV cacheTtl 30 s + isolate cache 15 s. This keeps KV reads low on the hot path.
No retry with another token A rejected call may already have run user middleware.
Internal routes still take a floor-checked project token An older proxy keeps working during deploy. The next PR removes this fallback.
Unknown cleanup jobs are skipped, not deleted Jobs from a newer release survive a rollback.

Known limit: a Worker kept for rollback keeps the token it was deployed with. The next PR patches those Workers during rotation, except code from people who lost access.

Rollout

deploy API, proxy, dispatch in any order ──► no floor written ──► every project behaves as today
  • No operator or user action. No new secret, binding or migration.
  • No merge gate. This PR must be live on prod before the rotation PR merges.
  • This PR alone keeps rollback to 0.26.0 safe: every Worker still gets the legacy token bytes. The rotation PR changes that; see its rollback note.

Testing

Command Result
vp run check pass
vp run test --base security/1a-quick-fixes pass: 13256 tests passed, 29 skipped
vp lint, vp fmt --check pass

🤖 Generated with Claude Code

@Brooooooklyn
Brooooooklyn added this pull request to stack #381 October 6, 2026 21:33
@Brooooooklyn Brooooooklyn changed the title security/1b proxy token v2 Add rotatable v2 proxy tokens and a proxy-only key for internal API routes Oct 6, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://security-1b-proxy-token-v2-void-cloud.voidzero-docs.workers.dev (commit a1431cc)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://0a724ca3-void-cloud.voidzero-docs.workers.dev a1431cc 2026-10-08T09:46:45.127Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://c2b653ee-void-cloud.voidzero-docs.workers.dev 6f2bb9a 2026-10-08T04:35:27.801Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://145fb26b-void-cloud.voidzero-docs.workers.dev 102b5b1 2026-10-08T03:29:11.883Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://3487048b-void-cloud.voidzero-docs.workers.dev ea1c32b 2026-10-08T03:01:07.321Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://0a18fbc6-void-cloud.voidzero-docs.workers.dev d5d58f2 2026-10-07T12:28:59.740Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://e3f93064-void-cloud.voidzero-docs.workers.dev f01b141 2026-10-07T09:34:22.372Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://1eefebae-void-cloud.voidzero-docs.workers.dev 14031a8 2026-10-07T08:23:03.875Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://3402ba78-void-cloud.voidzero-docs.workers.dev 4cfe2b9 2026-10-07T02:41:55.452Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://5184f0ed-void-cloud.voidzero-docs.workers.dev de445a7 2026-10-06T21:34:17.938Z Visit the dashboard ↗

@Brooooooklyn
Brooooooklyn force-pushed the security/1b-proxy-token-v2 branch from de445a7 to 4cfe2b9 Compare October 7, 2026 02:40
@Brooooooklyn Brooooooklyn changed the title Add rotatable v2 proxy tokens and a proxy-only key for internal API routes Add rotatable proxy tokens and a proxy-only key for internal API routes Oct 7, 2026
@Brooooooklyn
Brooooooklyn marked this pull request as ready for review October 7, 2026 09:24

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14031a87fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread platform/packages/api/src/proxy-token.ts
Comment thread platform/packages/api/src/proxy-token.ts
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T09:49:23.736677Z a1431cc New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Brooooooklyn
Brooooooklyn force-pushed the security/1b-proxy-token-v2 branch from 14031a8 to f01b141 Compare October 7, 2026 09:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f01b141d48

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread platform/packages/api/src/internal-proxy-auth.ts
@cpojer

This comment has been minimized.

@Brooooooklyn
Brooooooklyn force-pushed the security/1b-proxy-token-v2 branch from f01b141 to d5d58f2 Compare October 7, 2026 12:27
@Brooooooklyn

Copy link
Copy Markdown
Member Author

Fixed. Senders (proxy, cron consumer, queue relay) now send one token and never replay a rejected request, since user middleware runs before the Worker's own token check. The next PR records the generation each deployment's Worker holds, so the one token sent is always the right one.

@Brooooooklyn
Brooooooklyn removed this pull request from stack #381 October 7, 2026 19:58
@Brooooooklyn
Brooooooklyn added this pull request to stack #385 October 7, 2026 19:58
@Brooooooklyn
Brooooooklyn force-pushed the security/1b-proxy-token-v2 branch 3 times, most recently from 102b5b1 to 6f2bb9a Compare October 8, 2026 04:34
Brooooooklyn and others added 10 commits October 8, 2026 17:45
Tenant proxy tokens gain a v2 form, `v2.<gen>.<mac>`, signed with a key
derived from JWT_SECRET by HKDF. The legacy token stays generation 0 and
keeps its exact bytes. A per-project accept floor in ROUTING
(`proxy-token:<projectId>`) decides which generations are accepted; a
missing key is floor 0, so every current token keeps working.

Checks run the project id and syntax checks first, then the MAC, and read
the floor only for a valid MAC. A malformed or unreadable floor fails
closed. The module also has an isolate cache for floor reads and a
route-bound credential the proxy can use for the API metadata routes.

Nothing uses the module yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy's service-binding auth and /email/verify-receipt now check the
tenant project token through the shared proxy token module: the MAC
first, then the project's accept floor in ROUTING. Both the legacy token
and `v2.<gen>.<mac>` tokens are accepted while their generation is at or
above the floor. The proxy keeps one floor cache per isolate. A floor
that cannot be read or is malformed returns 503 instead of accepting the
token.

/internal/revalidate checks the forwarded token against the same floor,
so revalidation keeps working with v2 tokens and stops accepting a token
the proxy would refuse.

No floor is written yet, so every project keeps floor 0 and the legacy
token works exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy now picks the token it presents to a tenant Worker from the
project's token generation. When /internal/resolve-worker returns
`proxyTokenGen`, the proxy sends that generation's token. Without it, the
proxy sends the token for the project's cached accept floor, so tenant
Workers keep working when the API does not report a generation.

If the Worker rejects the token with its pre-handler 403 `unauthorized`
response, the proxy retries once with the previous generation's token and
the same body. It never retries after a thrown fetch or any other response.

If the floor cannot be read, a write fails with 502 and a read takes its
REST fallback, without dispatching.

With no floor written, every project still receives the legacy token in a
single request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
/internal/resolve-worker, /internal/resolve-resource and
/internal/verify-prerender-build accepted the same HMAC that tenant Workers
hold as their project token, so a tenant could call them directly on the
public API host. The proxy now also sends `x-void-proxy-internal`, an HMAC
bound to the route and project under a key derived from JWT_SECRET with
HKDF. It never leaves the platform.

The API accepts that header first and never falls through when it is
present but wrong. A request without it is checked like a tenant project
token: the MAC, then the project's accept floor. A legacy token therefore
stops opening these routes as soon as the floor rises past it, the same
moment it stops working at the proxy.

The proxy keeps sending the legacy `x-void-internal` header so an API that
predates the new credential keeps working while the two deploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
provisionBindings now takes the Worker's proxy token in its platform
options instead of deriving it from JWT_SECRET. The deploy upload issues
it with issueProxyToken, which returns the token for the project's accept
floor (the legacy token while no floor is set), so existing projects get
the same bytes as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cron fire messages, the CronScheduler state and queue routing entries can
now carry an optional `proxyTokenFallback`. When a tenant Worker rejects the
first token with its generated `unauthorized` response, the cron-fires
consumer and the dispatch queue relay try once more with the fallback token,
using the same request body and the same /__void then legacy path order.

Both rejects come before any cron or queue handler runs, so the second
attempt cannot run a handler twice. There is no second attempt after a
thrown fetch, another status or another body. If the fallback is rejected
too, the existing behavior applies: cron fires ack with the AUTH_DRIFT log,
and queue relays retry the batch.

The CronScheduler copies the fallback into each fire message and leaves it
out of /status, like `proxyToken`.

Nothing writes the fallback yet, so every relay and cron fire still sends
one request with one token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The scheduled cleanup runner now claims only the job kinds it knows how to
run. Rows of any other kind keep their attempts and payload instead of being
claimed and deleted, so they cannot fill the retry batch and they survive a
platform rollback. executeCleanupJob throws on an unknown kind as a guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Describe the legacy and v2 project token forms, the per-project accept
floor in ROUTING, how the proxy picks tokens for tenant Workers, and the
proxy-only key on the internal metadata routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy checks a 403 from the tenant Worker for the binding handler's
token reject before retrying with the previous token. That check parsed
the whole cloned body, so a Worker could make the shared proxy buffer an
arbitrarily large response. Skip bodies that declare more than 256 bytes
and stop reading a stream once it passes that size; the response returned
to the caller still streams unbuffered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brooooooklyn and others added 2 commits October 8, 2026 17:45
Tokens for generation 1 and above are now `<gen>.<mac>`. The dot still
keeps them apart from the 43-character original token. The HKDF labels
and signed messages carry the key's purpose instead of a version:
the tenant key uses salt `void-proxy-token`, info `proxy-token`, and
signs `void-proxy-token:<projectId>:<gen>`; the proxy internal key uses
salt `void-proxy-internal`, info `proxy-internal`, and signs
`void-proxy-internal\n<route>\n<projectId>`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant Worker runs its own middleware and routes before the generated
/__void/* binding, cron and queue handlers check the proxy token. A request
rejected with 401/403 has therefore already run user code, and sending it
again with a second token would run that code twice.

The proxy, the cron fire consumer and the queue relay now send exactly one
token per call. The proxy sends the generation resolve-worker reports, or the
floor's generation when none is reported. A rejected call is returned (proxy),
acked with AUTH_DRIFT (cron) or retried by the queue as before. The fallback
token fields, the 403 body sniffing and sendGenerations are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the security/1b-proxy-token-v2 branch from 6f2bb9a to a1431cc Compare October 8, 2026 09:45
@Brooooooklyn
Brooooooklyn merged commit 78a5c67 into main Oct 8, 2026
20 checks passed
@Brooooooklyn
Brooooooklyn deleted the security/1b-proxy-token-v2 branch October 8, 2026 13:00
Brooooooklyn added a commit that referenced this pull request Oct 8, 2026
## Attack surface before this PR

Every tenant Worker holds a project token, `__VOID_PROXY_TOKEN`. With
it, the Worker uses AI, email, D1, KV and R2 through the platform proxy.

**1. A removed teammate keeps access forever**

```
collaborator deploys code that reads env.__VOID_PROXY_TOKEN ──► keeps a copy
        │
        ▼
owner removes the collaborator ──► token still works ✗
                                   D1 SQL, KV, R2, AI, email — forever
```

The previous PR added numbered tokens (`<generation>.<signature>`) and a
per-project floor. Nothing raised the floor yet.

**2. A deleted project's token still works**

```
owner deletes project ──► floor and ai-block removed ──► old token on /ai/run ──► accepted ✗
                                                         (Workers AI on the platform account)
```

**3. Internal routes still take a project token**

```
internet ──► public API host /internal/resolve-worker, resolve-resource, verify-prerender-build
             x-void-internal: <project token>  ──► accepted ✗  (proxy-only routes)
```

## What this PR changes

```
access change ──► rotation ──► every Worker gets a new token ──► floor rises ──► old token refused ✓
new deploy    ──► Worker gets a numbered token
/internal/*   ──► only the proxy's own key (x-void-internal no longer accepted) ✓
```

### When a project is rotated

| Event | Rotates? |
| --- | --- |
| A collaborator or admin is removed, leaves, or their account is
deleted | Yes |
| A collaborator or admin is changed to reader | Yes |
| A platform admin suspends a collaborator or admin | Yes |
| A user is suspended for a usage limit | No (they can still deploy) |
| A project is deleted | Its floor is closed: no token of that project
works again |
| A reader is removed | No (readers can't deploy code, so they can't
read the token) |
| A deploy or rollback replaces code written by someone who can no
longer deploy | Yes, again |
| An operator runs `void platform system rotate-proxy-tokens --project
<id> --yes` (or `--all`) | Yes |
| Platform upgrade | No. Old tokens stay valid until the project has one
of the events above. |

We assume no current user has copied a token, so there is no fleet-wide
rotation on upgrade.

### How one rotation works (about 6 minutes)

```
1. new number   2. each Worker          3. record          4. lock out     5. wait
   gen + 1  ──►    gets new token  ──►    which token  ──►   old token  ──►  for caches
   (database)      (Cloudflare setting,   it holds           refused         to catch up
                   no rebuild)            (+ cron/queue copies)
```

- Senders always send the token that a Worker holds (step 3 records it).
They never retry with another token.
- The floor rises only after every Worker holds the new token.
- If a step fails, the job retries after 15 minutes. If it failed after
a PATCH may have reached a Worker, it retries on the next 2-minute run,
once per run, until it commits.
- Workers kept for rollback get the new token too, except code from
people who lost access.
- Live WebSocket connections reconnect once (step 2 starts a new Worker
version).

### A project's life after this PR

```
today (old token) ──deploy──► new Worker gets a numbered token; older Workers keep the old token (still accepted)
                  ──member removed──► rotation ──► every Worker gets a new token ──► old token refused
```

## What people see

| Who | What changes |
| --- | --- |
| App users | Nothing. |
| Project owners and admins | If a rotation fails 4 times in a row
(about 1 hour), `void project status` and `void project team list` show
a warning. |
| Operators | New command: `void platform system rotate-proxy-tokens`.
`--plan` shows progress. `--project <id> --yes` or `--all --yes` starts
a rotation. |

There is no outage on upgrade.

## Before merging

- [x] **Cloudflare check.** Done live on a test account (2026-10-07),
with the real rotation client, 6 rotations:
  ```
bindings: 13 others (plain_text, secret_text, d1, kv, r2, queue,
service, durable_object, assets) byte-identical
  secrets:  upload-metadata and PUT /secrets ones both kept
code: no upload, same etag; compat, tags, tail_consumers, observability,
placement unchanged
switch: new token at 0.4–11 s, last old token at ≤ 18 s (drain wait is
120 s)
  ```
- [ ] **#378 is live on prod.** The proxy must understand token
generations first.

After it reaches staging, rotate one test project with `--project <id>
--yes`. Then check AI, email, queues, crons, remote D1/KV/R2,
prerendering and WebSocket reconnects on that app.

After deploy, alert on log lines `[proxy-token-rotation] FAILED ...
attempts=4` or higher.

## Known limits (on purpose)

- **Very old projects.** A project whose live Worker is from before
early March 2026 (named `user-<slug>`, token under the old name
`__AI_TOKEN`) cannot rotate. The rotation stops at its first step and
retries every 15 minutes, the floor stays, and the app keeps working.
Owners and admins see the stuck-rotation warning after about an hour.
These projects already cannot deploy (`project_requires_normalization`),
so an operator must repair them before the rotation can finish. Only AI
used that old token. `platform/meta/operations.md` has a query that
lists these projects. Hosted only.
- **Code from a removed teammate** keeps running while it is the live
deployment, and gets each new token. The project rotates again as soon
as a deploy or rollback replaces it. Kept copies of that code get no new
tokens, so a rollback to one fails its AI, email and ISR revalidation
calls until a redeploy. Remote D1/KV/R2 keep working.
- **Short switch window.** While Cloudflare moves a Worker to its new
version, a call can reach the old version and get a clean 403. Cron runs
in that window are dropped (`[cron-fires] AUTH_DRIFT`); queue messages
are retried after 120 s.
- **Failed step.** If a step fails right after a token change, that
Worker's proxy calls get 403, its queue messages use up retries and its
cron fires are dropped until the next 2-minute run retries the step. A
failure that repeats keeps this going, one retry per run.
- **Cache delay.** The floor is in Cloudflare KV. In rare cases, the old
token can work for a short time after a rotation completes.
- **Platform rollback.** Do not roll back below this release. Each
Worker deploy moves its project to token generation 1, which 0.26.0 and
earlier cannot read, so every project deployed since the upgrade loses
AI, email, ISR revalidation, remote D1/KV/R2 and prerendering. A release
with only the token-generation PR is not a full fallback either: its
proxy sends those Workers the old token, so remote D1/KV/R2 and
prerendering fail. `void platform rollback` does not block it; the
operator docs say not to. After upgrading again from any rollback, run
`rotate-proxy-tokens --all --yes` and check with `--plan`.

  ```
  upgrade ──► deploy ──► project at generation 1
rollback to 0.26 ──► proxy reads only the legacy token ──► AI, email,
ISR, remote bindings, prerender ✗
  ```

## Testing

| Command | Result |
| --- | --- |
| `vp run check` | pass |
| `vp run test --base security/1b-proxy-token-v2` | pass: 13401 tests
passed, 30 skipped |
| `vp test run` (whole stack, on main `cf45d55db`) | pass: 13691 tests
passed, 30 skipped |
| `vp lint` | pass |
| `vp fmt --check` | pass |


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants