reconcile & fix changes in FCM & SMS notifications
This commit is contained in:
+2
-3
@@ -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(" "),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user