Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add App Check token to FirebaseServerApp #8651

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open

Conversation

DellaBitta
Copy link
Contributor

@DellaBitta DellaBitta commented Nov 22, 2024

Discussion

Currently a customer enforces App Check for a product in the Firebase console then there's no way to successfully use that product in SSR environments. This is due to the requirement for products SDKs to internally invoke getToken on the a instance of the App Check SDK that the customer's app has initialized. However, the App Check SDK can only be initialized in browser environments.

This PR aims to fix this. FirebaseServerApp will now accept an optional App Check token at initialization. The product SDKs will look for this token, and if it's present, the SDKs will use this value in lieu of calling getToken on App Check.

This change affects the following SDKs:

  • Auth
  • Cloud Functions
  • Data Connect
  • Firestore
  • Real Time Database
  • Vertex AI

Testing

Added tests to FirebaseServerApp's handling of the token. The product SDKs currently don't have viable App Check tests in our test suite, so I manually tested all of the SDKs by enabling App Check enforcement in the Firebase Console, watching them fail due to permissions, and then provided the App Check token to Firebase Server app and watched them succeed.

API Changes

This conforms to the previoulsy approved Existing FirebaseServerApp API proposal (internal) for FirebaseServerApp which includes App Check token support.

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Nov 22, 2024

Size Report 1

