diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index cd2a92ff3..3fa13a51d 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -15225,7 +15225,8 @@ "maxItems": 1000, "type": "array", "items": {"type": "string", "pattern": "^\\d{1,20}$"} - } + }, + "relay_consent_accepted": {"type": "boolean"} } }, "VoiceNoiseSuppressionConfigUpdateRequest": { @@ -15357,7 +15358,16 @@ "maxItems": 1000, "type": "array", "items": {"type": "string", "pattern": "^\\d{1,20}$"} - } + }, + "relay_consent_accepted": {"default": false, "type": "boolean"}, + "relay_consent_accepted_at": { + "default": null, + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"} }, "required": [ "enabled", @@ -15365,7 +15375,10 @@ "rollout_basis_points", "rollout_salt", "included_user_ids", - "excluded_user_ids" + "excluded_user_ids", + "relay_consent_accepted", + "relay_consent_accepted_at", + "relay_consent_accepted_by" ], "additionalProperties": false }, diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index f7da80b66..f62487814 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -552,6 +552,9 @@ pub struct PushServiceDeliveryConfigResponse { pub rollout_salt: String, pub included_user_ids: Vec, pub excluded_user_ids: Vec, + pub relay_consent_accepted: bool, + pub relay_consent_accepted_at: Option, + pub relay_consent_accepted_by: Option, } impl Default for PushServiceDeliveryConfigResponse { @@ -563,6 +566,9 @@ impl Default for PushServiceDeliveryConfigResponse { rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(), included_user_ids: Vec::new(), excluded_user_ids: Vec::new(), + relay_consent_accepted: false, + relay_consent_accepted_at: None, + relay_consent_accepted_by: None, } } } @@ -579,6 +585,8 @@ pub struct PushServiceDeliveryConfigUpdateRequest { pub included_user_ids: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_consent_accepted: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 1270dcab6..95b102ee7 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -680,6 +680,9 @@ fn build_push_service_delivery_update( .unwrap_or_default(), "Excluded user IDs", )?), + relay_consent_accepted: Some( + form.bool_value("push_service_delivery_relay_consent_accepted"), + ), }), ..Default::default() }) @@ -1731,6 +1734,30 @@ mod tests { } } + #[test] + fn build_push_service_delivery_update_reads_the_relay_consent_checkbox() { + let unchecked = MultiValueForm::parse(b"_csrf=token"); + assert_eq!( + build_push_service_delivery_update(&unchecked) + .expect("valid form") + .push_service_delivery + .expect("push service delivery update") + .relay_consent_accepted, + Some(false) + ); + + let checked = + MultiValueForm::parse(b"_csrf=token&push_service_delivery_relay_consent_accepted=true"); + assert_eq!( + build_push_service_delivery_update(&checked) + .expect("valid form") + .push_service_delivery + .expect("push service delivery update") + .relay_consent_accepted, + Some(true) + ); + } + #[test] fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() { let form = MultiValueForm::parse(b"_csrf=token"); diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index c182d414c..eec16e183 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -1191,6 +1191,14 @@ fn push_service_delivery_section( }; let included_user_ids = push_service_delivery.included_user_ids.join("\n"); let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n"); + let relay_consent_stamp = match ( + push_service_delivery.relay_consent_accepted_at.as_deref(), + push_service_delivery.relay_consent_accepted_by.as_deref(), + ) { + (Some(at), Some(by)) => Some(format!("Accepted {at} by user {by}")), + (Some(at), None) => Some(format!("Accepted {at}")), + _ => None, + }; section_card_with_description( "Push Service Delivery", "Routes push notification delivery for the selected accounts through the push service. \ @@ -1219,6 +1227,28 @@ fn push_service_delivery_section( effect at all." } + h3 class="text-sm font-semibold text-neutral-900" { "Managed relay consent" } + (checkbox( + "push_service_delivery_relay_consent_accepted", + "true", + "Accept the push relay supplemental privacy notice", + push_service_delivery.relay_consent_accepted, + true, + )) + p class="text-xs text-neutral-500" { + "Required only for the official mobile apps, whose notifications travel \ + through Fluxer's relay to Apple and Google. Until this is accepted those \ + notifications are dropped. Self-hosted UnifiedPush and ntfy endpoints \ + never reach the relay and are unaffected. " + a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer" + class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" { + "Read the notice" + } + } + @if let Some(stamp) = relay_consent_stamp { + p class="text-xs text-neutral-500" { (stamp) } + } + h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } (number_field( "push_service_delivery_rollout_basis_points", @@ -2086,6 +2116,29 @@ mod tests { assert!(!markup.contains("at the cap")); } + #[test] + fn push_service_delivery_section_shows_the_relay_consent_toggle() { + let accepted = PushServiceDeliveryConfigResponse { + relay_consent_accepted: true, + relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()), + relay_consent_accepted_by: Some("1130650140672000000".to_owned()), + ..PushServiceDeliveryConfigResponse::default() + }; + let markup = push_service_delivery_section("/admin", "csrf", &accepted).into_string(); + assert!(markup.contains("name=\"push_service_delivery_relay_consent_accepted\"")); + assert!(markup.contains("https://fluxer.com/push-relay")); + assert!(markup.contains("Accepted 2026-09-27T10:11:12.000Z by user 1130650140672000000")); + + let unaccepted = push_service_delivery_section( + "/admin", + "csrf", + &PushServiceDeliveryConfigResponse::default(), + ) + .into_string(); + assert!(unaccepted.contains("name=\"push_service_delivery_relay_consent_accepted\"")); + assert!(!unaccepted.contains("Accepted ")); + } + #[test] fn voice_noise_suppression_section_flags_a_list_at_its_cap() { let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse { diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index 7454f83dc..be3289052 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -415,7 +415,10 @@ fn deserialize_instance_config_response_with_unknown_keys() { "rollout_basis_points": 5000, "rollout_salt": "push-service-delivery-v1", "included_user_ids": ["1500000000000000002"], - "excluded_user_ids": [] + "excluded_user_ids": [], + "relay_consent_accepted": true, + "relay_consent_accepted_at": "2026-09-27T10:11:12.000Z", + "relay_consent_accepted_by": "1130650140672000000" }, "domain_migration": { "enabled": true, @@ -564,6 +567,7 @@ fn deserialize_instance_config_response_with_unknown_keys() { assert_eq!(resp.domain_migration.included_user_ids.len(), 1); assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100); assert!(resp.domain_migration.standalone_forwarding); + assert!(resp.push_service_delivery.relay_consent_accepted); assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300); assert!(resp.policy.single_community_guild_id.is_none()); assert_eq!(resp.policy.services.gif_enabled, Some(true)); @@ -596,6 +600,71 @@ fn deserialize_instance_config_response_with_unknown_keys() { ); } +#[test] +fn deserialize_push_service_delivery_relay_consent() { + let accepted: types::PushServiceDeliveryConfigResponse = serde_json::from_str( + r#"{ + "enabled": true, + "config_version": 3, + "rollout_basis_points": 5000, + "rollout_salt": "push-service-delivery-v1", + "included_user_ids": [], + "excluded_user_ids": [], + "relay_consent_accepted": true, + "relay_consent_accepted_at": "2026-09-27T10:11:12.000Z", + "relay_consent_accepted_by": "1130650140672000000" + }"#, + ) + .expect("an accepted relay consent must deserialize"); + + assert!(accepted.relay_consent_accepted); + assert_eq!( + accepted.relay_consent_accepted_at.as_deref(), + Some("2026-09-27T10:11:12.000Z") + ); + assert_eq!( + accepted.relay_consent_accepted_by.as_deref(), + Some("1130650140672000000") + ); + + let legacy: types::PushServiceDeliveryConfigResponse = serde_json::from_str( + r#"{ + "enabled": true, + "config_version": 3, + "rollout_basis_points": 5000, + "rollout_salt": "push-service-delivery-v1", + "included_user_ids": [], + "excluded_user_ids": [] + }"#, + ) + .expect("a response written before relay consent must still deserialize"); + + assert!(!legacy.relay_consent_accepted); + assert!(legacy.relay_consent_accepted_at.is_none()); + assert!(legacy.relay_consent_accepted_by.is_none()); +} + +#[test] +fn serialize_push_service_delivery_update_omits_an_unset_relay_consent() { + let without = types::PushServiceDeliveryConfigUpdateRequest { + enabled: Some(true), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&without).unwrap(), + serde_json::json!({"enabled": true}) + ); + + let with = types::PushServiceDeliveryConfigUpdateRequest { + relay_consent_accepted: Some(true), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&with).unwrap(), + serde_json::json!({"relay_consent_accepted": true}) + ); +} + #[test] fn deserialize_search_reports_response() { let json = r#"{ diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index 6430e3ae3..7fdf50df0 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -36,7 +36,11 @@ import { } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas'; -import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas'; +import { + type PushServiceDeliveryConfig, + PushServiceDeliveryConfigSchema, + type PushServiceDeliveryConfigUpdateRequest, +} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas'; import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas'; import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; @@ -194,6 +198,20 @@ async function grantSetupCompleterAdminACL(ctx: Context): Promise, + adminUserId: string, +): Partial { + const accepted = patch.relay_consent_accepted; + if (accepted === undefined || accepted === current.relay_consent_accepted) { + return {}; + } + return accepted + ? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId} + : {relay_consent_accepted_at: null, relay_consent_accepted_by: null}; +} + function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined { const sections = Object.entries(data) .filter(([, value]) => value != null) @@ -276,10 +294,12 @@ export function InstanceConfigAdminController(app: HonoApp) { if (data.push_service_delivery) { const patch = omitUndefinedFields(data.push_service_delivery); if (Object.keys(patch).length > 0) { + const adminUserId = ctx.get('adminUserId').toString(); const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) => PushServiceDeliveryConfigSchema.parse({ ...current, ...patch, + ...relayConsentStamp(current, patch, adminUserId), config_version: current.config_version + 1, }), ); diff --git a/fluxer_api/src/api/admin/tests/PushRelayConsentAdmin.test.ts b/fluxer_api/src/api/admin/tests/PushRelayConsentAdmin.test.ts new file mode 100644 index 000000000..9c3f942d8 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/PushRelayConsentAdmin.test.ts @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils'; +import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher'; +import type {ApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +describe('push relay supplemental notice consent', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + const createAdmin = async (): Promise => + await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.INSTANCE_CONFIG_VIEW, + AdminACLs.INSTANCE_CONFIG_UPDATE, + ]); + + const patchConfig = (admin: TestAccount, body: Record) => + createBuilder(harness, admin.token).patch('/admin/instance/config').body(body); + + const readConfig = (admin: TestAccount) => + createBuilder(harness, admin.token).get('/admin/instance/config'); + + it('reads back as unaccepted before an operator agrees', async () => { + const admin = await createAdmin(); + + const config = await readConfig(admin).execute(); + + expect(config.push_service_delivery).toMatchObject({ + relay_consent_accepted: false, + relay_consent_accepted_at: null, + relay_consent_accepted_by: null, + }); + }); + + it('stamps the acting admin and the acceptance time when consent is given', async () => { + const admin = await createAdmin(); + + const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute(); + + expect(updated.push_service_delivery.relay_consent_accepted).toBe(true); + expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId); + expect(Date.parse(updated.push_service_delivery.relay_consent_accepted_at ?? '')).not.toBeNaN(); + }); + + it('keeps the first acceptance stamp when a later patch changes only the rollout', async () => { + const admin = await createAdmin(); + const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute(); + + const rolledOut = await patchConfig(admin, { + push_service_delivery: {enabled: true, rollout_basis_points: 2500}, + }).execute(); + + expect(rolledOut.push_service_delivery).toMatchObject({ + enabled: true, + rollout_basis_points: 2500, + relay_consent_accepted: true, + relay_consent_accepted_at: accepted.push_service_delivery.relay_consent_accepted_at, + relay_consent_accepted_by: admin.userId, + }); + }); + + it('keeps the stamp untouched when consent is re-sent unchanged', async () => { + const admin = await createAdmin(); + const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute(); + + const resent = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute(); + + expect(resent.push_service_delivery.relay_consent_accepted_at).toBe( + accepted.push_service_delivery.relay_consent_accepted_at, + ); + }); + + it('clears the stamp when an operator withdraws consent', async () => { + const admin = await createAdmin(); + await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute(); + + const withdrawn = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: false}}).execute(); + + expect(withdrawn.push_service_delivery).toMatchObject({ + relay_consent_accepted: false, + relay_consent_accepted_at: null, + relay_consent_accepted_by: null, + }); + }); + + it('ignores an acceptance stamp supplied by the caller', async () => { + const admin = await createAdmin(); + + const updated = await patchConfig(admin, { + push_service_delivery: { + relay_consent_accepted: true, + relay_consent_accepted_at: '2020-01-01T00:00:00.000Z', + relay_consent_accepted_by: '1500000000000000009', + }, + }).execute(); + + expect(updated.push_service_delivery.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z'); + expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId); + }); + + it('publishes the consent to the delivery services', async () => { + const admin = await createAdmin(); + const publish = vi.mocked(PushServiceDeliveryConfigPublisher.prototype.publish); + + await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute(); + + expect(publish).toHaveBeenCalledWith(expect.objectContaining({relay_consent_accepted: true})); + }); +}); diff --git a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.module.css b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.module.css index 0bb5809d3..430028ab6 100644 --- a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.module.css +++ b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.module.css @@ -233,6 +233,15 @@ text-align: center; } +.noticeLink { + align-self: flex-start; + border-radius: 0.25rem; + color: var(--text-link); + font-size: 0.875rem; + line-height: 1.45; + text-decoration: underline; +} + .integrationFields { display: flex; flex-direction: column; diff --git a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx index b46612d65..55a52972a 100644 --- a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx +++ b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx @@ -30,6 +30,7 @@ import { MediaExpiryStep, type PremiumMode, PremiumStep, + PushRelayConsentStep, type RegistrationMode, RegistrationStep, type ServiceAvailability, @@ -459,6 +460,7 @@ export const SelfHostedSetupWizardGate = observer(() => { youtube: false, bluesky: false, }); + const [pushRelayConsentAccepted, setPushRelayConsentAccepted] = useState(false); const [premiumMode, setPremiumMode] = useState('mirror'); const [assets, setAssets] = useState>(() => BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})), @@ -549,6 +551,7 @@ export const SelfHostedSetupWizardGate = observer(() => { clearStepNavigationLock(); setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT}); setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT}); + setPushRelayConsentAccepted(false); setSmtpTesting(false); setSmtpTestResult(null); try { @@ -569,6 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => { setSingleCommunityEnabled(next.policy.single_community_enabled); setDirectMessagesDisabled(next.policy.direct_messages_disabled); setPremiumMode(next.policy.premium_mode); + setPushRelayConsentAccepted(next.push_service_delivery.relay_consent_accepted); setServiceSelection({ gif: next.policy.services_resolved.gif_enabled, youtube: next.policy.services_resolved.youtube_enabled, @@ -677,6 +681,7 @@ export const SelfHostedSetupWizardGate = observer(() => { if (step === 'branding') return !productNameError; if (step === 'community') return !singleCommunityNameError; if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft); + if (step === 'push_relay_consent') return true; const integrationKind = wizardStepToIntegrationKind(step); if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft); return true; @@ -766,6 +771,10 @@ export const SelfHostedSetupWizardGate = observer(() => { const nextConfig = await updateInstanceConfig({ integrations: buildIntegrationsPatch(integrationDraft), media: buildMediaPatch(mediaExpiryDraft), + push_service_delivery: + config.push_service_delivery.relay_consent_accepted === pushRelayConsentAccepted + ? undefined + : {relay_consent_accepted: pushRelayConsentAccepted}, registration: {mode: registrationMode}, app_public: { branding: { @@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => { singleCommunityEnabled, singleCommunityNameTrimmed, directMessagesDisabled, + pushRelayConsentAccepted, premiumMode, serviceAvailability, serviceSelection, @@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => { data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step" /> )} + {step === 'push_relay_consent' && ( + + )} {step === 'services' && ( { singleCommunityEnabled={singleCommunityEnabled} directMessagesDisabled={directMessagesDisabled} attachmentExpiryEnabled={mediaExpiryDraft.enabled} + pushRelayConsentAccepted={pushRelayConsentAccepted} premiumMode={premiumMode} submitError={submitError} data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step" diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts b/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts index 61a7d13cd..999afe111 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts +++ b/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts @@ -17,6 +17,7 @@ export type WizardStep = | 'integration_captcha' | 'integration_email' | 'integration_bluesky' + | 'push_relay_consent' | 'services' | 'premium' | 'finish'; @@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray = [ 'integration_captcha', 'integration_email', 'integration_bluesky', + 'push_relay_consent', 'services', 'premium', 'finish', diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx b/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx index 8519ac6fd..7142c1465 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx +++ b/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx @@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel import {Input} from '@app/features/ui/components/form/FormInput'; import {Switch} from '@app/features/ui/components/form/FormSwitch'; import {Spinner} from '@app/features/ui/components/Spinner'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup'; import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent'; import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab'; @@ -27,6 +28,8 @@ import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay'; + export type RegistrationMode = 'open' | 'approval' | 'closed'; export type PremiumMode = 'mirror' | 'everyone'; @@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({ comment: 'Label for attachment decay renewal window.', }); +const PUSH_RELAY_TITLE_DESCRIPTOR = msg({ + message: 'Mobile push notifications', + comment: 'Setup wizard push relay consent step title.', +}); +const PUSH_RELAY_BODY_DESCRIPTOR = msg({ + message: + "The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement.", + comment: 'Setup wizard push relay consent step body.', +}); +const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({ + message: 'Accept the push relay supplemental privacy notice', + comment: 'Label for the push relay consent switch during setup.', +}); +const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({ + message: + 'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.', + comment: 'Description for the push relay consent switch during setup.', +}); +const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({ + message: 'Read the supplemental privacy notice', + comment: 'Link to the push relay supplemental privacy notice shown during setup.', +}); + const SERVICES_TITLE_DESCRIPTOR = msg({ message: 'Optional services', comment: 'Setup wizard optional services step title.', @@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({ message: 'Attachment expiration', comment: 'Summary row label for the attachment expiry choice in the setup wizard.', }); +const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({ + message: 'Push relay notice', + comment: 'Summary row label for the push relay consent on the setup wizard finish step.', +}); const SUMMARY_PREMIUM_DESCRIPTOR = msg({ message: 'Premium model', comment: 'Summary row label for the premium model in the setup wizard.', }); +const SUMMARY_ACCEPTED_DESCRIPTOR = msg({ + message: 'Accepted', + comment: 'Summary value when the operator accepted the push relay notice.', +}); +const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({ + message: 'Not accepted', + comment: 'Summary value when the operator left the push relay notice unaccepted.', +}); const SUMMARY_ON_DESCRIPTOR = msg({ message: 'Enabled', comment: 'Summary value when a setup option is enabled.', @@ -1531,6 +1569,40 @@ export const IntegrationStep = observer( }, ); +export const PushRelayConsentStep = observer( + ({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => { + const {i18n} = useLingui(); + return ( +
+ + + + + {i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)} + + +
+ ); + }, +); + export const ServicesStep = observer( ({ available, @@ -1650,6 +1722,7 @@ export const FinishStep = observer( singleCommunityEnabled, directMessagesDisabled, attachmentExpiryEnabled, + pushRelayConsentAccepted, premiumMode, submitError, }: { @@ -1658,6 +1731,7 @@ export const FinishStep = observer( singleCommunityEnabled: boolean; directMessagesDisabled: boolean; attachmentExpiryEnabled: boolean; + pushRelayConsentAccepted: boolean; premiumMode: PremiumMode; submitError: string | null; }) => { @@ -1705,10 +1779,17 @@ export const FinishStep = observer( value={attachmentExpiryEnabled ? onLabel : offLabel} data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5" /> + {submitError && ( diff --git a/fluxer_docs/scripts/VerifyDocsStyle.ts b/fluxer_docs/scripts/VerifyDocsStyle.ts index c0e41dfd9..9acb28239 100644 --- a/fluxer_docs/scripts/VerifyDocsStyle.ts +++ b/fluxer_docs/scripts/VerifyDocsStyle.ts @@ -271,7 +271,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map>). -spec load() -> config(). load() -> @@ -87,6 +88,12 @@ env_gateway_base_config() -> <<"push_endpoint_guard_enabled">> => env_bool( "FLUXER_GATEWAY_PUSH_ENDPOINT_GUARD_ENABLED", true ), + <<"push_managed_relay_hosts">> => env_binary( + "FLUXER_GATEWAY_PUSH_MANAGED_RELAY_HOSTS", ?DEFAULT_MANAGED_RELAY_HOSTS + ), + <<"push_relay_consent_accepted">> => env_bool( + "FLUXER_GATEWAY_PUSH_RELAY_CONSENT_ACCEPTED", false + ), <<"push_outbox_request_timeout_ms">> => env_int( "FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS", 100000 ), @@ -268,6 +275,12 @@ build_push_config(Service, Public) -> push_endpoint_guard_enabled => get_bool( Service, <<"push_endpoint_guard_enabled">>, true ), + push_managed_relay_hosts => parse_host_list( + get_binary(Service, <<"push_managed_relay_hosts">>, ?DEFAULT_MANAGED_RELAY_HOSTS) + ), + push_relay_consent_accepted => get_bool( + Service, <<"push_relay_consent_accepted">>, false + ), push_outbox_max_queue => get_int(Service, <<"push_outbox_max_queue">>, 10000), push_outbox_max_inflight => get_int(Service, <<"push_outbox_max_inflight">>, 64), push_outbox_request_timeout_ms => get_int( @@ -590,6 +603,24 @@ to_binary(Str, _) when is_list(Str) -> list_to_binary(config_char_list(Str)); to_binary(Atom, _) when is_atom(Atom) -> list_to_binary(atom_to_list(Atom)); to_binary(_, Default) -> Default. +-spec parse_host_list(binary()) -> [binary()]. +parse_host_list(Bin) -> + parse_host_list(string:lexemes(binary_to_list(Bin), ", \t"), []). + +-spec parse_host_list([string()], [binary()]) -> [binary()]. +parse_host_list([], Acc) -> + lists:reverse(Acc); +parse_host_list([Host | Rest], Acc) -> + parse_host_list(Rest, [list_to_binary(lower_string(Host)) | Acc]). + +-spec lower_string(string()) -> string(). +lower_string(Value) -> + [lower_char(Char) || Char <- Value]. + +-spec lower_char(char()) -> char(). +lower_char(Char) when Char >= $A, Char =< $Z -> Char + 32; +lower_char(Char) -> Char. + -spec parse_node_list(binary() | undefined) -> [node()]. parse_node_list(undefined) -> []; diff --git a/fluxer_gateway/src/push/push_delivery_config.erl b/fluxer_gateway/src/push/push_delivery_config.erl index fdc03b2fc..39087776b 100644 --- a/fluxer_gateway/src/push/push_delivery_config.erl +++ b/fluxer_gateway/src/push/push_delivery_config.erl @@ -37,7 +37,8 @@ rollout_basis_points := non_neg_integer(), rollout_salt := binary(), included := user_id_set(), - excluded := user_id_set() + excluded := user_id_set(), + relay_consent_accepted := boolean() }. -type state() :: #{ nats_subscription := term(), @@ -170,7 +171,8 @@ default_config() -> rollout_basis_points => 0, rollout_salt => ?DEFAULT_SALT, included => #{}, - excluded => #{} + excluded => #{}, + relay_consent_accepted => false }. -spec fetch_config_from_api() -> store_result(). @@ -254,7 +256,8 @@ config_fields() -> {rollout_basis_points, <<"rollout_basis_points">>, fun validate_basis_points/1}, {rollout_salt, <<"rollout_salt">>, fun validate_salt/1}, {included, <<"included_user_ids">>, fun validate_user_ids/1}, - {excluded, <<"excluded_user_ids">>, fun validate_user_ids/1} + {excluded, <<"excluded_user_ids">>, fun validate_user_ids/1}, + {relay_consent_accepted, <<"relay_consent_accepted">>, fun validate_enabled/1} ]. -spec validate_field( @@ -419,7 +422,7 @@ result_index(rejected) -> 4. log_config_transitions(Previous, Current) -> lists:foreach( fun(Key) -> log_key_transition(Key, Previous, Current) end, - [enabled, rollout_basis_points, config_version] + [enabled, rollout_basis_points, config_version, relay_consent_accepted] ). -spec log_key_transition(atom(), config(), config()) -> ok. @@ -435,3 +438,22 @@ log_key_transition(Key, Previous, Current) -> [Key, PreviousValue, CurrentValue] ) end. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +an_accepted_relay_notice_is_read_off_the_wire_config_test() -> + {ok, Config} = validate_config(#{<<"relay_consent_accepted">> => true}), + ?assertEqual(true, maps:get(relay_consent_accepted, Config)). + +a_wire_config_without_a_relay_notice_has_not_been_accepted_test() -> + {ok, Config} = validate_config(#{<<"enabled">> => true}), + ?assertEqual(false, maps:get(relay_consent_accepted, Config)). + +a_relay_notice_that_is_not_a_boolean_is_refused_test() -> + ?assertMatch( + {error, {invalid_field, <<"relay_consent_accepted">>, _}}, + validate_config(#{<<"relay_consent_accepted">> => <<"yes">>}) + ). + +-endif. diff --git a/fluxer_gateway/src/push/push_sender_delivery.erl b/fluxer_gateway/src/push/push_sender_delivery.erl index 3d6a637af..f2eecb918 100644 --- a/fluxer_gateway/src/push/push_sender_delivery.erl +++ b/fluxer_gateway/src/push/push_sender_delivery.erl @@ -21,6 +21,12 @@ -define(OVERLOAD_MAX_DELAY_MS, 4000). -define(VAPID_TOKEN_TTL_SECONDS, 43200). -define(VAPID_TOKEN_SKEW_SECONDS, 60). +-define(DEFAULT_MANAGED_RELAY_HOSTS, ["push.fluxer.com"]). +-define(MANAGED_RELAY_PATH_PREFIXES, [ + "/relay/v1/apns/", + "/relay/v1/apns-voip/", + "/relay/v1/fcm/" +]). -type push_response() :: {ok, integer(), term(), binary()} | {error, term()}. @@ -40,12 +46,94 @@ send_webpush_notification(UserId, Subscription, Payload) -> send_to_allowed_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) -> case push_endpoint_guard:check(Endpoint) of ok -> - send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload); + send_to_consented_endpoint( + UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload + ); {error, Reason} -> log_endpoint_rejected(UserId, SubscriptionId, Reason), false end. +-spec send_to_consented_endpoint(integer(), binary(), binary(), binary(), binary(), map()) -> + false | {true, map()}. +send_to_consented_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) -> + case relay_consent_missing(Endpoint) of + false -> + send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload); + true -> + log_endpoint_rejected(UserId, SubscriptionId, relay_consent_required), + false + end. + +-spec relay_consent_missing(binary()) -> boolean(). +relay_consent_missing(Endpoint) -> + not relay_consent_accepted() andalso is_managed_relay_endpoint(Endpoint). + +-spec relay_consent_accepted() -> boolean(). +relay_consent_accepted() -> + env_relay_consent_accepted() orelse instance_relay_consent_accepted(). + +-spec env_relay_consent_accepted() -> boolean(). +env_relay_consent_accepted() -> + case fluxer_gateway_env:get(push_relay_consent_accepted) of + Accepted when is_boolean(Accepted) -> Accepted; + _ -> false + end. + +-spec instance_relay_consent_accepted() -> boolean(). +instance_relay_consent_accepted() -> + case maps:get(relay_consent_accepted, push_delivery_config:config(), false) of + Accepted when is_boolean(Accepted) -> Accepted; + _ -> false + end. + +-spec is_managed_relay_endpoint(binary()) -> boolean(). +is_managed_relay_endpoint(Endpoint) -> + case safe_parse_endpoint(Endpoint) of + {ok, Parsed} -> + Scheme = lower_string(to_string(maps:get(scheme, Parsed, ""))), + Host = lower_string(to_string(maps:get(host, Parsed, ""))), + Path = to_string(maps:get(path, Parsed, "")), + Scheme =:= "https" andalso + lists:member(Host, managed_relay_hosts()) andalso + is_managed_relay_path(Path); + error -> + false + end. + +-spec safe_parse_endpoint(binary()) -> {ok, map()} | error. +safe_parse_endpoint(Endpoint) -> + try uri_string:parse(binary_to_list(Endpoint)) of + Parsed when is_map(Parsed) -> {ok, Parsed}; + _ -> error + catch + _:_ -> error + end. + +-spec is_managed_relay_path(string()) -> boolean(). +is_managed_relay_path(Path) -> + lists:any(fun(Prefix) -> lists:prefix(Prefix, Path) end, ?MANAGED_RELAY_PATH_PREFIXES). + +-spec managed_relay_hosts() -> [string()]. +managed_relay_hosts() -> + case fluxer_gateway_env:get(push_managed_relay_hosts) of + Hosts when is_list(Hosts) -> [lower_string(to_string(Host)) || Host <- Hosts]; + _ -> ?DEFAULT_MANAGED_RELAY_HOSTS + end. + +-spec to_string(term()) -> string(). +to_string(Value) when is_list(Value) -> Value; +to_string(Value) when is_binary(Value) -> binary_to_list(Value); +to_string(_Value) -> "". + +-spec lower_string(string()) -> string(). +lower_string(Value) -> + [lower_char(Char) || Char <- Value]. + +-spec lower_char(char()) -> char(). +lower_char(Char) when Char >= $A, Char =< $Z -> Char + 32; +lower_char(Char) -> Char. + -spec log_endpoint_rejected(integer(), binary(), term()) -> ok. log_endpoint_rejected(UserId, SubscriptionId, Reason) -> logger:debug( @@ -775,10 +863,100 @@ capture_web_push(Payload) -> vapid_env_meck(vapid_email) -> <<"ops@example.com">>; vapid_env_meck(vapid_public_key) -> <<"public-key">>; vapid_env_meck(vapid_private_key) -> <<"private-key">>; +vapid_env_meck(push_relay_consent_accepted) -> true; +vapid_env_meck(push_managed_relay_hosts) -> [<<"push.fluxer.com">>]; vapid_env_meck(Key) -> meck:passthrough([Key]). capture_request_meck(push, post, _Endpoint, Headers, Body, _Opts) -> self() ! {captured_push, Headers, Body}, {ok, 201, [], <<>>}. +a_managed_relay_endpoint_is_refused_without_operator_consent_test() -> + ?assertEqual(no_push_request, attempt_push(false, managed_relay_endpoint(<<"apns">>))). + +every_managed_relay_leg_is_refused_without_operator_consent_test() -> + lists:foreach( + fun(Leg) -> + ?assertEqual(no_push_request, attempt_push(false, managed_relay_endpoint(Leg))) + end, + [<<"apns">>, <<"apns-voip">>, <<"fcm">>] + ). + +a_managed_relay_endpoint_is_delivered_once_the_operator_consents_test() -> + Endpoint = managed_relay_endpoint(<<"apns">>), + ?assertEqual(Endpoint, attempt_push(true, Endpoint)). + +a_notice_accepted_in_the_instance_config_lets_the_managed_relay_send_through_test() -> + Endpoint = managed_relay_endpoint(<<"apns">>), + ?assertEqual(Endpoint, attempt_push(false, true, Endpoint)). + +a_unified_push_endpoint_is_delivered_whatever_the_operator_accepted_test() -> + Endpoint = <<"https://ntfy.sh/upZzH87cT9jJCc?up=1">>, + ?assertEqual(Endpoint, attempt_push(false, Endpoint)), + ?assertEqual(Endpoint, attempt_push(true, Endpoint)). + +a_relay_we_do_not_operate_is_delivered_without_consent_test() -> + Endpoint = <<"https://push.example.org/relay/v1/apns/stable/production/token">>, + ?assertEqual(Endpoint, attempt_push(false, Endpoint)). + +managed_relay_endpoint(Leg) -> + <<"https://push.fluxer.com/relay/v1/", Leg/binary, "/stable/production/", + (binary:copy(<<"a">>, 64))/binary>>. + +attempt_push(EnvConsent, Endpoint) -> + attempt_push(EnvConsent, false, Endpoint). + +attempt_push(EnvConsent, InstanceConsent, Endpoint) -> + {PeerPub, _PeerPriv} = crypto:generate_key(ecdh, prime256v1), + Subscription = #{ + <<"endpoint">> => Endpoint, + <<"p256dh_key">> => push_utils:base64url_encode(PeerPub), + <<"auth_key">> => push_utils:base64url_encode(crypto:strong_rand_bytes(16)), + <<"subscription_id">> => <<"sub-1">> + }, + ok = push_ets_cache:init(), + ok = meck:new(fluxer_gateway_env, [passthrough, no_link]), + ok = meck:new(push_utils, [passthrough, no_link]), + ok = meck:new(gateway_http_client, [passthrough, no_link]), + ok = meck:new(push_endpoint_guard, [passthrough, no_link]), + ok = meck:new(push_delivery_config, [passthrough, no_link]), + try + ok = meck:expect(push_endpoint_guard, check, fun(_Endpoint) -> ok end), + ok = meck:expect(push_delivery_config, config, fun() -> + #{relay_consent_accepted => InstanceConsent} + end), + ok = meck:expect(fluxer_gateway_env, get, consent_env_meck(EnvConsent)), + ok = meck:expect(push_utils, generate_vapid_token, fun(_Claims, _Public, _Private) -> + <<"vapid-token">> + end), + ok = meck:expect(gateway_http_client, request, fun requested_endpoint_meck/6), + ?assertEqual( + false, send_webpush_notification(42, Subscription, alert_payload(<<"Hello">>)) + ), + receive + {push_requested, Requested} -> Requested + after 100 -> + no_push_request + end + after + meck:unload(push_delivery_config), + meck:unload(push_endpoint_guard), + meck:unload(gateway_http_client), + meck:unload(push_utils), + meck:unload(fluxer_gateway_env) + end. + +consent_env_meck(EnvConsent) -> + fun + (push_relay_consent_accepted) -> EnvConsent; + (push_managed_relay_hosts) -> [<<"push.fluxer.com">>]; + (Key) -> vapid_env_meck(Key) + end. + +-spec requested_endpoint_meck(atom(), atom(), binary(), list(), binary(), term()) -> + {ok, non_neg_integer(), list(), binary()}. +requested_endpoint_meck(push, post, Endpoint, _Headers, _Body, _Opts) -> + self() ! {push_requested, Endpoint}, + {ok, 201, [], <<>>}. + -endif. diff --git a/fluxer_push/src/config.rs b/fluxer_push/src/config.rs index 34e52e3ed..04329b911 100644 --- a/fluxer_push/src/config.rs +++ b/fluxer_push/src/config.rs @@ -29,6 +29,7 @@ const DEFAULT_FCM_BASE_URL: &str = "https://fcm.googleapis.com"; const DEFAULT_CLIENT_IP_HEADER_NAME: &str = "x-forwarded-for"; const APNS_PRODUCTION_BASE_URL: &str = "https://api.push.apple.com"; const APNS_DEVELOPMENT_BASE_URL: &str = "https://api.sandbox.push.apple.com"; +const DEFAULT_MANAGED_RELAY_HOST: &str = "push.fluxer.com"; const VOIP_TOPIC_SUFFIX: &str = ".voip"; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)] @@ -184,6 +185,8 @@ pub struct DeliveryConfig { pub apns: Option, pub fcm: Option, pub own_relay_hosts: Vec, + pub managed_relay_hosts: Vec, + pub relay_consent_accepted: bool, } #[derive(Clone, Copy, Debug)] @@ -264,6 +267,12 @@ impl DeliveryConfig { apns: apns_config(&env)?, fcm: fcm_config(&env)?, own_relay_hosts: own_relay_hosts(&env), + managed_relay_hosts: managed_relay_hosts(&env), + relay_consent_accepted: parse_bool( + "FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED", + env.get("FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED"), + )? + .unwrap_or(false), }) } } @@ -277,6 +286,16 @@ fn own_relay_hosts(env: &Env) -> Vec { .collect() } +fn managed_relay_hosts(env: &Env) -> Vec { + let Some(raw) = env.get("FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS") else { + return vec![DEFAULT_MANAGED_RELAY_HOST.to_owned()]; + }; + raw.split(',') + .map(|host| host.trim().to_ascii_lowercase()) + .filter(|host| !host.is_empty()) + .collect() +} + impl RelayConfig { pub fn load_from_iter(vars: I) -> anyhow::Result where diff --git a/fluxer_push/src/providers/mod.rs b/fluxer_push/src/providers/mod.rs index 9f4b27cfb..0a38be84d 100644 --- a/fluxer_push/src/providers/mod.rs +++ b/fluxer_push/src/providers/mod.rs @@ -91,16 +91,20 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen return SendOutcome::permanent("unsupported_platform"); }; let started_ms = now_ms(); - let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts); - let outcome = match (route, direct) { - (Route::WebPush, Some(hop)) => { - let hopped = hop.as_subscription(sub); - state.metrics.record_own_relay_shortcut(); - apns::send(state, &hopped, envelope).await + let outcome = if relay_consent_missing(state, &sub.endpoint) { + SendOutcome::permanent("relay_consent_required") + } else { + let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts); + match (route, direct) { + (Route::WebPush, Some(hop)) => { + let hopped = hop.as_subscription(sub); + state.metrics.record_own_relay_shortcut(); + apns::send(state, &hopped, envelope).await + } + (Route::WebPush, None) => web_push::send(state, sub, envelope).await, + (Route::LegacyApns, _) => apns::send(state, sub, envelope).await, + (Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await, } - (Route::WebPush, None) => web_push::send(state, sub, envelope).await, - (Route::LegacyApns, _) => apns::send(state, sub, envelope).await, - (Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await, }; state.metrics.record_send( provider_of(platform), @@ -113,6 +117,19 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen outcome } +fn relay_consent_missing(state: &AppState, endpoint: &str) -> bool { + !relay_consent_accepted(state) + && own_relay::is_managed(endpoint, &state.cfg.managed_relay_hosts) +} + +fn relay_consent_accepted(state: &AppState) -> bool { + state.cfg.relay_consent_accepted + || state + .rollout + .snapshot() + .is_some_and(|held| held.relay_consent_accepted) +} + fn in_process_hop(endpoint: &str, hosts: &[String]) -> Option { own_relay::parse(endpoint, hosts).filter(|hop| matches!(hop.leg, own_relay::Leg::Apns)) } @@ -172,3 +189,129 @@ mod hop_tests { assert!(in_process_hop(&voip, &ours()).is_none()); } } + +#[cfg(test)] +mod consent_tests { + use super::*; + use crate::config::DeliveryConfig; + use crate::server::AppState; + + const TOKEN: &str = "3dbc5a5ef1a1c1666afc26f466e1b3ebaaf4c66d92dddeb0fd1b69c49641d4cd"; + const NTFY_ENDPOINT: &str = "https://ntfy.sh/upZzH87cT9jJCc?up=1"; + + fn managed_endpoint() -> String { + format!("https://push.fluxer.com/relay/v1/apns/stable/production/{TOKEN}") + } + + fn state(relay_consent_accepted: bool) -> AppState { + let cfg = DeliveryConfig::load_from_iter([ + ("FLUXER_INTERNAL_API_ENDPOINT", "http://127.0.0.1:8080"), + ("FLUXER_GATEWAY_RPC_AUTH_TOKEN", "rpc-token"), + ("FLUXER_VAPID_EMAIL", "ops@fluxer.com"), + ("FLUXER_VAPID_PUBLIC_KEY", "public-key"), + ("FLUXER_VAPID_PRIVATE_KEY", "private-key"), + ( + "FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED", + if relay_consent_accepted { + "true" + } else { + "false" + }, + ), + ]) + .expect("the delivery config loads"); + AppState::try_new(cfg).expect("the delivery state builds") + } + + fn subscription(endpoint: &str) -> Subscription { + Subscription { + subscription_id: "sub-1".to_owned(), + endpoint: endpoint.to_owned(), + p256dh_key: None, + auth_key: None, + platform: Some("web_push".to_owned()), + app_id: None, + provider_environment: None, + } + } + + async fn outcome_of(relay_consent_accepted: bool, endpoint: &str) -> SendOutcome { + let state = state(relay_consent_accepted); + send(&state, &subscription(endpoint), &Value::Null).await + } + + #[tokio::test] + async fn a_managed_relay_send_waits_for_the_operator_to_accept_the_notice() { + assert_eq!( + outcome_of(false, &managed_endpoint()).await, + SendOutcome::permanent("relay_consent_required") + ); + } + + #[tokio::test] + async fn a_refused_managed_relay_send_keeps_the_registration() { + assert!(!outcome_of(false, &managed_endpoint()).await.deletes_token()); + } + + #[tokio::test] + async fn an_accepted_notice_lets_the_managed_relay_send_through() { + assert_eq!( + outcome_of(true, &managed_endpoint()).await, + SendOutcome::permanent("missing_keys") + ); + } + + #[tokio::test] + async fn a_unified_push_endpoint_is_sent_whatever_the_operator_accepted() { + assert_eq!( + outcome_of(false, NTFY_ENDPOINT).await, + SendOutcome::permanent("missing_keys") + ); + assert_eq!( + outcome_of(true, NTFY_ENDPOINT).await, + SendOutcome::permanent("missing_keys") + ); + } + + #[tokio::test] + async fn a_notice_accepted_in_the_instance_config_lets_the_send_through() { + let state = state(false); + state.rollout.update(&serde_json::json!({ + "enabled": true, + "config_version": 1, + "relay_consent_accepted": true, + })); + assert_eq!( + send(&state, &subscription(&managed_endpoint()), &Value::Null).await, + SendOutcome::permanent("missing_keys") + ); + } + + #[tokio::test] + async fn an_instance_config_that_has_not_accepted_still_refuses_the_send() { + let state = state(false); + state.rollout.update(&serde_json::json!({ + "enabled": true, + "config_version": 1, + "relay_consent_accepted": false, + })); + assert_eq!( + send(&state, &subscription(&managed_endpoint()), &Value::Null).await, + SendOutcome::permanent("relay_consent_required") + ); + } + + #[tokio::test] + async fn a_refused_managed_relay_send_is_still_counted() { + let state = state(false); + let outcome = send(&state, &subscription(&managed_endpoint()), &Value::Null).await; + assert_eq!(outcome, SendOutcome::permanent("relay_consent_required")); + let rendered = state.metrics.render(); + for series in [ + "fluxer_push_sends_total{provider=\"web_push\",result=\"permanent\"} 1", + "fluxer_push_delivery_routes_total{route=\"web_push\",result=\"permanent\"} 1", + ] { + assert!(rendered.contains(series), "{series} must be recorded"); + } + } +} diff --git a/fluxer_push/src/providers/own_relay.rs b/fluxer_push/src/providers/own_relay.rs index 427cef732..6a5e7a61a 100644 --- a/fluxer_push/src/providers/own_relay.rs +++ b/fluxer_push/src/providers/own_relay.rs @@ -97,6 +97,10 @@ pub fn parse(endpoint: &str, hosts: &[String]) -> Option { }) } +pub fn is_managed(endpoint: &str, hosts: &[String]) -> bool { + parse(endpoint, hosts).is_some() +} + fn decode(segment: &str) -> Option { percent_encoding::percent_decode_str(segment) .decode_utf8() @@ -185,6 +189,26 @@ mod tests { assert!(parse("https://ntfy.sh/upZzH87cT9jJCc?up=1", &ours()).is_none()); } + #[test] + fn every_managed_relay_leg_is_recognised_and_nothing_else_is() { + for path in [ + format!("apns/canary/production/{TOKEN}"), + format!("apns-voip/canary/production/{TOKEN}"), + "fcm/canary/dYC_x9gXTjyyrG8_Aw3nUM%3AAPA91bExample".to_owned(), + ] { + let endpoint = format!("https://push.fluxer.com/relay/v1/{path}"); + assert!( + is_managed(&endpoint, &ours()), + "{endpoint} must be a managed relay endpoint" + ); + } + assert!(!is_managed("https://ntfy.sh/upZzH87cT9jJCc?up=1", &ours())); + assert!(!is_managed( + "https://updates.push.services.mozilla.com/wpush/v2/gAAAAA", + &ours() + )); + } + #[test] fn a_malformed_relay_path_is_refused() { for endpoint in [ diff --git a/fluxer_push/src/rollout.rs b/fluxer_push/src/rollout.rs index 020d4de77..99de02fdc 100644 --- a/fluxer_push/src/rollout.rs +++ b/fluxer_push/src/rollout.rs @@ -54,6 +54,7 @@ pub struct RolloutSnapshot { pub enabled: bool, pub config_version: u64, pub rollout_basis_points: u32, + pub relay_consent_accepted: bool, } impl RolloutConfig for RolloutSnapshot { @@ -77,6 +78,7 @@ impl RolloutConfig for RolloutSnapshot { enabled: parse_enabled(config)?, config_version: parse_config_version(config)?, rollout_basis_points, + relay_consent_accepted: parse_flag(config, "relay_consent_accepted")?, }) } @@ -98,9 +100,13 @@ impl RolloutConfig for RolloutSnapshot { } pub fn parse_enabled(config: &Value) -> Option { - match config.get("enabled") { + parse_flag(config, "enabled") +} + +fn parse_flag(config: &Value, key: &str) -> Option { + match config.get(key) { None | Some(Value::Null) => Some(false), - Some(Value::Bool(enabled)) => Some(*enabled), + Some(Value::Bool(flag)) => Some(*flag), Some(_) => None, } } @@ -160,7 +166,7 @@ impl RolloutStore { self.update(config) } - fn update(&self, config: &Value) -> RolloutOutcome { + pub(crate) fn update(&self, config: &Value) -> RolloutOutcome { let Some(offered) = C::parse(config) else { warn!(config = C::NAME, "rollout config rejected as invalid"); return RolloutOutcome::Rejected; @@ -288,3 +294,33 @@ fn config_object<'a>(value: &'a Value, message_type: &str) -> Option<&'a Value> } value.is_object().then_some(value) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_operator_relay_consent_is_read_off_the_instance_config() { + let config = json!({ + "enabled": true, + "config_version": 3, + "relay_consent_accepted": true, + }); + let snapshot = RolloutSnapshot::parse(&config).expect("the config parses"); + assert!(snapshot.relay_consent_accepted); + } + + #[test] + fn an_instance_config_without_the_consent_field_has_not_consented() { + let config = json!({"enabled": true, "config_version": 3}); + let snapshot = RolloutSnapshot::parse(&config).expect("the config parses"); + assert!(!snapshot.relay_consent_accepted); + } + + #[test] + fn a_consent_field_that_is_not_a_boolean_is_refused() { + let config = json!({"enabled": true, "relay_consent_accepted": "yes"}); + assert!(RolloutSnapshot::parse(&config).is_none()); + } +} diff --git a/packages/schema/src/domains/admin/PushServiceDeliverySchemas.test.ts b/packages/schema/src/domains/admin/PushServiceDeliverySchemas.test.ts new file mode 100644 index 000000000..d443e24f7 --- /dev/null +++ b/packages/schema/src/domains/admin/PushServiceDeliverySchemas.test.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG, + type PushServiceDeliveryConfig, + PushServiceDeliveryConfigSchema, + PushServiceDeliveryConfigUpdateRequest, + pushServiceDeliveryEnrols, +} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas'; +import {describe, expect, test} from 'vitest'; + +const ADMIN_USER_ID = '1500000000000000001'; +const TARGETED_USER_ID = '1500000000000000002'; + +function createConfig(overrides: Partial = {}): PushServiceDeliveryConfig { + return { + ...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG, + included_user_ids: [], + excluded_user_ids: [], + ...overrides, + }; +} + +describe('push service delivery relay consent', () => { + test('a stored configuration that predates relay consent reads back as not accepted', () => { + expect( + PushServiceDeliveryConfigSchema.parse({ + enabled: true, + config_version: 4, + rollout_basis_points: 10000, + rollout_salt: 'push-service-delivery-v1', + included_user_ids: [], + excluded_user_ids: [], + }), + ).toMatchObject({ + relay_consent_accepted: false, + relay_consent_accepted_at: null, + relay_consent_accepted_by: null, + }); + }); + + test('the defaults export carries the unaccepted consent', () => { + expect(DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG.relay_consent_accepted).toBe(false); + expect(DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG.relay_consent_accepted_at).toBeNull(); + expect(DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG.relay_consent_accepted_by).toBeNull(); + }); + + test('an accepted consent round-trips through the stored schema', () => { + const accepted = { + ...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG, + relay_consent_accepted: true, + relay_consent_accepted_at: '2026-09-27T10:11:12.000Z', + relay_consent_accepted_by: ADMIN_USER_ID, + }; + expect(PushServiceDeliveryConfigSchema.parse(accepted)).toEqual(accepted); + }); + + test('the update request takes the consent flag on its own', () => { + expect(PushServiceDeliveryConfigUpdateRequest.parse({relay_consent_accepted: true})).toEqual({ + relay_consent_accepted: true, + }); + }); + + test('the update request refuses a client-supplied acceptance stamp', () => { + expect( + PushServiceDeliveryConfigUpdateRequest.parse({ + relay_consent_accepted: true, + relay_consent_accepted_at: '2020-01-01T00:00:00.000Z', + relay_consent_accepted_by: ADMIN_USER_ID, + }), + ).toEqual({relay_consent_accepted: true}); + }); + + test.each([ + {relay_consent_accepted_at: 'yesterday'}, + {relay_consent_accepted_at: '2026-09-27'}, + {relay_consent_accepted_by: 'not-an-id'}, + {relay_consent_accepted: 'yes'}, + ])('rejects a malformed stored consent: %j', (value) => { + expect(PushServiceDeliveryConfigSchema.safeParse(value).success).toBe(false); + }); + + test('consent alone enrols nobody and refusing it excludes nobody', () => { + const withConsent = createConfig({enabled: false, relay_consent_accepted: true}); + const withoutConsent = createConfig({enabled: true, rollout_basis_points: 10000}); + expect(pushServiceDeliveryEnrols(withConsent, TARGETED_USER_ID)).toBe(false); + expect(pushServiceDeliveryEnrols(withoutConsent, TARGETED_USER_ID)).toBe(true); + }); +}); diff --git a/packages/schema/src/domains/admin/PushServiceDeliverySchemas.ts b/packages/schema/src/domains/admin/PushServiceDeliverySchemas.ts index 7951ce0fd..b7423bc91 100644 --- a/packages/schema/src/domains/admin/PushServiceDeliverySchemas.ts +++ b/packages/schema/src/domains/admin/PushServiceDeliverySchemas.ts @@ -21,6 +21,9 @@ const pushServiceDeliveryConfigFields = { rollout_salt: z.string().trim().min(1).max(64).regex(PUSH_SERVICE_DELIVERY_SALT_PATTERN), included_user_ids: PushServiceDeliveryTargetedUserIdsSchema, excluded_user_ids: PushServiceDeliveryTargetedUserIdsSchema, + relay_consent_accepted: z.boolean(), + relay_consent_accepted_at: z.iso.datetime().nullable(), + relay_consent_accepted_by: PushServiceDeliveryTargetIdSchema.nullable(), }; export const PushServiceDeliveryConfigSchema = z.object({ @@ -30,6 +33,9 @@ export const PushServiceDeliveryConfigSchema = z.object({ rollout_salt: pushServiceDeliveryConfigFields.rollout_salt.default(DEFAULT_PUSH_SERVICE_DELIVERY_SALT), included_user_ids: pushServiceDeliveryConfigFields.included_user_ids.default([]), excluded_user_ids: pushServiceDeliveryConfigFields.excluded_user_ids.default([]), + relay_consent_accepted: pushServiceDeliveryConfigFields.relay_consent_accepted.default(false), + relay_consent_accepted_at: pushServiceDeliveryConfigFields.relay_consent_accepted_at.default(null), + relay_consent_accepted_by: pushServiceDeliveryConfigFields.relay_consent_accepted_by.default(null), }); export type PushServiceDeliveryConfig = z.infer; @@ -40,7 +46,7 @@ export const DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG: PushServiceDeliveryConfig = P export const PushServiceDeliveryConfigUpdateRequest = z .object(pushServiceDeliveryConfigFields) - .omit({config_version: true}) + .omit({config_version: true, relay_consent_accepted_at: true, relay_consent_accepted_by: true}) .partial(); export type PushServiceDeliveryConfigUpdateRequest = z.infer;