mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(push): gate relay delivery on operator consent (#2984)
This commit is contained in:
@@ -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
|
||||
},
|
||||
|
||||
@@ -552,6 +552,9 @@ pub struct PushServiceDeliveryConfigResponse {
|
||||
pub rollout_salt: String,
|
||||
pub included_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 {
|
||||
@@ -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<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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#"{
|
||||
|
||||
@@ -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<HonoEnv>): Promise<boole
|
||||
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 {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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<PremiumMode>('mirror');
|
||||
const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() =>
|
||||
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' && (
|
||||
<PushRelayConsentStep
|
||||
accepted={pushRelayConsentAccepted}
|
||||
disabled={submitting}
|
||||
onChange={setPushRelayConsentAccepted}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'services' && (
|
||||
<ServicesStep
|
||||
available={serviceAvailability}
|
||||
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
|
||||
pushRelayConsentAccepted={pushRelayConsentAccepted}
|
||||
premiumMode={premiumMode}
|
||||
submitError={submitError}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
|
||||
|
||||
@@ -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<WizardStep> = [
|
||||
'integration_captcha',
|
||||
'integration_email',
|
||||
'integration_bluesky',
|
||||
'push_relay_consent',
|
||||
'services',
|
||||
'premium',
|
||||
'finish',
|
||||
|
||||
@@ -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 (
|
||||
<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(
|
||||
({
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
|
||||
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>
|
||||
{submitError && (
|
||||
|
||||
@@ -271,7 +271,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
|
||||
['admin-api/discovery.mdx', {'table-identifier': 1}],
|
||||
['admin-api/guilds.mdx', {'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/reports.mdx', {'table-fit': 1, 'table-identifier': 2}],
|
||||
['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`) |
|
||||
| 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) |
|
||||
| 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
`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`.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
-type gateway_role() :: websocket | sessions | presence | guilds | calls | push | all.
|
||||
|
||||
-define(MAX_CLUSTER_STATIC_PEERS, 256).
|
||||
-define(DEFAULT_MANAGED_RELAY_HOSTS, <<"push.fluxer.com">>).
|
||||
|
||||
-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) ->
|
||||
[];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) -> <<"[email protected]">>;
|
||||
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.
|
||||
|
||||
@@ -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<ApnsConfig>,
|
||||
pub fcm: Option<FcmConfig>,
|
||||
pub own_relay_hosts: Vec<String>,
|
||||
pub managed_relay_hosts: Vec<String>,
|
||||
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<String> {
|
||||
.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 {
|
||||
pub fn load_from_iter<I, K, V>(vars: I) -> anyhow::Result<Self>
|
||||
where
|
||||
|
||||
@@ -91,8 +91,11 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
||||
return SendOutcome::permanent("unsupported_platform");
|
||||
};
|
||||
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 outcome = match (route, direct) {
|
||||
match (route, direct) {
|
||||
(Route::WebPush, Some(hop)) => {
|
||||
let hopped = hop.as_subscription(sub);
|
||||
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::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::Hop> {
|
||||
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", "[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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 [
|
||||
|
||||
@@ -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<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),
|
||||
Some(Value::Bool(enabled)) => Some(*enabled),
|
||||
Some(Value::Bool(flag)) => Some(*flag),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -160,7 +166,7 @@ impl<C: RolloutConfig> RolloutStore<C> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
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<typeof PushServiceDeliveryConfigSchema>;
|
||||
@@ -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<typeof PushServiceDeliveryConfigUpdateRequest>;
|
||||
|
||||
Reference in New Issue
Block a user