Skip to content

fix(relay): reject malformed percent-escapes on upgrade instead of throwing - #18547

Merged
Jinwoo-H merged 1 commit into
mainfrom
fix/relay-upgrade-malformed-uri
Sep 4, 2026
Merged

Jinwoo-H merged 1 commit into
mainfrom
fix/relay-upgrade-malformed-uri

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​122 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​122
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​13 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​10

Bug

decodeURIComponent on the /v1/connect/<hostId> and /v1/host/data/<connId> path segments in relay-server.ts's upgrade listener throws URIError: URI malformed for any bad percent-escape (%, %E0%A4%A, %C0%AF). Node's upgrade event has no error boundary and the relay registers no uncaughtException handler, so the throw kills the process: a cell drops every connection it holds, a director instance restarts. Verified against the real server, not by reading: the new blackbox test captured three process-level URIErrors from three requests before the fix.

Pre-existing since the splice landed (orca-cloud #20, 2026-07-12); the import in #18413 carried it over unchanged. Flagged by an automated review.

Fix

A decodePathSegment helper returns null on URIError, and both sites take the existing 4xx reject branch. No behavior change for well-formed targets.

Test

relay-upgrade-malformed-uri.blackbox.test.ts starts the real relay server, sends the malformed targets over a raw TCP socket, asserts no uncaughtException, every response is 4xx, and a well-formed /v1/connect/<16 chars> still gets 101 afterwards. Mutation-checked: reverting the connect site alone fails the test with the three captured URIErrors.

Full relay suite: 47 files / 423 tests pass (Postgres-gated suites skipped, unrelated). Typecheck clean.

@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown

Too many files changed for review (149 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 80f0809c-e9d3-4c67-b90e-c0f1d46bd003

📥 Commits

Reviewing files that changed from the base of the PR and between 71dfa156a534c09f5a01089427bcc8e508e58a6c and f86328e9de58c0a06e0ec32f711b911d09905fe6.

📒 Files selected for processing (2)
  • cloud/.gitleaks.toml
  • cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The relay server now safely decodes path segments in the /v1/connect/ and /v1/host/data/ upgrade handlers. Malformed percent-encoded segments return 429 responses instead of causing uncaught URIError exceptions. A blackbox test sends malformed upgrade targets, checks for 4xx responses and no uncaught exceptions, and confirms that a valid upgrade returns 101.

Merge Risk: ⚪ Minimal · up to 08cc3

Malformed relay upgrade paths now receive a client-error response rather than terminating the relay process, while valid upgrades continue to succeed. No current merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, fix, impact, and test results. However, it omits most required template sections, including ELI5, What Changed, Why, Visual Proof or N/A, AI Disclosure, Review, Agent… Complete the required template sections. Add an ELI5 summary, explicit What Changed and Why sections, a Linked Issue entry such as Fixes #<issue>, N/A with a reason for Visual Proof, applicable AI Disclosure and Review details, the Agen…
Docstring Coverage ⚠️ Warning Docstring coverage is 2.94% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 136 functions across 52 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting malformed percent-escapes during relay upgrades instead of throwing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the bug, fix, impact, and test results. However, it omits most required template sections, including ELI5, What Changed, Why, Visual Proof or N/A, AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist items. It also does not provide a Linked Issue entry in the required format.

Resolution

Complete the required template sections. Add an ELI5 summary, explicit What Changed and Why sections, a Linked Issue entry such as Fixes #&lt;issue&gt;, N/A with a reason for Visual Proof, applicable AI Disclosure and Review details, the Agent skill upstream boundary checkbox, Notes, and completed Checklist items.

Full details: Docstring Coverage

Explanation

Docstring coverage is 2.94% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 136 functions across 52 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (8)
cloud/apps/relay/src/cell-heartbeat-client.test.ts (1)

55-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the negative branch for regionalRehomeProtocol.

The test name states "only when its dedicated trust boundary is configured", but the test asserts only the positive case. The 0 branch in cell-heartbeat-client.ts (Line 95) stays uncovered. A regression that always advertises 1 would still pass.

Add one case that omits rehomeAudience and rehomeDirectorServiceAccount and asserts regionalRehomeProtocol: 0.

♻️ Proposed additional case
+  it('advertises no per-host drain protocol without the paired trust boundary', async () => {
+    const requests: RequestInit[] = []
+    const client = startCellHeartbeat(CONFIG, {
+      ready: async () => true,
+      observedRequests: () => 0,
+      connectionCounts: () => ({
+        totalConnections: 0,
+        inFlightConnections: 0,
+        reservedConnectionUnits: 0,
+        enforcedConnectionUnits: 0
+      }),
+      regionalRehomeSafety: () => ({
+        observedAt: 120,
+        sqlFailures: 0,
+        reconnects: 0,
+        controlActivityRecoveryFailures: 0,
+        databasePoolWaiting: 0,
+        databasePoolWaitersMax: 0,
+        databasePoolWaitMsMax: 0
+      }),
+      fetch: async (_input, init) => {
+        requests.push(init ?? {})
+        return new Response('{}', { status: 200 })
+      },
+      identityToken: async () => 'secret-token',
+      now: () => 123,
+      incarnation: '11111111-1111-4111-8111-111111111111',
+      intervalMs: 60_000
+    })!
+    await vi.waitFor(() => expect(requests).toHaveLength(2))
+    client.stop()
+
+    expect(JSON.parse(String(requests[1]!.body))).toMatchObject({
+      regionalRehomeProtocol: 0
+    })
+  })

Also applies to: 94-105

cloud/apps/relay/src/relay-connection-ledger.test.ts (1)

153-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make this assertion discriminate the behavior it claims.

The test name states that a claimed reservation is not restored after the phone closes. Line 153 cannot prove that. At this point the ledger is empty, so tryReserveHostData returns non-null on both paths: the restored-reservation path and the reserveUpgrade(this.normalAdmissionLimit) fallback in relay-connection-ledger.ts (Line 146). A regression that wrongly restored the reservation would still pass.

Assert the resulting counts instead. A fresh upgrade adds one in-flight unit and leaves reservedConnectionUnits at 0. A restored reservation would consume a reserved unit.

♻️ Proposed assertion
-    expect(ledger.tryReserveHostData('connection-1')).not.toBeNull()
+    expect(ledger.tryReserveHostData('connection-1')).not.toBeNull()
+    // A fresh upgrade, not a restored reservation: no reserved unit is consumed.
+    expect(ledger.counts()).toMatchObject({
+      inFlightConnections: 1,
+      reservedConnectionUnits: 0,
+      enforcedConnectionUnits: 1
+    })
cloud/apps/relay/src/host-session-registry.test.ts (1)

144-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the real activate member type.

HostSessionRegistry.activate is a private method with the eight-parameter signature shown in cloud/apps/relay/src/host-session-registry.ts:625-634. The as unknown as cast disconnects bound from that declaration, so later signature changes will not produce compile errors at its call sites. Access the member directly and suppress only the private-member diagnostic, or add a typed test seam.

cloud/apps/relay/src/postgres-maintenance-sweep-plans.test.ts (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the teardown resilient so the 20,000 seeded rows are always removed.

client is assigned last in beforeAll. If any earlier statement rejects, client stays undefined and afterAll throws on client.end(). The row cleanup and database.close() then never run, and the shared database keeps 20,000 sweep-plan- rows that skew plans for the other PostgreSQL suites.

Delete the owned rows first, and end the client only if it exists.

♻️ Proposed teardown order
   afterAll(async () => {
-    await client.end()
     await database.query(
       `DELETE FROM relay_connection_bases WHERE basis_conn_id LIKE ?`,
       [`${OWNED_PREFIX}%`]
     )
+    await client?.end()
     await database.close()
   })

client needs a nullable declaration for this:

-  let client: pg.Client
+  let client: pg.Client | undefined
cloud/apps/relay/src/relay-readiness.ts (1)

53-63: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Release the JWKS response body after checking its status.

The default Node fetch path reads only response.ok. It does not consume or cancel response.body before the SQL query or the non-OK exception. An unconsumed undici body can keep its connection unavailable for reuse on each 10-second probe. Cancel or consume the body in a cleanup path that also runs for non-OK responses.

cloud/apps/relay/src/database.ts (2)

767-786: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Keep pool-level queryLocked from being used for serialization

PostgresDatabase.queryLocked appends FOR UPDATE and calls this.query. PostgresDatabase.query releases the pooled client after the statement, so PostgreSQL releases the row locks before follow-up operations run. Use a transaction handle for lock-and-update sequences, or make pool-level use fail explicitly.


799-826: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Destroy the client when ROLLBACK fails.

Track whether ROLLBACK fails, then call client.release(true). pg.PoolClient.release uses a boolean destroy flag. Calling client.release() can return an aborted client to the pool, where later statements may fail with 25P02.

cloud/apps/relay/src/cell-admission-migration-registration.ts (1)

278-282: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Batch the per-cell verification queries.

The loop runs three awaited queries for each cell, and normalizeMigrationCells permits up to 128 cells. That is up to 384 sequential round trips while the transaction still holds the relay_cells and selector locks from lines 53 to 56. The extended lock hold time blocks concurrent admission updates.

Fetch the region, admission state, and connection limit for all cell ids in three IN (...) queries before the loop, then compare in memory.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 62abbf78-fbf1-40ab-a07f-6a02060a511c

📥 Commits

Reviewing files that changed from the base of the PR and between e42c60e and b96d057eda454fb58e625682dcce90a7c181d773.

⛔ Files ignored due to path filters (1)
  • cloud/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (148)
  • .github/scripts/check-root-directory-entries.mjs
  • .github/workflows/cloud-verify.yml
  • .oxfmtrc.json
  • .oxlintrc.json
  • README.md
  • cloud/.dockerignore
  • cloud/.editorconfig
  • cloud/.gitignore
  • cloud/.gitleaks.toml
  • cloud/.node-version
  • cloud/.npmrc
  • cloud/.trufflehog-exclude-paths.txt
  • cloud/.trufflehog-include-paths.txt
  • cloud/README.md
  • cloud/apps/relay/Dockerfile
  • cloud/apps/relay/package.json
  • cloud/apps/relay/src/admin-token-verifier.test.ts
  • cloud/apps/relay/src/admin-token-verifier.ts
  • cloud/apps/relay/src/app.ts
  • cloud/apps/relay/src/assignment-cleanup-steps.test.ts
  • cloud/apps/relay/src/assignment-cleanup-steps.ts
  • cloud/apps/relay/src/assignment-connection-headroom-postgres.test.ts
  • cloud/apps/relay/src/assignment-connection-headroom-query.ts
  • cloud/apps/relay/src/assignment-connection-headroom.test.ts
  • cloud/apps/relay/src/assignment-control-supersession-postgres.test.ts
  • cloud/apps/relay/src/assignment-deployment-status-postgres.test.ts
  • cloud/apps/relay/src/assignment-identity-queue.test.ts
  • cloud/apps/relay/src/assignment-identity-queue.ts
  • cloud/apps/relay/src/assignment-inventory-snapshot.test.ts
  • cloud/apps/relay/src/assignment-inventory-snapshot.ts
  • cloud/apps/relay/src/assignment-rejection-log-window.test.ts
  • cloud/apps/relay/src/assignment-rejection-log-window.ts
  • cloud/apps/relay/src/assignment-rejection-logging.test.ts
  • cloud/apps/relay/src/assignment-store-lock-order.test.ts
  • cloud/apps/relay/src/assignment-store.test.ts
  • cloud/apps/relay/src/assignment-store.ts
  • cloud/apps/relay/src/cell-admission-migration-registration.ts
  • cloud/apps/relay/src/cell-admission-selector.test.ts
  • cloud/apps/relay/src/cell-admission-selector.ts
  • cloud/apps/relay/src/cell-admission-startup-postgres.test.ts
  • cloud/apps/relay/src/cell-admission-startup.test.ts
  • cloud/apps/relay/src/cell-admission-startup.ts
  • cloud/apps/relay/src/cell-connection-hard-cap-consistency.test.ts
  • cloud/apps/relay/src/cell-fence-legacy-adoption-postgres.test.ts
  • cloud/apps/relay/src/cell-heartbeat-client.test.ts
  • cloud/apps/relay/src/cell-heartbeat-client.ts
  • cloud/apps/relay/src/config.test.ts
  • cloud/apps/relay/src/config.ts
  • cloud/apps/relay/src/connection-reservation-debt-retention.test.ts
  • cloud/apps/relay/src/control-lease-recovery-postgres.test.ts
  • cloud/apps/relay/src/control-renewal-postgres.test.ts
  • cloud/apps/relay/src/control-reservation-reconnect-claim.test.ts
  • cloud/apps/relay/src/credential-store.test.ts
  • cloud/apps/relay/src/credential-store.ts
  • cloud/apps/relay/src/database-postgres-timeout.test.ts
  • cloud/apps/relay/src/database-transient-error.test.ts
  • cloud/apps/relay/src/database.test.ts
  • cloud/apps/relay/src/database.ts
  • cloud/apps/relay/src/fault-injection-test-entry.ts
  • cloud/apps/relay/src/google-metadata-identity-token.ts
  • cloud/apps/relay/src/host-session-registry.test.ts
  • cloud/apps/relay/src/host-session-registry.ts
  • cloud/apps/relay/src/index.ts
  • cloud/apps/relay/src/migration-recovery-postgres.test.ts
  • cloud/apps/relay/src/observed-relay-database.ts
  • cloud/apps/relay/src/postgres-drain-send-locking.test.ts
  • cloud/apps/relay/src/postgres-idle-client-error.test.ts
  • cloud/apps/relay/src/postgres-maintenance-sweep-plans.test.ts
  • cloud/apps/relay/src/postgres-pool-pressure.test.ts
  • cloud/apps/relay/src/postgres-pool-pressure.ts
  • cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts
  • cloud/apps/relay/src/postgres-schema-startup.ts
  • cloud/apps/relay/src/postgres-transaction-recovery.test.ts
  • cloud/apps/relay/src/public-assignment-admission.test.ts
  • cloud/apps/relay/src/public-assignment-admission.ts
  • cloud/apps/relay/src/public-assignment-circuit-breaker.test.ts
  • cloud/apps/relay/src/public-assignment-overload.test.ts
  • cloud/apps/relay/src/public-assignment-reconnect-lane.test.ts
  • cloud/apps/relay/src/regional-host-drain-app.test.ts
  • cloud/apps/relay/src/regional-rehome-postgres.test.ts
  • cloud/apps/relay/src/regional-rehome-safety.test.ts
  • cloud/apps/relay/src/regional-rehome-safety.ts
  • cloud/apps/relay/src/regional-rehome-store.test.ts
  • cloud/apps/relay/src/regional-rehome-target-selection.test.ts
  • cloud/apps/relay/src/regional-rehome-trust-probe.ts
  • cloud/apps/relay/src/regional-rehome-worker.test.ts
  • cloud/apps/relay/src/regional-rehome-worker.ts
  • cloud/apps/relay/src/registered-migration-abandonment.ts
  • cloud/apps/relay/src/registered-migration-inventory-postgres.test.ts
  • cloud/apps/relay/src/registered-migration-inventory.test.ts
  • cloud/apps/relay/src/registered-migration-inventory.ts
  • cloud/apps/relay/src/relay-background-operation.test.ts
  • cloud/apps/relay/src/relay-background-operation.ts
  • cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts
  • cloud/apps/relay/src/relay-connection-ledger.test.ts
  • cloud/apps/relay/src/relay-connection-ledger.ts
  • cloud/apps/relay/src/relay-first-frame-failure.blackbox.test.ts
  • cloud/apps/relay/src/relay-host-log-digest.ts
  • cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts
  • cloud/apps/relay/src/relay-observability.test.ts
  • cloud/apps/relay/src/relay-observability.ts
  • cloud/apps/relay/src/relay-readiness.test.ts
  • cloud/apps/relay/src/relay-readiness.ts
  • cloud/apps/relay/src/relay-region-app.test.ts
  • cloud/apps/relay/src/relay-region-placement.test.ts
  • cloud/apps/relay/src/relay-server.ts
  • cloud/apps/relay/src/relay-token-verifier.ts
  • cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts
  • cloud/apps/relay/src/relay-websocket-close.test.ts
  • cloud/apps/relay/src/relay-websocket-close.ts
  • cloud/apps/relay/src/relay.blackbox.test.ts
  • cloud/apps/relay/src/splice-forwarder.test.ts
  • cloud/apps/relay/src/splice-forwarder.ts
  • cloud/apps/relay/src/staging-asia-proof-admission.test.ts
  • cloud/apps/relay/src/staging-asia-proof-admission.ts
  • cloud/apps/relay/tsconfig.build.json
  • cloud/apps/relay/tsconfig.json
  • cloud/apps/relay/vitest.config.ts
  • cloud/package.json
  • cloud/packages/relay-contract/package.json
  • cloud/packages/relay-contract/src/admission-budgets.ts
  • cloud/packages/relay-contract/src/assignment-invariants.ts
  • cloud/packages/relay-contract/src/close-codes.ts
  • cloud/packages/relay-contract/src/contract.test.ts
  • cloud/packages/relay-contract/src/control-continuity.ts
  • cloud/packages/relay-contract/src/control-messages.ts
  • cloud/packages/relay-contract/src/credential-messages.ts
  • cloud/packages/relay-contract/src/director-messages.ts
  • cloud/packages/relay-contract/src/host-proof-transcript.ts
  • cloud/packages/relay-contract/src/index.ts
  • cloud/packages/relay-contract/src/persistence-invariants.test.ts
  • cloud/packages/relay-contract/src/persistence-invariants.ts
  • cloud/packages/relay-contract/src/protocol-limits.ts
  • cloud/packages/relay-contract/src/relay-regions.ts
  • cloud/packages/relay-contract/src/resume-confirmation-contract.ts
  • cloud/packages/relay-contract/src/splice-state-machine.ts
  • cloud/packages/relay-contract/src/wire-scalars.ts
  • cloud/packages/relay-contract/tsconfig.build.json
  • cloud/packages/relay-contract/tsconfig.json
  • cloud/pnpm-workspace.yaml
  • cloud/tsconfig.base.json
  • config/oxlint-react-doctor.json
  • config/scripts/check-changed-code-quality.mjs
  • config/scripts/check-changed-code-quality.test.mjs
  • config/scripts/check-root-directory-entries.test.mjs
  • config/scripts/pr-code-change-scope.mjs
  • config/scripts/pr-code-change-scope.test.mjs
  • package.json

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines +177 to +191
function encodeCells(cells: NormalizedMigrationCellRegistration[]): string {
return JSON.stringify(
cells.map((cell) =>
cell.region === RELAY_DEFAULT_REGION
? {
id: cell.id,
url: cell.url,
capacityRequests: cell.capacityRequests,
connectionHardCap: cell.connectionHardCap,
connectionUnobservedBound: cell.connectionUnobservedBound
}
: cell
)
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Canonicalize encodeCells for non-default regions.

normalizeMigrationCells preserves caller property order and extra properties with { ...cell, region: ... }. encodeCells passes non-default-region cells directly to JSON.stringify, so equivalent retries can produce different cells_json values and trigger admission_selector_attempt_mismatch. Emit the declared fields in a fixed order and append region only for non-default regions.

}
const cells = input
.map((cell) => ({ ...cell, region: cell.region ?? RELAY_DEFAULT_REGION }))
.sort((left, right) => left.id.localeCompare(right.id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a locale-independent order for validated cell IDs.

CellIdSchema accepts any non-empty string up to 128 characters, so Unicode IDs are valid. normalizeMigrationCells uses String.prototype.localeCompare before encodeCells persists the order in cells_json. On a replay of the same admin request, RelayMigrationCellRegistrar.add compares that persisted JSON with newly encoded JSON. A different default locale or ICU dataset can produce a different order and cause admission_selector_attempt_mismatch. Use a code-unit comparison with < and > instead.

Comment on lines +51 to +61
ORCA_RELAY_CELL_CONNECTION_HARD_CAP: z.coerce
.number()
.int()
.pipe(RelayCellConnectionHardCapSchema)
.optional(),
ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: z.coerce
.number()
.int()
.nonnegative()
.max(RELAY_MAX_CELL_CONNECTION_UNOBSERVED_BOUND)
.optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Treat an empty string as unset for the optional numeric variables.

z.coerce.number() converts '' to 0. If a deployment template sets ORCA_RELAY_CELL_CONNECTION_HARD_CAP=, ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND=, or ORCA_RELAY_DATABASE_POOL_MAX= with an empty value, the value becomes 0 instead of undefined. RelayCellConnectionHardCapSchema rejects 0, and .positive() rejects 0 on line 80, so the relay fails to start rather than using its default.

The optional service-account fields already handle this with z.preprocess on lines 29 to 32 and 71 to 74. Apply the same preprocessing to the optional numeric fields.

🔧 Proposed fix
+const OptionalNumberSchema = <T extends z.ZodTypeAny>(schema: T) =>
+  z.preprocess((value) => (value === '' ? undefined : value), schema.optional())
+
-  ORCA_RELAY_CELL_CONNECTION_HARD_CAP: z.coerce
-    .number()
-    .int()
-    .pipe(RelayCellConnectionHardCapSchema)
-    .optional(),
-  ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: z.coerce
-    .number()
-    .int()
-    .nonnegative()
-    .max(RELAY_MAX_CELL_CONNECTION_UNOBSERVED_BOUND)
-    .optional(),
+  ORCA_RELAY_CELL_CONNECTION_HARD_CAP: OptionalNumberSchema(
+    z.coerce.number().int().pipe(RelayCellConnectionHardCapSchema)
+  ),
+  ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: OptionalNumberSchema(
+    z.coerce
+      .number()
+      .int()
+      .nonnegative()
+      .max(RELAY_MAX_CELL_CONNECTION_UNOBSERVED_BOUND)
+  ),

Apply the same wrapper to ORCA_RELAY_DATABASE_POOL_MAX on line 80.

Also applies to: 80-80

Comment on lines +214 to +225
const adminServiceAccounts = [
parsed.ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT,
parsed.ORCA_RELAY_CAPACITY_SERVICE_ACCOUNT,
parsed.ORCA_RELAY_ASIA_PROOF_SERVICE_ACCOUNT,
parsed.ORCA_RELAY_MONITOR_SERVICE_ACCOUNT,
parsed.ORCA_RELAY_FENCE_SERVICE_ACCOUNT,
parsed.ORCA_RELAY_FENCE_BROKER_SERVICE_ACCOUNT,
parsed.ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT
].filter((value): value is string => value !== undefined)
if (new Set(adminServiceAccounts).size !== adminServiceAccounts.length) {
throw new Error('relay admin service accounts must be distinct')
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check which admin roles each configured service account grants, and whether runtimeServiceAccount is used as a caller identity.
rg -n -C6 'runtimeServiceAccount|fenceServiceAccount|monitorServiceAccount' cloud/apps/relay/src --type=ts -g '!*.test.ts'
fd -t f 'admin-token-verifier.ts' cloud/apps/relay/src --exec cat -n {}

Repository: stablyai/orca

Length of output: 15490


🏁 Script executed:

#!/bin/bash
sed -n '205,245p' cloud/apps/relay/src/config.ts
rg -n -C5 'verifyRuntimeToken|createRuntimeTokenVerifier|googleMetadataIdentityToken|runtimeServiceAccount|verifyReadOnlyAdminToken|verifyAdminToken' cloud/apps/relay/src --type=ts -g '!*.test.ts'

Repository: stablyai/orca

Length of output: 43325


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 37475


Authorization Bypass (CWE-269): Improper Privilege Management

Reachability: Internal · Exploitability: Difficult

Include the runtime service account in the distinctness check.

When ORCA_RELAY_RUNTIME_SERVICE_ACCOUNT matches ORCA_RELAY_FENCE_SERVICE_ACCOUNT or ORCA_RELAY_MONITOR_SERVICE_ACCOUNT, the runtime identity also passes that admin verifier. Reject this explicit overlap, but preserve the existing fallback to ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT.

Comment on lines +249 to +252
const configuredCells = z
.array(RelayCellConfigSchema)
.max(128)
.parse(JSON.parse(parsed.ORCA_RELAY_CELLS_JSON) as unknown)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Wrap the ORCA_RELAY_CELLS_JSON parse in a descriptive error.

JSON.parse on line 252 throws a raw SyntaxError for malformed JSON. The message names no environment variable. Every other invalid value in this loader produces a named error. An operator debugging startup has to guess which of the many variables is malformed.

🔧 Proposed fix
+  let cellsJson: unknown
+  try {
+    cellsJson = JSON.parse(parsed.ORCA_RELAY_CELLS_JSON)
+  } catch {
+    throw new Error('ORCA_RELAY_CELLS_JSON must be valid JSON')
+  }
   const configuredCells = z
     .array(RelayCellConfigSchema)
     .max(128)
-    .parse(JSON.parse(parsed.ORCA_RELAY_CELLS_JSON) as unknown)
+    .parse(cellsJson)
     .map((cell) => ({ ...cell, url: canonicalOrigin(cell.url, `cell ${cell.id}`) }))

Comment on lines +22 to +37
function wrap(transaction: RelayDatabase): RelayDatabase {
return {
query: async (sql, params) => {
if (pattern && !faulted && sql.includes(pattern)) {
faulted = true
throw new Error('injected SQL failure')
}
return await transaction.query(sql, params)
},
queryLocked: async (sql, params, options) =>
await transaction.queryLocked(sql, params, options),
transaction: async (operation) =>
await transaction.transaction(async (nested) => await operation(wrap(nested))),
close: async () => await transaction.close()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Description: Find code that branches on RelayDatabase.dialect and on transaction options.
rg -nP --type=ts -C3 '\.dialect\b' cloud/apps/relay/src cloud/packages
rg -nP --type=ts -C3 'reportRetries' cloud/apps/relay/src

Repository: stablyai/orca

Length of output: 4119


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- fault-injection wrapper ---'
cat -n cloud/apps/relay/src/fault-injection-test-entry.ts | sed -n '1,90p'
printf '%s\n' '--- database contract and PostgreSQL transaction implementation ---'
cat -n cloud/apps/relay/src/database.ts | sed -n '1,45p'
cat -n cloud/apps/relay/src/database.ts | sed -n '770,825p'
printf '%s\n' '--- sibling wrapper ---'
cat -n cloud/apps/relay/src/observed-relay-database.ts | sed -n '20,55p'
printf '%s\n' '--- dialect-dependent consumer ---'
cat -n cloud/apps/relay/src/assignment-store.ts | sed -n '3265,3285p'

Repository: stablyai/orca

Length of output: 8806


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings

Length of output: 22717


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- database factories and dialect assignments ---'
rg -n -C4 'openRelayDatabase|dialect:' cloud/apps/relay/src/database.ts
printf '%s\n' '--- transaction callers that pass options ---'
rg -n -C5 'database\.transaction\([^;\n]*\{|database\.transaction\(' cloud/apps/relay/src --type=ts
printf '%s\n' '--- relay server database flow ---'
rg -n -C3 'createRelayServer|RelayDatabase' cloud/apps/relay/src/relay-server.ts cloud/apps/relay/src/assignment-store.ts --type=ts | head -160

Repository: stablyai/orca

Length of output: 50369


Preserve dialect and transaction options in wrap. When the wrapped database is PostgreSQL, AssignmentStore receives undefined for dialect and selects the non-PostgreSQL renewal path. The wrapper also drops options, so reportRetries does not reach PostgresDatabase.transaction.

Comment on lines +956 to +964
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)

await expect(completionStore.completeReadyEvacuations()).resolves.toBe(0)
await expect(legacyTransaction).resolves.toBeUndefined()
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining('orca_relay_postgres_transaction_retry')
)
await expect(store.completeReadyEvacuations()).resolves.toBe(1)
}, 15_000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore each console.warn spy before its test ends.

The relay Vitest configuration does not enable restoreMocks, and the file’s afterEach does not restore mocks. Add warn.mockRestore() to the tests at lines 956, 1176, and 1285. Otherwise, later tests in the same worker can have relay warnings suppressed.

Comment on lines +27 to +32
if (
invite.state === INVITE_STATE.RESERVED &&
(!invite.reservationId || !invite.reservationExpiresAt)
) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'reserved invite requires a lease' })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compare reservationExpiresAt with undefined, not falsiness.

!invite.reservationExpiresAt is also true when reservationExpiresAt is 0. A reserved invite whose lease expiry is epoch 0 then fails validation with "reserved invite requires a lease". Line 33 already uses === undefined for cooldownUntil, so the two refinements disagree.

🔧 Proposed fix
     if (
       invite.state === INVITE_STATE.RESERVED &&
-      (!invite.reservationId || !invite.reservationExpiresAt)
+      (invite.reservationId === undefined || invite.reservationExpiresAt === undefined)
     ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
invite.state === INVITE_STATE.RESERVED &&
(!invite.reservationId || !invite.reservationExpiresAt)
) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'reserved invite requires a lease' })
}
if (
invite.state === INVITE_STATE.RESERVED &&
(invite.reservationId === undefined || invite.reservationExpiresAt === undefined)
) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'reserved invite requires a lease' })
}

