Files
crowd-funder-for-time-pwa/src/services/notifications/alertAuthorization.ts
T

206 lines
6.1 KiB
TypeScript

/**
* Manual upload of a push-channel AlertSearch authorization, for the
* notification debug panel: mint a delegated JWT batch for the active identity
* and `PUT /notifications/alert-authorization`, with the send time fixed at
* 09:00 in the device's zone.
*
* The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
* `@/interfaces/notifyApi`.
*/
import type { AlertAuthorizationRequestBody } from "@/interfaces/notifyApi";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import {
ALERT_AUTHORIZATION_BATCH_DAYS,
type AlertAuthorizationBatch,
mintAlertAuthorizationBatch,
} from "./alertAuthorizationBatch";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
import {
getActiveNotificationDid,
getNotificationApiHeaders,
httpAuthErrorMessage,
} from "./notificationApiAuth";
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
export type AlertAuthorizationUploadResult =
| {
ok: true;
status: number;
batchId: string;
timezone: string;
jwtCount: number;
firstDay: string;
lastDay: string;
}
| {
ok: false;
errorMessage: string;
status?: number;
errorCode?: string;
};
type FetchNotificationApi = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export interface AlertAuthorizationDependencies {
getActiveDid: () => Promise<string | null>;
isAuthBypassEnabled: () => boolean;
mintBatch: (did: string) => Promise<AlertAuthorizationBatch>;
/** IANA zone whose 09:00 becomes the batch's UTC send time. */
getTimeZone: () => string;
getAuthHeaders: typeof getNotificationApiHeaders;
getBaseUrl: () => string;
fetch: FetchNotificationApi;
}
const defaultDependencies: AlertAuthorizationDependencies = {
getActiveDid: getActiveNotificationDid,
isAuthBypassEnabled: shouldBypassNotificationAuth,
mintBatch: (did) => mintAlertAuthorizationBatch(did),
getTimeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
getAuthHeaders: getNotificationApiHeaders,
getBaseUrl: getNotificationApiBaseUrl,
fetch: (input, init) => globalThis.fetch(input, init),
};
function buildRequestBody(
batch: AlertAuthorizationBatch,
timeZone: string,
): AlertAuthorizationRequestBody {
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
throw new Error(
`Expected ${ALERT_AUTHORIZATION_BATCH_DAYS} delegated JWTs, received ${batch.jwts.length}.`,
);
}
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone);
return {
batchId: batch.batchId,
notifyHourUtc: notifyTime.notifyHourUtc,
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
timezone: timeZone,
jwts: batch.jwts,
};
}
/**
* The code and message of a refusal, and nothing else from its body. The
* service sends an `AlertAuthorizationFailure`: the code, when there is one,
* under `error`, and the sentence under `message`.
*/
async function readRefusal(
response: Response,
): Promise<{ message?: string; errorCode?: string }> {
let body: unknown;
try {
body = await response.json();
} catch {
return {};
}
if (typeof body !== "object" || body === null) {
return {};
}
const { error, message } = body as { error?: unknown; message?: unknown };
return {
...(typeof message === "string" && message.trim()
? { message: message.trim() }
: {}),
...(typeof error === "string" && error.trim()
? { errorCode: error.trim() }
: {}),
};
}
/**
* Mint and manually upload a delegated AlertSearch authorization batch.
* This function never retries and refuses notification auth bypass mode.
*/
export async function uploadAlertSearchAuthorization(
dependencies: AlertAuthorizationDependencies = defaultDependencies,
): Promise<AlertAuthorizationUploadResult> {
try {
const did = await dependencies.getActiveDid();
if (!did) {
return { ok: false, errorMessage: "No active identity is available." };
}
if (!did.startsWith(ETHR_DID_PREFIX)) {
return {
ok: false,
errorMessage:
"AlertSearch authorization requires an active did:ethr identity.",
};
}
if (dependencies.isAuthBypassEnabled()) {
return {
ok: false,
errorMessage:
"AlertSearch authorization cannot be uploaded while JWT authentication is skipped.",
};
}
const batch = await dependencies.mintBatch(did);
const timeZone = dependencies.getTimeZone();
const body = buildRequestBody(batch, timeZone);
const auth = await dependencies.getAuthHeaders(undefined, did);
if (!auth.ok) {
return {
ok: false,
errorMessage: `Authentication unavailable: ${auth.message}`,
};
}
if (!auth.authenticated) {
return {
ok: false,
errorMessage:
"AlertSearch authorization requires authenticated notification API headers.",
};
}
const response = await dependencies.fetch(
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
{
method: "PUT",
headers: auth.headers,
body: JSON.stringify(body),
},
);
if (!response.ok) {
const refusal = await readRefusal(response);
const fallback =
response.status === 401 || response.status === 403
? httpAuthErrorMessage(response.status)
: `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
errorMessage: refusal.message || fallback,
...(refusal.errorCode ? { errorCode: refusal.errorCode } : {}),
};
}
return {
ok: true,
status: response.status,
batchId: body.batchId,
timezone: timeZone,
jwtCount: body.jwts.length,
firstDay: body.jwts[0]!.day,
lastDay: body.jwts[body.jwts.length - 1]!.day,
};
} catch (error: unknown) {
return {
ok: false,
errorMessage: error instanceof Error ? error.message : "Upload failed.",
};
}
}