/** * 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; export interface AlertAuthorizationDependencies { getActiveDid: () => Promise; isAuthBypassEnabled: () => boolean; mintBatch: (did: string) => Promise; /** 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 { 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.", }; } }