Comment thread cloud/README.md
Comment on lines +42 to +44
Configuration is read from environment variables validated in
`apps/relay/src/config.ts`. `ORCA_RELAY_ASSIGNMENT_SIGNING_KEY` (at least 32
bytes) is the only required value; everything else has a local default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the list of required environment variables.

EnvSchema in cloud/apps/relay/src/config.ts declares several variables with no default: ORCA_RELAY_PUBLIC_URL, ORCA_RELAY_CELL_URL, ORCA_RELAY_AUTH_ISSUER, ORCA_RELAY_JWKS_URL, ORCA_RELAY_ADMIN_AUDIENCE, and ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT. A developer who sets only ORCA_RELAY_ASSIGNMENT_SIGNING_KEY gets a Zod validation error at startup.

📝 Proposed documentation fix
 Configuration is read from environment variables validated in
-`apps/relay/src/config.ts`. `ORCA_RELAY_ASSIGNMENT_SIGNING_KEY` (at least 32
-bytes) is the only required value; everything else has a local default.
+`apps/relay/src/config.ts`. The required values are
+`ORCA_RELAY_ASSIGNMENT_SIGNING_KEY` (at least 32 bytes),
+`ORCA_RELAY_PUBLIC_URL`, `ORCA_RELAY_CELL_URL`, `ORCA_RELAY_AUTH_ISSUER`,
+`ORCA_RELAY_JWKS_URL`, `ORCA_RELAY_ADMIN_AUDIENCE`, and
+`ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT`. Everything else has a local default.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Configuration is read from environment variables validated in
`apps/relay/src/config.ts`. `ORCA_RELAY_ASSIGNMENT_SIGNING_KEY` (at least 32
bytes) is the only required value; everything else has a local default.
Configuration is read from environment variables validated in
`apps/relay/src/config.ts`. The required values are
`ORCA_RELAY_ASSIGNMENT_SIGNING_KEY` (at least 32 bytes),
`ORCA_RELAY_PUBLIC_URL`, `ORCA_RELAY_CELL_URL`, `ORCA_RELAY_AUTH_ISSUER`,
`ORCA_RELAY_JWKS_URL`, `ORCA_RELAY_ADMIN_AUDIENCE`, and
`ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT`. Everything else has a local default.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The fix is correct and well-tested, but this PR's diff against main is dominated by the unrelated mobile-relay import, not the ~137-line fix. Confirm intent before merging.