Affected Products

  • @firebase/app

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser18.4 kB19.2 kB+846 B (+4.6%)
    main19.3 kB20.1 kB+838 B (+4.4%)
    module18.4 kB19.2 kB+846 B (+4.6%)
  • @firebase/auth

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser188 kB188 kB+107 B (+0.1%)
    cordova164 kB164 kB+107 B (+0.1%)
    main145 kB145 kB+111 B (+0.1%)
    module188 kB188 kB+107 B (+0.1%)
    react-native163 kB163 kB+113 B (+0.1%)
  • @firebase/auth-cordova

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser164 kB164 kB+107 B (+0.1%)
    module164 kB164 kB+107 B (+0.1%)
  • @firebase/auth-web-extension

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser140 kB140 kB+107 B (+0.1%)
    main157 kB157 kB+113 B (+0.1%)
    module140 kB140 kB+107 B (+0.1%)
  • @firebase/auth/internal

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser199 kB199 kB+107 B (+0.1%)
    main171 kB171 kB+113 B (+0.1%)
    module199 kB199 kB+107 B (+0.1%)
  • @firebase/data-connect

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser20.1 kB20.3 kB+157 B (+0.8%)
    main22.0 kB22.2 kB+148 B (+0.7%)
    module20.1 kB20.3 kB+157 B (+0.8%)
  • @firebase/database

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser249 kB249 kB+332 B (+0.1%)
    main254 kB254 kB+325 B (+0.1%)
    module249 kB249 kB+332 B (+0.1%)
  • @firebase/database-compat/standalone

    TypeBase (0b318a9)Merge (fca7193)Diff
    main340 kB365 kB+25.9 kB (+7.6%)
  • @firebase/firestore

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser382 kB382 kB+189 B (+0.0%)
    main589 kB589 kB+270 B (+0.0%)
    module382 kB382 kB+189 B (+0.0%)
    react-native382 kB382 kB+188 B (+0.0%)
  • @firebase/firestore-lite

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser111 kB112 kB+175 B (+0.2%)
    main154 kB154 kB+269 B (+0.2%)
    module111 kB112 kB+175 B (+0.2%)
    react-native112 kB112 kB+175 B (+0.2%)
  • @firebase/functions

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser13.7 kB14.0 kB+313 B (+2.3%)
    main14.2 kB14.6 kB+306 B (+2.1%)
    module13.7 kB14.0 kB+313 B (+2.3%)
  • @firebase/remote-config

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser21.7 kB21.7 kB-1 B (-0.0%)
    main22.9 kB22.9 kB-1 B (-0.0%)
    module21.7 kB21.7 kB-1 B (-0.0%)
  • @firebase/storage

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser57.8 kB58.0 kB+128 B (+0.2%)
    main59.3 kB59.4 kB+111 B (+0.2%)
    module57.8 kB58.0 kB+128 B (+0.2%)
  • @firebase/vertexai

    TypeBase (0b318a9)Merge (fca7193)Diff
    browser28.8 kB29.0 kB+220 B (+0.8%)
    main29.6 kB29.9 kB+203 B (+0.7%)
    module28.8 kB29.0 kB+220 B (+0.8%)
  • bundle

    40 size changes

    TypeBase (0b318a9)Merge (fca7193)Diff
    auth (Anonymous)76.2 kB76.4 kB+130 B (+0.2%)
    auth (EmailAndPassword)86.4 kB86.5 kB+130 B (+0.2%)
    auth (GoogleFBTwitterGitHubPopup)103 kB103 kB+130 B (+0.1%)
    auth (GooglePopup)100 kB100 kB+130 B (+0.1%)
    auth (GoogleRedirect)100 kB101 kB+130 B (+0.1%)
    auth (Phone)93.8 kB93.9 kB+130 B (+0.1%)
    database (Append to a list of data)149 kB149 kB+372 B (+0.2%)
    database (Filtering data)148 kB148 kB+372 B (+0.3%)
    database (Listen for child events)164 kB165 kB+372 B (+0.2%)
    database (Listen for value events + Detach listeners)164 kB165 kB+372 B (+0.2%)
    database (Listen for value events)164 kB165 kB+372 B (+0.2%)
    database (Read data once)164 kB164 kB+372 B (+0.2%)
    database (Save data as transactions)166 kB167 kB+372 B (+0.2%)
    database (Sort data)150 kB150 kB+372 B (+0.2%)
    database (Write data)148 kB149 kB+372 B (+0.3%)
    firestore (CSI Auto Indexing Disable and Delete)272 kB273 kB+221 B (+0.1%)
    firestore (CSI Auto Indexing Enable)272 kB273 kB+221 B (+0.1%)
    firestore (Persistence)304 kB304 kB+221 B (+0.1%)
    firestore (Query Cursors)249 kB249 kB+218 B (+0.1%)
    firestore (Query)247 kB247 kB+218 B (+0.1%)
    firestore (Read data once)235 kB235 kB+218 B (+0.1%)
    firestore (Read Write w Persistence)328 kB329 kB+221 B (+0.1%)
    firestore (Realtime updates)237 kB237 kB+218 B (+0.1%)
    firestore (Transaction)214 kB214 kB+218 B (+0.1%)
    firestore (Write data)213 kB214 kB+218 B (+0.1%)
    firestore-lite (Query Cursors)103 kB103 kB+211 B (+0.2%)
    firestore-lite (Query)98.8 kB99.0 kB+211 B (+0.2%)
    firestore-lite (Read data once)74.3 kB74.5 kB+208 B (+0.3%)
    firestore-lite (Transaction)99.5 kB99.8 kB+211 B (+0.2%)
    firestore-lite (Write data)83.9 kB84.1 kB+211 B (+0.3%)
    functions (call)34.4 kB34.7 kB+318 B (+0.9%)
    remote-config (getAndFetch)47.5 kB47.5 kB-1 B (-0.0%)
    storage (getBytes)42.1 kB42.3 kB+175 B (+0.4%)
    storage (getDownloadURL)44.2 kB44.4 kB+175 B (+0.4%)
    storage (getMetadata)43.6 kB43.8 kB+175 B (+0.4%)
    storage (list + listAll)43.1 kB43.2 kB+175 B (+0.4%)
    storage (updateMetadata)43.9 kB44.1 kB+175 B (+0.4%)
    storage (uploadBytes)48.8 kB48.9 kB+175 B (+0.4%)
    storage (uploadBytesResumable)58.7 kB58.9 kB+175 B (+0.3%)
    storage (uploadString)49.0 kB49.1 kB+175 B (+0.4%)

  • firebase

    21 size changes

    TypeBase (0b318a9)Merge (fca7193)Diff
    firebase-app-compat.js31.8 kB32.3 kB+552 B (+1.7%)
    firebase-app.js101 kB102 kB+1.43 kB (+1.4%)
    firebase-auth-compat.js143 kB143 kB+109 B (+0.1%)
    firebase-auth-cordova.js136 kB136 kB+87 B (+0.1%)
    firebase-auth-web-extension.js119 kB119 kB+87 B (+0.1%)
    firebase-auth.js155 kB155 kB+87 B (+0.1%)
    firebase-compat.js798 kB800 kB+1.36 kB (+0.2%)
    firebase-data-connect.js16.7 kB16.8 kB+170 B (+1.0%)
    firebase-database-compat.js166 kB166 kB+305 B (+0.2%)
    firebase-database.js186 kB187 kB+309 B (+0.2%)
    firebase-firestore-compat.js347 kB347 kB+155 B (+0.0%)
    firebase-firestore-lite.js130 kB130 kB+163 B (+0.1%)
    firebase-firestore.js441 kB441 kB+172 B (+0.0%)
    firebase-functions-compat.js10.4 kB10.6 kB+231 B (+2.2%)
    firebase-functions.js14.6 kB14.8 kB+236 B (+1.6%)
    firebase-performance-standalone-compat.js93.7 kB94.2 kB+585 B (+0.6%)
    firebase-remote-config-compat.js28.4 kB28.4 kB-2 B (-0.0%)
    firebase-remote-config.js31.3 kB31.3 kB-2 B (-0.0%)
    firebase-storage-compat.js40.3 kB40.4 kB+109 B (+0.3%)
    firebase-storage.js46.2 kB46.3 kB+113 B (+0.2%)
    firebase-vertexai.js23.8 kB24.0 kB+177 B (+0.7%)

