206 lines
6.1 KiB
TypeScript
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.",
|
|
};
|
|
}
|
|
}
|