Files
Desktop/ts/services/donationPermits.preload.ts
T

174 lines
4.7 KiB
TypeScript

// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
DonationPermit,
DonationPermitRequestContext,
DonationPermitResponse,
ServerPublicParams,
} from '@signalapp/libsignal-client/zkgroup.js';
import { z } from 'zod';
import * as Bytes from '../Bytes.std.ts';
import { strictAssert } from '../util/assert.std.ts';
import { isInFuture, isInPast } from '../util/timestamp.std.ts';
import { createDonationPermits } from '../textsecure/WebAPI.preload.ts';
import { createLogger } from '../logging/log.std.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import { safeParseStrict, safeParseUnknown } from '../util/schemas.std.ts';
import { DAY } from '../util/durations/index.std.ts';
const log = createLogger('donationPermits');
const MAX_PERMIT_CACHE_TIME = DAY;
const PERMIT_COUNT_PER_REQUEST = 3;
const PERMIT_STORAGE_KEY = 'donationPermits';
const permitStorageSchema = z.object({
permitsBase64: z.array(z.string()),
expiresAt: z.number(),
});
let cachedPermits: Array<DonationPermit> = [];
let cachedPermitsExpiresAt: number | undefined;
let storageLoaded = false;
/**
* @throws {Error} If a permit could not be fetched.
*/
export async function fetchDonationPermit(): Promise<DonationPermit> {
if (!storageLoaded) {
loadPermitsFromStorage();
}
if (isCacheRefreshNeeded()) {
log.info(`Requesting ${PERMIT_COUNT_PER_REQUEST} donation permits`);
const { permits, expiresAt } = await requestDonationPermits();
cachedPermitsExpiresAt = expiresAt;
cachedPermits = permits;
log.info(`Got ${permits.length} permits expiring ${expiresAt}`);
} else {
log.info('Using saved donation permit');
}
const permit = cachedPermits.shift();
strictAssert(permit, 'Donation permit is required');
await savePermitsToStorage({
permits: cachedPermits,
expiresAt: cachedPermitsExpiresAt,
});
return permit;
}
function isCacheRefreshNeeded(): boolean {
return (
cachedPermits.length === 0 ||
cachedPermitsExpiresAt == null ||
isInPast(cachedPermitsExpiresAt)
);
}
async function requestDonationPermits(): Promise<{
permits: Array<DonationPermit>;
expiresAt: number;
}> {
const serverPublicParams = new ServerPublicParams(
Bytes.fromBase64(window.getServerPublicParams())
);
const context = DonationPermitRequestContext.forCount(
PERMIT_COUNT_PER_REQUEST
);
const request = context.request();
const permitRequest = Bytes.toBase64(request.serialize());
const response = await createDonationPermits({ permitRequest });
const { permitResponse: permitResponseBytes } = response;
const permitResponse = new DonationPermitResponse(
Bytes.fromBase64(permitResponseBytes)
);
const permits = context.receive(
permitResponse,
serverPublicParams,
new Date()
);
strictAssert(permits.length, 'Donation permits must be present');
const expiresAt = Math.min(
permitResponse.expiration.getTime(),
new Date().getTime() + MAX_PERMIT_CACHE_TIME
);
strictAssert(isInFuture(expiresAt), 'Expiry must be in future');
return { permits, expiresAt };
}
function loadPermitsFromStorage(): void {
const permitsJson = itemStorage.get(PERMIT_STORAGE_KEY);
if (!permitsJson) {
return;
}
try {
const permitsData = JSON.parse(permitsJson) as unknown;
const result = safeParseUnknown(permitStorageSchema, permitsData);
if (!result.success) {
throw new Error(
`Could not load permits from storage: ${z.prettifyError(result.error)}`
);
}
const { permitsBase64, expiresAt } = result.data;
const permits = permitsBase64.map(
permitBase64 => new DonationPermit(Bytes.fromBase64(permitBase64))
);
cachedPermits = permits;
cachedPermitsExpiresAt = expiresAt;
storageLoaded = true;
log.info(
`Loaded from storage: ${permits.length} permits, expiring ${expiresAt}`
);
} catch (error) {
log.error(error);
}
}
async function savePermitsToStorage({
permits,
expiresAt,
}: {
permits: Array<DonationPermit>;
expiresAt: number | undefined;
}): Promise<void> {
if (!expiresAt) {
log.warn('expiresAt is required to save permits to storage');
return;
}
try {
const permitsBase64 = permits.map(permit =>
Bytes.toBase64(permit.serialize())
);
const storageValue = { permitsBase64, expiresAt };
const result = safeParseStrict(permitStorageSchema, storageValue);
if (!result.success) {
throw new Error(
`Permit storage validation failed: ${z.prettifyError(result.error)}`
);
}
await itemStorage.put(PERMIT_STORAGE_KEY, JSON.stringify(storageValue));
log.info(
`Saved to storage: ${permits.length} permits, expiring ${expiresAt}`
);
} catch (error) {
log.error(error);
}
}