Reviewed changes

  • decodePathSegment helper — wraps decodeURIComponent in a try/catch and returns null on URIError, so a malformed percent-escape becomes a 4xx reject instead of an uncaught throw out of the upgrade listener.
  • /v1/connect/ site — now rejects on hostId === null in addition to the existing /^[A-Za-z0-9_-]{16}$/ check.
  • /v1/host/data/ site — swaps decodeURIComponent for decodePathSegment; its pre-existing !connId || connId.length > 128 already covers null.
  • relay-upgrade-malformed-uri.blackbox.test.ts — new blackbox test driving the real server over a raw TCP socket: four malformed targets (%, %E0%A4%A, %C0%AF on connect, % on host-data) assert no uncaughtException, every response is 4xx, and a well-formed connect still upgrades to 101. The assertions pin exact behavior (toEqual([]) for uncaught, 4\d\d/101 status), so it genuinely fails when either decode site is reverted.

The fix itself is isolated, minimal, and idiomatic (the bare catch {} matches the surrounding firstPayload style, and 429-for-invalid-hostId preserves the existing anti-enumeration behavior). No code concerns.

⚠️ PR scope: the diff carries the full relay import, not just the fix

The branch is based on 373deb8209 ("chore(relay): import the Orca mobile relay"), which is the head of the now-closed PR #18264 (mergedAt: null). Merging this PR would therefore land the entire cloud/apps/relay/** and cloud/packages/relay-contract/** tree (~43.8k lines) alongside the fix, under a "fix" title and description that never mention it. Notably, the changed root guard script itself comments that the entry "stays listed until it lands on main" — confirming the import is expected to land through its own path, with this branch stacked on top of it.