Test Logs

  1. https://storage.googleapis.com/firebase-sdk-metric-reports/tE6wAw4WOB.html

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Nov 22, 2024

Size Analysis Report 1

This report is too large (416,182 characters) to be displayed here in a GitHub comment. Please use the below link to see the full report on Google Cloud Storage.

Test Logs

  1. https://storage.googleapis.com/firebase-sdk-metric-reports/p72HYQKCcC.html

Copy link

changeset-bot bot commented Dec 4, 2024

🦋 Changeset detected

Latest commit: 3352b7f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@firebase/app Minor
firebase Minor
@firebase/data-connect Patch
@firebase/firestore Patch
@firebase/functions Patch
@firebase/database Patch
@firebase/vertexai Patch
@firebase/storage Patch
@firebase/auth Patch
@firebase/app-compat Patch
@firebase/firestore-compat Patch
@firebase/functions-compat Patch
@firebase/database-compat Patch
@firebase/storage-compat Patch
@firebase/auth-compat Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

constructor(
private appName_: string,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This value was never used.

private appCheckProvider?: Provider<AppCheckInternalComponentName>
) {
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
Copy link
Contributor Author

@DellaBitta DellaBitta Dec 16, 2024

Choose a reason for hiding this comment

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

During the initialization of the Data Connect-specific AppCheckTokenProvider, check if the provided app is a FirebaseServerApp that contains an App Check token. If it does, store the token locally so getToken can return it (below).

this.appName = app.name;
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
Copy link
Contributor Author

@DellaBitta DellaBitta Dec 16, 2024

Choose a reason for hiding this comment

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

Like Data Connect above, check if the provided app is a FirebaseServerApp that contains an App Check token. If it does, store the token locally so getToken can return it (below).

if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
}
Copy link
Contributor Author

@DellaBitta DellaBitta Dec 16, 2024

Choose a reason for hiding this comment

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

Like the other SDKs (above), for Firestore check if the provided app is a FirebaseServerApp that contains an App Check token. If it does, store the token locally so getToken can return it (below).

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any concern of this token expiring?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I attempted to answer this in the main thread of the PR.

