reconcile & fix changes in FCM & SMS notifications

This commit is contained in:
2026-09-14 10:23:06 -06:00
parent 44695a51cc
commit 6baf2a98ac
9 changed files with 128 additions and 427 deletions
+2 -3
View File
@@ -11,9 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Full flow for setting up SMS notifications: phone registration and code
verification, delegated alertSearch JWT batches with a send hour, and
revocation to stop the texts
- Push-channel client for the notify-api's alert authorization
(`authorizePushAlertSearch`, `revokePushAlertSearch`), built on the same batch
minter as the SMS channel
- Notification debug panel action to mint and upload a push-channel alertSearch
authorization, built on the same batch minter as the SMS channel
## [1.3.8] - 2026
@@ -497,7 +497,6 @@ async function onUploadAlertAuthorization(): Promise<void> {
`Uploaded ${result.jwtCount} JWTs (HTTP ${result.status}).`,
`Batch ${result.batchId}.`,
`${result.timezone}: ${result.firstDay} through ${result.lastDay}.`,
result.message,
]
.filter(Boolean)
.join(" "),
-101
View File
@@ -1,101 +0,0 @@
import { DateTime } from "luxon";
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import {
buildDelegatedNotificationJwtWindows,
mintDelegatedNotificationJwtBatch,
} from "@/libs/delegatedNotificationJwt";
jest.mock("@/libs/util", () => ({
retrieveAccountMetadata: jest.fn().mockResolvedValue({}),
}));
jest.mock("@/libs/crypto/vc", () => ({
isFromPasskey: jest.fn().mockReturnValue(false),
}));
jest.mock("@/libs/endorserServer", () => ({
createEndorserJwtForDid: jest.fn(
async (_did: string, claims: { jti?: string }) =>
`signed:${String(claims.jti)}`,
),
}));
const FIXED_NOW = new Date("2026-09-10T16:00:00.000Z");
const UTC_DAY_0 = "2026-09-10";
const NBF_0 = Math.floor(Date.UTC(2026, 8, 10) / 1000);
const EXP_0 = Math.floor(Date.UTC(2026, 8, 11) / 1000);
describe("buildDelegatedNotificationJwtWindows", () => {
it("builds 100 consecutive UTC calendar-day windows from a fixed now", () => {
const windows = buildDelegatedNotificationJwtWindows(
DELEGATED_NOTIFICATION_JWT_COUNT,
"Asia/Manila",
FIXED_NOW,
);
expect(windows).toHaveLength(DELEGATED_NOTIFICATION_JWT_COUNT);
expect(windows[0]!.sequence).toBe(1);
expect(windows[0]!.utcDay).toBe(UTC_DAY_0);
expect(windows[0]!.nbf).toBe(NBF_0);
expect(windows[0]!.exp).toBe(EXP_0);
expect(windows[0]!.exp - windows[0]!.nbf).toBe(86_400);
const manilaLocalDate = DateTime.fromJSDate(FIXED_NOW, {
zone: "Asia/Manila",
}).toFormat("yyyy-LL-dd");
expect(manilaLocalDate).toBe("2026-09-11");
expect(windows[0]!.utcDay).not.toBe(manilaLocalDate);
for (let i = 0; i < windows.length; i++) {
const window = windows[i]!;
const expectedDay = DateTime.fromMillis(FIXED_NOW.getTime(), {
zone: "utc",
})
.startOf("day")
.plus({ days: i });
expect(window.sequence).toBe(i + 1);
expect(window.utcDay).toBe(expectedDay.toFormat("yyyy-LL-dd"));
expect(window.utcDay).toBe(
new Date(window.nbf * 1000).toISOString().slice(0, 10),
);
expect(window.nbf).toBe(Math.floor(expectedDay.toSeconds()));
expect(window.exp).toBe(
Math.floor(expectedDay.plus({ days: 1 }).toSeconds()),
);
expect(window.exp - window.nbf).toBe(86_400);
}
expect(windows[windows.length - 1]!.sequence).toBe(100);
expect(windows[windows.length - 1]!.utcDay).toBe("2026-12-18");
});
});
describe("mintDelegatedNotificationJwtBatch jti", () => {
it("uses the UTC calendar day in jti even when the device zone is Asia/Manila", async () => {
const { createEndorserJwtForDid } = jest.requireMock(
"@/libs/endorserServer",
) as {
createEndorserJwtForDid: jest.Mock;
};
createEndorserJwtForDid.mockClear();
const did = `did:ethr:0x${"a".repeat(40)}`;
const batch = await mintDelegatedNotificationJwtBatch(did, {
timeZone: "Asia/Manila",
now: FIXED_NOW,
});
expect(batch.tokens[0]!.utcDay).toBe(UTC_DAY_0);
expect(createEndorserJwtForDid).toHaveBeenCalled();
const firstClaims = createEndorserJwtForDid.mock.calls[0]![1] as {
jti: string;
nbf: number;
exp: number;
};
expect(firstClaims.jti).toBe(`${did}#delegated-notify#${UTC_DAY_0}`);
expect(firstClaims.jti).not.toContain("2026-09-11");
expect(firstClaims.nbf).toBe(NBF_0);
expect(firstClaims.exp).toBe(EXP_0);
});
});
@@ -1,5 +1,4 @@
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
import type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
import {
type AlertAuthorizationDependencies,
uploadAlertSearchAuthorization,
@@ -9,8 +8,9 @@ import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime
jest.mock("@/libs/crypto/vc", () => ({
ETHR_DID_PREFIX: "did:ethr:",
}));
jest.mock("@/libs/delegatedNotificationJwt", () => ({
mintDelegatedNotificationJwtBatch: jest.fn(),
jest.mock("./alertAuthorizationBatch", () => ({
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
mintAlertAuthorizationBatch: jest.fn(),
}));
jest.mock("./NotificationDebugConfig", () => ({
getNotificationApiBaseUrl: jest.fn(),
@@ -24,27 +24,21 @@ jest.mock("./notificationApiDebugMode", () => ({
shouldBypassNotificationAuth: jest.fn(),
}));
const BATCH_DAYS = 100;
const ACTIVE_DID = `did:ethr:0x${"0".repeat(40)}`;
const TIME_ZONE = "America/Denver";
const BATCH_ID = "batch-fixture";
function createMintedBatch(): DelegatedNotificationJwtBatch {
function createMintedBatch(): AlertAuthorizationBatch {
return {
did: ACTIVE_DID,
timeZone: TIME_ZONE,
mintedAtEpoch: 1_788_220_800,
tokens: Array.from(
{ length: DELEGATED_NOTIFICATION_JWT_COUNT },
(_, index) => ({
sequence: index + 1,
utcDay: new Date(Date.UTC(2026, 8, index + 1))
.toISOString()
.slice(0, 10),
nbf: 1_788_220_800 + index * 86_400,
exp: 1_788_307_200 + index * 86_400,
jwt: `delegated-token-${index + 1}`,
}),
),
batchId: BATCH_ID,
jwts: Array.from({ length: BATCH_DAYS }, (_, index) => ({
sequence: index,
day: new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10),
nbf: 1_788_220_800 + index * 86_400,
exp: 1_788_307_200 + index * 86_400,
jwt: `delegated-token-${index}`,
})),
};
}
@@ -62,6 +56,7 @@ function createResponse(
function createDependencies(overrides?: {
did?: string | null;
bypassAuth?: boolean;
timeZone?: string;
response?: Response;
}): {
dependencies: AlertAuthorizationDependencies;
@@ -93,9 +88,9 @@ function createDependencies(overrides?: {
.fn()
.mockReturnValue(overrides?.bypassAuth ?? false),
mintBatch: mintMock,
getTimeZone: () => overrides?.timeZone ?? TIME_ZONE,
getAuthHeaders: authMock,
getBaseUrl: () => "https://notification-backend.invalid",
createBatchId: () => BATCH_ID,
fetch: fetchMock,
},
fetchMock,
@@ -105,17 +100,21 @@ function createDependencies(overrides?: {
}
describe("uploadAlertSearchAuthorization", () => {
it("uploads the existing 100-token batch with authenticated PUT semantics", async () => {
it("uploads the minted batch with authenticated PUT semantics", async () => {
const { dependencies, fetchMock, mintMock, authMock } =
createDependencies();
const minted = createMintedBatch();
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toMatchObject({
expect(result).toEqual({
ok: true,
status: 200,
batchId: BATCH_ID,
timezone: TIME_ZONE,
jwtCount: DELEGATED_NOTIFICATION_JWT_COUNT,
jwtCount: BATCH_DAYS,
firstDay: minted.jwts[0]!.day,
lastDay: minted.jwts[BATCH_DAYS - 1]!.day,
});
expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID);
expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID);
@@ -131,46 +130,13 @@ describe("uploadAlertSearchAuthorization", () => {
Authorization: "Bearer access-token-fixture",
});
const body = JSON.parse(String(init.body)) as {
batchId: string;
notifyHourUtc: number;
notifyMinuteUtc: number;
timezone: string;
jwts: Array<{
sequence: number;
day: string;
nbf: number;
exp: number;
jwt: string;
}>;
};
const expectedNotify = alertSearchNotifyTimeUtcFromLocalNineAm(TIME_ZONE);
expect(body.batchId).toBe(BATCH_ID);
expect(body.notifyHourUtc).toBe(expectedNotify.notifyHourUtc);
expect(body.notifyMinuteUtc).toBe(expectedNotify.notifyMinuteUtc);
expect(Number.isInteger(body.notifyHourUtc)).toBe(true);
expect(Number.isInteger(body.notifyMinuteUtc)).toBe(true);
expect(body.notifyHourUtc).toBeGreaterThanOrEqual(0);
expect(body.notifyHourUtc).toBeLessThanOrEqual(23);
expect(body.notifyMinuteUtc).toBeGreaterThanOrEqual(0);
expect(body.notifyMinuteUtc).toBeLessThanOrEqual(59);
expect(body.timezone).toBe(TIME_ZONE);
expect(body.jwts).toHaveLength(DELEGATED_NOTIFICATION_JWT_COUNT);
expect(body.jwts.map((entry) => entry.sequence)).toEqual(
Array.from(
{ length: DELEGATED_NOTIFICATION_JWT_COUNT },
(_, index) => index + 1,
),
);
expect(body.jwts.map((entry) => entry.jwt)).toEqual(
createMintedBatch().tokens.map((slot) => slot.jwt),
);
expect(body.jwts[0]).toEqual({
sequence: 1,
day: createMintedBatch().tokens[0]!.utcDay,
nbf: createMintedBatch().tokens[0]!.nbf,
exp: createMintedBatch().tokens[0]!.exp,
jwt: createMintedBatch().tokens[0]!.jwt,
expect(JSON.parse(String(init.body))).toEqual({
batchId: BATCH_ID,
notifyHourUtc: expectedNotify.notifyHourUtc,
notifyMinuteUtc: expectedNotify.notifyMinuteUtc,
timezone: TIME_ZONE,
jwts: minted.jwts,
});
});
@@ -219,11 +185,26 @@ describe("uploadAlertSearchAuthorization", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces non-2xx responses using only safe response fields", async () => {
it("does not submit when the device zone cannot be resolved", async () => {
const { dependencies, fetchMock } = createDependencies({
timeZone: "Not/AZone",
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result.ok).toBe(false);
expect(result.ok ? "" : result.errorMessage).toContain("Not/AZone");
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces a coded refusal using only its code and message", async () => {
const { dependencies } = createDependencies({
response: createResponse(422, {
code: "INVALID_BATCH",
message: "Batch validation failed.",
response: createResponse(400, {
success: false,
error: "ALERT_AUTHORIZATION_INVALID_BATCH",
message:
"Delegated notification-JWT batch was rejected. Nothing was stored.",
details: ["jwts[0].jwt failed verification"],
jwt: "must-not-be-returned",
}),
});
@@ -232,10 +213,28 @@ describe("uploadAlertSearchAuthorization", () => {
expect(result).toEqual({
ok: false,
status: 422,
errorCode: "INVALID_BATCH",
errorMessage: "Batch validation failed.",
status: 400,
errorCode: "ALERT_AUTHORIZATION_INVALID_BATCH",
errorMessage:
"Delegated notification-JWT batch was rejected. Nothing was stored.",
});
expect(JSON.stringify(result)).not.toContain("must-not-be-returned");
});
it("surfaces an uncoded auth refusal without an error code", async () => {
const { dependencies } = createDependencies({
response: createResponse(401, {
success: false,
message: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z",
}),
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
status: 401,
errorMessage: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z",
});
});
});
@@ -1,7 +1,21 @@
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
/**
* 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 { mintDelegatedNotificationJwtBatch } from "@/libs/delegatedNotificationJwt";
import {
ALERT_AUTHORIZATION_BATCH_DAYS,
type AlertAuthorizationBatch,
mintAlertAuthorizationBatch,
} from "./alertAuthorizationBatch";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
import {
@@ -11,20 +25,6 @@ import {
} from "./notificationApiAuth";
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
export interface AlertAuthorizationRequestBody {
batchId: string;
notifyHourUtc: number;
notifyMinuteUtc: number;
timezone: string;
jwts: Array<{
sequence: number;
day: string;
nbf: number;
exp: number;
jwt: string;
}>;
}
export type AlertAuthorizationUploadResult =
| {
ok: true;
@@ -34,7 +34,6 @@ export type AlertAuthorizationUploadResult =
jwtCount: number;
firstDay: string;
lastDay: string;
message?: string;
}
| {
ok: false;
@@ -51,58 +50,51 @@ type FetchNotificationApi = (
export interface AlertAuthorizationDependencies {
getActiveDid: () => Promise<string | null>;
isAuthBypassEnabled: () => boolean;
mintBatch: (did: string) => Promise<DelegatedNotificationJwtBatch>;
mintBatch: (did: string) => Promise<AlertAuthorizationBatch>;
/** IANA zone whose 09:00 becomes the batch's UTC send time. */
getTimeZone: () => string;
getAuthHeaders: typeof getNotificationApiHeaders;
getBaseUrl: () => string;
createBatchId: () => string;
fetch: FetchNotificationApi;
}
function createBatchId(): string {
if (!globalThis.crypto?.randomUUID) {
throw new Error("Secure batch ID generation is unavailable.");
}
return globalThis.crypto.randomUUID();
}
const defaultDependencies: AlertAuthorizationDependencies = {
getActiveDid: getActiveNotificationDid,
isAuthBypassEnabled: shouldBypassNotificationAuth,
mintBatch: mintDelegatedNotificationJwtBatch,
mintBatch: (did) => mintAlertAuthorizationBatch(did),
getTimeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
getAuthHeaders: getNotificationApiHeaders,
getBaseUrl: getNotificationApiBaseUrl,
createBatchId,
fetch: globalThis.fetch.bind(globalThis),
fetch: (input, init) => globalThis.fetch(input, init),
};
function buildRequestBody(
batchId: string,
batch: DelegatedNotificationJwtBatch,
batch: AlertAuthorizationBatch,
timeZone: string,
): AlertAuthorizationRequestBody {
if (batch.tokens.length !== DELEGATED_NOTIFICATION_JWT_COUNT) {
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
throw new Error(
`Expected ${DELEGATED_NOTIFICATION_JWT_COUNT} delegated JWTs, received ${batch.tokens.length}.`,
`Expected ${ALERT_AUTHORIZATION_BATCH_DAYS} delegated JWTs, received ${batch.jwts.length}.`,
);
}
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(batch.timeZone);
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone);
return {
batchId,
batchId: batch.batchId,
notifyHourUtc: notifyTime.notifyHourUtc,
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
timezone: batch.timeZone,
jwts: batch.tokens.map((slot) => ({
sequence: slot.sequence,
day: slot.utcDay,
nbf: slot.nbf,
exp: slot.exp,
jwt: slot.jwt,
})),
timezone: timeZone,
jwts: batch.jwts,
};
}
async function readSafeResponseDetail(
/**
* 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;
@@ -115,19 +107,14 @@ async function readSafeResponseDetail(
return {};
}
const record = body as Record<string, unknown>;
const messageKeys = ["message", "reason", "error"] as const;
const codeKeys = ["code", "errorCode"] as const;
const message = messageKeys
.map((key) => record[key])
.find((value): value is string => typeof value === "string" && !!value);
const errorCode = codeKeys
.map((key) => record[key])
.find((value): value is string => typeof value === "string" && !!value);
const { error, message } = body as { error?: unknown; message?: unknown };
return {
...(message ? { message: message.trim() } : {}),
...(errorCode ? { errorCode: errorCode.trim() } : {}),
...(typeof message === "string" && message.trim()
? { message: message.trim() }
: {}),
...(typeof error === "string" && error.trim()
? { errorCode: error.trim() }
: {}),
};
}
@@ -159,15 +146,8 @@ export async function uploadAlertSearchAuthorization(
}
const batch = await dependencies.mintBatch(did);
if (batch.did !== did) {
return {
ok: false,
errorMessage:
"Delegated JWT batch identity does not match active identity.",
};
}
const batchId = dependencies.createBatchId();
const body = buildRequestBody(batchId, batch);
const timeZone = dependencies.getTimeZone();
const body = buildRequestBody(batch, timeZone);
const auth = await dependencies.getAuthHeaders(undefined, did);
if (!auth.ok) {
@@ -192,9 +172,9 @@ export async function uploadAlertSearchAuthorization(
body: JSON.stringify(body),
},
);
const detail = await readSafeResponseDetail(response);
if (!response.ok) {
const refusal = await readRefusal(response);
const fallback =
response.status === 401 || response.status === 403
? httpAuthErrorMessage(response.status)
@@ -202,20 +182,19 @@ export async function uploadAlertSearchAuthorization(
return {
ok: false,
status: response.status,
errorMessage: detail.message || fallback,
...(detail.errorCode ? { errorCode: detail.errorCode } : {}),
errorMessage: refusal.message || fallback,
...(refusal.errorCode ? { errorCode: refusal.errorCode } : {}),
};
}
return {
ok: true,
status: response.status,
batchId,
timezone: body.timezone,
batchId: body.batchId,
timezone: timeZone,
jwtCount: body.jwts.length,
firstDay: body.jwts[0]!.day,
lastDay: body.jwts[body.jwts.length - 1]!.day,
...(detail.message ? { message: detail.message } : {}),
};
} catch (error: unknown) {
return {
@@ -8,7 +8,7 @@
* and stops when the inventory runs out.
*
* Both delivery channels take the same batch: push through
* `PUT /notifications/alert-authorization` (see `pushAlertAuthorizationApi.ts`)
* `PUT /notifications/alert-authorization` (see `alertAuthorization.ts`)
* and SMS through `POST /notify-sms/alert-authorization` (see
* `smsNotificationApi.ts`). The service validates both with one rule and keeps
* a separate inventory per channel, so this module is the only minter for
+1 -10
View File
@@ -39,10 +39,7 @@ export { NotificationService, registerToken } from "./NotificationService";
export { NativeNotificationService } from "./NativeNotificationService";
export { WebPushNotificationService } from "./WebPushNotificationService";
export { uploadAlertSearchAuthorization } from "./alertAuthorization";
export type {
AlertAuthorizationRequestBody,
AlertAuthorizationUploadResult,
} from "./alertAuthorization";
export type { AlertAuthorizationUploadResult } from "./alertAuthorization";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export {
@@ -69,12 +66,6 @@ export {
} from "./alertAuthorizationBatch";
export type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
export {
authorizePushAlertSearch,
PushAlertAuthorizationError,
revokePushAlertSearch,
} from "./pushAlertAuthorizationApi";
export {
authorizeSmsAlertSearch,
deleteSmsPhone,
@@ -1,169 +0,0 @@
/**
* Client for the notify-api's push-channel alertSearch authorization,
* `/notifications/alert-authorization`.
*
* The push counterpart of `authorizeSmsAlertSearch` / `revokeSmsAlertSearch`:
* the same batch body, stored in the push channel's own inventory, so the daily
* digest arrives as an FCM message on the DID's registered devices.
*
* Requests carry an ordinary access token for the DID. There is no per-action
* claim as on the SMS routes, and the service refuses the `testMode` bypass on
* these routes, so the notification debug auth bypass does not apply.
*
* The wire shapes are in `@/interfaces/notifyApi`; the route contract is in
* `notification-wakeup-service/README.md`.
*/
import type {
AlertAuthorizationRequestBody,
AlertAuthorizationResponse,
AlertAuthorizationRevokeResponse,
} from "@/interfaces/notifyApi";
import { getHeaders } from "@/libs/endorserServer";
import { logger } from "@/utils/logger";
import { buildAlertAuthorizationBody } from "./alertAuthorizationBatch";
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
const ALERT_AUTHORIZATION_PATH = "/notifications/alert-authorization";
/** A push alert-authorization call that did not succeed. */
export class PushAlertAuthorizationError extends Error {
/**
* The service's code, such as `ALERT_AUTHORIZATION_INVALID_BATCH`;
* `NO_ACTIVE_IDENTITY` or `NO_ACCESS_TOKEN` when no request was sent; "" when
* the refusal carried no code.
*/
readonly code: string;
/** HTTP status, or 0 when no request was sent. */
readonly status: number;
/** Specific problems with a refused batch; empty for any other refusal. */
readonly details: string[];
constructor(
code: string,
status: number,
message: string,
details: string[],
) {
super(message);
this.name = "PushAlertAuthorizationError";
this.code = code;
this.status = status;
this.details = details;
}
}
async function pushAlertAuthorizationRequest<T>(
did: string,
method: "PUT" | "DELETE",
body?: AlertAuthorizationRequestBody,
): Promise<T> {
if (!did) {
throw new PushAlertAuthorizationError(
"NO_ACTIVE_IDENTITY",
0,
"No active identity to authorize the request",
[],
);
}
// Authenticate as `did` itself rather than whichever identity is active: the
// service rejects a batch whose JWTs were issued by anyone but the caller.
const headers = await getHeaders(did);
if (!headers.Authorization) {
throw new PushAlertAuthorizationError(
"NO_ACCESS_TOKEN",
0,
"Could not create an access token for the request",
[],
);
}
const response = await fetch(
`${getNotificationApiBaseUrl()}${ALERT_AUTHORIZATION_PATH}`,
{
method,
headers,
body: body ? JSON.stringify(body) : undefined,
},
);
const text = await response.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
}
if (!response.ok) {
// An `AlertAuthorizationFailure`, or from a proxy, something else entirely.
const refusal = (parsed && typeof parsed === "object" ? parsed : {}) as {
error?: unknown;
message?: unknown;
details?: unknown;
};
const code = typeof refusal.error === "string" ? refusal.error : "";
const message =
typeof refusal.message === "string" && refusal.message
? refusal.message
: `HTTP ${response.status}`;
const details = Array.isArray(refusal.details)
? refusal.details.map(String)
: [];
logger.warn("[pushAlertAuthorizationApi] request failed", {
method,
status: response.status,
code,
});
throw new PushAlertAuthorizationError(
code,
response.status,
message,
details,
);
}
return (parsed ?? {}) as T;
}
/**
* Hand the push channel a fresh inventory of delegated JWTs and the UTC time of
* day to notify. Replaces any unused batch already stored, so it is also how
* the notify time is changed.
*
* @param notifyHourUtc UTC hour, 0-23
* @param notifyMinuteUtc UTC minute, 0-59
* @throws UnsupportedIdentityError for passkey identities, before any request
*/
export async function authorizePushAlertSearch(
did: string,
notifyHourUtc: number,
notifyMinuteUtc: number,
): Promise<AlertAuthorizationResponse> {
const body = await buildAlertAuthorizationBody(
did,
notifyHourUtc,
notifyMinuteUtc,
);
return pushAlertAuthorizationRequest<AlertAuthorizationResponse>(
did,
"PUT",
body,
);
}
/**
* Turn the push digest off: every push batch and JWT for this DID is removed.
* Device registrations and the SMS channel are untouched.
*/
export async function revokePushAlertSearch(
did: string,
): Promise<AlertAuthorizationRevokeResponse> {
return pushAlertAuthorizationRequest<AlertAuthorizationRevokeResponse>(
did,
"DELETE",
);
}
+4
View File
@@ -14,6 +14,10 @@ jest.mock("@/services/notifications/notificationApiDebugMode", () => ({
jest.mock("@/services/notifications/NotificationDebugEvents", () => ({
logNotification: jest.fn(),
}));
// The real module reads `@/constants/app`, whose `import.meta.env` Jest cannot parse.
jest.mock("@/services/notifications/NotificationDebugConfig", () => ({
getNotificationDebugOverrideHeaders: () => ({}),
}));
describe("notificationApiFailureMessage", () => {
it("reads a device route's `error` sentence", () => {