Technical details
# Resolve PR scope before merging

## Affected sites
- Both `373deb8209` (import) and `b96d057ed` (fix) are ancestors of this branch but not of `main`.
- `main` does not contain `cloud/apps/relay/src/relay-server.ts` (it appears as a new 543-line file in the diff), so the import is genuinely part of this PR's merge surface.

## Required outcome
- If the import lands separately: retarget this branch (or rebase onto `main` once the import merges) so the PR diff is only the fix.
- If the import is meant to land here: retitle and describe the PR accordingly, and make sure the import content itself has been reviewed (its prior PR is closed, unmerged).

## Open questions for the human
- Why did #18264 close without merging, and does that closure state need to be resolved before the import lands here?

ℹ️ Nitpicks

  • The PR description attributes the buggy code to "#18413", but that merged PR ("add the relay fence broker, ops console, Terraform root, scripts...") does not contain relay-server.ts (it is absent from main). The decodeURIComponent upgrade listener was introduced by the import in #18264; consider correcting the cross-reference.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@Jinwoo-H
Jinwoo-H force-pushed the fix/relay-upgrade-malformed-uri branch from b96d057 to 71dfa15 Compare September 4, 2026 00:42
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@Jinwoo-H
Jinwoo-H force-pushed the fix/relay-upgrade-malformed-uri branch from 71dfa15 to f86328e Compare September 4, 2026 00:46

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Rebased onto main — the relay import landed in main via #18413, so this PR's diff is now a clean 3-file fix (134+/3-) instead of the ~43.8k-line import plus fix flagged in the prior review.
  • cloud/.gitleaks.toml allowlist — added an entry so the RFC 6455 §1.3 example Sec-WebSocket-Key nonce the blackbox test sends is not flagged by the gitleaks generic-key rule.
  • relay-upgrade-malformed-uri.blackbox.test.ts — added a comment pointing the nonce at the allowlist. The decodePathSegment fix and all assertions are otherwise unchanged from the prior review, where the fix was already assessed as correct, minimal, and idiomatic.

