Skip to content

fix(oauth2): use exact matching for JS client origin validation#545

Open
mulldug wants to merge 1 commit into
mainfrom
fix/oauth2-exact-origin-matching
Open

fix(oauth2): use exact matching for JS client origin validation#545
mulldug wants to merge 1 commit into
mainfrom
fix/oauth2-exact-origin-matching

Conversation

@mulldug
Copy link
Copy Markdown
Collaborator

@mulldug mulldug commented May 13, 2026

ref: https://app.clickup.com/t/86b9xky5x

Summary

  • Replace str_contains substring check with in_array exact match in OAuth2BearerAccessTokenRequestValidator for JS_CLIENT origin validation
  • Splits the space-delimited allowed_origins string from the IDP introspection response into individual entries, trims each, and strips trailing slashes before comparing
  • Prevents crafted short origins (e.g. example) from satisfying the guard by matching as a substring of a legitimate allowed origin

Notes

This is a pre-existing bug, not introduced by recent work. Separated into its own PR to keep the security fix reviewable independently.

Test plan

  • JS client requests with a valid origin are accepted
  • JS client requests with an origin that is a substring of a valid origin (e.g. example when https://example.com is allowed) are rejected
  • JS client requests with no origin are rejected

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added API endpoints to retrieve counts of speaker and submitter activities for summits with optional filtering support.
  • Bug Fixes

    • Improved origin validation logic for OAuth2 bearer token requests.
    • Enhanced error handling for profile serialization to prevent exceptions from disrupting operations.
  • Chores

    • Updated CORS configuration format in environment example.
    • Added Docker Compose testing configuration.
    • Updated .gitignore to exclude Docker Compose override files.
  • Tests

    • Added comprehensive test coverage for new activity count endpoints and repository functionality.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

This PR adds two new protected API endpoints that return unique activity counts for speakers and submitters on a per-summit basis, with optional filter support. The implementation spans repository layer (data access), controller layer (HTTP endpoints), routing, and comprehensive test coverage.

Changes

Activity Count Endpoints for Speakers and Submitters

Layer / File(s) Summary
Repository Contracts
app/Models/Foundation/Summit/Repositories/ISpeakerRepository.php, app/Models/Foundation/Main/Repositories/IMemberRepository.php
Two repository interfaces declare the new getUniqueActivitiesCountBySummit(Summit $summit, Filter $filter = null): int contract.
Speaker Repository Implementation
app/Repositories/Summit/DoctrineSpeakerRepository.php
Implements distinct speaker ID query for a summit, applies optional filters via repository mappings, and executes native SQL COUNT(DISTINCT ...) across presentations linked to those speaker IDs (via moderator or join table).
Member/Submitter Repository Implementation
app/Repositories/Summit/DoctrineMemberRepository.php
Adds null-safe guard in applyExtraJoins() and implements distinct member ID query, filter application, and native SQL presentation counting for the summit.
API Endpoint Controllers
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php, app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php
Two new protected endpoints accept optional filter query parameters, parse and validate them against supported fields, delegate to repository methods, and return 200 OK { "count": int } or 404 if summit is not found.
Route Registration and Endpoint Seeding
routes/api_v1.php, database/seeders/ApiEndpointsSeeder.php
Adds two authenticated GET routes (/summits/{id}/speakers/all/events/count and /summits/{id}/submitters/all/events/count) and registers endpoint metadata with OAuth2 scopes and authorization constraints.
Feature Tests
tests/SubmitterRepositoryTest.php, tests/oauth2/OAuth2SummitSubmittersApiTest.php, tests/oauth2/OAuth2SummitSpeakersApiTest.php
Tests verify repository method returns integer counts with and without filters, confirms API endpoints return JSON count payloads, and validates filter correctness and count ordering.
Supporting Infrastructure and Robustness Updates
app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php, app/ModelSerializers/Summit/SummitSerializer.php, database/seeders/ConfigSeeder.php, .env.example, .gitignore, docker-compose.override.testing.yml
Strengthens OAuth2 origin validation with array-based membership check and trailing-slash normalization, adds exception handling to payment profile serialization, evicts Doctrine L2 cache in seeders to prevent FK violations, updates CORS env format, ignores Docker override config, and adds test-specific Docker Compose overrides.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Controller as Controller:<br/>getSpeakersActivitiesCount
  participant Repository as Repository:<br/>getUniqueActivitiesCountBySummit
  participant Database as Database
  Client->>Controller: GET /summits/{id}/speakers/all/events/count<br/>?filter=...
  Controller->>Controller: Load summit by ID
  Controller->>Controller: Parse and validate filter
  Controller->>Repository: Call with summit and filter
  Repository->>Repository: Query distinct speaker IDs<br/>for summit
  Repository->>Repository: Apply optional filter
  Repository->>Database: SELECT COUNT(DISTINCT presentations)<br/>WHERE speaker_id IN (...)
  Database-->>Repository: count integer
  Repository-->>Controller: count integer
  Controller-->>Client: 200 OK<br/>{ "count": N }
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested Reviewers

  • smarcet
  • romanetar
  • martinquiroga-exo

Poem

🐰 With twitching whiskers, I hopped through the code,
Counting activities along the summit road,
Speakers and submitters, their events now clear,
Filtered with grace and precision, my dear!
Two endpoints at work, no presentation to fear! 🎤✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title 'fix(oauth2): use exact matching for JS client origin validation' directly describes the main change in OAuth2BearerAccessTokenRequestValidator.php, but the changeset also includes extensive new activity count features across multiple controllers, repositories, and tests that are not mentioned in the title. The title should be revised to reflect the primary scope of changes. Consider a broader title like 'feat: add speakers/submitters activity count endpoints and fix OAuth2 origin validation' to accurately represent both the new feature and the security fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/oauth2-exact-origin-matching

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 and usage tips.

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-545/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
app/ModelSerializers/Summit/SummitSerializer.php (1)

335-342: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Log the full exception object, not just the message.

Same issue as the registration profile handler above.

🐛 Proposed fix
                 } catch (\Exception $ex) {
-                    Log::warning($ex->getMessage());
+                    Log::warning($ex);
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ModelSerializers/Summit/SummitSerializer.php` around lines 335 - 342, The
catch block around $build_default_payment_gateway_profile_strategy->build in
SummitSerializer:: (where you call SerializerRegistry::getInstance(),
$serializer->serialize and AbstractSerializer::filterExpandByPrefix) currently
logs only $ex->getMessage(); change it to log the full exception (message plus
stack trace) so you capture context—e.g., pass the exception object or its full
string/trace to Log::warning instead of only getMessage(); follow the same
pattern you used in the registration profile handler to ensure consistency.
🧹 Nitpick comments (3)
tests/oauth2/OAuth2SummitSpeakersApiTest.php (3)

1903-1936: ⚡ Quick win

Consider using assertGreaterThanOrEqual(0) for count assertions.

Line 1918 uses assertGreaterThan(0) which assumes test data always contains speaker activities. For consistency with the submitters tests and improved robustness, consider using assertGreaterThanOrEqual(0).

♻️ Recommended adjustment
         $this->assertResponseStatus(200);
         $unfilteredData = json_decode($unfilteredResponse->getContent());
-        $this->assertGreaterThan(0, $unfilteredData->count);
+        $this->assertGreaterThanOrEqual(0, $unfilteredData->count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php` around lines 1903 - 1936, In
testGetCurrentSummitSpeakersActivitiesCountFilteredBySelPlan update the initial
unfiltered count assertion to allow zero results by replacing the strict
assertGreaterThan(0) with assertGreaterThanOrEqual(0) so the test no longer
assumes there is at least one speaker activity; modify the assertion in this
test method where $unfilteredData->count is checked to use
assertGreaterThanOrEqual(0).

1874-1901: ⚡ Quick win

Consider using assertGreaterThanOrEqual(0) for count assertions.

Line 1900 uses assertGreaterThan(0) which requires the count to be strictly positive. This makes the test dependent on specific test data always having speaker activities. The equivalent submitters test (line 269 in OAuth2SummitSubmittersApiTest.php) uses assertGreaterThanOrEqual(0), which is more robust since a count of zero is a valid response for an activities count endpoint.

♻️ Recommended adjustment for consistency and robustness
         $this->assertNotNull($data);
         $this->assertTrue(isset($data->count));
-        $this->assertGreaterThan(0, $data->count);
+        $this->assertGreaterThanOrEqual(0, $data->count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php` around lines 1874 - 1901, The
test method testGetCurrentSummitSpeakersActivitiesCount currently asserts a
strictly positive count using $this->assertGreaterThan(0); change this to allow
zero by using $this->assertGreaterThanOrEqual(0) in the same method so the
speakers activities count can be zero without failing the test; update the
assertion call in testGetCurrentSummitSpeakersActivitiesCount accordingly for
consistency with the submitters test.

1938-1968: ⚡ Quick win

Consider using assertGreaterThanOrEqual(0) for count assertions.

Line 1967 uses assertGreaterThan(0), which is inconsistent with the submitters tests. A count of zero is a valid response when filtering yields no results, so assertGreaterThanOrEqual(0) would be more appropriate and consistent.

♻️ Recommended adjustment
         $this->assertNotNull($data);
         $this->assertTrue(isset($data->count));
-        $this->assertGreaterThan(0, $data->count);
+        $this->assertGreaterThanOrEqual(0, $data->count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php` around lines 1938 - 1968, In
testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentations replace the
strict positive assertion with a non-negative one: change the assertion on
$data->count (currently assertGreaterThan(0)) to assertGreaterThanOrEqual(0) so
the test accepts zero results when the filter yields none; update the assertion
call in that method accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php`:
- Around line 510-515: The controller currently casts the path parameter
$summit_id with intval, which converts any slug to 0 and breaks slug support;
update OAuth2SummitSubmittersApiController to stop unconditionally calling
intval($summit_id) — either pass $summit_id through unchanged to downstream
lookups or implement a conditional: if (is_numeric($summit_id)) cast to int and
use numeric lookup, otherwise treat it as a slug and call the slug-based lookup
routine; replace occurrences of intval($summit_id) (including the instances
around $summit_id at the top of the file and the later usage) so the endpoint
honors the documented “Summit ID or slug” contract.

In `@app/ModelSerializers/Summit/SummitSerializer.php`:
- Around line 323-330: The catch block currently logs only $ex->getMessage(),
losing stack and type; update the catch in the try around
$build_default_payment_gateway_profile_strategy->build(...) /
SerializerRegistry::getInstance()->getSerializer(...) so the full exception is
logged (for example pass the exception object to Log::warning or include it in
the context), e.g. replace Log::warning($ex->getMessage()) with a call that logs
the whole $ex so stack trace and exception type are preserved.

---

Duplicate comments:
In `@app/ModelSerializers/Summit/SummitSerializer.php`:
- Around line 335-342: The catch block around
$build_default_payment_gateway_profile_strategy->build in SummitSerializer::
(where you call SerializerRegistry::getInstance(), $serializer->serialize and
AbstractSerializer::filterExpandByPrefix) currently logs only $ex->getMessage();
change it to log the full exception (message plus stack trace) so you capture
context—e.g., pass the exception object or its full string/trace to Log::warning
instead of only getMessage(); follow the same pattern you used in the
registration profile handler to ensure consistency.

---

Nitpick comments:
In `@tests/oauth2/OAuth2SummitSpeakersApiTest.php`:
- Around line 1903-1936: In
testGetCurrentSummitSpeakersActivitiesCountFilteredBySelPlan update the initial
unfiltered count assertion to allow zero results by replacing the strict
assertGreaterThan(0) with assertGreaterThanOrEqual(0) so the test no longer
assumes there is at least one speaker activity; modify the assertion in this
test method where $unfilteredData->count is checked to use
assertGreaterThanOrEqual(0).
- Around line 1874-1901: The test method
testGetCurrentSummitSpeakersActivitiesCount currently asserts a strictly
positive count using $this->assertGreaterThan(0); change this to allow zero by
using $this->assertGreaterThanOrEqual(0) in the same method so the speakers
activities count can be zero without failing the test; update the assertion call
in testGetCurrentSummitSpeakersActivitiesCount accordingly for consistency with
the submitters test.
- Around line 1938-1968: In
testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentations replace the
strict positive assertion with a non-negative one: change the assertion on
$data->count (currently assertGreaterThan(0)) to assertGreaterThanOrEqual(0) so
the test accepts zero results when the filter yields none; update the assertion
call in that method accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62fea433-b047-49d2-8cc2-668acf17a485

📥 Commits

Reviewing files that changed from the base of the PR and between 7cf7fae and 68235b1.

📒 Files selected for processing (17)
  • .env.example
  • .gitignore
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php
  • app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php
  • app/ModelSerializers/Summit/SummitSerializer.php
  • app/Models/Foundation/Main/Repositories/IMemberRepository.php
  • app/Models/Foundation/Summit/Repositories/ISpeakerRepository.php
  • app/Repositories/Summit/DoctrineMemberRepository.php
  • app/Repositories/Summit/DoctrineSpeakerRepository.php
  • database/seeders/ApiEndpointsSeeder.php
  • database/seeders/ConfigSeeder.php
  • docker-compose.override.testing.yml
  • routes/api_v1.php
  • tests/SubmitterRepositoryTest.php
  • tests/oauth2/OAuth2SummitSpeakersApiTest.php
  • tests/oauth2/OAuth2SummitSubmittersApiTest.php

Comment on lines +510 to +515
name: "id",
in: "path",
required: true,
description: "Summit ID or slug",
schema: new OA\Schema(type: "string")
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't cast away documented slug support.

This endpoint advertises "Summit ID or slug", but intval($summit_id) turns any non-numeric slug into 0, so slug-based requests will always 404. Either pass $summit_id through unchanged or narrow the contract to integer IDs only.

Suggested fix
-            $summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->getResourceServerContext())->find(intval($summit_id));
+            $summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->getResourceServerContext())->find($summit_id);

Also applies to: 543-544

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php`
around lines 510 - 515, The controller currently casts the path parameter
$summit_id with intval, which converts any slug to 0 and breaks slug support;
update OAuth2SummitSubmittersApiController to stop unconditionally calling
intval($summit_id) — either pass $summit_id through unchanged to downstream
lookups or implement a conditional: if (is_numeric($summit_id)) cast to int and
use numeric lookup, otherwise treat it as a slug and call the slug-based lookup
routine; replace occurrences of intval($summit_id) (including the instances
around $summit_id at the top of the file and the later usage) so the endpoint
honors the documented “Summit ID or slug” contract.

Comment on lines +323 to +330
try {
$profile = $build_default_payment_gateway_profile_strategy->build(IPaymentConstants::ApplicationTypeRegistration);
$serializer = SerializerRegistry::getInstance()->getSerializer($profile, $this->getSerializerType());
if (!is_null($serializer))
$values['payment_profiles'][] = $serializer->serialize(AbstractSerializer::filterExpandByPrefix($expand, 'payment_profiles'));
} catch (\Exception $ex) {
Log::warning($ex->getMessage());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Log the full exception object, not just the message.

Logging only $ex->getMessage() loses the stack trace and exception type, making debugging difficult. Use Log::warning($ex) to capture complete context.

🐛 Proposed fix
                 } catch (\Exception $ex) {
-                    Log::warning($ex->getMessage());
+                    Log::warning($ex);
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ModelSerializers/Summit/SummitSerializer.php` around lines 323 - 330, The
catch block currently logs only $ex->getMessage(), losing stack and type; update
the catch in the try around
$build_default_payment_gateway_profile_strategy->build(...) /
SerializerRegistry::getInstance()->getSerializer(...) so the full exception is
logged (for example pass the exception object to Log::warning or include it in
the context), e.g. replace Log::warning($ex->getMessage()) with a call that logs
the whole $ex so stack trace and exception type are preserved.

Replace str_contains substring check with in_array exact match to prevent
crafted short origins from satisfying the allowed-origins guard on
JS_CLIENT tokens. Splits the space-delimited allowed_origins string from
the introspection response into individual entries before comparing.
@mulldug mulldug force-pushed the fix/oauth2-exact-origin-matching branch from 68235b1 to f305115 Compare May 13, 2026 17:04
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-545/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR tightens OAuth2 JS client origin validation by replacing substring matching with exact matching against normalized allowed origins.

Changes:

  • Parses the space-delimited allowed_origins token field into individual entries.
  • Trims entries and removes trailing slashes before comparison.
  • Uses strict in_array exact matching to prevent substring-origin bypasses.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if (
$token_info->getApplicationType() === 'JS_CLIENT'
&& (is_null($origin) || empty($origin)|| str_contains($token_info->getAllowedOrigins(), $origin) === false )
&& (is_null($origin) || empty($origin) || !in_array(rtrim($origin, '/'), $allowedOrigins, true))
Copy link
Copy Markdown
Collaborator

@smarcet smarcet May 13, 2026

Choose a reason for hiding this comment

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

@mulldug
Asymmetric normalization — risk of false 403s for legitimate JS clients

$origin is built by RequestUtils::getOrigin(), which runs the header through URL\Normalizer (lowercases scheme/host, strips default ports :80/:443, canonicalizes percent-encoding, resolves dot segments, may add/strip trailing /).

$allowedOrigins here only gets trim() + rtrim('/') — no host-lowercasing, no port normalization, no percent-encoding canonicalization.

Combined with strict in_array(..., true) (byte-exact, case-sensitive), any IDP record stored as e.g. https://Example.com, https://example.com:443, or with percent-encoded chars will silently start returning 403 unauthorized_client after this ships — even though the old str_contains accidentally tolerated those mismatches.

Failure mode is silent and uniform across affected clients, and local tests won't catch it (fixtures use allowed_origins => '', so the JS_CLIENT branch is skipped).

Suggested fix: normalize each entry through the same URL\Normalizer before the strict compare, so both sides come out of the same pipeline:

$allowedOrigins = array_filter(array_map(function ($o) {
    $o = trim($o);
    if ($o === '') return '';
    try { $o = (new \URL\Normalizer($o))->normalize(); } catch (\Throwable $e) {}
    return rtrim($o, '/');
}, explode(' ', $token_info->getAllowedOrigins() ?? '')));

Recommended pre-rollout check: query the token store for allowed_origins entries containing uppercase letters, :80/:443, or %-encoding to surface clients at risk.

@smarcet
Copy link
Copy Markdown
Collaborator

smarcet commented May 13, 2026

@mulldug all proposed PR needs

  1. ref: <ticket_url> header
  2. the proper regresion tests that prove the bug:
    Add a unit test with a mocked $token_service returning a token whose allowed_origins = "https://example.com https://foo.bar" and assert (a) example is rejected, (b)
    https://example.com is accepted, (c) https://example.com/ is accepted, (d) empty Origin is rejected.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

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

@mulldug please review comments

);
throw new InvalidGrantTypeException(OAuth2Protocol::OAuth2Protocol_Error_InvalidToken);
}
$allowedOrigins = array_filter(array_map(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@mulldug
Move the array build inside the JS_CLIENT branch so server-to-server tokens do not pay the cost

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