M3-21: FCM adapter with Fake-FCM¶
Status: ready | Depends on: M3-19 | Milestone: M3
Goal¶
After this ticket the job notify.push_send of M3-19 can deliver hints to Android devices: internal/notify/adapters/fcm implements the provider port PushProvider of M3-19 for registrations with provider = fcm. The adapter sends one HTTP v1 request POST https://fcm.googleapis.com/v1/projects/{project_id}/messages:send per hint (13 5.2), only through the egress client of M1-22, as a pure data message: no notification block, no collapse_key (R-PN-08), android.priority high or normal for quiet hints, android.ttl from push.ttl, and a data map that holds only the string keys of 13 6.2 (IDs, numbers and fixed keys, never a name or a text), plus the sync form k = "sync" with priority normal of 13 8. It authenticates with an OAuth 2.0 access token of the service account that the secret provider of M1-09 returns for push.fcm.service_account_ref: an RS256 assertion signed with the account's key goes to the token endpoint, and the access token is reused for half its lifetime, with at most one token request in flight however many sends run in parallel. Every answer of 13 7.2 maps to exactly one result class of M3-19 (domain.SendResult), so the job deactivates, retries, drops and alerts as the table says: 404 UNREGISTERED and an INVALID_ARGUMENT caused by the token deactivate with token_invalid, 403 SENDER_ID_MISMATCH deactivates with sender_mismatch and raises a configuration alert, 401 THIRD_PARTY_AUTH_ERROR is a configuration alert, 429 QUOTA_EXCEEDED waits at least one minute, and 503 UNAVAILABLE and 500 INTERNAL retry with the Retry-After of the answer honoured. A Fake-FCM (HTTP with TLS on loopback, serving both the token endpoint and the send endpoint, verifying the assertion and the bearer token and recording every request) proves all of it here and is the provider fake that later client tests (M5-05) read their payload fixture from. Device tokens, the assertion, the access token and the key never reach a log, an error text or telemetry.
Read first¶
docs/spec/13-push-notifications.md: 1 "Goals" (point 1: no content at Google; point 5: every push has an ID); 5.2 "Android: FCM data message" (all of it: endpoint, OAuth scope, data message only, priority,android.ttlin"<seconds>s"with the 28 day limit, nocollapse_key, overflow); 5.4 "Later" (the portPushProvider); 6.2 "FCM" (the payload, the string values, the sync formk = "sync"withbadgeandcleared); 7.2 "FCM" (the error table); 7.3 "Common" (failure count,push.max_age, tokens never in logs); 8 "Badge and sync" (the badge indata.badgeand the Android sync with prioritynormal); 9 "Delivery measurement" (t1 = the provider accepted with200); 10 "Privacy"; 11 "Push keys and white label" (the token endpoint "from the service account", a token only valid for the sender of its Firebase project); 14 "Tests" (rows "Payload allow-list" and "Fake FCM with every response from 7.2"); 15 R-PN-08, R-PN-10; 17 "Configuration values of this chapter" (push.ttl,push.retry_base,push.retry_cap,push.fcm.project_id,push.fcm.service_account_ref); 18 sources Q8 to Q12 and Q16.docs/spec/02-architecture.md: 2 "Context (C4 level 1)" (row "Push services": not trusted, only IDs and placeholders); 5 "Layers within a module" (adapters may useplatformandkernel); 6 "Code layout" (test helpers next to the adapter); 12.2 queuedeliveryand 12.3 rownotify.push_send; 13 "Configuration loading" (items 3 to 5: secret references, start checks, cross-checks); 14.2 "Deadlines" (row "outbound calls":egress.timeout); 14.4 "Failure of a dependency" (rows "push services" and "secret provider").docs/spec/19-security.md: 8 "SSRF ban and outgoing connections" (SEC-NET-1, SEC-NET-2: push services are allowed targets that the consumer names inOptions.Hosts); SEC-TLS-4; SEC-LOG-4 (push tokens never in logs); SEC-SEC-3; the row "Push credentials (APNs key, FCM service account, WNS client)" of the secrets table (secret_ref); SEC-T02 (lint of HTTP clients).docs/spec/16-operations.md: 4.1 "Basic rules" (point 3: onlyworkerneeds egress to push services); 5.2 "Secret provider (ADR 0011)"; 12 "Status page" (health_sourcepush.fcm, built by M7-09, reads the outcomes this ticket returns).docs/operations/configuration.md: section 2 (secret_ref, egress target), the rowspush.ttl,push.retry_base,push.retry_cap,push.fcm.project_id,push.fcm.service_account_ref,egress.allowed_hosts,egress.timeout,egress.max_response_bytes,secrets.dir,secrets.cache_ttl; section 5 "Secrets" (rowfcm_service_account.json).docs/observability/signals.md: 4.4 rowsbothy.push.sent(the outcomesaccepted,rejected_token,rejected_other,throttled,provider_error) andbothy.push.send.duration(both recorded by M3-19 aroundSend); 5.2 spanpush.send; 5.3 attributes (bothy.push.providerwith the valuefcm; no device, person or thread IDs on metrics); 7 "Logs".docs/observability/alerting.mdBothyPushAcceptanceBudgetBurnanddocs/observability/slos.mdbothy-push/provider-acceptance(a send withrejected_otherburns the budget;rejected_tokendoes not).- ADR 0031 (push without content; Confirmation: a test checks the serialized payload against an allow list of keys and value types), ADR 0067 (JOSE profile with the standard library; item 2 lists
RS256for signing assertions; item 5: the signer stays with the caller), ADR 0011 (secret provider), ADR 0008 (hexagonal modules). docs/DECISIONS.md: D-R-PUSH, D-PN-06 (no FCMcollapse_key), D-W-100, D-W-108 (M3-19 owns the provider port, M3-20 and M3-21 implement it; (6) M3-19 also builds the badge count and the badge sync of 13 8), section 10 "Rulings of the build milestones" (format of the rowsD-M3-21-<n>).docs/security/threat-model.md: TB7, 5.12 "Push", T-PU-01.docs/PLAN.md: 3 "Way of working per ticket"; 5 "Test strategy" (row "Test doubles": Fake-FCM); 9 "M3 Platform services" ("Test strategy": Fake-FCM; "Red team": content in push payloads, T-PU-01); the rows M3-19 (the port), M3-20 (the APNs sibling, not in this ticket's closure), M3-21 (this ticket, the contract), M3-22 (consumer of this adapter); 12 row M6-13, 13 row M7-09 and the row M5-05 (later consumers: measure points, status page, the instrumented test with a Fake-FCM payload); 19 P-09, P-10, P-11.- Dependencies: the header names M3-19; its closure brings what this brief uses: M3-17 (
internal/notify/domain), M3-18, M2-01, M1-71, M1-68, M1-33 (internal/kernel/jose), M1-22 (internal/platform/egress), M1-14 (platform/jobs), M1-13, M1-12 (pgtest), M1-09 (internal/platform/secrets), M1-08, M1-07 (configuration schema), M1-05, M1-01 and the rest of their closure. Not in the closure and not needed: M1-41 (RS256verification inkernel/jose; this ticket brings its own signer, Design note 3), M3-20 (egress.Options.HTTP2; FCM speaks HTTP/1.1, Design note 1 d). - Briefs (merged names win, D-W-100):
M3-19-notify-jobs.md(Design note 11: the steps ofnotify.push_send; Design note 12:PushProvider,Registration,NotificationwithBadge,HasBadgeandCleared, the constantKindSync; Design note 16: the badge number and the badge sync; Design note 13:domain.SendResult,ResultClasswithaccepted,disable,retry,throttled,config_error,program_error,ApplyResult,RetryDelay; Design note 14:internal/wire/configkeys_push.go; Design note 15: logs and outcomes; the provider set ininternal/wire;fakepush.FakeProvider),M1-22-egress-client.md(New,Config,Options,Deps,HostPolicy,RedirectNever,ErrBlocked,ErrResponseTooLarge, the Semgrep rulesbothy-go-egress-onlyandbothy-go-tls-verify),M1-09-secret-provider.md(Ref,Value,Value.Bytes,Version,Provider.Resolve, thefiledriver rules,bothy config check --secrets),M1-33-identity-devices-dpop.md(jose.SignCompact,jose.Signer,jose.Header),M1-07-config-schema-loader.md(config.KeywithMinandMax,KindSecretRefand the placeholder<secrets.dir>in defaults). - Code on the base branch (all under
services/server/):internal/platform/egress(client.go:Options,Deps.RootCAs,Client.Do,Client.HTTPClient; HTTP/1.1 with keep-alive;README.md),internal/platform/secrets,internal/kernel/jose,internal/kernel/clock(Clock,Fake),internal/notify(app/ports.gowithPushProvider,app/send.go,domain/result.go,adapters/fakepush,adapters/jobs),internal/wire(config.go,configchecks.gowithconfigChecks,configchapters.go,configkeys_push.go,egress.gowithEgressConfig,config_test.gowithTestConfigSchemaBuilds),internal/platform/config(Key,Min,Max,KindSecretRef: an empty default is allowed only for string and list keys),.semgrep/,architecture/modules.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Sending:
POST https://fcm.googleapis.com/v1/projects/{project_id}/messages:sendwithproject_id=push.fcm.project_idand an OAuth 2.0 access token of the service account with the scopehttps://www.googleapis.com/auth/firebase.messaging(13 5.2). - Every connection goes through the egress client (19 SEC-NET-1); the adapter names exactly
fcm.googleapis.comand the token endpoint host as its allowed targets (SEC-NET-2, 13 11); TLS is always verified (SEC-TLS-4); no redirect is followed. - Data messages only: no
notificationblock, nocollapse_key, noapnsorwebpushblock; the app replaces notifications by a tag equal to the thread ID (13 5.2, R-PN-08, D-PN-06). android.priorityhigh,normalfor quiet hints and for the sync form;android.ttl=push.ttlas whole seconds with the suffixs, at most 28 days (13 5.2, 8).- Payload exactly as 13 6.2:
messagewithtoken,androidanddata;dataholds onlyv,nid,k,i,t,m,badge,quietand, for the sync form,k = "sync",badgeandcleared; every value is a string that holds an ID, a number or a fixed key (13 6.2, ADR 0031). - Answers as the table of 13 7.2:
404 UNREGISTEREDdeactivates withtoken_invalid;400 INVALID_ARGUMENTcaused by an invalid token deactivates withtoken_invalid, any otherINVALID_ARGUMENTis a program error with alert;403 SENDER_ID_MISMATCHdeactivates withsender_mismatchand raises a configuration alert;401 THIRD_PARTY_AUTH_ERRORis a configuration alert;429 QUOTA_EXCEEDEDbacks off with a first wait of at least one minute;503 UNAVAILABLEhonoursRetry-After, then exponential backoff with jitter;500 INTERNALbacks off betweenpush.retry_baseandpush.retry_cap(the backoff itself is M3-19'sRetryDelay). - No content and no secret leaves the server or reaches telemetry: Google sees the token, the time, the size and the IDs; logs, spans, metrics and error texts never carry a device token, the assertion, the access token, the key, a name or a text (13 7.3, 13 10, SEC-LOG-4, SEC-SEC-3).
- Push credentials are instance configuration of the operator: the Firebase project ID and the service account key as a reference to the secret provider (13 11, 19 secrets table row "Push credentials").
Scope¶
All paths under services/server/ unless they start with docs/. Where M3-19 or M1-22 already created a file named below, extend it. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0 (make licenses).
Create:
internal/notify/adapters/fcm/doc.go: package comment (what the adapter does, the two endpoints, the OAuth flow, the payload rules, that it is built only when FCM is configured, a short wiring example).internal/notify/adapters/fcm/config.go:Config,Config.Enabled,Config.Validate(Design note 2).internal/notify/adapters/fcm/account.go:ServiceAccount,ParseServiceAccountand the unexportedRS256signer that implementsjose.Signer(Design note 3).internal/notify/adapters/fcm/token.go: the unexported access token cache and the pause (Design notes 3 and 4).internal/notify/adapters/fcm/payload.go: the message builder (Design note 5).internal/notify/adapters/fcm/classify.go: the answer table and theRetry-Afterparser (Design note 6).internal/notify/adapters/fcm/adapter.go:Adapter,Deps,Endpoints,SecretResolver,New,Adapter.Name,Adapter.Sendand a compile-time assertion that*Adapterimplements the portPushProviderof M3-19 (Design notes 1, 4, 7).internal/notify/adapters/fcm/fcmtest/server.go: Fake-FCMServer,Options,Response,TokenResponse,Request,Start(Design note 8).- Tests (design of each in "Tests"):
config_test.go,account_test.go,token_test.go,payload_test.go,classify_test.go,adapter_test.go,logs_test.go,fcmtest/server_test.go, andtestdata/payload_message.golden. internal/wire/push_fcm.go: builds the adapter from the configuration and adds it to the provider set of M3-19 in the roles that runnotify.push_send(Design note 9), withpush_fcm_test.go.
Change:
internal/wire/configkeys_push.goof M3-19: the keys of Design note 2 if absent, and the bounds ofpush.ttl;internal/wire/configchecks.go: the cross-checkpush_fcm_project_id;internal/wire/config_test.go:TestConfigSchemaBuildslists the keys.cmd/bothy:bothy config check --secretsresolvespush.fcm.service_account_refonly when FCM is enabled, if the merged command resolves every*_refkey unconditionally (Design note 2).- Docs (Design note 11; all new text in English, like the existing text):
docs/spec/13-push-notifications.md5.2, 6.2, 7.2, 11, 17 and 18;docs/spec/02-architecture.md13 item 4 (the new cross-check);docs/operations/configuration.mdregenerated (make config-reference). internal/notify/README.md: an English section on the adapterfcm(configuration, endpoints, token cache, pause, answer table, payload allow list, Fake-FCM); keep the lineMay use the ports of these modules: ...and the section## Tablesexactly as they are, because M1-06 parses them.services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M3-21-1toD-M3-21-8(Design note 12).
This ticket adds no migration, no operation, no action, no event, no problem code, no metric, no job kind, no new configuration key and no top-level Makefile target. It registers the existing keys of 13 17 that it reads, the bounds of push.ttl and one cross-check.
Out of scope¶
- The hint pipeline: the consumer
push, the jobnotify.push_sendwith its steps, the rights check before sending,push.max_age, the semaphore ofpush.max_concurrent_requests,domain.ApplyResult,domain.RetryDelaywithpush.retry_base,push.retry_capand the jitter, the metricsbothy.push.sent,bothy.push.send.duration,bothy.push.skipped,bothy.push.registrations.activeand the spanpush.send: M3-19. This ticket returns adomain.SendResultand lets the job do the rest. - The badge number, the list of cleared threads and when a badge sync is due (13 8): M3-19 (D-W-108 (6)). This ticket writes
Badgeof everyNotificationand the sync formKindSyncwithCleared(Design note 5 a, d). - APNs (
internal/notify/adapters/apns, Fake-APNs,egress.Options.HTTP2): M3-20.unifiedpush,wnsand a push relay: later (13 5.3, 5.4, 11, R-PN-10). registerPush,unregisterPush, the preview token, the preview endpoint, the deactivation on logout, device revocation and user disable, the canary scenario with a push token (SEC-T10): M3-22.- The Android push service that shows the placeholder and fetches the preview, and its instrumented test: M5-05 (it may read
testdata/payload_message.goldenof this ticket). The status page and itshealth_sourcepush.fcm: M7-09. Measure points ofnotifyfor the own statistics: M6-13. - New alert rules: the configuration alert of 13 7.2 is the Error log record plus, where the class is
config_errororprogram_error, the outcomerejected_otherthatBothyPushAcceptanceBudgetBurnalready watches (Design note 7). - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-21-fcm-adapter; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(notify): ...,feat(wire): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; Fake-FCM is an in-process TLS server on loopback; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - No real Google service in any test, no Firebase project, no real service account: test keys are RSA keys generated per run with
crypto/rsa, and the service account JSON is written to a file with mode0600in at.TempDir()for thefiledriver of the secret provider; the project ID isexample-chat, the account e-mailpush@example-chat.example.com, the key ID containsEXAMPLE. Nothing in this ticket costs money. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end. - Never read or print secrets. Never log, print or persist a device token, an assertion, an access token or key material; no test turns off certificate verification (the Semgrep rule
bothy-go-tls-verifyapplies to tests too): clients trust the certificate of Fake-FCM throughegress.Deps.RootCAs. No network calls except package downloads for pinned tools and Go modules. - Never lower a threshold, disable a gate or delete a test.
- Time only through
kernel/clock; tests move a fake clock, they never sleep for an interval. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). This ticket touches no core table, event or action; the points it settles (Design note 11) are in chapter 13. - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M3-19 (and M3-17, M3-18): follow its names for
PushProvider,Registration,Notification,domain.SendResult, the result classes, the provider set and the key file, and list each difference.
Design notes¶
-
Place, port and connection (02 5, 6; 13 5.2, 11; 19 SEC-NET-1, SEC-NET-2; Ruling for docs/DECISIONS.md,
D-M3-21-1).a. The adapter lives in
internal/notify/adapters/fcmand implements the outgoing portPushProviderof M3-19 (Name() stringreturnsfcm;Send(ctx, reg Registration, n Notification) (domain.SendResult, error)). It imports the own module'sappanddomain,kernel(clock,ids,jose,errs),platform/egressandplatform/secrets, and the standard library only: no Firebase SDK, nogolang.org/x/oauth2, because both would open their own HTTP clients past the egress client.architecture/modules.yamldoes not change.b. Public surface (names may follow the merged code, D-W-100; the fields are binding):
// SecretResolver resolves a secret reference; *secrets.Provider implements it (M1-09). type SecretResolver interface { Resolve(ctx context.Context, raw string) (secrets.Value, secrets.Version, error) } // Endpoints are base URLs for tests only; the zero value means the Google endpoints. type Endpoints struct { Send string // replaces https://fcm.googleapis.com Token string // replaces https://oauth2.googleapis.com/token } type Deps struct { Egress egress.Config // egress.* through wire.EgressConfig EgressDeps egress.Deps // resolver, root CAs, logger, metrics Secrets SecretResolver Clock clock.Clock Logger *slog.Logger Endpoints Endpoints // tests only; zero in production } func New(ctx context.Context, cfg Config, deps Deps) (*Adapter, error) func (a *Adapter) Name() string func (a *Adapter) Send(ctx context.Context, reg app.Registration, n app.Notification) (domain.SendResult, error)c. One egress client per adapter, built in
Newwithegress.Options{Purpose: "push_fcm", Hosts: googleHosts, Redirects: egress.RedirectNever};googleHostsallows exactly schemehttps, port443and the hostsfcm.googleapis.comandoauth2.googleapis.com(an operator needs noegress.allowed_hostsentry, 19 SEC-NET-2). Time and size limits areegress.timeoutandegress.max_response_bytes(02 14.2; chapter 13 names no own deadline). Send and token requests share the client and its kept-alive connections.d. HTTP/1.1 with keep-alive, no HTTP/2: the FCM HTTP v1 API accepts HTTP/1.1 [Q8], chapter 13 demands HTTP/2 only for APNs (5.1, 12 point 5), and the option
egress.Options.HTTP2of M3-20 is outside this ticket's closure. Do not set it even when it is merged, so the behaviour does not depend on the merge order; a later ticket may switch it on with its own test.e. The token endpoint is fixed: 13 11 names "the token endpoint from the service account", and the service account JSON names it in
token_uri. The adapter never takes a host from a secret file: it posts tohttps://oauth2.googleapis.com/token(orEndpoints.Tokenin tests) and refuses at start a service account whosetoken_uriis present and differs from that URL (Design note 3 a). Reason: a URL from a file would make the secret a second egress configuration and an SSRF path past SEC-NET-2.f.
Deps.Endpointsexists only so that tests reach Fake-FCM: production code ininternal/wirenever sets it. A test that sets it still runs every egress check: the Fake listens on loopback, so the test configuration carries the egress rule127.0.0.1:<port>;allow_private(fcmtest.Server.EgressRule) and trusts the Fake's certificate throughegress.Deps.RootCAs. -
Configuration and start (13 11, 17; 02 13 items 3 to 5; 02 14.4 row "secret provider"; Ruling for docs/DECISIONS.md,
D-M3-21-2).a. Keys in
internal/wire/configkeys_push.go(created by M3-19), each only if absent (M3-20 registerspush.ttltoo), with the chapter constant of 13 and the defaults of 13 17:push.ttl(duration,24h,Min1s,Max672h),push.fcm.project_id(string, empty),push.fcm.service_account_ref(secret reference). TheMaxofpush.ttlis the FCM limit of 28 days (13 5.2 [Q11]); ifpush.ttlis already registered without it, add the bound. TheMinmakesandroid.ttlat least one whole second. Both bounds hold in every role, so a larger or smaller value stops the start, naming the key and never the value.b. The default of
push.fcm.service_account_refbecomesfile:<secrets.dir>/fcm_service_account.json: 13 17 says "empty", but the merged schema allows an empty default only for string and list keys, anddocs/operations/configuration.mdsection 5 already namesfcm_service_account.jsonas the secret behind this key. Change the row in 13 17 in the same change and regenerate the reference.c.
Config{ProjectID, ServiceAccountRef string; TTL, PauseFor time.Duration}, withPauseFor=push.retry_cap(Design note 4 c).Enabled()is true exactly whenProjectIDis not empty. The cross-checkpush_fcm_project_idinconfigChecksrefuses a non-emptypush.fcm.project_idthat is not only lower-case letters, digits and hyphens starting with a letter, because the value becomes a path segment of the send URL; its message names the key. Add it to 02 13 item 4 ("Cross conditions today").d. Not enabled:
internal/wirebuilds no adapter and reads no secret; jobs forfcmregistrations end with the skip reasonno_providerof M3-19. Enabled: in the rolesworkerandall(the roles that runnotify.push_send),Newresolvespush.fcm.service_account_refand parses it (Design note 3); an error stops the start and namespush.fcm.service_account_ref, never a value (fail fast).Newsends no request: the first token request happens at the first send, so a start without network works. The roleapibuilds no adapter. Where the mergedbothy config check --secretsresolves every*_refkey, it resolves this one only when FCM is enabled, because the default reference points at a file that an instance without FCM does not have. -
Service account and assertion (13 5.2, 11; ADR 0067; Ruling for docs/DECISIONS.md,
D-M3-21-3).a.
ParseServiceAccount(v secrets.Value, tokenURL string) (ServiceAccount, error)reads the JSON object withtype,client_email,private_key_id,private_keyand, if present,token_uri(other fields are ignored). It requirestype = service_account, a non-emptyclient_emailandprivate_key_id, atoken_urithat is absent or equal totokenURL, and aprivate_keyof exactly one PEM block of typePRIVATE KEYin PKCS#8 holding an RSA key (x509.ParsePKCS8PrivateKey). Every error text names the field and the reason and never contains a byte of the value.ServiceAccountkeeps the key unexported and has noStringor JSON form that could print it.b. The unexported signer implements
jose.Signerof M1-33:Alg()returnsRS256;Signhashes the signing input with SHA-256 and returnsrsa.SignPKCS1v15(RFC 7518 3.3). Signing an assertion withRS256is in the profile of ADR 0067 item 2, and item 5 keeps the private key with the caller; M1-41 (RS256verification) is not in the closure and not needed. If the mergedkernel/josealready exports anRS256signer, use it and drop the own one.c. One assertion is
jose.SignCompact(signer, header, payload)with a header of exactlyalg(RS256),typ(JWT) andkid(private_key_id), and a payload of exactlyiss(client_email),scope(https://www.googleapis.com/auth/firebase.messaging),aud(the token URL),iat(the clock's Unix seconds) andexp(iatplus one hour, the longest lifetime Google accepts for an assertion [Q17], a named constant whose comment cites the source: a provider fact, not an operator value). If the mergedjose.Headercannot carrytypandkidor cannot leave out a further field, add an option tokernel/jose(additive, with a test) rather than send a header this note does not name.d. The token request is
POSTto the token URL withcontent-type: application/x-www-form-urlencodedand exactly the fieldsgrant_type=urn:ietf:params:oauth:grant-type:jwt-bearerandassertion=<assertion>(RFC 7523 2.1). A200answer must carryaccess_token(non-empty),token_typeequal toBearerignoring case and a positiveexpires_in; otherwise the answer is a Go error (transient). Every token request resolvespush.fcm.service_account_refagain throughDeps.Secrets(the provider caches forsecrets.cache_ttl) and parses it, so a rotated key is used from the next token on without a restart (16 5.2). -
Access token cache and pause (13 5.2, 7.2, 11; Ruling for docs/DECISIONS.md,
D-M3-21-4andD-M3-21-5).a. State of the adapter: the current access token, the time it was obtained, its
expires_in, andpausedUntil. One mutex guards it, and the token request happens under that lock, so any number of parallel sends wait for one request. A token is reused whilenow < obtained + expires_in / 2; otherwise the first send that needs one requests it. Reason: half the lifetime leaves a wide margin for clock skew and request time without a new configuration key, and the ratio derives from the provider's own answer. A failed request stores nothing; the next send tries again.b.
401from the send endpoint without the FCM error codeTHIRD_PARTY_AUTH_ERROR(statusUNAUTHENTICATED: the access token expired or was revoked): if the request used the current token, drop it, request a new one and send the same message once more with it; a parallel send that got401for the same token reuses the replacement and also retries once. A second401for the replacement is a configuration error (c).c. Configuration error (Design note 6: the rows marked "pause", and a token request that the token endpoint refuses with a
4xxanswer, or a failed resolve or parse of the secret at that time): setpausedUntil = now + push.retry_capand drop the token; write one Error record (Design note 7); returnconfig_error. Whilenow < pausedUntil,Sendmakes no request and returnsconfig_errorwithout a log record (the job drops the hint and countsrejected_other). The first send at or afterpausedUntilrequests a new token, reading the key again, and so probes once: an answer other than a configuration error ends the pause with one Info record; a configuration error starts the next pause. Reason: 13 7.2 asks for an alert and no repetition, but without a pause every hint would ask the token endpoint again with a revoked key;push.retry_capis the longest wait the push path already names, so a rotated key or a fixed project heals the instance within it without a manual step, and hints dropped meanwhile are carried by the unread counts (13 7.3). The pause lives in the process; each worker probes for itself. Answers that concern one registration (SENDER_ID_MISMATCH,THIRD_PARTY_AUTH_ERROR) never pause the adapter.d. Token endpoint answers
429,5xxor no answer: a Go error (transient, M3-19 counts it and retries); no pause, because the credentials are not shown to be wrong. -
Payload (13 5.2, 6.2, 8; ADR 0031; R-PN-08; Ruling for docs/DECISIONS.md,
D-M3-21-6).a. Request body of a hint, built from Go structs with JSON tags in the key order of 13 6.2 (the
datamap is a struct withomitemptystring fields, not a Go map, so no other key can appear):Key Value message.tokenthe registration's token message.android.priorityhigh;normalwhenQuietmessage.android.ttlpush.ttlin whole seconds pluss(86400sfor24h)message.data.v1message.data.nidthe NotificationIDmessage.data.kthe kind ( NotificationKind)message.data.ithe instance ID message.data.tthe thread ID; absent when the hint has none ( security,admin)message.data.mthe message ID; absent when the hint has none message.data.badgeBadgeof theNotificationin decimal (M3-19 Design note 12, 13 6.2, 8); always presentmessage.data.quiet1whenQuiet, else0UUIDs in canonical lower-case form; every
datavalue is a JSON string (13 6.2 "Values indataare strings"). Nonotification,apns,webpush,fcm_options,collapse_key,android.notificationorandroid.collapse_keykey exists in the structs (13 5.2, R-PN-08).CountandTargetIDof theNotificationare not written: 13 6.2 has no key for them, and the app reads both through the preview and its own sync; this matches the APNs payload of 13 6.1.b. Headers:
authorization: Bearer <access token>,content-type: application/json; methodPOST, path/v1/projects/<push.fcm.project_id>/messages:send.c. Checks before any request (no token is requested, the job sees the result at once):
reg.Providerother thanfcm, an unknown kind, a zeronidor instance ID, orHasBadgefalse giveprogram_error; a token that is empty or holds a byte outside printable ASCII without space givesdisablewithtoken_invalid(it can never be valid and would otherwise break the request).reg.Environmentandreg.AppIDare not read: FCM has no sandbox, and a token of another app or project answersSENDER_ID_MISMATCH(13 11). The serializedmessage.datastays under the FCM limit of 4096 bytes for data messages [Q9]; a message above it givesprogram_errorand is never truncated. The limit is one named constant whose comment cites 13 6.2: it is Google's fixed limit, not an operator value.d. The badge sync of 13 8 (
n.Kind == app.KindSync, M3-19 Design notes 12 and 16):datais exactlyk = "sync",badgefromBadgeand, whenClearedis not empty,cleared(the IDs ofCleared, comma-separated, in the order given);v,nidandiare present as in a hint, so the app can match the instance;t,mandquietare absent;android.priorityisnormal. If the IDs would pushdataover the limit of c,clearedis left out entirely and the app keeps those notifications until it opens, the same known limit 13 8 states for iOS; never a partial list, so a list is either complete or absent. -
Answer table (13 7.2; Ruling for docs/DECISIONS.md,
D-M3-21-7).classify(status, body, header, now)is a pure function. The body{"error": {"code": ..., "status": "...", "details": [...]}}is read withinegress.max_response_bytes; the FCM error code iserrorCodeof the detail with@typetype.googleapis.com/google.firebase.fcm.v1.FcmError, the token cause is afieldViolationsentry withfieldmessage.tokenin the detail with@typetype.googleapis.com/google.rpc.BadRequest[Q16].error.messageis never read into a log or an error text, because Google may echo request values in it. A missing or broken body leaves the status and thestatusfield to decide. The class names are those of M3-19 (domain.ResultClass).Answer Class Fields Log (Design note 7) 200accepted404 UNREGISTEREDdisableDisableReasontoken_invalid400 INVALID_ARGUMENTwith a violation onmessage.tokendisabletoken_invalid400 INVALID_ARGUMENTotherwise, any other400program_errorError 403 SENDER_ID_MISMATCHdisablesender_mismatchError (configuration alert) 401 THIRD_PARTY_AUTH_ERRORconfig_error, no pauseError 401otherwise (UNAUTHENTICATED)renew and retry once (Design note 4 b); a second 401isconfig_error, pauseError at the pause 403otherwise (PERMISSION_DENIED)config_error, pauseError 404otherwise (NOT_FOUND: unknown project)config_error, pauseError 429 QUOTA_EXCEEDED, any other429throttledRetryAfter=Retry-After, at least one minute503 UNAVAILABLE,500 INTERNAL, any other5xxretryRetryAfter=Retry-Afterwhen present, else zero (the job's backoffdomain.RetryDelayof M3-19 applies: with its jitter in[0.5, 1]the first retry comes between half ofpush.retry_baseandpush.retry_base, every retry at mostpush.retry_caplater)any other 4xxprogram_errorError any other status ( 1xx,2xxother than200,3xx)program_errorError no answer (DNS, connect, TLS, timeout, egress refusal, oversize body), a token request without answer or with 429or5xxGo error M3-19 counts it as transient Retry-Afteris read as delta seconds or as an HTTP date (RFC 9110 10.2.3) against the clock; a negative, unparsable or past value counts as absent. The minimum wait of one minute afterQUOTA_EXCEEDEDis Google's documented rule [Q16], one named constant whose comment cites 13 7.2. The unlisted rows follow the nearest listed answer of the same status, because Google documents the HTTP status as the class and the error code as detail [Q16].RetryDelayof M3-19 keeps every retry at or afterRetryAfter, so aRetry-Afterbeyondpush.max_ageends the hint asexpired. -
Alert and logs (13 7.2, 7.3, 9, 10; SEC-LOG-4; signals.md 5.3, 7; Ruling for docs/DECISIONS.md,
D-M3-21-8).a. "alert" and "configuration alert" in 13 7.2 mean: one Error record (
fcm configuration error,fcm program error,fcm sender mismatch) withbothy.push.provider = fcmanderror.type= the FCM error code of the table when it is one, else thestatusfield when it is one ofUNAUTHENTICATED,PERMISSION_DENIED,NOT_FOUND, else the OAutherrorof a refused token request when it is one ofinvalid_grant,invalid_client,unauthorized_client,invalid_scope,invalid_request, elseother; plus the outcomerejected_otherthat M3-19 records forconfig_errorandprogram_error, which the SLObothy-push/provider-acceptancecounts against its budget, soBothyPushAcceptanceBudgetBurnfires and the status page of M7-09 sees the error share ofpush.fcm.SENDER_ID_MISMATCHis classdisablewith outcomerejected_token, which does not burn the budget: the token belongs to another project's app, not to a broken server; its Error record is the configuration alert, one per answer. No new metric, attribute or alert rule.b. A pause logs once when it begins and once when a probe ends it, not per dropped hint.
c. Never in a log record, span, metric or error text: the device token (also no hash prefix; the adapter needs none), the assertion, the access token, the
authorizationheader, key material, the client e-mail, the project ID,nid, instance, thread, message or person IDs,error.messageof Google, a name or a text. Errors of the egress client name method, scheme and host only (M1-22); the adapter wraps them with fixed text and never adds the URL, which holds the project ID. -
Fake-FCM (
internal/notify/adapters/fcm/fcmtest; PLAN 5 row "Test doubles", PLAN 9 "Test strategy"). A package withouttestingimports, so that later tests can use it as well:type Options struct { ProjectID string ClientEmail string KeyID string PublicKey *rsa.PublicKey // verifies the assertion Lifetime time.Duration // expires_in of issued tokens; 0 means one hour } type Response struct { Status int // 0 means 200 ErrorCode string // FcmError errorCode; empty: no FcmError detail RPCStatus string // error.status, for example "UNAVAILABLE" TokenField bool // adds a BadRequest violation on message.token RetryAfter string // Retry-After header verbatim; empty: absent } type TokenResponse struct { Status int // 0 means 200 with a new token Error string // OAuth error for a non-200 answer } type Request struct { Proto, Method, Path string Header http.Header // a copy Body []byte Bearer string } func Start(opts Options) (*Server, error) // HTTP/1.1 with TLS on 127.0.0.1:0 func (s *Server) URL() string // base URL for Endpoints.Send func (s *Server) TokenURL() string // URL() + "/token", for Endpoints.Token func (s *Server) RootCAs() *x509.CertPool func (s *Server) EgressRule() string // "127.0.0.1:<port>;allow_private" func (s *Server) ServiceAccountJSON(key *rsa.PrivateKey) []byte func (s *Server) Script(token string, rs ...Response) // send answers in order, then 200 func (s *Server) ScriptToken(rs ...TokenResponse) // token answers in order, then 200 func (s *Server) Requests() []Request // send requests func (s *Server) TokenRequests() int func (s *Server) RevokeTokens() // issued tokens answer 401 from now on func (s *Server) SetPublicKey(k *rsa.PublicKey) // after a key rotation func (s *Server) Close()It is built on
httptest.NewUnstartedServerandStartTLS.ServiceAccountJSONwrites a service account withtype,client_email,private_key_id,private_key(PKCS#8 PEM) andtoken_uri=TokenURL(). The token handler accepts onlyPOSTwith the two form fields of Design note 3 d, verifies the assertion (header exactlyalg = RS256,typ = JWT,kid = KeyID; payload exactlyiss = ClientEmail, the FCM scope,aud = TokenURL(),exp > iat,exp - iatat most one hour; signature withcrypto/rsaVerifyPKCS1v15, becauseJWS.Verifyof M1-33 coversES256andEdDSAonly), answers the token script, and otherwise issues a random token withexpires_in=Lifetimein seconds; a bad assertion gets400with OAutherrorinvalid_grant. The send handler answers405to anything butPOST,404(NOT_FOUND, no FcmError) to a path other than/v1/projects/<ProjectID>/messages:send,401(UNAUTHENTICATED) to a bearer that it did not issue or revoked,400 INVALID_ARGUMENTto a body that is not a JSON object with amessageobject, records the request (withr.Proto), and then answers the script of the body'smessage.tokenwith the FCM error JSON of Design note 6. It is safe for concurrent use. A test runsgo list -deps ./cmd/bothyand fails iffcmtestappears (pattern ofidentitytest, M1-33). -
Wiring (02 13 item 5; M3-19 Design note 12).
internal/wire/push_fcm.goreads the keys of Design note 2 andpush.retry_capintofcm.Config, and whenEnabled()it builds the adapter withwire.EgressConfig, the process'segress.Deps(resolver, logger, the egress metrics), the secret provider,clock.System()and the logger, and adds it to the provider set of M3-19 underName()in the rolesworkerandall.Deps.Endpointsstays zero there. The instance ID thatNotificationcarries is set by M3-19's wiring; the adapter does not readinstance. -
Concurrency.
Adapteris safe for concurrent use; the job of M3-19 bounds parallel sends withpush.max_concurrent_requests.Senddoes no I/O besides the egress requests (the send and, when due, one token request) and the secret resolve of a token request, holds no lock during the send request and honours the context deadline; a send that waits for the token lock gives up when its context ends. -
Specification (rule 10; nothing in 00, 03, 04 or 05 changes; all new text in English, like the existing text):
docs/spec/13-push-notifications.md5.2: a paragraph: the connection runs through the egress client over HTTP/1.1 withfcm.googleapis.comandoauth2.googleapis.comas the adapter's targets; the OAuth flow of Design note 3 (assertion header and claims, the JWT bearer grant, the fixed token URL and thetoken_uricheck); the token cache with reuse for half ofexpires_in, one request in flight, the renewal after a401(Design note 4 a, b); the pause with one probe afterpush.retry_cap(Design note 4 c).- 13 6.2: a paragraph: the value table of Design note 5 a (absent keys without thread or message,
badgealways present,CountandTargetIDnot carried, nocollapse_keyand nonotificationblock anywhere), the checks of 5 c (size checked before sending against the 4096 byte data limit [Q9], never truncated), the sync form of 5 d withv,nid,i, prioritynormaland the all-or-nothing rule forcleared. - 13 7.2: a paragraph after the table: the rows "otherwise" and "any other" of Design note 6, how the token cause of
INVALID_ARGUMENTis recognised, the401renewal, theRetry-Afterforms, the retry delay withoutRetry-After(jitter in[0.5, 1], so the first retry may come beforepush.retry_base; "backoff betweenpush.retry_baseandpush.retry_cap" is read with that jitter, Design note 6), token endpoint failures, and what "alert" means (Design note 7 a). - 13 11: after "the token endpoint from the service account" the English rule: the token endpoint is
https://oauth2.googleapis.com/token; a service account whosetoken_urinames another URL is refused at start. - 13 17: default of
push.fcm.service_account_reffile:<secrets.dir>/fcm_service_account.json(Design note 2 b); the meaning ofpush.ttlgains "between 1 second and 28 days (FCM limit)". - 13 18: a new source Q17 "Google, Using OAuth 2.0 for Server to Server Applications: https://developers.google.com/identity/protocols/oauth2/service-account" for the assertion claims and the one hour limit; this build has no network, so mark the statements that cite it
[derived]and say in the report that the source was not retrieved in this ticket. docs/spec/02-architecture.md13 item 4: the cross-checkpush_fcm_project_id.
-
Rulings for
docs/DECISIONS.md(section 10, one row each with date, decision, reason and place):D-M3-21-1egress over HTTP/1.1 with the two Google hosts as the adapter's policy, the fixed token URL,Deps.Endpointsfor tests only (note 1);D-M3-21-2FCM enabled exactly withpush.fcm.project_id, the cross-check, the default ofpush.fcm.service_account_ref, the bounds ofpush.ttl, the start check inworkerandall, the conditional secret check (note 2);D-M3-21-3service account format,RS256signer, assertion header and claims, the key read at each token request (note 3);D-M3-21-4access token cache with reuse for half its lifetime and the renewal after401(note 4 a, b);D-M3-21-5pause forpush.retry_capwith one probe, only for credential errors (note 4 c, d);D-M3-21-6payload rules, the pre-checks, the data size limit and the sync form with its all-or-nothingcleared(note 5);D-M3-21-7the answer table with its unlisted rows, theRetry-Afterrules and the retry delay bounds with jitter (note 6);D-M3-21-8alert and logs,SENDER_ID_MISMATCHas a logged configuration alert without budget burn (note 7). Rulings 3 to 8 also go into 13 (note 11).
Tests (write them first)¶
Test world: Sam has an Android phone (android) with an fcm registration (environment = production, app_id com.example.chat); Robin has a second Android phone with an fcm registration; Alex has no Android device. Configuration in tests: push.fcm.project_id = example-chat, push.fcm.service_account_ref pointing at a file secret in t.TempDir() that holds fcmtest.Server.ServiceAccountJSON with an RSA key generated per run; durations are fixture values of the test configuration. Every adapter in tests talks to one Fake-FCM through Deps.Endpoints (URL() and TokenURL()), egress.allowed_hosts with the Fake's EgressRule and egress.Deps.RootCAs with its RootCAs; the clock is clock.Fake. Unless a test says otherwise, Send gets a Notification of kind message with thread, message, nid, instance ID and Badge 3 with HasBadge true.
Unit, internal/notify/adapters/fcm and fcmtest:
TestConfigEnabledAndValidate: a project ID gives enabled; an empty one gives disabled;Example-Chat,1chat,chat/xandchat projectfail the cross-checkpush_fcm_project_idwith a message naming the key and not the value;example-chatpasses.TestParseServiceAccount: the Fake's JSON parses; a missing or wrongtype, an emptyclient_emailorprivate_key_id, atoken_uriofhttps://token.example.com/token, an EC key, a PKCS#1RSA PRIVATE KEYblock, two PEM blocks, broken JSON and an empty value fail; no error text contains a byte of the key, the e-mail or the URL.TestAssertion_HeaderAndClaims(13 5.2): the assertion that Fake-FCM receives has a header of exactlyalg = RS256,typ = JWT,kid= the key ID and a payload of exactlyiss= the e-mail,scope= the FCM scope,aud= the token URL,iat= the fake clock in Unix seconds andexp=iatplus one hour; the signature verifies withrsa.VerifyPKCS1v15; the form has exactlygrant_typeandassertion.TestTokenCache_OnePerHalfLifetime: 200 parallel sends (-race) give exactly one token request and every send request carries that token; the clock moved to one second before half the lifetime: still one; moved to half the lifetime: exactly two; 200 further parallel sends then: still two.TestTokenCache_KeyRotation: the secret file gets a new key and the Fake the new public key; aftersecrets.cache_ttland the next renewal the next assertion is signed with the new key and accepted, without a new adapter.TestTokenRenewOn401:RevokeTokensbefore a send: two send requests and two token requests, the second send with the new token, resultaccepted; revoked again after the renewal within the same send:config_errorand the adapter pauses; ten parallel sends that all get401for the same token create exactly one replacement and each retries once.TestPayloadAllowList(PLAN row "payload allow-list", 13 14 row "Payload allow-list", ADR 0031 Confirmation): for every kind ofNotificationKindtimesQuiettimes with and without message ID times aBadgeof 0 and 3, and for the sync form with and without cleared threads: the request body parsed as generic JSON has only the top-level keymessage,messageonlytoken,androidanddata,androidonlypriorityandttl,dataonly keys of 13 6.2 (v,nid,k,i,t,m,badge,quiet, and for the sync formk,badge,clearedbesidev,nid,i); everydatavalue is a JSON string;badgeis present in every message and equalsBadge;nid,i,tandmare canonical UUIDs,v,badgeandquietare decimal digits,kcomes fromNotificationKindor issync,clearedis UUIDs joined by commas,priorityishighornormal,ttlmatches^[0-9]+s$;securityandadminhave not; nocollapse_keyand nonotificationkey appears at any depth; areflectwalk of the payload structs finds no string field outside these enums, IDs, numbers and the token, no map and no interface.TestPayloadMatchesChapterExample: the test reads the JSON block of 13 "6.2 FCM" fromdocs/spec/13-push-notifications.md(walking up to the directory that holdsdocs/spec), builds the message for the same kind, IDs and badge, not quiet,push.ttl24h, replacesmessage.tokenof the example by the test token, and compares both as JSON values;testdata/payload_message.goldenholds the exact bytes and-updaterewrites it.TestPriorityAndTTL(13 5.2, 8): not quiet giveshigh, quiet givesnormalfor every kind, the sync form givesnormal;quietis1exactly when quiet;push.ttl24hgives86400s,90mgives5400s.TestDataSizeLimit: every combination ofTestPayloadAllowList, with the badge at the largest value its type allows, keepsdataunder the FCM limit; a hint whosedatais made larger through an unexported test hook givesprogram_errorand the Fake counts no request; a sync form with more cleared threads than fit is sent withoutclearedand withbadge.TestBadgeAndSyncForm(13 6.2, 8; M3-19 Design note 12): a hint withBadge7 givesdata.badge = "7"; aNotificationwithKindapp.KindSync,Badge2 andCleared[T1, T2] givesdataof exactlyv,nid,k = "sync",i,badge = "2"andcleared= T1 and T2 joined by a comma in that order, not,morquiet, andandroid.prioritynormal; the same withClearednil gives noclearedkey.TestHeadersAndPath: methodPOST, path/v1/projects/example-chat/messages:send,authorizationBearerplus the issued token,content-typeapplication/json,ProtoHTTP/1.1.TestPrechecksWithoutRequest(Design note 5 c): providerapns, an unknown kind, a zeronidandHasBadgefalse giveprogram_error; an empty token and a token with a space or a newline givedisablewithtoken_invalid; the Fake counts no send request and no token request; anenvironmentofsandboxand anapp_idofcom.example.otherare sent normally.TestAnswerTable(PLAN row "every response from 13 'FCM' triggers the expected consequence", 13 14 row "Fake FCM with every response from 7.2"): one subtest per row of Design note 6 with the Fake scripted for the token: the returnedSendResult(class,DisableReason,RetryAfter), whether a Go error is returned (Fake closed; egress refusal; token endpoint503) and the log level; the unlisted rows with400without FcmError,401 UNAUTHENTICATEDtwice,403 PERMISSION_DENIED,404 NOT_FOUND,409,502,204and301.TestRetryAfterHonoured(PLAN row "Retry-Afterrespected", 13 7.2):503 UNAVAILABLEwithRetry-After: 120givesRetryAfterof two minutes; with an HTTP date 90 seconds after the fake clock gives 90 seconds; with a past date,-5orsoongives zero; without the header zero;500 INTERNALwithRetry-After: 30gives 30 seconds;429 QUOTA_EXCEEDEDwithout the header gives one minute, withRetry-After: 10one minute, withRetry-After: 300five minutes;retryfor500and503,throttledfor429.TestConfigErrorPauses:403 PERMISSION_DENIEDgivesconfig_errorand one Error record; the next 50 sends giveconfig_errorwith no request at the Fake and no further log record; atpausedUntil(fake clock pluspush.retry_cap) exactly one probe with a new token request goes out;200ends the pause with one Info record and the following sends go out. The same for a token request refused with OAutherrorinvalid_grant(scripted withScriptToken), and for a secret file deleted before a token request (resolve error: the Error record namespush.fcm.service_account_ref).403 SENDER_ID_MISMATCHand401 THIRD_PARTY_AUTH_ERRORfor Sam's token do not pause: the next send for Robin's token goes out.TestEgressOnly(19 SEC-NET-1, SEC-NET-2): without the Fake's rule inegress.allowed_hostsa send returns an error witherrors.Is(err, egress.ErrBlocked)and the Fake counts no request; the adapter's host policy allowshttpson port443for exactlyfcm.googleapis.comandoauth2.googleapis.comand refusesgoogleapis.com,www.googleapis.com, port8443andhttp; an adapter built with zeroDeps.Endpointstargets exactlyhttps://fcm.googleapis.comandhttps://oauth2.googleapis.com/token, and a service account with anothertoken_urimakesNewfail.TestNoSecretsInLogsOrErrors(13 7.3, SEC-LOG-4, SEC-SEC-3): with a capturedsloghandler, run every row ofTestAnswerTable, a pause, a probe, a renewal after401, a key rotation and a send against a closed Fake, with the Fake'serror.messageset to a canary string; neither the captured records nor any returned error text contain the device token, a prefix of it longer than four characters, the assertion, the access token, a line of the PEM key, the client e-mail, the project ID,nid, the instance, thread or message ID or the canary; Error records carry exactlybothy.push.provideranderror.type.fcmtest:TestFakeVerifiesAssertion(an assertion of another key, a wrongkid,iss,audorscope, or a lifetime above one hour givesinvalid_grant; a valid one a token),TestFakeRequiresPostPathAndBearer(405,404,401for an unknown or revoked bearer),TestFakeScriptOrder(scripted answers in order, then200; token script likewise),TestFakeNotInBinary(go list -deps ./cmd/bothydoes not listfcmtest).
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, the job handler of notify.push_send of M3-19 called directly after moving the fake clock; registrations are fixture rows written through the pgtest connection, because registerPush comes with M3-22):
TestConfigLoad_PushTTLBounds: loading the configuration withpush.ttl673hor500msfails with a message naming the key and not the value;1s,24hand672hload.TestConfigSchemaBuildslists every key of Design note 2.TestFCMWiring: FCM not enabled: the provider set has nofcm, no secret is read (a missing service account file is no error), and a job for Sam's phone ends withno_provider; enabled: the set hasfcmin the rolesworkerandalland none inapi, built with zeroEndpoints, and no request leaves the process at start; enabled with a missing file, a broken JSON or an EC key: the start fails namingpush.fcm.service_account_ref.TestPushSendThroughFCM(PLAN row "every response from 13 'FCM' triggers the expected consequence,Retry-Afterrespected", end to end through the job): onenotify.push_sendfor Sam's phone per answer, the registration row and the jobs checked after the handler:200setslast_success_atandfailure_count = 0;404 UNREGISTEREDsetsdisabled_atanddisabled_reason = 'token_invalid';400 INVALID_ARGUMENTwith the token violation disables withtoken_invalid, without it leaves the row and inserts no retry;403 SENDER_ID_MISMATCHdisables withsender_mismatch;429 QUOTA_EXCEEDEDleavesfailure_countand inserts the retry job no earlier than one minute later;503 UNAVAILABLEwithRetry-After: 120raisesfailure_countby one and inserts the retry job no earlier than two minutes later;500 INTERNALraisesfailure_countand inserts the retry job no earlier than half ofpush.retry_baseand no later thanpush.retry_cap(the bounds ofdomain.RetryDelaywith its jitter in[0.5, 1]);401 THIRD_PARTY_AUTH_ERRORand403 PERMISSION_DENIEDleave the row, insert no retry and countbothy.push.sentwithrejected_other(SDK manual reader); a revoked access token then200gives one hint delivered with two send requests.TestHintReachesFakeFCM(PLAN 9 "Red team": content in push payloads): Alex writes a message whose text contains a canary string in a thread Sam follows; the consumerpushand the job deliver exactly one request to the Fake for Sam's phone withdata.k = message,data.t= the thread,data.m= the message,data.i= the instance ID anddata.nid= the job'sNotificationIDanddata.badge=BadgeCounter.Forof Sam at the send time; neither body nor headers contain the canary string, a display name, the space or channel name.TestBadgeSyncReachesFakeFCM(13 8, D-W-108 (6)): Sam has a counted unread thread T and reads it; the jobsnotify.badge_syncandnotify.push_sendof M3-19 deliver exactly one request to the Fake for Sam's phone withdata.k = sync,data.badge= the lowered number,data.cleared= T, not,morquiet, andandroid.prioritynormal.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/notify/... ./internal/kernel/jose/... ./internal/wire/... ./cmd/...make semgrep(the rulesbothy-go-egress-onlyandbothy-go-tls-verifystay green without a newnosemgrep),make lint,make archmake config-reference(no diff afterwards),make config-doc-checkmake docs,make linkcheckmake check
Acceptance criteria¶
-
internal/notify/adapters/fcmimplementsPushProviderof M3-19 forprovider = fcmand sends HTTP v1 data messages tofcm.googleapis.comwith an OAuth access token fromoauth2.googleapis.com, only through the egress client. - Every answer of 13 7.2, and every unlisted answer of Design note 6, gives the expected result class and, through the job of M3-19, the expected change of the registration, retry or drop;
Retry-Afteris honoured in both forms andQUOTA_EXCEEDEDwaits at least one minute (TestAnswerTable,TestRetryAfterHonoured,TestPushSendThroughFCM). - At most one token request is in flight, a token is reused for half its lifetime, a
401renews it once, and a credential error pauses the adapter until one probe afterpush.retry_cap(TestTokenCache_OnePerHalfLifetime,TestTokenRenewOn401,TestConfigErrorPauses). - The serialized message holds only the keys and string values of 13 6.2 for every kind and the sync form, has no
collapse_keyand nonotificationblock, matches the example of 13 6.2 and keepsdataunder the FCM limit; priority andandroid.ttlfollow 13 5.2 (TestPayloadAllowList,TestPayloadMatchesChapterExample,TestPriorityAndTTL,TestDataSizeLimit). - Every hint carries
Badgeof theNotificationasdata.badge, and aNotificationofKindKindSyncis sent as the sync formk = "sync"withbadge,clearedfromCleared(complete or absent) and prioritynormal(TestBadgeAndSyncForm,TestBadgeSyncReachesFakeFCM). - No device token, assertion, access token, key, ID or text appears in logs, spans or error texts; a message text never reaches Fake-FCM (
TestNoSecretsInLogsOrErrors,TestHintReachesFakeFCM). - Fake-FCM (
fcmtest) verifies the assertion and the bearer token, serves both endpoints with TLS on loopback, answers by script and is not part of the server binary. - 13 5.2, 6.2, 7.2, 11, 17 and 18, 02 13,
configuration.md, thenotifyREADME, the CHANGELOG and the rowsD-M3-21-1toD-M3-21-8are updated;make semgrep,make archandmake config-doc-checkare green. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- README of
notifyupdated in English (the adapterfcm: configuration, endpoints, token cache, pause, answer table, payload allow list, Fake-FCM), keeping the lineMay use the ports of these modules: ...and the section## Tablesin their form for M1-06. - Rulings of Design note 12 recorded in
docs/DECISIONS.md(one row each with reason); rulings 3 to 8 also indocs/spec/13-push-notifications.md. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (at least: behaviour against the real FCM service and the real token endpoint, which no test contacts; the shape of Google's error details, proven only against Fake-FCM; the source Q17, which was not retrieved in this ticket).
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.