The prior review's scope concern is resolved (the import is no longer part of this PR's merge surface), and the "#18413" cross-reference is now accurate — the import did land through #18413.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Jinwoo-H added a commit that referenced this pull request Sep 4, 2026
…grade tests

Cloud Verify's Secret scan runs gitleaks over --all refs, so the raw-socket
upgrade test on fix/relay-upgrade-malformed-uri (#18547) trips every cloud PR's
scan until its allowlist reaches main. Land the allowlist here first.
Jinwoo-H added a commit that referenced this pull request Sep 4, 2026
…a startup (#18553)

* fix(cloud): retry the committed-winner collision codes in relay schema startup

`CREATE TABLE IF NOT EXISTS` only checks the name before the catalog inserts, so
the loser of a concurrent CREATE fails in one of two ways depending on timing:
on the catalog unique index (23505, which the startup retry already handled) or,
when the winner has committed by the time the loser reaches TypeCreate /
heap_create_with_catalog, on the name check those routines repeat (42710
duplicate type, 42P07 duplicate relation). The predicate treated the latter as
fatal, so a director could fail startup on a table it was about to find present.

This is what turned `postgres-schema-concurrency-postgres.test.ts` red on main
and on every relay PR (CI's shared runner loses the race more often than a dev
box): a throwaway diagnostic run in CI reported 42710 from TypeCreate and 42P07
from heap_create_with_catalog as the only rejection reasons.

Treat 42710/42P07 as retryable for `CREATE TABLE IF NOT EXISTS` and 42P07 for
`CREATE [UNIQUE] INDEX IF NOT EXISTS`; every other statement shape still fails
fast. The concurrency test now runs ten rounds and reports the loser's SQLSTATE
instead of a bare boolean.

* chore(cloud): allowlist the RFC 6455 example Sec-WebSocket-Key for upgrade tests

Cloud Verify's Secret scan runs gitleaks over --all refs, so the raw-socket
upgrade test on fix/relay-upgrade-malformed-uri (#18547) trips every cloud PR's
scan until its allowlist reaches main. Land the allowlist here first.
…rowing

decodeURIComponent on the /v1/connect/ and /v1/host/data/ path segments threw
URIError out of the http 'upgrade' listener, which is uncaught and kills the
relay process. Any client that sends GET /v1/connect/% could take down a cell
(and every connection on it) or a director instance. Pre-existing since the
splice landed (orca-cloud #20); not introduced by the import.

A malformed escape now takes the existing 4xx reject branch. The blackbox test
sends three malformed connect targets and one host-data target to the real
server and asserts no uncaughtException fires and a well-formed upgrade still
gets 101 afterwards; reverting either site fails it.
@Jinwoo-H
Jinwoo-H force-pushed the fix/relay-upgrade-malformed-uri branch from f86328e to 08cc38c Compare September 4, 2026 01:20
@Jinwoo-H
Jinwoo-H merged commit 11aace8 into main Sep 4, 2026
25 checks passed
@Jinwoo-H
Jinwoo-H deleted the fix/relay-upgrade-malformed-uri branch September 4, 2026 01:32
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…a startup (stablyai#18553)

* fix(cloud): retry the committed-winner collision codes in relay schema startup

`CREATE TABLE IF NOT EXISTS` only checks the name before the catalog inserts, so
the loser of a concurrent CREATE fails in one of two ways depending on timing:
on the catalog unique index (23505, which the startup retry already handled) or,
when the winner has committed by the time the loser reaches TypeCreate /
heap_create_with_catalog, on the name check those routines repeat (42710
duplicate type, 42P07 duplicate relation). The predicate treated the latter as
fatal, so a director could fail startup on a table it was about to find present.

This is what turned `postgres-schema-concurrency-postgres.test.ts` red on main
and on every relay PR (CI's shared runner loses the race more often than a dev
box): a throwaway diagnostic run in CI reported 42710 from TypeCreate and 42P07
from heap_create_with_catalog as the only rejection reasons.

Treat 42710/42P07 as retryable for `CREATE TABLE IF NOT EXISTS` and 42P07 for
`CREATE [UNIQUE] INDEX IF NOT EXISTS`; every other statement shape still fails
fast. The concurrency test now runs ten rounds and reports the loser's SQLSTATE
instead of a bare boolean.

* chore(cloud): allowlist the RFC 6455 example Sec-WebSocket-Key for upgrade tests

Cloud Verify's Secret scan runs gitleaks over --all refs, so the raw-socket
upgrade test on fix/relay-upgrade-malformed-uri (stablyai#18547) trips every cloud PR's
scan until its allowlist reaches main. Land the allowlist here first.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…rowing (stablyai#18547)

decodeURIComponent on the /v1/connect/ and /v1/host/data/ path segments threw
URIError out of the http 'upgrade' listener, which is uncaught and kills the
relay process. Any client that sends GET /v1/connect/% could take down a cell
(and every connection on it) or a director instance. Pre-existing since the
splice landed (orca-cloud stablyai#20); not introduced by the import.

A malformed escape now takes the existing 4xx reject branch. The blackbox test
sends three malformed connect targets and one host-data target to the real
server and asserts no uncaughtException fires and a well-formed upgrade still
gets 101 afterwards; reverting either site fails it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant