feat(push): relay notifications as encrypted web push (#2906)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-23 14:04:55 +02:00
1 parent 7021a58090
commit 18c303abf6
111 files changed
+12473 -1177

No files matched your search

@@ -15,6 +15,10 @@ import {
GatewayRolloutConfigResponse,
GatewayRolloutConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
PushServiceDeliveryConfigResponse,
PushServiceDeliveryConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {
ScreenShareDeliveryConfigResponse,
ScreenShareDeliveryConfigUpdateRequest,
@@ -649,6 +653,7 @@ export const InstanceConfigResponse = z.object({
gateway_rollout: GatewayRolloutConfigResponse,
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
screen_share_delivery: ScreenShareDeliveryConfigResponse,
push_service_delivery: PushServiceDeliveryConfigResponse,
experiment_delivery: ExperimentDeliveryConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
@@ -686,6 +691,7 @@ export const InstanceConfigUpdateRequest = z.object({
gateway_rollout: GatewayRolloutConfigUpdateRequest.nullish(),
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
screen_share_delivery: ScreenShareDeliveryConfigUpdateRequest.nullish(),
push_service_delivery: PushServiceDeliveryConfigUpdateRequest.nullish(),
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
registration: z
.object({
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const PUSH_SERVICE_DELIVERY_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
const PUSH_SERVICE_DELIVERY_MAX_TARGETED_USERS = 1000;
const DEFAULT_PUSH_SERVICE_DELIVERY_SALT = 'push-service-delivery-v1';
const PUSH_SERVICE_DELIVERY_SALT_PATTERN = /^[\x20-\x7e]+$/u;
const PushServiceDeliveryTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
const PushServiceDeliveryTargetedUserIdsSchema = z
.array(PushServiceDeliveryTargetIdSchema)
.max(PUSH_SERVICE_DELIVERY_MAX_TARGETED_USERS);
const pushServiceDeliveryConfigFields = {
enabled: z.boolean(),
config_version: z.number().int().min(0),
rollout_basis_points: z.number().int().min(0).max(PUSH_SERVICE_DELIVERY_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(PUSH_SERVICE_DELIVERY_SALT_PATTERN),
included_user_ids: PushServiceDeliveryTargetedUserIdsSchema,
excluded_user_ids: PushServiceDeliveryTargetedUserIdsSchema,
};
export const PushServiceDeliveryConfigSchema = z.object({
enabled: pushServiceDeliveryConfigFields.enabled.default(false),
config_version: pushServiceDeliveryConfigFields.config_version.default(0),
rollout_basis_points: pushServiceDeliveryConfigFields.rollout_basis_points.default(0),
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([]),
});
export type PushServiceDeliveryConfig = z.infer<typeof PushServiceDeliveryConfigSchema>;
export const DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG: PushServiceDeliveryConfig = PushServiceDeliveryConfigSchema.parse(
{},
);
export const PushServiceDeliveryConfigUpdateRequest = z
.object(pushServiceDeliveryConfigFields)
.omit({config_version: true})
.partial();
export type PushServiceDeliveryConfigUpdateRequest = z.infer<typeof PushServiceDeliveryConfigUpdateRequest>;
export const PushServiceDeliveryConfigResponse = PushServiceDeliveryConfigSchema;
export type PushServiceDeliveryConfigResponse = z.infer<typeof PushServiceDeliveryConfigResponse>;
export function pushServiceDeliveryEnrols(config: PushServiceDeliveryConfig, userId: string): boolean {
if (!config.enabled) return false;
if (config.excluded_user_ids.includes(userId)) return false;
if (config.included_user_ids.includes(userId)) return true;
return experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points;
}
@@ -2,6 +2,7 @@
import {RTC_REGION_ID_MAX_LENGTH, RTC_REGION_ID_MIN_LENGTH} from '@fluxer/constants/src/LimitConstants';
import {GatewayRolloutConfigResponse} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigResponse} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {WebAuthnCredentialResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ChannelResponse, RtcRegionResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
@@ -212,6 +213,11 @@ export const RpcRequest = z.discriminatedUnion('type', [
z.object({
type: z.literal('get_gateway_rollout_config').describe('Request type for fetching gateway rollout configuration'),
}),
z.object({
type: z
.literal('get_push_service_delivery_config')
.describe('Request type for fetching push service delivery configuration'),
}),
]);
export type RpcRequest = z.infer<typeof RpcRequest>;
@@ -517,6 +523,16 @@ export const RpcResponse = z.discriminatedUnion('type', [
})
.describe('Gateway rollout config result'),
}),
z.object({
type: z
.literal('get_push_service_delivery_config')
.describe('Response type for push service delivery configuration'),
data: z
.object({
config: PushServiceDeliveryConfigResponse.describe('Push service delivery configuration'),
})
.describe('Push service delivery config result'),
}),
]);
export type RpcResponse = z.infer<typeof RpcResponse>;
@@ -481,7 +481,9 @@ const MobilePushProviderEnvironmentSchema = createNamedStringLiteralUnion(
export const RegisterMobileDeviceRequest = z
.object({
platform: MobilePushPlatformSchema.describe('The mobile push notification platform'),
token: createStringType(1, 4096).describe('The platform-specific push notification token or endpoint URL'),
token: createStringType(1, 4096).describe(
'The Web Push endpoint URL when encryption keys are supplied, otherwise the raw platform push token',
),
user_agent: createStringType(1, 1024).optional().describe('The user agent string identifying the device'),
app_id: createStringType(1, 128)
.optional()
@@ -491,32 +493,44 @@ export const RegisterMobileDeviceRequest = z
),
encryption_key: createStringType(1, 1024)
.optional()
.describe('The P-256 ECDH public key for UnifiedPush encryption (base64url)'),
.describe('The P-256 ECDH public key for Web Push encryption (base64url)'),
auth_secret: createStringType(1, 1024)
.optional()
.describe('The authentication secret for UnifiedPush encryption (base64url)'),
.describe('The authentication secret for Web Push encryption (base64url)'),
})
.superRefine((value, ctx) => {
if (value.platform !== 'android_unified_push') return;
if (!URLType.safeParse(value.token).success) {
const tokenIsUrl = URLType.safeParse(value.token).success;
const isWebPushRegistration =
value.platform === 'android_unified_push' || value.encryption_key != null || value.auth_secret != null;
if (!isWebPushRegistration) {
if (tokenIsUrl) {
ctx.addIssue({
code: 'custom',
path: ['token'],
message: 'Endpoint URL registrations require encryption_key and auth_secret',
});
}
return;
}
if (!tokenIsUrl) {
ctx.addIssue({
code: 'custom',
path: ['token'],
message: 'UnifiedPush registrations require a valid endpoint URL',
message: 'Web Push registrations require a valid endpoint URL',
});
}
if (!value.encryption_key) {
ctx.addIssue({
code: 'custom',
path: ['encryption_key'],
message: 'UnifiedPush registrations require encryption_key',
message: 'Web Push registrations require encryption_key',
});
}
if (!value.auth_secret) {
ctx.addIssue({
code: 'custom',
path: ['auth_secret'],
message: 'UnifiedPush registrations require auth_secret',
message: 'Web Push registrations require auth_secret',
});
}
});
@@ -525,7 +539,9 @@ export type RegisterMobileDeviceRequest = z.infer<typeof RegisterMobileDeviceReq
export const UnregisterMobileDeviceRequest = z.object({
platform: MobilePushPlatformSchema.describe('The mobile push notification platform'),
token: createStringType(1, 4096).describe('The platform-specific push notification token to unregister'),
token: createStringType(1, 4096).describe(
'The Web Push endpoint URL or raw platform push token used at registration',
),
app_id: createStringType(1, 128)
.optional()
.describe('Client app channel or bundle mapping identifier, such as stable, beta, or canary'),