feat(push): gate relay delivery on operator consent (#2984)

This commit is contained in:
Hampus
2026-09-27 20:33:10 +02:00
committed by GitHub
parent 336b8b7dcd
commit 01f53a168d
22 changed files with 1012 additions and 26 deletions
+16 -3
View File
@@ -15225,7 +15225,8 @@
"maxItems": 1000, "maxItems": 1000,
"type": "array", "type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"} "items": {"type": "string", "pattern": "^\\d{1,20}$"}
} },
"relay_consent_accepted": {"type": "boolean"}
} }
}, },
"VoiceNoiseSuppressionConfigUpdateRequest": { "VoiceNoiseSuppressionConfigUpdateRequest": {
@@ -15357,7 +15358,16 @@
"maxItems": 1000, "maxItems": 1000,
"type": "array", "type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"} "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": [ "required": [
"enabled", "enabled",
@@ -15365,7 +15375,10 @@
"rollout_basis_points", "rollout_basis_points",
"rollout_salt", "rollout_salt",
"included_user_ids", "included_user_ids",
"excluded_user_ids" "excluded_user_ids",
"relay_consent_accepted",
"relay_consent_accepted_at",
"relay_consent_accepted_by"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -552,6 +552,9 @@ pub struct PushServiceDeliveryConfigResponse {
pub rollout_salt: String, pub rollout_salt: String,
pub included_user_ids: Vec<String>, pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>, pub excluded_user_ids: Vec<String>,
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
} }
impl Default for PushServiceDeliveryConfigResponse { impl Default for PushServiceDeliveryConfigResponse {
@@ -563,6 +566,9 @@ impl Default for PushServiceDeliveryConfigResponse {
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(), rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(), included_user_ids: Vec::new(),
excluded_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<Vec<String>>, pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>, pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_consent_accepted: Option<bool>,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
+27
View File
@@ -680,6 +680,9 @@ fn build_push_service_delivery_update(
.unwrap_or_default(), .unwrap_or_default(),
"Excluded user IDs", "Excluded user IDs",
)?), )?),
relay_consent_accepted: Some(
form.bool_value("push_service_delivery_relay_consent_accepted"),
),
}), }),
..Default::default() ..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] #[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() { fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token"); let form = MultiValueForm::parse(b"_csrf=token");
@@ -1191,6 +1191,14 @@ fn push_service_delivery_section(
}; };
let included_user_ids = push_service_delivery.included_user_ids.join("\n"); 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 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( section_card_with_description(
"Push Service Delivery", "Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \ "Routes push notification delivery for the selected accounts through the push service. \
@@ -1219,6 +1227,28 @@ fn push_service_delivery_section(
effect at all." 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" } h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field( (number_field(
"push_service_delivery_rollout_basis_points", "push_service_delivery_rollout_basis_points",
@@ -2086,6 +2116,29 @@ mod tests {
assert!(!markup.contains("at the cap")); 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] #[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() { fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse { let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
+70 -1
View File
@@ -415,7 +415,10 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"rollout_basis_points": 5000, "rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1", "rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"], "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": { "domain_migration": {
"enabled": true, "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.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100); assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding); assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.push_service_delivery.relay_consent_accepted);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300); assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none()); assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true)); 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] #[test]
fn deserialize_search_reports_response() { fn deserialize_search_reports_response() {
let json = r#"{ let json = r#"{
@@ -36,7 +36,11 @@ import {
} from '@fluxer/schema/src/domains/admin/AdminSchemas'; } from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas'; 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 {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -194,6 +198,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
return true; return true;
} }
function relayConsentStamp(
current: PushServiceDeliveryConfig,
patch: Partial<PushServiceDeliveryConfigUpdateRequest>,
adminUserId: string,
): Partial<PushServiceDeliveryConfig> {
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 { function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data) const sections = Object.entries(data)
.filter(([, value]) => value != null) .filter(([, value]) => value != null)
@@ -276,10 +294,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
if (data.push_service_delivery) { if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery); const patch = omitUndefinedFields(data.push_service_delivery);
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) => const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({ PushServiceDeliveryConfigSchema.parse({
...current, ...current,
...patch, ...patch,
...relayConsentStamp(current, patch, adminUserId),
config_version: current.config_version + 1, config_version: current.config_version + 1,
}), }),
); );
@@ -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<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const readConfig = (admin: TestAccount) =>
createBuilder<InstanceConfigResponse>(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}));
});
});
@@ -233,6 +233,15 @@
text-align: center; 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 { .integrationFields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -30,6 +30,7 @@ import {
MediaExpiryStep, MediaExpiryStep,
type PremiumMode, type PremiumMode,
PremiumStep, PremiumStep,
PushRelayConsentStep,
type RegistrationMode, type RegistrationMode,
RegistrationStep, RegistrationStep,
type ServiceAvailability, type ServiceAvailability,
@@ -459,6 +460,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
youtube: false, youtube: false,
bluesky: false, bluesky: false,
}); });
const [pushRelayConsentAccepted, setPushRelayConsentAccepted] = useState(false);
const [premiumMode, setPremiumMode] = useState<PremiumMode>('mirror'); const [premiumMode, setPremiumMode] = useState<PremiumMode>('mirror');
const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() => const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() =>
BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})), BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})),
@@ -549,6 +551,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
clearStepNavigationLock(); clearStepNavigationLock();
setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT}); setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT});
setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT}); setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT});
setPushRelayConsentAccepted(false);
setSmtpTesting(false); setSmtpTesting(false);
setSmtpTestResult(null); setSmtpTestResult(null);
try { try {
@@ -569,6 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSingleCommunityEnabled(next.policy.single_community_enabled); setSingleCommunityEnabled(next.policy.single_community_enabled);
setDirectMessagesDisabled(next.policy.direct_messages_disabled); setDirectMessagesDisabled(next.policy.direct_messages_disabled);
setPremiumMode(next.policy.premium_mode); setPremiumMode(next.policy.premium_mode);
setPushRelayConsentAccepted(next.push_service_delivery.relay_consent_accepted);
setServiceSelection({ setServiceSelection({
gif: next.policy.services_resolved.gif_enabled, gif: next.policy.services_resolved.gif_enabled,
youtube: next.policy.services_resolved.youtube_enabled, youtube: next.policy.services_resolved.youtube_enabled,
@@ -677,6 +681,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
if (step === 'branding') return !productNameError; if (step === 'branding') return !productNameError;
if (step === 'community') return !singleCommunityNameError; if (step === 'community') return !singleCommunityNameError;
if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft); if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft);
if (step === 'push_relay_consent') return true;
const integrationKind = wizardStepToIntegrationKind(step); const integrationKind = wizardStepToIntegrationKind(step);
if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft); if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft);
return true; return true;
@@ -766,6 +771,10 @@ export const SelfHostedSetupWizardGate = observer(() => {
const nextConfig = await updateInstanceConfig({ const nextConfig = await updateInstanceConfig({
integrations: buildIntegrationsPatch(integrationDraft), integrations: buildIntegrationsPatch(integrationDraft),
media: buildMediaPatch(mediaExpiryDraft), media: buildMediaPatch(mediaExpiryDraft),
push_service_delivery:
config.push_service_delivery.relay_consent_accepted === pushRelayConsentAccepted
? undefined
: {relay_consent_accepted: pushRelayConsentAccepted},
registration: {mode: registrationMode}, registration: {mode: registrationMode},
app_public: { app_public: {
branding: { branding: {
@@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
singleCommunityEnabled, singleCommunityEnabled,
singleCommunityNameTrimmed, singleCommunityNameTrimmed,
directMessagesDisabled, directMessagesDisabled,
pushRelayConsentAccepted,
premiumMode, premiumMode,
serviceAvailability, serviceAvailability,
serviceSelection, serviceSelection,
@@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step" data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
/> />
)} )}
{step === 'push_relay_consent' && (
<PushRelayConsentStep
accepted={pushRelayConsentAccepted}
disabled={submitting}
onChange={setPushRelayConsentAccepted}
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
/>
)}
{step === 'services' && ( {step === 'services' && (
<ServicesStep <ServicesStep
available={serviceAvailability} available={serviceAvailability}
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
singleCommunityEnabled={singleCommunityEnabled} singleCommunityEnabled={singleCommunityEnabled}
directMessagesDisabled={directMessagesDisabled} directMessagesDisabled={directMessagesDisabled}
attachmentExpiryEnabled={mediaExpiryDraft.enabled} attachmentExpiryEnabled={mediaExpiryDraft.enabled}
pushRelayConsentAccepted={pushRelayConsentAccepted}
premiumMode={premiumMode} premiumMode={premiumMode}
submitError={submitError} submitError={submitError}
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step" data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
@@ -17,6 +17,7 @@ export type WizardStep =
| 'integration_captcha' | 'integration_captcha'
| 'integration_email' | 'integration_email'
| 'integration_bluesky' | 'integration_bluesky'
| 'push_relay_consent'
| 'services' | 'services'
| 'premium' | 'premium'
| 'finish'; | 'finish';
@@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
'integration_captcha', 'integration_captcha',
'integration_email', 'integration_email',
'integration_bluesky', 'integration_bluesky',
'push_relay_consent',
'services', 'services',
'premium', 'premium',
'finish', 'finish',
@@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel
import {Input} from '@app/features/ui/components/form/FormInput'; import {Input} from '@app/features/ui/components/form/FormInput';
import {Switch} from '@app/features/ui/components/form/FormSwitch'; import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {Spinner} from '@app/features/ui/components/Spinner'; 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 {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent'; import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab'; 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 type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} 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 RegistrationMode = 'open' | 'approval' | 'closed';
export type PremiumMode = 'mirror' | 'everyone'; export type PremiumMode = 'mirror' | 'everyone';
@@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
comment: 'Label for attachment decay renewal window.', 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({ const SERVICES_TITLE_DESCRIPTOR = msg({
message: 'Optional services', message: 'Optional services',
comment: 'Setup wizard optional services step title.', comment: 'Setup wizard optional services step title.',
@@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
message: 'Attachment expiration', message: 'Attachment expiration',
comment: 'Summary row label for the attachment expiry choice in the setup wizard.', 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({ const SUMMARY_PREMIUM_DESCRIPTOR = msg({
message: 'Premium model', message: 'Premium model',
comment: 'Summary row label for the premium model in the setup wizard.', 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({ const SUMMARY_ON_DESCRIPTOR = msg({
message: 'Enabled', message: 'Enabled',
comment: 'Summary value when a setup option is 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 (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
<StepHeader
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
/>
<Switch
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
value={accepted}
onChange={onChange}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
/>
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
<a
className={styles.noticeLink}
href={PUSH_RELAY_NOTICE_URL}
target="_blank"
rel="noreferrer"
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
>
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
</a>
</FocusRing>
</section>
);
},
);
export const ServicesStep = observer( export const ServicesStep = observer(
({ ({
available, available,
@@ -1650,6 +1722,7 @@ export const FinishStep = observer(
singleCommunityEnabled, singleCommunityEnabled,
directMessagesDisabled, directMessagesDisabled,
attachmentExpiryEnabled, attachmentExpiryEnabled,
pushRelayConsentAccepted,
premiumMode, premiumMode,
submitError, submitError,
}: { }: {
@@ -1658,6 +1731,7 @@ export const FinishStep = observer(
singleCommunityEnabled: boolean; singleCommunityEnabled: boolean;
directMessagesDisabled: boolean; directMessagesDisabled: boolean;
attachmentExpiryEnabled: boolean; attachmentExpiryEnabled: boolean;
pushRelayConsentAccepted: boolean;
premiumMode: PremiumMode; premiumMode: PremiumMode;
submitError: string | null; submitError: string | null;
}) => { }) => {
@@ -1705,10 +1779,17 @@ export const FinishStep = observer(
value={attachmentExpiryEnabled ? onLabel : offLabel} value={attachmentExpiryEnabled ? onLabel : offLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5" data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
/> />
<SummaryRow
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
value={
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
/>
<SummaryRow <SummaryRow
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)} label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
value={premiumLabel} value={premiumLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6" data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
/> />
</div> </div>
{submitError && ( {submitError && (
+1 -1
View File
@@ -271,7 +271,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['admin-api/discovery.mdx', {'table-identifier': 1}], ['admin-api/discovery.mdx', {'table-identifier': 1}],
['admin-api/guilds.mdx', {'table-identifier': 3}], ['admin-api/guilds.mdx', {'table-identifier': 3}],
['admin-api/index.mdx', {'table-fit': 1, 'table-identifier': 3}], ['admin-api/index.mdx', {'table-fit': 1, 'table-identifier': 3}],
['admin-api/instance.mdx', {'table-fit': 1, 'table-identifier': 6}], ['admin-api/instance.mdx', {'table-fit': 1, 'table-identifier': 7}],
['admin-api/messages.mdx', {'table-identifier': 1}], ['admin-api/messages.mdx', {'table-identifier': 1}],
['admin-api/reports.mdx', {'table-fit': 1, 'table-identifier': 2}], ['admin-api/reports.mdx', {'table-fit': 1, 'table-identifier': 2}],
['admin-api/users.mdx', {'table-fit': 1, 'table-identifier': 1}], ['admin-api/users.mdx', {'table-fit': 1, 'table-identifier': 1}],
@@ -136,9 +136,14 @@ The instance rollout of push service delivery.
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `push-service-delivery-v1`) | | rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `push-service-delivery-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) | | included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) | | excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
| relay_consent_accepted | boolean | Whether the operator accepted the push relay supplemental privacy notice (default false) |
| relay_consent_accepted_at | ?string | ISO 8601 timestamp of that acceptance, or null when the notice stands unaccepted (default null) |
| relay_consent_accepted_by | ?snowflake | Admin account that accepted the notice, or null when the notice stands unaccepted (default null) |
Every field is present on read. An absent document or missing field uses the defaults above. Every field is present on read. An absent document or missing field uses the defaults above.
The notice covers only the Fluxer-run relay that reaches Apple and Google for the official mobile apps. A subscription whose endpoint points at another distributor, such as a self-hosted UnifiedPush server or ntfy, never reaches that relay and needs no acceptance.
## Domain migration configuration object ## Domain migration configuration object
The instance rollout that moves the official web client from its legacy origin to a new one. [Experiments](/http-api/experiments/#domain-migration-assignment-object) defines what a signed-in client resolves from it. The [instance discovery document](/http-api/instance/#domain-migration-object) publishes `enabled`, `anonymous_rollout_basis_points`, `rollout_salt`, and `standalone_forwarding` for clients that are not signed in. The instance rollout that moves the official web client from its legacy origin to a new one. [Experiments](/http-api/experiments/#domain-migration-assignment-object) defines what a signed-in client resolves from it. The [instance discovery document](/http-api/instance/#domain-migration-object) publishes `enabled`, `anonymous_rollout_basis_points`, `rollout_salt`, and `standalone_forwarding` for clients that are not signed in.
@@ -591,7 +596,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti
`voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone. `voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
`push_service_delivery` works the same way, over the [push service delivery configuration](#push-service-delivery-configuration-object) fields and its own `config_version`. `push_service_delivery` works the same way, over the [push service delivery configuration](#push-service-delivery-configuration-object) fields and its own `config_version`. It also takes `relay_consent_accepted`. Fluxer sets `relay_consent_accepted_at` and `relay_consent_accepted_by` itself on the request that changes that flag, and accepts neither from a request: turning the flag on stamps the current time and the acting Admin account, and turning it off clears both to null. A request that repeats the flag it already holds leaves the stamp alone.
`domain_migration` works the same way, over the [domain migration configuration](#domain-migration-configuration-object) fields and its own `config_version`. `domain_migration` works the same way, over the [domain migration configuration](#domain-migration-configuration-object) fields and its own `config_version`.
@@ -19,6 +19,7 @@
-type gateway_role() :: websocket | sessions | presence | guilds | calls | push | all. -type gateway_role() :: websocket | sessions | presence | guilds | calls | push | all.
-define(MAX_CLUSTER_STATIC_PEERS, 256). -define(MAX_CLUSTER_STATIC_PEERS, 256).
-define(DEFAULT_MANAGED_RELAY_HOSTS, <<"push.fluxer.com">>).
-spec load() -> config(). -spec load() -> config().
load() -> load() ->
@@ -87,6 +88,12 @@ env_gateway_base_config() ->
<<"push_endpoint_guard_enabled">> => env_bool( <<"push_endpoint_guard_enabled">> => env_bool(
"FLUXER_GATEWAY_PUSH_ENDPOINT_GUARD_ENABLED", true "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( <<"push_outbox_request_timeout_ms">> => env_int(
"FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS", 100000 "FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS", 100000
), ),
@@ -268,6 +275,12 @@ build_push_config(Service, Public) ->
push_endpoint_guard_enabled => get_bool( push_endpoint_guard_enabled => get_bool(
Service, <<"push_endpoint_guard_enabled">>, true 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_queue => get_int(Service, <<"push_outbox_max_queue">>, 10000),
push_outbox_max_inflight => get_int(Service, <<"push_outbox_max_inflight">>, 64), push_outbox_max_inflight => get_int(Service, <<"push_outbox_max_inflight">>, 64),
push_outbox_request_timeout_ms => get_int( 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(Atom, _) when is_atom(Atom) -> list_to_binary(atom_to_list(Atom));
to_binary(_, Default) -> Default. 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()]. -spec parse_node_list(binary() | undefined) -> [node()].
parse_node_list(undefined) -> parse_node_list(undefined) ->
[]; [];
@@ -37,7 +37,8 @@
rollout_basis_points := non_neg_integer(), rollout_basis_points := non_neg_integer(),
rollout_salt := binary(), rollout_salt := binary(),
included := user_id_set(), included := user_id_set(),
excluded := user_id_set() excluded := user_id_set(),
relay_consent_accepted := boolean()
}. }.
-type state() :: #{ -type state() :: #{
nats_subscription := term(), nats_subscription := term(),
@@ -170,7 +171,8 @@ default_config() ->
rollout_basis_points => 0, rollout_basis_points => 0,
rollout_salt => ?DEFAULT_SALT, rollout_salt => ?DEFAULT_SALT,
included => #{}, included => #{},
excluded => #{} excluded => #{},
relay_consent_accepted => false
}. }.
-spec fetch_config_from_api() -> store_result(). -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_basis_points, <<"rollout_basis_points">>, fun validate_basis_points/1},
{rollout_salt, <<"rollout_salt">>, fun validate_salt/1}, {rollout_salt, <<"rollout_salt">>, fun validate_salt/1},
{included, <<"included_user_ids">>, fun validate_user_ids/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( -spec validate_field(
@@ -419,7 +422,7 @@ result_index(rejected) -> 4.
log_config_transitions(Previous, Current) -> log_config_transitions(Previous, Current) ->
lists:foreach( lists:foreach(
fun(Key) -> log_key_transition(Key, Previous, Current) end, 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. -spec log_key_transition(atom(), config(), config()) -> ok.
@@ -435,3 +438,22 @@ log_key_transition(Key, Previous, Current) ->
[Key, PreviousValue, CurrentValue] [Key, PreviousValue, CurrentValue]
) )
end. 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.
@@ -21,6 +21,12 @@
-define(OVERLOAD_MAX_DELAY_MS, 4000). -define(OVERLOAD_MAX_DELAY_MS, 4000).
-define(VAPID_TOKEN_TTL_SECONDS, 43200). -define(VAPID_TOKEN_TTL_SECONDS, 43200).
-define(VAPID_TOKEN_SKEW_SECONDS, 60). -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()}. -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) -> send_to_allowed_endpoint(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload) ->
case push_endpoint_guard:check(Endpoint) of case push_endpoint_guard:check(Endpoint) of
ok -> ok ->
send_with_vapid(UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload); send_to_consented_endpoint(
UserId, Endpoint, P256dhKey, AuthKey, SubscriptionId, Payload
);
{error, Reason} -> {error, Reason} ->
log_endpoint_rejected(UserId, SubscriptionId, Reason), log_endpoint_rejected(UserId, SubscriptionId, Reason),
false false
end. 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. -spec log_endpoint_rejected(integer(), binary(), term()) -> ok.
log_endpoint_rejected(UserId, SubscriptionId, Reason) -> log_endpoint_rejected(UserId, SubscriptionId, Reason) ->
logger:debug( logger:debug(
@@ -775,10 +863,100 @@ capture_web_push(Payload) ->
vapid_env_meck(vapid_email) -> <<"[email protected]">>; vapid_env_meck(vapid_email) -> <<"[email protected]">>;
vapid_env_meck(vapid_public_key) -> <<"public-key">>; vapid_env_meck(vapid_public_key) -> <<"public-key">>;
vapid_env_meck(vapid_private_key) -> <<"private-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]). vapid_env_meck(Key) -> meck:passthrough([Key]).
capture_request_meck(push, post, _Endpoint, Headers, Body, _Opts) -> capture_request_meck(push, post, _Endpoint, Headers, Body, _Opts) ->
self() ! {captured_push, Headers, Body}, self() ! {captured_push, Headers, Body},
{ok, 201, [], <<>>}. {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. -endif.
+19
View File
@@ -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 DEFAULT_CLIENT_IP_HEADER_NAME: &str = "x-forwarded-for";
const APNS_PRODUCTION_BASE_URL: &str = "https://api.push.apple.com"; const APNS_PRODUCTION_BASE_URL: &str = "https://api.push.apple.com";
const APNS_DEVELOPMENT_BASE_URL: &str = "https://api.sandbox.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"; const VOIP_TOPIC_SUFFIX: &str = ".voip";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
@@ -184,6 +185,8 @@ pub struct DeliveryConfig {
pub apns: Option<ApnsConfig>, pub apns: Option<ApnsConfig>,
pub fcm: Option<FcmConfig>, pub fcm: Option<FcmConfig>,
pub own_relay_hosts: Vec<String>, pub own_relay_hosts: Vec<String>,
pub managed_relay_hosts: Vec<String>,
pub relay_consent_accepted: bool,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -264,6 +267,12 @@ impl DeliveryConfig {
apns: apns_config(&env)?, apns: apns_config(&env)?,
fcm: fcm_config(&env)?, fcm: fcm_config(&env)?,
own_relay_hosts: own_relay_hosts(&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<String> {
.collect() .collect()
} }
fn managed_relay_hosts(env: &Env) -> Vec<String> {
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 { impl RelayConfig {
pub fn load_from_iter<I, K, V>(vars: I) -> anyhow::Result<Self> pub fn load_from_iter<I, K, V>(vars: I) -> anyhow::Result<Self>
where where
+144 -1
View File
@@ -91,8 +91,11 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
return SendOutcome::permanent("unsupported_platform"); return SendOutcome::permanent("unsupported_platform");
}; };
let started_ms = now_ms(); let started_ms = now_ms();
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); let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts);
let outcome = match (route, direct) { match (route, direct) {
(Route::WebPush, Some(hop)) => { (Route::WebPush, Some(hop)) => {
let hopped = hop.as_subscription(sub); let hopped = hop.as_subscription(sub);
state.metrics.record_own_relay_shortcut(); state.metrics.record_own_relay_shortcut();
@@ -101,6 +104,7 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
(Route::WebPush, None) => web_push::send(state, sub, envelope).await, (Route::WebPush, None) => web_push::send(state, sub, envelope).await,
(Route::LegacyApns, _) => apns::send(state, sub, envelope).await, (Route::LegacyApns, _) => apns::send(state, sub, envelope).await,
(Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await, (Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await,
}
}; };
state.metrics.record_send( state.metrics.record_send(
provider_of(platform), provider_of(platform),
@@ -113,6 +117,19 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
outcome 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::Hop> { fn in_process_hop(endpoint: &str, hosts: &[String]) -> Option<own_relay::Hop> {
own_relay::parse(endpoint, hosts).filter(|hop| matches!(hop.leg, own_relay::Leg::Apns)) 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()); 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", "[email protected]"),
("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");
}
}
}
+24
View File
@@ -97,6 +97,10 @@ pub fn parse(endpoint: &str, hosts: &[String]) -> Option<Hop> {
}) })
} }
pub fn is_managed(endpoint: &str, hosts: &[String]) -> bool {
parse(endpoint, hosts).is_some()
}
fn decode(segment: &str) -> Option<String> { fn decode(segment: &str) -> Option<String> {
percent_encoding::percent_decode_str(segment) percent_encoding::percent_decode_str(segment)
.decode_utf8() .decode_utf8()
@@ -185,6 +189,26 @@ mod tests {
assert!(parse("https://ntfy.sh/upZzH87cT9jJCc?up=1", &ours()).is_none()); 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] #[test]
fn a_malformed_relay_path_is_refused() { fn a_malformed_relay_path_is_refused() {
for endpoint in [ for endpoint in [
+39 -3
View File
@@ -54,6 +54,7 @@ pub struct RolloutSnapshot {
pub enabled: bool, pub enabled: bool,
pub config_version: u64, pub config_version: u64,
pub rollout_basis_points: u32, pub rollout_basis_points: u32,
pub relay_consent_accepted: bool,
} }
impl RolloutConfig for RolloutSnapshot { impl RolloutConfig for RolloutSnapshot {
@@ -77,6 +78,7 @@ impl RolloutConfig for RolloutSnapshot {
enabled: parse_enabled(config)?, enabled: parse_enabled(config)?,
config_version: parse_config_version(config)?, config_version: parse_config_version(config)?,
rollout_basis_points, 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<bool> { pub fn parse_enabled(config: &Value) -> Option<bool> {
match config.get("enabled") { parse_flag(config, "enabled")
}
fn parse_flag(config: &Value, key: &str) -> Option<bool> {
match config.get(key) {
None | Some(Value::Null) => Some(false), None | Some(Value::Null) => Some(false),
Some(Value::Bool(enabled)) => Some(*enabled), Some(Value::Bool(flag)) => Some(*flag),
Some(_) => None, Some(_) => None,
} }
} }
@@ -160,7 +166,7 @@ impl<C: RolloutConfig> RolloutStore<C> {
self.update(config) self.update(config)
} }
fn update(&self, config: &Value) -> RolloutOutcome { pub(crate) fn update(&self, config: &Value) -> RolloutOutcome {
let Some(offered) = C::parse(config) else { let Some(offered) = C::parse(config) else {
warn!(config = C::NAME, "rollout config rejected as invalid"); warn!(config = C::NAME, "rollout config rejected as invalid");
return RolloutOutcome::Rejected; 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) 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());
}
}
@@ -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> = {}): 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);
});
});
@@ -21,6 +21,9 @@ const pushServiceDeliveryConfigFields = {
rollout_salt: z.string().trim().min(1).max(64).regex(PUSH_SERVICE_DELIVERY_SALT_PATTERN), rollout_salt: z.string().trim().min(1).max(64).regex(PUSH_SERVICE_DELIVERY_SALT_PATTERN),
included_user_ids: PushServiceDeliveryTargetedUserIdsSchema, included_user_ids: PushServiceDeliveryTargetedUserIdsSchema,
excluded_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({ 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), rollout_salt: pushServiceDeliveryConfigFields.rollout_salt.default(DEFAULT_PUSH_SERVICE_DELIVERY_SALT),
included_user_ids: pushServiceDeliveryConfigFields.included_user_ids.default([]), included_user_ids: pushServiceDeliveryConfigFields.included_user_ids.default([]),
excluded_user_ids: pushServiceDeliveryConfigFields.excluded_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<typeof PushServiceDeliveryConfigSchema>; export type PushServiceDeliveryConfig = z.infer<typeof PushServiceDeliveryConfigSchema>;
@@ -40,7 +46,7 @@ export const DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG: PushServiceDeliveryConfig = P
export const PushServiceDeliveryConfigUpdateRequest = z export const PushServiceDeliveryConfigUpdateRequest = z
.object(pushServiceDeliveryConfigFields) .object(pushServiceDeliveryConfigFields)
.omit({config_version: true}) .omit({config_version: true, relay_consent_accepted_at: true, relay_consent_accepted_by: true})
.partial(); .partial();
export type PushServiceDeliveryConfigUpdateRequest = z.infer<typeof PushServiceDeliveryConfigUpdateRequest>; export type PushServiceDeliveryConfigUpdateRequest = z.infer<typeof PushServiceDeliveryConfigUpdateRequest>;