authProvider: Provider<FirebaseAuthInternalName>,
messagingProvider: Provider<MessagingInternalComponentName>,
appCheckProvider: Provider<AppCheckInternalComponentName>
) {
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
Copy link
Contributor Author

@DellaBitta DellaBitta Dec 16, 2024

Choose a reason for hiding this comment

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

Like the other SDKs (above), for Functions check if the provided app is a FirebaseServerApp that contains an App Check token. If it does, store the token locally so getToken can return it (below).

@@ -262,6 +266,9 @@ export class FirebaseStorageImpl implements FirebaseStorage {
}

async _getAppCheckToken(): Promise<string | null> {
if (_isFirebaseServerApp(this.app) && this.app.settings.appCheckToken) {
return this.app.settings.appCheckToken;
}
Copy link
Contributor Author

@DellaBitta DellaBitta Dec 16, 2024

Choose a reason for hiding this comment

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

Check to see if Storage's app is a FirebaseServerApp that contains an App Check token. If it does, return the token instead of invoking App Check.

this._apiSettings.getAppCheckToken = () => {
return Promise.resolve({ token });
};
} else if ((vertexAI as VertexAIService).appCheck) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Similiar to the other SDKs (above) check to see if the provided instance of FirebaseApp is one of FirebaseServerApp, and if it contains an App Check token, then return it instead of invoking the App Check SDK.

This implementation differes only slightly to the other SDKs (which use Providers) becuase we already have a direct reference to the FirebaseApp instance itself, similiar to the Storage implementation.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this PR is the time to do it, but we should probably abstract all this logic (maybe also the model name processing) out of GenerativeModel to some kind of shared function that ImagenModel can also use, will give @dlarocque a heads up.


await deleteApp(serverApp);
});

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Invalid tokens are now handled by a new flow, which we test in app/src/FirebaseServerApp.test.ts, so I'm removing this test here.

@DellaBitta DellaBitta marked this pull request as ready for review December 17, 2024 18:11
@DellaBitta DellaBitta requested review from a team as code owners December 17, 2024 18:11
Copy link
Contributor

@MarkDuckworth MarkDuckworth left a comment

Choose a reason for hiding this comment

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

Looks okay. One comment. Also, we we're wondering if/how this solution deals with the expiration or short lifespan of app check tokens?

this.appName = app.name;
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
this.appCheck = appCheckProvider?.getImmediate({ optional: true });
if (!this.appCheck) {
appCheckProvider?.get().then(appCheck => (this.appCheck = appCheck));
}
}

getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult> {
Copy link
Contributor

Choose a reason for hiding this comment

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

if forceRefresh && this.serverAppAppCheckToken should this throw or otherwise indicate that an unsupported call was made?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just checking: is the only time that forceRefresh is set to true for the App Check token is when the service returns an UNAUTHENTICATED error?

Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like, in RTDB, it will be set to true on any failed request (status !== 'ok') to the auth or app check endpoints.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I suppose that throwing here would then be valid, as it would reduce the burden on the service on subsequent requests, if the app attempts to continue to use the defunct App Check Token. WDYT @hsubox76 ?

Copy link
Contributor

Choose a reason for hiding this comment

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

LGTM from my end

@DellaBitta
Copy link
Contributor Author

DellaBitta commented Dec 18, 2024

Looks okay. One comment. Also, we we're wondering if/how this solution deals with the expiration or short lifespan of app check tokens?

It's assumed that they will be created via getToken on the client-side and then passed up to the SSR phase via cookies or service workers, probably at first at client sign in.

We'll post a best practice that apps attempt to use that App Check token on the server side, and either check the validity of the token themselves, or call initializeServerApp with a try / catch block to check for token expiration. If the server side encounters an expired token then the app should route to a client-side page that regenerates a new token, and then route back to the page that uses SSR.

Copy link
Contributor

@hsubox76 hsubox76 left a comment

Choose a reason for hiding this comment

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

LG, just nits!

this.appName = app.name;
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
this.appCheck = appCheckProvider?.getImmediate({ optional: true });
if (!this.appCheck) {
appCheckProvider?.get().then(appCheck => (this.appCheck = appCheck));
}
}

getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult> {
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like, in RTDB, it will be set to true on any failed request (status !== 'ok') to the auth or app check endpoints.

authProvider: Provider<FirebaseAuthInternalName>,
messagingProvider: Provider<MessagingInternalComponentName>,
appCheckProvider: Provider<AppCheckInternalComponentName>
) {
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't attach comment to line 85 but can we put a ? there so it's appCheckProvider?.get() like RTDB and DataConnect etc seem to have done and I just noticed in this PR, for max efficiency in not bothering to even call get() in the case of no provider.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

if ((vertexAI as VertexAIService).appCheck) {

if (
vertexAI.app &&
Copy link
Contributor

Choose a reason for hiding this comment

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

Can there be a vertexAI instance that doesn't have an app property? How does that happen? Actually come to think of it, what do you think about putting a ? here https://github.com/firebase/firebase-js-sdk/blob/main/packages/app/src/internal.ts#L173 so that it can handle being passed undefined/null, so it would cover if any other caller happens to pass it that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To answer your first question, I'm not sure. I don't think it's possible but the rest of the code (above) checks for it.

As for the latter, I'll add the ability for the function to take null / undefined!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

this._apiSettings.getAppCheckToken = () => {
return Promise.resolve({ token });
};
} else if ((vertexAI as VertexAIService).appCheck) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this PR is the time to do it, but we should probably abstract all this logic (maybe also the model name processing) out of GenerativeModel to some kind of shared function that ImagenModel can also use, will give @dlarocque a heads up.

@NhienLam NhienLam self-requested a review December 18, 2024 18:20
Copy link
Contributor

@NhienLam NhienLam left a comment

Choose a reason for hiding this comment

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

Auth: LGTM

@@ -42,7 +47,11 @@ export class AppCheckTokenProvider {
}
}

getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult> {
getToken(): Promise<AppCheckTokenResult> {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

forceRefresh was never used, so I've removed it for now.

Copy link
Contributor

@egilmorez egilmorez left a comment

Choose a reason for hiding this comment

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

Couple of things to look at, thanks!

@@ -67,6 +93,16 @@ export class FirebaseServerAppImpl
...serverConfig
};

// Ensure that the current time is within the authIdtoken window of validity.
Copy link
Contributor

Choose a reason for hiding this comment

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

authIdtoken and appChecktoken look like literals that should be backticked.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@@ -196,6 +196,12 @@ export interface FirebaseServerAppSettings
*/
authIdToken?: string;

/**
* An optional App Check token. If provided, the Firebase SDKs that use App Check will utilizze
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo "utilize."

Personally I like in lieu, but I'll bet good money our style guide advises something more like "instead of" or "in place of."

Copy link
Contributor Author

@DellaBitta DellaBitta Jan 14, 2025

Choose a reason for hiding this comment

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

Fixed! I went with "in place of".

@DellaBitta
Copy link
Contributor Author

Couple of things to look at, thanks!

Thanks @egilmorez. Could you give it a review again? I fixed the problems you found and updated some other comments, so it's worth a full review again.

@DellaBitta DellaBitta requested a review from egilmorez January 16, 2025 13:35
Copy link
Contributor

@egilmorez egilmorez left a comment

Choose a reason for hiding this comment

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

LG with one final possible nit, thanks!

'@firebase/auth': patch
---

`FirebaseServerApp` may now be initalized with an App Check token in leu of invoking the App Check
Copy link
Contributor

Choose a reason for hiding this comment

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

Do these strings get pulled into release notes or other public docs?

If so, I'd go with "can now be initialized" and "instead of invoking."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They don't directly. Later, the release engineer crafts the release notes using these as ... inspiration. Updated!

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.

9 participants