feat(mobile): hard-block under-18 users on the store age signal - #4665
Conversation
Co-authored-by: Tom Brow <tomb@block.xyz> Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz> Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz> Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
🔐 Codex Security Review
|
|
@codex review |
|
@builderbot review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6769a0a80
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@buzz-security-review b6769a0 |
Signed-off-by: Tom Brow <tomb@block.xyz>
|
@codex review |
|
@builderbot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4fbb4fdc47
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com> Ai-assisted: true
|
@codex review |
|
@builderbot review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@buzz-security-review 3ab80dd |
Signed-off-by: Tom Brow <tomb@block.xyz>
|
@codex review |
|
@builderbot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a46c3f995
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com> Ai-assisted: true
|
@codex review |
|
@builderbot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4a7643fc4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com> Ai-assisted: true
|
@codex review |
|
@builderbot review |
jedwards27
left a comment
There was a problem hiding this comment.
Reviewed by :bot: Jude’s code review agent.
Verdict: REQUEST CHANGES
Reviewed: c045321a7fb3ca8939f28519ce7a555a6f597728..475f2731b05dfb368b4a77467fb756d0219d82b4 (exact live head 475f2731b05dfb368b4a77467fb756d0219d82b4)
Risk: high — this changes the native store-age bridge, launch authorization state machine, cross-process notification fencing, signing entitlement, and local/remote push cleanup on both mobile platforms.
Blocking findings
[P1] Keep snapshot dedupe coherent across ordinary and strict writers
mobile/lib/shared/community/community_provider.dart:239-240,323-349
_CommunitySnapshotSync tracks independent successful fingerprints for the ordinary and age-gate writers even though both replace the same native community snapshot and NSE signing-key set. The reachable sequence ordinary [] -> strict [C] -> remove C -> ordinary [] leaves the ordinary fingerprint at [], so the final clear is suppressed. A seam reproduction expected native writes [[], []] but observed only [[]].
That can leave removed community C and its signing key usable by the notification extension while remote revocation is pending, violating local account-removal/sign-out isolation.
Author action: use resource-coherent dedupe state, or invalidate the other writer’s cached fingerprint after every successful replacement. Add the exact interleaving regression and assert that the final ordinary clear reaches native snapshot/key storage.
Verification owner: author for code/test; reviewer for exact-head regression and community/push-suite rerun.
[P2] Do not make the notification-service expiry handoff wait for asynchronous cleanup
mobile/ios/NotificationService/NotificationService.swift:104-135
serviceExtensionTimeWillExpire() calls finish(), which consumes contentHandler immediately. When the restriction fence changed, finish() then waits for interactionDeletionDeadline.deleteAll before handing off restricted fallback content. That helper permits a five-second wait. If deletion never returns before the system terminates the already-expiring extension, a second expiry callback cannot recover the nil handler and iOS may deliver the original gateway payload rather than the privacy-safe restricted fallback.
Author action: preserve an exactly-once synchronous handoff of the restricted fallback when expiry/fence change is known; perform interaction deletion and notification purge best-effort afterward. Add a deadline-seam regression with a changed fence and deletion callback that never completes.
Verification owner: author for code/test; reviewer for deterministic Swift regression and full BuzzPushKit/mobile gates; Mobile release QA for a real NSE deadline exercise.
Product/UI and boundary review
No additional blocking product/UI defect was found. Tests and source bind inclusive upper age 17 to restriction, 18 and open-ended ranges to allowance, explicit no-signal to allowance, and malformed/missing/timeout/transient results to a gated retry. Authenticated content and its navigator remain unmounted until allowance; affirmative under-18 state is full-screen with no in-app bypass. iOS normalizes Apple’s exclusive bound (18 -> 17), while Android forwards Play’s inclusive ageUpper. The reviewed payload/logging/storage paths expose only status plus upper bound to Flutter and do not persist raw age evidence.
Validation at matching clean HEAD
just mobile-check— PASS (format unchanged; analysis clean).- Focused changed-boundary Flutter suites (
features/age_gate, community provider/storage, push bridge, widget root) — 95/95 PASS. swift test --package-path mobile/ios/BuzzPushKit— PASS: 68 XCTest + 33 Swift Testing cases.- Full
just mobile-testreached 2,118 cases but failed one unrelatedvoice_note_recording_test.darttemp-directory teardown (PathNotFoundException); an independent run also saw unrelated huddle timing flakiness. Changed age/community/push coverage stayed green, and CI’s exact-head Mobile and Mobile Swift lanes are green. - Android native unit execution was unavailable locally because this checkout has no Gradle wrapper; exact-head CI Mobile is green.
- Fresh exact-head checks reported no failed or pending check.
Authorize Security Reviewpassed, butRun Codex Security Review,Post Codex Security Review, and CI Security were skipped; the exact-range security-review execution evidence remains an external gate/status concern, not a third author defect.
Manual/native evidence and residual risk
The supplied screenshot matches the implemented restriction layout, but it is not independent device/provenance proof. No independent physical Android/iOS store-signal run, TalkBack/VoiceOver and system-back/focus exercise, real NSE deadline observation, or signed release/profile validation of the Android dependency and iOS entitlement was available. Mobile release/device QA owns those confidence gaps. They do not replace the two concrete code defects above.
Any head change invalidates this verdict until its delta and affected evidence are reviewed.
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent — REQUEST CHANGES
Reviewed exact head 475f2731b05dfb368b4a77467fb756d0219d82b4 against base c045321a7fb3ca8939f28519ce7a555a6f597728.
Two integration defects remain:
-
[P1] Ordinary/strict snapshot dedupe can leave a removed community’s notification-service snapshot and signing key active.
_CommunitySnapshotSynckeeps independent successful fingerprints for ordinary and age-gate writers even though both replace the same native snapshot/key resource (mobile/lib/shared/community/community_provider.dart:239-240,323-329,345-349). Sequence: ordinary[]→ strict[C]→ remove C → ordinary[]; the final clear is suppressed by the stale ordinary fingerprint. A production-seam regression reproducedExpected [[], []], Actual [[]].Author action: use one resource-coherent successful fingerprint, or invalidate the sibling path’s cached fingerprint after every successful replacement. Add the exact interleaving regression and prove final native snapshot/key deletion after account/community removal.
-
[P2] Notification-service expiry can miss the restricted fallback and let iOS deliver the original gateway payload. Expiry calls
finish(bestAttemptContent)(mobile/ios/NotificationService/NotificationService.swift:104-108);finishconsumescontentHandlerimmediately (:110-112) but, after a changed restriction fence, delays fallback handoff until asynchronous interaction deletion completes (:123-135). A second expiry cannot recover because the handler is nil. If deletion misses the extension deadline, iOS may use the unmodified original content.Author action: synchronously hand off the privacy-safe restricted fallback once expiry/fence change is known; keep interaction deletion/purge best-effort. Add a deadline-seam regression where deletion never completes.
Verification owner: author for both fixes and causal regressions; reviewer/CI for exact-new-head community/push and BuzzPushKit gates; release QA for real notification-extension deadline and signed store-profile/device evidence.
The age-gate UX itself is sound in reviewed paths: inclusive 17 restricts, 18/open-ended/no-signal allows, malformed/transient results remain gated for retry, authenticated content stays unmounted while unresolved, and the restriction surface has no in-app bypass. mobile-check, 95 focused tests, and BuzzPushKit 68 XCTest + 33 Swift Testing cases passed. The full Flutter suite’s unrelated voice-note temp cleanup failure is not a PR defect. Native store-signal, TalkBack/VoiceOver, entitlement/signing, and real deadline observation remain confidence gaps.
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
|
@codex review |
|
@builderbot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: be88a99ee7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Tom Brow <tomb@block.xyz>
blockers resolved
Signed-off-by: Tom Brow <tomb@block.xyz>
|
@codex review |
|
@builderbot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9f13315a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
|
@codex review |
|
@builderbot review |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent — APPROVE at exact head 6ba37f8ee0da00dc70a7061f5a832f81f550cef1.
The two prior blockers are fixed:
mobile/lib/shared/community/community_provider.dart:230-327now serializes suspension, restore, storage reload, and ordinary exports through one mutation tail and maintains one resource-coherent successful fingerprint. The regressions atmobile/test/shared/community/community_provider_test.dart:675-782cover the staleordinary [] → strict [C] → remove C → ordinary []clear and restore/removal ordering races.mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationHandoff.swift:3-33takes exactly-once handoff ownership under a lock, synchronously chooses and delivers the resolved or restricted fallback, then performs cleanup best-effort.BuzzNotificationHandoffTests.swift:7-49covers stalled cleanup plus late resolution.
Integrated review found no remaining author-actionable defect. Product behavior remains fail-closed while evidence is unresolved: 17 restricts, 18 allows, malformed/operational failures remain gated, authenticated navigation stays unmounted, and restriction/retry UI remains non-bypassable and accessible by construction.
Exact-head evidence on clean worktrees:
just mobile-check: PASS (format + Flutter analyzer).- Full Flutter suite: 2,123 PASS.
- Focused community/age/push suite: 74/74 PASS.
swift testfor BuzzPushKit: 68 XCTest + 38 Swift Testing PASS.- Causal mutations independently made each new regression fail, then were reverted; tree returned clean.
- Current GitHub Mobile, Mobile Swift, Security, and DCO result lanes are terminal green.
Residual confidence gaps, not author defects: no independent physical-device store-age journey, signed release-profile observation, accessibility hardware pass, or real notification-service deadline exercise. Mobile release/device QA owns those checks. Any new head invalidates this approval pending delta review.
jedwards27
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Reviewed: c045321a7fb3ca8939f28519ce7a555a6f597728..6ba37f8ee0da00dc70a7061f5a832f81f550cef1 (exact head 6ba37f8ee0da00dc70a7061f5a832f81f550cef1)
Risk: high — this changes a launch-time access gate, native store-age bridges, persisted push eligibility, and iOS notification-extension expiry behavior.
Behavior/contracts traced: Flutter launch gating and retry/restriction states; the 17/18 boundary and malformed/missing/error signals; Android/iOS native payload boundaries; community snapshot serialization, dedupe, restore, and removal persistence; push badge/notification cleanup; iOS NSE exactly-once handoff and deadline fallback; accessibility semantics and navigator mounting.
Findings: no blocking or non-blocking code defect found at this head. The two prior blockers are resolved:
mobile/lib/shared/community/community_provider.dart:230-327,421-477now uses one mode-sensitive successful-resource fingerprint and one mutation tail across suspension, restore/storage reload, exports, persistence, and native clear. The ordering regressions atmobile/test/shared/community/community_provider_test.dart:675-782cover strict→ordinary removal, restore-vs-mutation, and stale-read removal races.mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationHandoff.swift:3-33takes exactly-once delivery ownership before post-handoff best-effort cleanup;mobile/ios/NotificationService/NotificationService.swift:51-132uses it for normal and expiry completion. The stalled-cleanup/late-resolution regression is atmobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift:7-49.
Product/UI review also found the boundary correct (17 restricted, 18 allowed), authenticated content unmounted while checking/retryable/restricted, cleanup enforced while access is denied, retry recoverable, and the restriction UI bounded, safe-area aware, and semantically labeled.
Author action: none.
Verification owner: Mobile release/device QA for signed store-profile behavior, the real iOS notification-service deadline, Android/iOS store-age journeys, and physical-device VoiceOver/TalkBack/large-text observation.
Validation at matching clean HEAD:
- full
flutter test: 2,123 passed dart format --output=none --set-exit-if-changed . && flutter analyze: passed- focused Flutter community/age/push suites: 74/74 passed
swift testinmobile/ios/BuzzPushKit: 68 XCTest + 38 Swift Testing passedjust mobile-check: passed (555 files format-clean; analyzer clean)git diff --check: passed- causal mutation proof: collapsing strict/ordinary fingerprint modes failed the exact removal regression, then restoration returned the tree clean
- causal mutation proof: moving cleanup before fallback failed the expiry regression, then restoration returned the tree clean
- refreshed GitHub head remained exact, mergeable, and terminal green across Mobile, Mobile Swift, Security, and DCO result lanes. The Codex run/post jobs were skipped after authorization while the repository Security Domain passed.
Manual/native evidence: source and automated boundary evidence only; no signed store profile, physical-device native prompt, accessibility pass, or real NSE deadline was independently observed.
Residual risk: native store SDK/profile behavior and deadline timing remain release/device confidence gaps, not identified author defects.
— :bot: Jude’s code review agent
…eway-origin * origin/main: Fix desktop onboarding regressions (#7659) Deduplicate thread context by ACP session delivery (#7620) feat(mobile): hard-block under-18 users on the store age signal (#4665) Document incoming Buzz turn structure (#7624) Refresh desktop onboarding flow (#7528) Signed-off-by: Tom Brow <tomb@block.xyz>
…ush-cache-offload * commit '41c5ace93740261ee5a5d962c2a17e8d846c2c1b': perf(mobile): move profile sig checks off main thread (#7648) perf(mobile): avoid redundant message list sorting (#7647) perf(mobile): avoid reparsing unchanged Markdown (#7649) Fix desktop onboarding regressions (#7659) Deduplicate thread context by ACP session delivery (#7620) feat(mobile): hard-block under-18 users on the store age signal (#4665) Document incoming Buzz turn structure (#7624) Refresh desktop onboarding flow (#7528) Fix Apple Silicon iOS simulator builds (#7646) Signed-off-by: Tom Brow <tomb@block.xyz>
What changed?
Buzz mobile checks the app-store age signal at launch and hard-blocks the app behind a full-screen 18+ notice when the store says the signed-in person is under 18.
No age signal (e.g. declined permission, indeterminate value, unsupported OS versions) results in no block.
The age value is never persisted. It is read, used to compute one boolean, and discarded. Nothing is written to disk, and neither the age value nor the range is logged or sent to analytics.
Platform notes:
com.google.android.play:age-signals:0.0.4. This is the only way to read the Play signal. Its AAR declaresminSdkVersion 23, below the app's 24, so it forces no minSdk bump, and it self-declares its own consent activity, so no AndroidManifest change is needed.DeclaredAgeRangeautolinks from the import.Runner.entitlementsnow declarescom.apple.developer.declared-age-range, and the call remains behind#available(iOS 26.0, *).Why?
App stores now expose a declared-age range, and Buzz's Terms require users to be 18 or older. This wires the store signal to that rule with the smallest change that is defensible.
Absent signals, declined or undeterminable results, and unsupported OS versions allow the app through. A transient native request failure does not become evidence that access is allowed: the app remains gated until a retry produces a result. Only an affirmative under-18 signal produces the permanent restriction screen.
How is it tested?
Added tests:
age_signal_provider_testcovers the 17/18 boundary, open-ended ranges, no-signal, transient recovery, bounded retry exhaustion, malformed payloads, and request-once behaviorage_gate_app_testproves authenticated content remains unmounted while the signal is pending or retryable, and verifies the retry pathage_restriction_page_testasserts the block screen offers no bypass affordancecommunity_provider_testverifies age restriction clears push state, persists inactive leases, and retries a failed tombstoneAgeSignalPayloadTestpins the Android success payload to exactly{status, ageUpper}and verifies failures return the distinct retryable platform errorDeployment note
Enable the Declared Age Range capability and regenerate signing profiles carrying the entitlement. This relops/App Store Connect task is not performed by this PR.
🤖 This PR was authored with an agent.