From 972333e2abd3d7b2069df98c36382e87b26ababc Mon Sep 17 00:00:00 2001 From: justinattw Date: Tue, 6 Jan 2026 15:08:18 +0000 Subject: [PATCH 01/11] Add QuotaFailure --- messaging/messaging.go | 135 ---------------- messaging/messaging_errors.go | 241 +++++++++++++++++++++++++++++ messaging/messaging_errors_test.go | 208 +++++++++++++++++++++++++ 3 files changed, 449 insertions(+), 135 deletions(-) create mode 100644 messaging/messaging_errors.go create mode 100644 messaging/messaging_errors_test.go diff --git a/messaging/messaging.go b/messaging/messaging.go index 2eda22a9..ed76aa68 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -39,15 +39,6 @@ const ( apiFormatVersionHeader = "X-GOOG-API-FORMAT-VERSION" apiFormatVersion = "2" - apnsAuthError = "APNS_AUTH_ERROR" - internalError = "INTERNAL" - thirdPartyAuthError = "THIRD_PARTY_AUTH_ERROR" - invalidArgument = "INVALID_ARGUMENT" - quotaExceeded = "QUOTA_EXCEEDED" - senderIDMismatch = "SENDER_ID_MISMATCH" - unregistered = "UNREGISTERED" - unavailable = "UNAVAILABLE" - rfc3339Zulu = "2006-01-02T15:04:05.000000000Z" ) @@ -994,99 +985,6 @@ func (c *fcmClient) makeSendRequest(ctx context.Context, req *fcmRequest) (strin return result.Name, err } -// IsInternal checks if the given error was due to an internal server error. -func IsInternal(err error) bool { - return hasMessagingErrorCode(err, internalError) -} - -// IsInvalidAPNSCredentials checks if the given error was due to invalid APNS certificate or auth -// key. -// -// Deprecated: Use IsThirdPartyAuthError(). -func IsInvalidAPNSCredentials(err error) bool { - return IsThirdPartyAuthError(err) -} - -// IsThirdPartyAuthError checks if the given error was due to invalid APNS certificate or auth -// key. -func IsThirdPartyAuthError(err error) bool { - return hasMessagingErrorCode(err, thirdPartyAuthError) || hasMessagingErrorCode(err, apnsAuthError) -} - -// IsInvalidArgument checks if the given error was due to an invalid argument in the request. -func IsInvalidArgument(err error) bool { - return hasMessagingErrorCode(err, invalidArgument) -} - -// IsMessageRateExceeded checks if the given error was due to the client exceeding a quota. -// -// Deprecated: Use IsQuotaExceeded(). -func IsMessageRateExceeded(err error) bool { - return IsQuotaExceeded(err) -} - -// IsQuotaExceeded checks if the given error was due to the client exceeding a quota. -func IsQuotaExceeded(err error) bool { - return hasMessagingErrorCode(err, quotaExceeded) -} - -// IsMismatchedCredential checks if the given error was due to an invalid credential or permission -// error. -// -// Deprecated: Use IsSenderIDMismatch(). -func IsMismatchedCredential(err error) bool { - return IsSenderIDMismatch(err) -} - -// IsSenderIDMismatch checks if the given error was due to an invalid credential or permission -// error. -func IsSenderIDMismatch(err error) bool { - return hasMessagingErrorCode(err, senderIDMismatch) -} - -// IsRegistrationTokenNotRegistered checks if the given error was due to a registration token that -// became invalid. -// -// Deprecated: Use IsUnregistered(). -func IsRegistrationTokenNotRegistered(err error) bool { - return IsUnregistered(err) -} - -// IsUnregistered checks if the given error was due to a registration token that -// became invalid. -func IsUnregistered(err error) bool { - return hasMessagingErrorCode(err, unregistered) -} - -// IsServerUnavailable checks if the given error was due to the backend server being temporarily -// unavailable. -// -// Deprecated: Use IsUnavailable(). -func IsServerUnavailable(err error) bool { - return IsUnavailable(err) -} - -// IsUnavailable checks if the given error was due to the backend server being temporarily -// unavailable. -func IsUnavailable(err error) bool { - return hasMessagingErrorCode(err, unavailable) -} - -// IsTooManyTopics checks if the given error was due to the client exceeding the allowed number -// of topics. -// -// Deprecated: Always returns false. -func IsTooManyTopics(err error) bool { - return false -} - -// IsUnknown checks if the given error was due to unknown error returned by the backend server. -// -// Deprecated: Always returns false. -func IsUnknown(err error) bool { - return false -} - type fcmRequest struct { ValidateOnly bool `json:"validate_only,omitempty"` Message *Message `json:"message,omitempty"` @@ -1095,36 +993,3 @@ type fcmRequest struct { type fcmResponse struct { Name string `json:"name"` } - -type fcmErrorResponse struct { - Error struct { - Details []struct { - Type string `json:"@type"` - ErrorCode string `json:"errorCode"` - } - } `json:"error"` -} - -func handleFCMError(resp *internal.Response) error { - base := internal.NewFirebaseErrorOnePlatform(resp) - var fe fcmErrorResponse - json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level - for _, d := range fe.Error.Details { - if d.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmError" { - base.Ext["messagingErrorCode"] = d.ErrorCode - break - } - } - - return base -} - -func hasMessagingErrorCode(err error, code string) bool { - fe, ok := err.(*internal.FirebaseError) - if !ok { - return false - } - - got, ok := fe.Ext["messagingErrorCode"] - return ok && got == code -} diff --git a/messaging/messaging_errors.go b/messaging/messaging_errors.go new file mode 100644 index 00000000..ebd94c17 --- /dev/null +++ b/messaging/messaging_errors.go @@ -0,0 +1,241 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "encoding/json" + + "firebase.google.com/go/v4/errorutils" + "firebase.google.com/go/v4/internal" +) + +// FCM error codes +const ( + apnsAuthError = "APNS_AUTH_ERROR" + internalError = "INTERNAL" + thirdPartyAuthError = "THIRD_PARTY_AUTH_ERROR" + invalidArgument = "INVALID_ARGUMENT" + quotaExceeded = "QUOTA_EXCEEDED" + senderIDMismatch = "SENDER_ID_MISMATCH" + unregistered = "UNREGISTERED" + unavailable = "UNAVAILABLE" +) + +// QuotaViolation describes a single quota violation, identifying which quota +// was exceeded. +// See https://docs.cloud.google.com/tasks/docs/reference/rpc/google.rpc#google.rpc.QuotaFailure.Violation +// for more information on the google.rpc.QuotaFailure.Violation type. +type QuotaViolation struct { + // Subject is the subject on which the quota check failed. + // For example, "clientip:" or "project:". + Subject string + // Description explains how the quota check failed. + Description string + // APIService is the API service from which the QuotaFailure originates. + APIService string + // QuotaMetric is the metric of the violated quota (e.g., "compute.googleapis.com/cpus"). + QuotaMetric string + // QuotaID is the unique identifier of a quota (e.g., "CPUS-per-project-region"). + QuotaID string + // QuotaDimensions contains the dimensions of the violated quota. + QuotaDimensions map[string]string + // QuotaValue is the enforced quota value at the time of the failure. + QuotaValue int64 + // FutureQuotaValue is the new quota value being rolled out, if a rollout is in progress. + FutureQuotaValue int64 +} + +// QuotaFailure contains information about quota violations from FCM. +// This is returned when a rate limit is exceeded (device, topic, or overall). +type QuotaFailure struct { + Violations []*QuotaViolation +} + +// IsInternal checks if the given error was due to an internal server error. +func IsInternal(err error) bool { + return hasMessagingErrorCode(err, internalError) +} + +// IsInvalidAPNSCredentials checks if the given error was due to invalid APNS certificate or auth +// key. +// +// Deprecated: Use IsThirdPartyAuthError(). +func IsInvalidAPNSCredentials(err error) bool { + return IsThirdPartyAuthError(err) +} + +// IsThirdPartyAuthError checks if the given error was due to invalid APNS certificate or auth +// key. +func IsThirdPartyAuthError(err error) bool { + return hasMessagingErrorCode(err, thirdPartyAuthError) || hasMessagingErrorCode(err, apnsAuthError) +} + +// IsInvalidArgument checks if the given error was due to an invalid argument in the request. +func IsInvalidArgument(err error) bool { + return hasMessagingErrorCode(err, invalidArgument) +} + +// IsMessageRateExceeded checks if the given error was due to the client exceeding a quota. +// +// Deprecated: Use IsQuotaExceeded(). +func IsMessageRateExceeded(err error) bool { + return IsQuotaExceeded(err) +} + +// IsQuotaExceeded checks if the given error was due to the client exceeding a quota. +func IsQuotaExceeded(err error) bool { + return hasMessagingErrorCode(err, quotaExceeded) +} + +// GetQuotaFailure extracts the QuotaFailure details from a FirebaseError. +// Returns nil if the error does not contain quota failure information. +// The QuotaFailure indicates which rate limit was violated: device, topic, or overall. +func GetQuotaFailure(err *errorutils.FirebaseError) *QuotaFailure { + if err == nil || err.Ext == nil { + return nil + } + + qf, ok := err.Ext["quotaFailure"].(*QuotaFailure) + if !ok { + return nil + } + + return qf +} + +// IsMismatchedCredential checks if the given error was due to an invalid credential or permission +// error. +// +// Deprecated: Use IsSenderIDMismatch(). +func IsMismatchedCredential(err error) bool { + return IsSenderIDMismatch(err) +} + +// IsSenderIDMismatch checks if the given error was due to an invalid credential or permission +// error. +func IsSenderIDMismatch(err error) bool { + return hasMessagingErrorCode(err, senderIDMismatch) +} + +// IsRegistrationTokenNotRegistered checks if the given error was due to a registration token that +// became invalid. +// +// Deprecated: Use IsUnregistered(). +func IsRegistrationTokenNotRegistered(err error) bool { + return IsUnregistered(err) +} + +// IsUnregistered checks if the given error was due to a registration token that +// became invalid. +func IsUnregistered(err error) bool { + return hasMessagingErrorCode(err, unregistered) +} + +// IsServerUnavailable checks if the given error was due to the backend server being temporarily +// unavailable. +// +// Deprecated: Use IsUnavailable(). +func IsServerUnavailable(err error) bool { + return IsUnavailable(err) +} + +// IsUnavailable checks if the given error was due to the backend server being temporarily +// unavailable. +func IsUnavailable(err error) bool { + return hasMessagingErrorCode(err, unavailable) +} + +// IsTooManyTopics checks if the given error was due to the client exceeding the allowed number +// of topics. +// +// Deprecated: Always returns false. +func IsTooManyTopics(err error) bool { + return false +} + +// IsUnknown checks if the given error was due to unknown error returned by the backend server. +// +// Deprecated: Always returns false. +func IsUnknown(err error) bool { + return false +} + +type fcmErrorResponse struct { + Error struct { + Details []fcmErrorDetail `json:"details"` + } `json:"error"` +} + +type fcmErrorDetail struct { + Type string `json:"@type"` + ErrorCode string `json:"errorCode"` + Violations []struct { + Subject string `json:"subject"` + Description string `json:"description"` + APIService string `json:"api_service"` + QuotaMetric string `json:"quota_metric"` + QuotaID string `json:"quota_id"` + QuotaDimensions map[string]string `json:"quota_dimensions"` + QuotaValue int64 `json:"quota_value"` + FutureQuotaValue int64 `json:"future_quota_value"` + } `json:"violations"` +} + +func handleFCMError(resp *internal.Response) error { + base := internal.NewFirebaseErrorOnePlatform(resp) + var fe fcmErrorResponse + json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level + + // FCM error responses include a "details" array with typed extensions. + // See https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + for _, d := range fe.Error.Details { + // FcmError extension contains the FCM-specific error code. + // See https://firebase.google.com/docs/reference/fcm/rest/v1/FcmError + if d.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmError" { + base.Ext["messagingErrorCode"] = d.ErrorCode + } + // QuotaFailure extension is returned when QUOTA_EXCEEDED error occurs. + // See https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + // "An extension of type google.rpc.QuotaFailure is returned to specify which quota was exceeded." + if d.Type == "type.googleapis.com/google.rpc.QuotaFailure" && len(d.Violations) > 0 { + violations := make([]*QuotaViolation, len(d.Violations)) + for i, v := range d.Violations { + violations[i] = &QuotaViolation{ + Subject: v.Subject, + Description: v.Description, + APIService: v.APIService, + QuotaMetric: v.QuotaMetric, + QuotaID: v.QuotaID, + QuotaDimensions: v.QuotaDimensions, + QuotaValue: v.QuotaValue, + FutureQuotaValue: v.FutureQuotaValue, + } + } + base.Ext["quotaFailure"] = &QuotaFailure{Violations: violations} + } + } + + return base +} + +func hasMessagingErrorCode(err error, code string) bool { + fe, ok := err.(*internal.FirebaseError) + if !ok { + return false + } + + got, ok := fe.Ext["messagingErrorCode"] + return ok && got == code +} diff --git a/messaging/messaging_errors_test.go b/messaging/messaging_errors_test.go new file mode 100644 index 00000000..c1d1396f --- /dev/null +++ b/messaging/messaging_errors_test.go @@ -0,0 +1,208 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "firebase.google.com/go/v4/errorutils" + "firebase.google.com/go/v4/internal" +) + +func TestGetQuotaFailureEdgeCases(t *testing.T) { + tests := []struct { + name string + err *errorutils.FirebaseError + }{ + {"nil error", nil}, + {"no ext", &errorutils.FirebaseError{ErrorCode: internal.Unknown, Message: "test"}}, + {"empty ext", &errorutils.FirebaseError{ErrorCode: internal.Unknown, Ext: map[string]interface{}{}}}, + {"wrong type", &errorutils.FirebaseError{ErrorCode: internal.Unknown, Ext: map[string]interface{}{"quotaFailure": "string"}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if qf := GetQuotaFailure(tc.err); qf != nil { + t.Errorf("GetQuotaFailure() = %v; want nil", qf) + } + }) + } +} + +// TestQuotaFailureParsing verifies all QuotaViolation fields are parsed correctly from FCM responses. +func TestQuotaFailureParsing(t *testing.T) { + resp := `{ + "error": { + "status": "RESOURCE_EXHAUSTED", + "message": "Quota exceeded", + "details": [ + {"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "QUOTA_EXCEEDED"}, + { + "@type": "type.googleapis.com/google.rpc.QuotaFailure", + "violations": [ + { + "subject": "project:test-project", + "description": "Device message rate exceeded", + "api_service": "firebasecloudmessaging.googleapis.com", + "quota_metric": "firebasecloudmessaging.googleapis.com/device_messages", + "quota_id": "DeviceMessagesPerMinute", + "quota_dimensions": {"device_token": "abc123"}, + "quota_value": 100, + "future_quota_value": 200 + }, + {"subject": "device:token123", "description": "Second violation"} + ] + } + ] + } + }` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(resp)) + })) + defer ts.Close() + + client, err := NewClient(context.Background(), testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + client.fcmClient.httpClient.RetryConfig = nil + + _, sendErr := client.Send(context.Background(), &Message{Topic: "topic"}) + if sendErr == nil { + t.Fatal("Send() = nil; want error") + } + + // Verify error type checks work + if !IsQuotaExceeded(sendErr) { + t.Error("IsQuotaExceeded() = false; want true") + } + if !IsMessageRateExceeded(sendErr) { // deprecated alias + t.Error("IsMessageRateExceeded() = false; want true") + } + + fe := sendErr.(*errorutils.FirebaseError) + qf := GetQuotaFailure(fe) + if qf == nil { + t.Fatal("GetQuotaFailure() = nil; want non-nil") + } + if len(qf.Violations) != 2 { + t.Fatalf("len(Violations) = %d; want 2", len(qf.Violations)) + } + + // Verify all fields on first violation + v := qf.Violations[0] + if v.Subject != "project:test-project" { + t.Errorf("Subject = %q; want %q", v.Subject, "project:test-project") + } + if v.APIService != "firebasecloudmessaging.googleapis.com" { + t.Errorf("APIService = %q; want %q", v.APIService, "firebasecloudmessaging.googleapis.com") + } + if v.QuotaMetric != "firebasecloudmessaging.googleapis.com/device_messages" { + t.Errorf("QuotaMetric = %q; want correct value", v.QuotaMetric) + } + if v.QuotaID != "DeviceMessagesPerMinute" { + t.Errorf("QuotaID = %q; want %q", v.QuotaID, "DeviceMessagesPerMinute") + } + if v.QuotaDimensions == nil || v.QuotaDimensions["device_token"] != "abc123" { + t.Errorf("QuotaDimensions = %v; want map with device_token=abc123", v.QuotaDimensions) + } + if v.QuotaValue != 100 || v.FutureQuotaValue != 200 { + t.Errorf("QuotaValue=%d, FutureQuotaValue=%d; want 100, 200", v.QuotaValue, v.FutureQuotaValue) + } + + // Verify second violation + if qf.Violations[1].Subject != "device:token123" { + t.Errorf("Violations[1].Subject = %q; want %q", qf.Violations[1].Subject, "device:token123") + } +} + +// TestDeprecatedErrorFunctions verifies deprecated functions still work for backwards compatibility. +func TestDeprecatedErrorFunctions(t *testing.T) { + tests := []struct { + name string + httpStatus int + resp string + deprecated func(error) bool + current func(error) bool + }{ + { + name: "IsInvalidAPNSCredentials", + httpStatus: http.StatusUnauthorized, + resp: `{"error": {"status": "UNAUTHENTICATED", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "THIRD_PARTY_AUTH_ERROR"}]}}`, + deprecated: IsInvalidAPNSCredentials, + current: IsThirdPartyAuthError, + }, + { + name: "IsMismatchedCredential", + httpStatus: http.StatusForbidden, + resp: `{"error": {"status": "PERMISSION_DENIED", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "SENDER_ID_MISMATCH"}]}}`, + deprecated: IsMismatchedCredential, + current: IsSenderIDMismatch, + }, + { + name: "IsRegistrationTokenNotRegistered", + httpStatus: http.StatusNotFound, + resp: `{"error": {"status": "NOT_FOUND", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED"}]}}`, + deprecated: IsRegistrationTokenNotRegistered, + current: IsUnregistered, + }, + { + name: "IsServerUnavailable", + httpStatus: http.StatusServiceUnavailable, + resp: `{"error": {"status": "UNAVAILABLE", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNAVAILABLE"}]}}`, + deprecated: IsServerUnavailable, + current: IsUnavailable, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.httpStatus) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(tc.resp)) + })) + defer ts.Close() + + client, _ := NewClient(context.Background(), testMessagingConfig) + client.fcmEndpoint = ts.URL + client.fcmClient.httpClient.RetryConfig = nil + + _, err := client.Send(context.Background(), &Message{Topic: "topic"}) + if !tc.deprecated(err) { + t.Errorf("%s() = false; want true", tc.name) + } + if !tc.current(err) { + t.Errorf("current function = false; want true") + } + }) + } +} + +// TestDeprecatedFunctionsAlwaysFalse verifies IsTooManyTopics and IsUnknown always return false. +func TestDeprecatedFunctionsAlwaysFalse(t *testing.T) { + if IsTooManyTopics(nil) { + t.Error("IsTooManyTopics(nil) = true; want false") + } + if IsUnknown(nil) { + t.Error("IsUnknown(nil) = true; want false") + } +} From d4093043f20279aed3eb77c4f68497fb0fe8df6b Mon Sep 17 00:00:00 2001 From: justinattw Date: Tue, 6 Jan 2026 16:21:12 +0000 Subject: [PATCH 02/11] Update messaging/messaging_errors.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- messaging/messaging_errors.go | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/messaging/messaging_errors.go b/messaging/messaging_errors.go index ebd94c17..d8198309 100644 --- a/messaging/messaging_errors.go +++ b/messaging/messaging_errors.go @@ -99,21 +99,6 @@ func IsQuotaExceeded(err error) bool { return hasMessagingErrorCode(err, quotaExceeded) } -// GetQuotaFailure extracts the QuotaFailure details from a FirebaseError. -// Returns nil if the error does not contain quota failure information. -// The QuotaFailure indicates which rate limit was violated: device, topic, or overall. -func GetQuotaFailure(err *errorutils.FirebaseError) *QuotaFailure { - if err == nil || err.Ext == nil { - return nil - } - - qf, ok := err.Ext["quotaFailure"].(*QuotaFailure) - if !ok { - return nil - } - - return qf -} // IsMismatchedCredential checks if the given error was due to an invalid credential or permission // error. From 501baf05b92ff9be88c26edde2815a83f7a1b197 Mon Sep 17 00:00:00 2001 From: justinattw Date: Tue, 6 Jan 2026 16:25:12 +0000 Subject: [PATCH 03/11] Implemented Gemini suggestion --- messaging/messaging_errors.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/messaging/messaging_errors.go b/messaging/messaging_errors.go index d8198309..0c43ad83 100644 --- a/messaging/messaging_errors.go +++ b/messaging/messaging_errors.go @@ -163,19 +163,21 @@ type fcmErrorResponse struct { } `json:"error"` } +type fcmQuotaViolation struct { + Subject string `json:"subject"` + Description string `json:"description"` + APIService string `json:"api_service"` + QuotaMetric string `json:"quota_metric"` + QuotaID string `json:"quota_id"` + QuotaDimensions map[string]string `json:"quota_dimensions"` + QuotaValue int64 `json:"quota_value"` + FutureQuotaValue int64 `json:"future_quota_value"` +} + type fcmErrorDetail struct { - Type string `json:"@type"` - ErrorCode string `json:"errorCode"` - Violations []struct { - Subject string `json:"subject"` - Description string `json:"description"` - APIService string `json:"api_service"` - QuotaMetric string `json:"quota_metric"` - QuotaID string `json:"quota_id"` - QuotaDimensions map[string]string `json:"quota_dimensions"` - QuotaValue int64 `json:"quota_value"` - FutureQuotaValue int64 `json:"future_quota_value"` - } `json:"violations"` + Type string `json:"@type"` + ErrorCode string `json:"errorCode"` + Violations []fcmQuotaViolation `json:"violations"` } func handleFCMError(resp *internal.Response) error { From 5c4f704f9d966e587741edfb6504bb00cebe5e97 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:49:26 -0500 Subject: [PATCH 04/11] chore: Update github actions workflows and integration test resources (#740) * chore: Pinned github actions to a full-length commit SHA * chore: Update integration test resources * chore: Added environment label to release action * Trigger integration tests --- .../resources/integ-service-account.json.gpg | Bin 1762 -> 1756 bytes .github/workflows/ci.yml | 4 ++-- .github/workflows/nightly.yml | 8 ++++---- .github/workflows/release.yml | 11 ++++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg index 7740dccd8bdada2eecc181f75c552c00e912e5c2..5a52805c9a854fdea91101dfa1822cdd5e79c874 100644 GIT binary patch literal 1756 zcmV<21|#{54Fm}T3b4{&ennUfga6X%0rw}><=tSBuPhv7FQy8=I?3Lmw%%fwb*z*z z=6KA27D5iuBgx~x$Nyfl&GpjP(Qv$$D`=U2l?uxu35Sn`=|kyz<8?VA+jsyF+7=2@ z<@|}U!==V573LAFo)Fk!&)W@2^oQ_BI8xVK1@Fz@nY52OgWsOxEWs4?ws$bNy2VpekV}20eGu8>0Z((IN{nq@79Jv z>qJM3@B0X3ROSPK`6JTc9rmfZp<%iCo=L%D$)96DgP`ezf<5a55_+{><$|JWn2{6- zu8|~zd^P6mDHo4^^s;zYo=gn$v3_|-6h#Q*v>YB4FwzzYxJ$OFI2qsh-<}WE`UP0o zvNc^F5c1`|u5^Lo6gM(449xUT0J_lPfT`o-Rv3YXMm45zU)xYv%V8E`t!^%3W2T@d zME7SXe{`62EFI}_gg}|*iwSRjWZ)dX-wOf2SENE#%c{=78oej87A$)iafd`bW8SH6 zJ-kB6gL(_ov>A`sM?z99l_M;#(lNwfS+_jn-)(p+mR%!(X&i-aQ;uoOep`8uJh{NU z`;X((7+wEarUlyb_5_u^GXdq zaHU$=fZ8&|fJOjb;?BfCUET*97w=(u`g8iTDN%`zGy@e26Rze|?C=eo-{bX~^~{(OslofH)*vmX`q?uh;mD5YR6@E1U1-|M>X0?* znFf7wQOrblD2(asM#I1dWRAPl@zh(QD z3XsUb(1C36YvZSlmD6!_%N!na+}IMOU|^m>_N`mzbT zZz&Ee7(3&M^=M8Xx_;F26O2}mG$#jR3sA&;ot)ktfCj624V{@9&f|P1`hLMTpnf^v zyhk%W;T7PT7K8gpz*`iIN{Id&dQVMOU|K%KwbY*8!(h}Mh1@VUgaWc{)H1_HNj{KH zq!~m9Ks6xb#27@Jch`+}Rsh!qa(SViGg&w78YzoJc~jys?pKii|FV@TzhUOuk}*;X z4UM8Nu9^|{7&XbCsEJX@rHSaw)%Fuy4-8YCUH<)teY{M^oANVK%EBx4h~)y7RP!u+ zG4q4$%cl%;>0k?v&6UdW=S7~lH-VQ%rOiDIg!tzMdLHwerG6__b|UZ{+x*``6FM_x z9Q%*J0L1Bea||NK+{ND-NlRa5UL%ezMBxFUxC1VlSB|DLWU6iI+7OPXmC?gb}g23VDc?X&{)zPJ~y802E;k33pZuLifi1$Fy+IgceC8QUX(c8vuYUjQKL zPLpo*M3{Hm$;qSSe&l)rR9` zt9n!#Tg#AJ^E292w0{@6@OsPYQ6>R0#1H-qumS64GunHce~@%Y9h397pn53owR5DC zt*{lGzs20XTi%=eqw1~J<2TYgq7Y{bA?-e8DSjCROK>ezwNru{_CUNLu@-pZpX7y~ z;J^s(GiVC9k!jG$m?zZX8w$6!$XoIBdUL|re!W6X_8{PtSQ38mbE@D8(%y$_zcNI6 zQOb{GR=s*=H?m`D`{_yr@g0Jr`3B&P>JtuTtoLNZgc9|vz>at=Wdh*Zk?DLam2X-i zvOz~E+b``WR>({bK{cqmo9hPH%#g(%I1-u!fh6lSWv!AmZ6+;`&jUAf%zUT!Y_d3L ye!cL($Ue&aX9i7=EYc2_I+(Y=Ru;Ur6L%VZx+|5b-zMEMj_Xxk_VrmJgD2Lh0c>#q literal 1762 zcmV<81|9i~4Fm}T0)n*iur~xoxnP%@af)-$RhZ&7)3w(?>+$-b1cw>C)uo%^A>P*=<*0iel03 za}Fsg1`ruYzno_!>aY;)OsTkc%h&&pe{$nZw5?@UjcH^%VjHu)VgwEnKQUK4HJk0= zyZ5gIrQ>xo>l)_!-wlcQad@p8gshGgv1xcg_Yjj!nW#U|n*LMBm3Wlbk?i_CZ5Ume zal+1184AaJ?*V#JRCa=MTz1K6B-nEb*&W)}8_{YN|7UTh+U!ds%WR1v?9Hbxk@ z+EogP!glp>+*yB=n_Y5O!Q;p67Vmj%K0Z&IQHoSQT$5HK`B~z1yiX&eY#WwU=~;7S z*R}F3&c9V&rDM=B?(>!Z9}?HjEfYzeD!pR<0+x@F>TU9zk7}`hv#*;%+wZyrU0HEofh5VjbOiejUW3pY z12D!+Wf#4>(VKPmD}rZ71T zja(Co^VJ`de?Q=DM7vMvigtGHs?54vGS7;%Hh%Kgpcm|%^7?KEi%S69Cr3HH;P{#2 zQKW(H3{(*{l=~Bf7Rl-tT)anXQbTFgdpURPL%zy_*wvVIb#5uC(O*IiG}XiMGUA%y z{hDOjK*5$4CjH6|s5_9ya=cleH7`i?@E_&%aD!Azwfl->_ z*G%8=hHUxvc;-w-8SZhmd?W7x5Q>Gdn&TWCqCer@&YB71xG=dZ z)$Q#4CnyByk9+}lQNhr0X%Yl^Z@x^E{?CS9l0qFDy5|%MRx3RMp&FRUbXi#z!svt^ z$1+LBRDKak{r8lR!uG7M$3y`i=mM%I6C;mOE*azp=yC&EjEO@hZAV~%vb<>vw*Tx}<@1-F%E1MJ z&Kx%>OC=ss;A2c#Xs*e!O^%Y!Dj=VPAc>{~z^9u8{@H%2A-4|8w>siL6sFHXXGAGp z6u#;-ci&~<6YWll-R0BZ(9=FJ*d=Z^1zpP&loXySLaK2LILSvW!Dc?^#B&6$r*#Z0VQL=xlo$_b_5MO*>kW6!}M4b=rO4;kC? z7>1#k9x6oenqHJ=>|?M)^;{lP0reAV^DsBc!yt{hJ=)44ze!{NMcolK=)E*z3xPIFQ}^9xIoZ8E2fWR@#G|3UOCwegz@y^#$8?WWnJ zs2>$dD-U!>e@QG8RfQO0o(=0E;V|(Wz7kVeam0oZT7r|6xiFJ zNd8hqdzqJ0-`fL-a@okskU*`zWMi=%n1PPxq!&m!cL|J#l~QZ}A3JebK?T zy$sIEE!|IkVGk9;6HzHA)9Y-IXTCA)$QGOVqdT1OhM%`yYT53qvYtc=4KjLTfqCH9 zgC*H0Jk^%P@UH!391-ApSu$Z{4D#3;W{vq*kO zsL+qpghA`Jm|4{gJrTOb%(>>3vR5{Am*n3SBI9H-+h-WW$}kgTg9MyYUUi*K+#nml Ero6&wRR910 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70970b34..f4f1ac58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: ${{ matrix.go }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b5b7665e..44b94950 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,12 +29,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.23' @@ -53,7 +53,7 @@ jobs: - name: Send email on failure if: failure() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} @@ -68,7 +68,7 @@ jobs: - name: Send email on cancelled if: cancelled() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b32ee10..e916cc4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,12 +40,12 @@ jobs: # via the 'ref' client parameter. steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.23' @@ -76,12 +76,13 @@ jobs: startsWith(github.event.pull_request.title, '[chore] Release ') runs-on: ubuntu-latest + environment: Release permissions: contents: write steps: - name: Checkout source for publish - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false @@ -91,7 +92,7 @@ jobs: # We authorize this step with an access token that has write access to the master branch. - name: Merge to master - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: github-token: ${{ secrets.FIREBASE_GITHUB_TOKEN }} script: | @@ -115,7 +116,7 @@ jobs: - name: Post to Twitter if: success() && contains(github.event.pull_request.labels.*.name, 'release:tweet') - uses: firebase/firebase-admin-node/.github/actions/send-tweet@master + uses: firebase/firebase-admin-node/.github/actions/send-tweet@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: status: > ${{ steps.preflight.outputs.version }} of @Firebase Admin Go SDK is available. From 1fe3c70adaca36917725742a55b6d94905b071d6 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:37:50 -0500 Subject: [PATCH 05/11] feat(firestore): Added Firestore Multi Database Support (#733) * feat(firestore): Add Firestore Multi Database Support * fix: Address gemini review * fix: gemini review * fix: Update name from API review * fix: Address review comments * chore: Fix CONTRIBUTING.md typo Co-authored-by: Lahiru Maramba --------- Co-authored-by: Lahiru Maramba --- CONTRIBUTING.md | 8 +- firebase.go | 8 +- firebase_test.go | 16 ++++ integration/firestore/firestore_test.go | 116 +++++++++++++++++++++--- 4 files changed, 134 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eacfcda4..901fad26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,8 +148,12 @@ Set up your Firebase project as follows: 2. Enable Firestore: 1. Go to the Firebase Console, and select **Firestore Database** from the **Build** menu. - 2. Click on the **Create database** button. You can choose to set up Firestore either in - the production mode or in the test mode. + 2. Click on the **Create database** button and create a default database. You can choose + to set up Firestore either in the production mode or in the test mode. + > **Note:** Integration tests are run against both the default database and an additional + database named "testing-database". + 3. After the default database is created, click the **Add database** button to create a + second database named "testing-database". 3. Enable Realtime Database: diff --git a/firebase.go b/firebase.go index 9373ae23..33a0ae65 100644 --- a/firebase.go +++ b/firebase.go @@ -105,10 +105,16 @@ func (a *App) Storage(ctx context.Context) (*storage.Client, error) { // Firestore returns a new firestore.Client instance from the https://godoc.org/cloud.google.com/go/firestore // package. func (a *App) Firestore(ctx context.Context) (*firestore.Client, error) { + return a.FirestoreWithDatabaseID(ctx, firestore.DefaultDatabaseID) +} + +// FirestoreWithDatabaseID returns a new firestore.Client instance with the specified named database from the +// https://godoc.org/cloud.google.com/go/firestore package. +func (a *App) FirestoreWithDatabaseID(ctx context.Context, databaseID string) (*firestore.Client, error) { if a.projectID == "" { return nil, errors.New("project id is required to access Firestore") } - return firestore.NewClient(ctx, a.projectID, a.opts...) + return firestore.NewClientWithDatabase(ctx, a.projectID, databaseID, a.opts...) } // InstanceID returns an instance of iid.Client. diff --git a/firebase_test.go b/firebase_test.go index 2699146a..ddc130a1 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -287,6 +287,18 @@ func TestFirestore(t *testing.T) { } } +func TestFirestoreWithDatabaseID(t *testing.T) { + ctx := context.Background() + app, err := NewApp(ctx, nil, option.WithCredentialsFile("testdata/service_account.json")) + if err != nil { + t.Fatal(err) + } + + if c, err := app.FirestoreWithDatabaseID(ctx, "other-db"); c == nil || err != nil { + t.Errorf("FirestoreWithDatabaseID() = (%v, %v); want (client, nil)", c, err) + } +} + func TestFirestoreWithProjectID(t *testing.T) { verify := func(varName string) { current := os.Getenv(varName) @@ -336,6 +348,10 @@ func TestFirestoreWithNoProjectID(t *testing.T) { if c, err := app.Firestore(ctx); c != nil || err == nil { t.Errorf("Firestore() = (%v, %v); want (nil, error)", c, err) } + + if c, err := app.FirestoreWithDatabaseID(ctx, "other-db"); c != nil || err == nil { + t.Errorf("FirestoreWithDatabaseID() = (%v, %v); want (nil, error)", c, err) + } } func TestInstanceID(t *testing.T) { diff --git a/integration/firestore/firestore_test.go b/integration/firestore/firestore_test.go index 3e6d2da9..3300f882 100644 --- a/integration/firestore/firestore_test.go +++ b/integration/firestore/firestore_test.go @@ -16,18 +16,42 @@ package firestore import ( "context" + "flag" "log" + "os" "reflect" "testing" "firebase.google.com/go/v4/integration/internal" ) -func TestFirestore(t *testing.T) { +const testDatabaseID = "testing-database" + +var ( + cityData = map[string]interface{}{ + "name": "Mountain View", + "country": "USA", + "population": int64(77846), + "capital": false, + } + movieData = map[string]interface{}{ + "Name": "Interstellar", + "Year": int64(2014), + "Runtime": "2h 49m", + "Academy Award Winner": true, + } +) + +func TestMain(m *testing.M) { + flag.Parse() if testing.Short() { log.Println("skipping Firestore integration tests in short mode.") - return + os.Exit(0) } + os.Exit(m.Run()) +} + +func TestFirestore(t *testing.T) { ctx := context.Background() app, err := internal.NewTestApp(ctx, nil) if err != nil { @@ -40,23 +64,93 @@ func TestFirestore(t *testing.T) { } doc := client.Collection("cities").Doc("Mountain View") - data := map[string]interface{}{ - "name": "Mountain View", - "country": "USA", - "population": int64(77846), - "capital": false, + if _, err := doc.Set(ctx, cityData); err != nil { + t.Fatal(err) + } + defer doc.Delete(ctx) + + snap, err := doc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(snap.Data(), cityData) { + t.Errorf("Get() = %v; want %v", snap.Data(), cityData) + } +} + +func TestFirestoreWithDatabaseID(t *testing.T) { + ctx := context.Background() + app, err := internal.NewTestApp(ctx, nil) + if err != nil { + t.Fatal(err) + } + + // This test requires the target non-default database to exist in the project. + // If it doesn't exist, this test will fail. + client, err := app.FirestoreWithDatabaseID(ctx, testDatabaseID) + if err != nil { + t.Fatal(err) } - if _, err := doc.Set(ctx, data); err != nil { + + doc := client.Collection("cities").NewDoc() + if _, err := doc.Set(ctx, cityData); err != nil { t.Fatal(err) } + defer doc.Delete(ctx) + snap, err := doc.Get(ctx) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(snap.Data(), data) { - t.Errorf("Get() = %v; want %v", snap.Data(), data) + if !reflect.DeepEqual(snap.Data(), cityData) { + t.Errorf("Get() = %v; want %v", snap.Data(), cityData) } - if _, err := doc.Delete(ctx); err != nil { +} + +func TestFirestoreMultiDB(t *testing.T) { + ctx := context.Background() + app, err := internal.NewTestApp(ctx, nil) + if err != nil { t.Fatal(err) } + + cityClient, err := app.Firestore(ctx) + if err != nil { + t.Fatal(err) + } + // This test requires the target non-default database to exist in the project. + // If it doesn't exist, this test will fail. + movieClient, err := app.FirestoreWithDatabaseID(ctx, testDatabaseID) + if err != nil { + t.Fatal(err) + } + + cityDoc := cityClient.Collection("cities").NewDoc() + movieDoc := movieClient.Collection("movies").NewDoc() + + if _, err := cityDoc.Set(ctx, cityData); err != nil { + t.Fatal(err) + } + defer cityDoc.Delete(ctx) + + if _, err := movieDoc.Set(ctx, movieData); err != nil { + t.Fatal(err) + } + defer movieDoc.Delete(ctx) + + citySnap, err := cityDoc.Get(ctx) + if err != nil { + t.Fatal(err) + } + movieSnap, err := movieDoc.Get(ctx) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(citySnap.Data(), cityData) { + t.Errorf("City Get() = %v; want %v", citySnap.Data(), cityData) + } + if !reflect.DeepEqual(movieSnap.Data(), movieData) { + t.Errorf("Movie Get() = %v; want %v", movieSnap.Data(), movieData) + } } From d7795a6ec2c5adf8f5d8147337e808b34efb7e16 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Mon, 19 Jan 2026 19:03:15 -0500 Subject: [PATCH 06/11] feat(auth): Add `QueryUsers` API (#727) This change implements the accounts:query functionality, providing a new QueryUsers method that allows searching for users with filters and sorting options. RELEASE_NOTE: Added QueryUsers() API to support querying user accounts with filters, sorting, and pagination. --- AGENTS.md | 1 + auth/tenant_mgt_test.go | 29 ++++ auth/user_mgt.go | 172 ++++++++++++++++++++ auth/user_mgt_test.go | 241 ++++++++++++++++++++++++++++ integration/auth/tenant_mgt_test.go | 17 ++ integration/auth/user_mgt_test.go | 36 +++++ 6 files changed, 496 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e6b95039..b2d6d89a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ The Firebase Admin Go SDK enables server-side (backend) applications to interact - **DO:** Use the centralized HTTP client in `internal/http_client.go` for all network calls. - **DO:** Pass `context.Context` as the first argument to all functions that perform I/O or other blocking operations. +- **DO:** Run `go fmt` after implementing a change and fix any linting errors. - **DON'T:** Expose types or functions from the `internal/` directory in the public API. - **DON'T:** Introduce new third-party dependencies without a strong, documented justification and team consensus. diff --git a/auth/tenant_mgt_test.go b/auth/tenant_mgt_test.go index e411793c..539d24e5 100644 --- a/auth/tenant_mgt_test.go +++ b/auth/tenant_mgt_test.go @@ -90,6 +90,35 @@ func TestTenantGetUser(t *testing.T) { } } +func TestTenantQueryUsers(t *testing.T) { + resp := `{ + "usersInfo": [], + "recordsCount": "0" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + tenantClient, err := s.Client.TenantManager.AuthForTenant("test-tenant") + if err != nil { + t.Fatalf("Failed to create tenant client: %v", err) + } + + returnUserInfo := true + query := &QueryUsersRequest{ + ReturnUserInfo: &returnUserInfo, + } + + _, err = tenantClient.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() with tenant client = %v", err) + } + + wantPath := "/projects/mock-project-id/tenants/test-tenant/accounts:query" + if s.Req[0].RequestURI != wantPath { + t.Errorf("QueryUsers() URL = %q; want = %q", s.Req[0].RequestURI, wantPath) + } +} + func TestTenantGetUserByEmail(t *testing.T) { s := echoServer(testGetUserResponse, t) defer s.Close() diff --git a/auth/user_mgt.go b/auth/user_mgt.go index bee027e3..05e123a1 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -1048,6 +1048,178 @@ func (c *baseClient) GetUsers( return &GetUsersResult{userRecords, notFound}, nil } +// QueryUserInfoResponse is the response from the QueryUsers function. +type QueryUserInfoResponse struct { + Users []*UserRecord + Count int64 +} + +type queryUsersResponse struct { + Users []*userQueryResponse `json:"userInfo"` + Count int64 `json:"recordsCount,string,omitempty"` +} + +// Expression represents a query condition used to filter results. +// +// Specify only one of Email, PhoneNumber, or UID. If you specify more than one, +// only the first (in order of Email, PhoneNumber, then UID) is applied. +type Expression struct { + // Email is a case-insensitive string that the account's email must match. + Email string `json:"email,omitempty"` + // PhoneNumber is a string that the account's phone number must match. + PhoneNumber string `json:"phoneNumber,omitempty"` + // UID is a string that the account's local ID must match. + UID string `json:"userId,omitempty"` +} + +// QueryUsersRequest is the request structure for the QueryUsers function. +type QueryUsersRequest struct { + // ReturnUserInfo specifies whether to return user accounts that match the query. + // If set to false, only the count of matching accounts is returned. + // Defaults to true. + ReturnUserInfo *bool `json:"returnUserInfo,omitempty"` + // Limit is the maximum number of accounts to return with an upper limit of 500. + // Defaults to 500. This field is valid only when ReturnUserInfo is true. + Limit int64 `json:"limit,string,omitempty"` + // Offset is the number of accounts to skip from the beginning of matching records. + // This field is valid only when ReturnUserInfo is true. + Offset int64 `json:"offset,string,omitempty"` + // SortBy is the field to use for sorting user accounts. + SortBy SortBy `json:"-"` + // Order is the sort order for the query results. + Order Order `json:"-"` + // TenantID is the ID of the tenant to which the results are scoped. + TenantID string `json:"tenantId,omitempty"` + // Expression is a list of query conditions used to filter the results. + Expression []*Expression `json:"expression,omitempty"` +} + +// build builds the query request (for internal use only). +func (q *QueryUsersRequest) build() interface{} { + var sortBy string + if q.SortBy != sortByUnspecified { + sortBys := map[SortBy]string{ + UID: "USER_ID", + Name: "NAME", + CreatedAt: "CREATED_AT", + LastLoginAt: "LAST_LOGIN_AT", + UserEmail: "USER_EMAIL", + } + sortBy = sortBys[q.SortBy] + } + + var order string + if q.Order != orderUnspecified { + orders := map[Order]string{ + Asc: "ASC", + Desc: "DESC", + } + order = orders[q.Order] + } + + type queryUsersRequestInternal QueryUsersRequest + internal := (*queryUsersRequestInternal)(q) + if internal.ReturnUserInfo == nil { + t := true + internal.ReturnUserInfo = &t + } + + return &struct { + SortBy string `json:"sortBy,omitempty"` + Order string `json:"order,omitempty"` + *queryUsersRequestInternal + }{ + SortBy: sortBy, + Order: order, + queryUsersRequestInternal: internal, + } +} + +func (q *QueryUsersRequest) validate() error { + if q.Limit != 0 && (q.Limit < 1 || q.Limit > 500) { + return fmt.Errorf("limit must be between 1 and 500") + } + if q.Offset < 0 { + return fmt.Errorf("offset must be non-negative") + } + for _, exp := range q.Expression { + if exp.Email != "" { + if err := validateEmail(exp.Email); err != nil { + return err + } + } + if exp.PhoneNumber != "" { + if err := validatePhone(exp.PhoneNumber); err != nil { + return err + } + } + if exp.UID != "" { + if err := validateUID(exp.UID); err != nil { + return err + } + } + } + return nil +} + +// SortBy defines the fields available for sorting user accounts. +type SortBy int + +const ( + sortByUnspecified SortBy = iota + // UID sorts results by user ID. + UID + // Name sorts results by name. + Name + // CreatedAt sorts results by creation time. + CreatedAt + // LastLoginAt sorts results by the last login time. + LastLoginAt + // UserEmail sorts results by user email. + UserEmail +) + +// Order defines the sort order for query results. +type Order int + +const ( + orderUnspecified Order = iota + // Asc sorts results in ascending order. + Asc + // Desc sorts results in descending order. + Desc +) + +// QueryUsers queries for user accounts based on the provided query configuration. +func (c *baseClient) QueryUsers(ctx context.Context, query *QueryUsersRequest) (*QueryUserInfoResponse, error) { + if query == nil { + return nil, fmt.Errorf("query request must not be nil") + } + if err := query.validate(); err != nil { + return nil, err + } + + var parsed queryUsersResponse + _, err := c.post(ctx, "/accounts:query", query.build(), &parsed) + if err != nil { + return nil, err + } + + var userRecords []*UserRecord + for _, user := range parsed.Users { + userRecord, err := user.makeUserRecord() + if err != nil { + return nil, fmt.Errorf("error while parsing response: %w", err) + } + userRecords = append(userRecords, userRecord) + } + + return &QueryUserInfoResponse{ + Users: userRecords, + Count: parsed.Count, + }, nil +} + type userQueryResponse struct { UID string `json:"localId,omitempty"` DisplayName string `json:"displayName,omitempty"` diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index 01d8734f..094716c2 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -1899,6 +1899,247 @@ func TestDeleteUsers(t *testing.T) { }) } +func TestQueryUsers(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser", + "email": "testuser@example.com", + "phoneNumber": "+1234567890", + "emailVerified": true, + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "validSince": "1494364393", + "disabled": false, + "createdAt": "1234567890000", + "lastLoginAt": "1233211232000", + "customAttributes": "{\"admin\": true, \"package\": \"gold\"}", + "tenantId": "testTenant", + "providerUserInfo": [{ + "providerId": "password", + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "email": "testuser@example.com", + "rawId": "testuid" + }, { + "providerId": "phone", + "phoneNumber": "+1234567890", + "rawId": "testuid" + }], + "mfaInfo": [{ + "phoneInfo": "+1234567890", + "mfaEnrollmentId": "enrolledPhoneFactor", + "displayName": "My MFA Phone", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + }, { + "totpInfo": {}, + "mfaEnrollmentId": "enrolledTOTPFactor", + "displayName": "My MFA TOTP", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + }] + }], + "recordsCount": "1" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + returnUserInfo := true + query := &QueryUsersRequest{ + ReturnUserInfo: &returnUserInfo, + Limit: 1, + SortBy: UserEmail, + Order: Asc, + Expression: []*Expression{ + { + Email: "testuser@example.com", + }, + }, + } + + result, err := s.Client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + + if len(result.Users) != 1 { + t.Fatalf("QueryUsers() returned %d users; want 1", len(result.Users)) + } + + if result.Count != 1 { + t.Errorf("QueryUsers() returned count %d; want 1", result.Count) + } + + if !reflect.DeepEqual(result.Users[0], testUser) { + t.Errorf("QueryUsers() = %#v; want = %#v", result.Users[0], testUser) + } + + wantPath := "/projects/mock-project-id/accounts:query" + if s.Req[0].RequestURI != wantPath { + t.Errorf("QueryUsers() URL = %q; want = %q", s.Req[0].RequestURI, wantPath) + } +} + +func TestQueryUsersError(t *testing.T) { + resp := `{ + "error": { + "message": "INVALID_QUERY" + } + }` + s := echoServer([]byte(resp), t) + defer s.Close() + s.Status = http.StatusBadRequest + + returnUserInfo := true + query := &QueryUsersRequest{ + ReturnUserInfo: &returnUserInfo, + Limit: 1, + SortBy: UserEmail, + Order: Asc, + Expression: []*Expression{ + { + Email: "testuser@example.com", + }, + }, + } + + result, err := s.Client.QueryUsers(context.Background(), query) + if result != nil || err == nil { + t.Fatalf("QueryUsers() = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersNilQuery(t *testing.T) { + s := echoServer([]byte("{}"), t) + defer s.Close() + result, err := s.Client.QueryUsers(context.Background(), nil) + if result != nil || err == nil { + t.Fatalf("QueryUsers(nil) = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersMalformedCustomAttributes(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser", + "customAttributes": "invalid-json" + }] + }` + s := echoServer([]byte(resp), t) + defer s.Close() + query := &QueryUsersRequest{} + result, err := s.Client.QueryUsers(context.Background(), query) + if result != nil || err == nil { + t.Fatalf("QueryUsers() = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersMalformedLastRefreshTimestamp(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser", + "lastRefreshAt": "invalid-timestamp" + }] + }` + s := echoServer([]byte(resp), t) + defer s.Close() + query := &QueryUsersRequest{} + result, err := s.Client.QueryUsers(context.Background(), query) + if result != nil || err == nil { + t.Fatalf("QueryUsers() = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersDefaultReturnUserInfo(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser" + }], + "recordsCount": "1" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + // ReturnUserInfo is nil, should default to true in build() + query := &QueryUsersRequest{ + Limit: 1, + } + + _, err := s.Client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(s.Rbody, &got); err != nil { + t.Fatal(err) + } + + if got["returnUserInfo"] != true { + t.Errorf("QueryUsers() request[\"returnUserInfo\"] = %v; want true", got["returnUserInfo"]) + } +} + +func TestQueryUsersValidation(t *testing.T) { + s := echoServer([]byte("{}"), t) + defer s.Close() + + tests := []struct { + name string + query *QueryUsersRequest + }{ + { + name: "Invalid Limit Low", + query: &QueryUsersRequest{ + Limit: -1, + }, + }, + { + name: "Invalid Limit High", + query: &QueryUsersRequest{ + Limit: 501, + }, + }, + { + name: "Invalid Offset", + query: &QueryUsersRequest{ + Offset: -1, + }, + }, + { + name: "Invalid Email in Expression", + query: &QueryUsersRequest{ + Expression: []*Expression{ + {Email: "invalid-email"}, + }, + }, + }, + { + name: "Invalid Phone in Expression", + query: &QueryUsersRequest{ + Expression: []*Expression{ + {PhoneNumber: "invalid-phone"}, + }, + }, + }, + { + name: "Invalid UID in Expression", + query: &QueryUsersRequest{ + Expression: []*Expression{ + {UID: string(make([]byte, 129))}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := s.Client.QueryUsers(context.Background(), tt.query) + if err == nil { + t.Errorf("QueryUsers() with %s; want error, got nil", tt.name) + } + }) + } +} + func TestMakeExportedUser(t *testing.T) { queryResponse := &userQueryResponse{ UID: "testuser", diff --git a/integration/auth/tenant_mgt_test.go b/integration/auth/tenant_mgt_test.go index a1349d4c..c5ab6de2 100644 --- a/integration/auth/tenant_mgt_test.go +++ b/integration/auth/tenant_mgt_test.go @@ -427,6 +427,23 @@ func testTenantAwareUserManagement(t *testing.T, id string) { } }) + t.Run("QueryUsers()", func(t *testing.T) { + query := &auth.QueryUsersRequest{ + Expression: []*auth.Expression{ + { + Email: want.Email, + }, + }, + } + result, err := tenantClient.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + if len(result.Users) != 1 || result.Users[0].UID != user.UID { + t.Errorf("QueryUsers(email=%s) = %v; want user %s", want.Email, result.Users, user.UID) + } + }) + t.Run("DeleteUser()", func(t *testing.T) { if err := tenantClient.DeleteUser(context.Background(), user.UID); err != nil { t.Fatalf("DeleteUser() = %v", err) diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index cae59097..399c3fe2 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -1442,3 +1442,39 @@ func deletePhoneNumberUser(t *testing.T, phoneNumber string) { t.Fatal(err) } } +func TestQueryUsers(t *testing.T) { + u1 := newUserWithParams(t) + defer deleteUser(u1.UID) + u2 := newUserWithParams(t) + defer deleteUser(u2.UID) + + // Query by email + query := &auth.QueryUsersRequest{ + Expression: []*auth.Expression{ + { + Email: u1.Email, + }, + }, + } + result, err := client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + if len(result.Users) != 1 || result.Users[0].UID != u1.UID { + t.Errorf("QueryUsers(uid=%s) = %v; want user %s", u1.UID, result.Users, u1.UID) + } + + // Query with limit and sort + query = &auth.QueryUsersRequest{ + Limit: 2, + SortBy: auth.CreatedAt, + Order: auth.Desc, + } + result, err = client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + if len(result.Users) < 2 { + t.Errorf("QueryUsers(limit=2) = %d users; want >= 2", len(result.Users)) + } +} From 0af6fb950de609cbc5bc6e192899f35a11a2bbe2 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 20 Jan 2026 12:04:23 -0500 Subject: [PATCH 07/11] Update the release actions process (#741) This PR refactors the release automation to improve security by removing the direct merge from the workflow. --- .github/workflows/release.yml | 43 ++++++------------------ .github/workflows/tag_release.yml | 54 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/tag_release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e916cc4e..197438a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,39 +90,14 @@ jobs: id: preflight run: ./.github/scripts/publish_preflight_check.sh - # We authorize this step with an access token that has write access to the master branch. - - name: Merge to master - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - with: - github-token: ${{ secrets.FIREBASE_GITHUB_TOKEN }} - script: | - github.rest.repos.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - base: 'master', - head: 'dev' - }) - - # See: https://cli.github.com/manual/gh_release_create - - name: Create release tag + # Create a PR to merge dev into master. + - name: Create Release PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create ${{ steps.preflight.outputs.version }} - --title "Firebase Admin Go SDK ${{ steps.preflight.outputs.version }}" - --notes '${{ steps.preflight.outputs.changelog }}' - --target "master" - - # Post to Twitter if explicitly opted-in by adding the label 'release:tweet'. - - name: Post to Twitter - if: success() && - contains(github.event.pull_request.labels.*.name, 'release:tweet') - uses: firebase/firebase-admin-node/.github/actions/send-tweet@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 - with: - status: > - ${{ steps.preflight.outputs.version }} of @Firebase Admin Go SDK is available. - https://github.com/firebase/firebase-admin-go/releases/tag/${{ steps.preflight.outputs.version }} - consumer-key: ${{ secrets.FIREBASE_TWITTER_CONSUMER_KEY }} - consumer-secret: ${{ secrets.FIREBASE_TWITTER_CONSUMER_SECRET }} - access-token: ${{ secrets.FIREBASE_TWITTER_ACCESS_TOKEN }} - access-token-secret: ${{ secrets.FIREBASE_TWITTER_ACCESS_TOKEN_SECRET }} - continue-on-error: true + run: | + gh pr create \ + --base master \ + --head dev \ + --title "[chore] Release ${{ steps.preflight.outputs.version }}" \ + --body "${{ steps.preflight.outputs.changelog }}" \ + --label "release:tag" diff --git a/.github/workflows/tag_release.yml b/.github/workflows/tag_release.yml new file mode 100644 index 00000000..27a55918 --- /dev/null +++ b/.github/workflows/tag_release.yml @@ -0,0 +1,54 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Tag Release + +on: + pull_request: + types: [closed] + +jobs: + tag_release: + # Trigger only when: + # 1. The PR is merged. + # 2. The PR targets the master branch. + # 3. The PR has the label 'release:tag'. + if: github.event.pull_request.merged && + github.event.pull_request.base.ref == 'master' && + contains(github.event.pull_request.labels.*.name, 'release:tag') && + startsWith(github.event.pull_request.title, '[chore] Release ') + + runs-on: ubuntu-latest + environment: Release + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: master + + - name: Publish preflight check + id: preflight + run: ./.github/scripts/publish_preflight_check.sh + + # See: https://cli.github.com/manual/gh_release_create + - name: Create release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create ${{ steps.preflight.outputs.version }} + --title "Firebase Admin Go SDK ${{ steps.preflight.outputs.version }}" + --notes '${{ steps.preflight.outputs.changelog }}' + --target "master" From 3a86709f08e30870c9a66dc0b0f4cb995b12e55c Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 21 Jan 2026 10:59:40 -0500 Subject: [PATCH 08/11] [chore] Release 4.19.0 (#742) --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index 33a0ae65..d9709f98 100644 --- a/firebase.go +++ b/firebase.go @@ -40,7 +40,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.18.0" +const Version = "4.19.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" From 1c367cad983d7cf8eff3f1ae25269324f81ee673 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 21 Jan 2026 12:53:58 -0500 Subject: [PATCH 09/11] Revert "[chore] Release 4.19.0 (#742)" (#744) This reverts commit 3a86709f08e30870c9a66dc0b0f4cb995b12e55c. --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index d9709f98..33a0ae65 100644 --- a/firebase.go +++ b/firebase.go @@ -40,7 +40,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.19.0" +const Version = "4.18.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" From 6e297d467f6014c1756ba304f326c498d9a91e7c Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 21 Jan 2026 16:07:28 -0500 Subject: [PATCH 10/11] chore: Update Release Workflows for Push triggers (#745) * chore: Update release actions to run on Release env * Trigger CI * update the actions * update the base_ref * remove fetch-depth to prevent conflicts --- .github/workflows/release.yml | 17 +++++++---------- .github/workflows/tag_release.yml | 27 +++++++++++---------------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 197438a0..533b4e0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,13 +36,9 @@ jobs: runs-on: ubuntu-latest - # When manually triggering the build, the requester can specify a target branch or a tag - # via the 'ref' client parameter. steps: - name: Check out code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 @@ -71,19 +67,19 @@ jobs: # 3. with the label 'release:publish', and # 4. the title prefix '[chore] Release '. if: github.event.pull_request.merged && - github.ref == 'refs/heads/dev' && + github.base_ref == 'dev' && contains(github.event.pull_request.labels.*.name, 'release:publish') && startsWith(github.event.pull_request.title, '[chore] Release ') runs-on: ubuntu-latest - environment: Release permissions: - contents: write + pull-requests: write steps: - name: Checkout source for publish uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: + ref: dev persist-credentials: false - name: Publish preflight check @@ -94,10 +90,11 @@ jobs: - name: Create Release PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BODY: ${{ steps.preflight.outputs.changelog }} + RELEASE_TITLE: "[chore] Release ${{ steps.preflight.outputs.version }}" run: | gh pr create \ --base master \ --head dev \ - --title "[chore] Release ${{ steps.preflight.outputs.version }}" \ - --body "${{ steps.preflight.outputs.changelog }}" \ - --label "release:tag" + --title "$RELEASE_TITLE" \ + --body "$RELEASE_BODY" diff --git a/.github/workflows/tag_release.yml b/.github/workflows/tag_release.yml index 27a55918..a209e704 100644 --- a/.github/workflows/tag_release.yml +++ b/.github/workflows/tag_release.yml @@ -15,19 +15,15 @@ name: Tag Release on: - pull_request: - types: [closed] + push: + branches: + - master + paths: + - 'firebase.go' jobs: tag_release: - # Trigger only when: - # 1. The PR is merged. - # 2. The PR targets the master branch. - # 3. The PR has the label 'release:tag'. - if: github.event.pull_request.merged && - github.event.pull_request.base.ref == 'master' && - contains(github.event.pull_request.labels.*.name, 'release:tag') && - startsWith(github.event.pull_request.title, '[chore] Release ') + if: startsWith(github.event.head_commit.message, '[chore] Release ') runs-on: ubuntu-latest environment: Release @@ -37,18 +33,17 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: master - name: Publish preflight check id: preflight run: ./.github/scripts/publish_preflight_check.sh - # See: https://cli.github.com/manual/gh_release_create - name: Create release tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create ${{ steps.preflight.outputs.version }} - --title "Firebase Admin Go SDK ${{ steps.preflight.outputs.version }}" - --notes '${{ steps.preflight.outputs.changelog }}' + RELEASE_VER: ${{ steps.preflight.outputs.version }} + RELEASE_NOTES: ${{ steps.preflight.outputs.changelog }} + run: gh release create "$RELEASE_VER" \ + --title "Firebase Admin Go SDK $RELEASE_VER" \ + --notes "$RELEASE_NOTES" \ --target "master" From c06a67424bde05401339b9698be4d67adf0a36f2 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 21 Jan 2026 16:11:42 -0500 Subject: [PATCH 11/11] [chore] Release 4.19.0 Take 2 (#746) --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index 33a0ae65..d9709f98 100644 --- a/firebase.go +++ b/firebase.go @@ -40,7 +40,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.18.0" +const Version = "4.19.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG"