diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb1bbff..78b263f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/components/dev/NotificationDebugPanel.vue b/src/components/dev/NotificationDebugPanel.vue index df86e799..63b40a30 100644 --- a/src/components/dev/NotificationDebugPanel.vue +++ b/src/components/dev/NotificationDebugPanel.vue @@ -497,7 +497,6 @@ async function onUploadAlertAuthorization(): Promise { `Uploaded ${result.jwtCount} JWTs (HTTP ${result.status}).`, `Batch ${result.batchId}.`, `${result.timezone}: ${result.firstDay} through ${result.lastDay}.`, - result.message, ] .filter(Boolean) .join(" "), diff --git a/src/libs/delegatedNotificationJwt.test.ts b/src/libs/delegatedNotificationJwt.test.ts deleted file mode 100644 index 2065e437..00000000 --- a/src/libs/delegatedNotificationJwt.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/services/notifications/alertAuthorization.test.ts b/src/services/notifications/alertAuthorization.test.ts index 88623d83..73ff3b8c 100644 --- a/src/services/notifications/alertAuthorization.test.ts +++ b/src/services/notifications/alertAuthorization.test.ts @@ -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", + }); + }); }); diff --git a/src/services/notifications/alertAuthorization.ts b/src/services/notifications/alertAuthorization.ts index a24e5309..9c10485b 100644 --- a/src/services/notifications/alertAuthorization.ts +++ b/src/services/notifications/alertAuthorization.ts @@ -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; isAuthBypassEnabled: () => boolean; - mintBatch: (did: string) => Promise; + mintBatch: (did: string) => Promise; + /** 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; - 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 { diff --git a/src/services/notifications/alertAuthorizationBatch.ts b/src/services/notifications/alertAuthorizationBatch.ts index ce2d39e6..d3b44dec 100644 --- a/src/services/notifications/alertAuthorizationBatch.ts +++ b/src/services/notifications/alertAuthorizationBatch.ts @@ -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 diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index b2a3033a..df73cdfe 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -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, diff --git a/src/services/notifications/pushAlertAuthorizationApi.ts b/src/services/notifications/pushAlertAuthorizationApi.ts deleted file mode 100644 index a2f79805..00000000 --- a/src/services/notifications/pushAlertAuthorizationApi.ts +++ /dev/null @@ -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( - did: string, - method: "PUT" | "DELETE", - body?: AlertAuthorizationRequestBody, -): Promise { - 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 { - const body = await buildAlertAuthorizationBody( - did, - notifyHourUtc, - notifyMinuteUtc, - ); - return pushAlertAuthorizationRequest( - 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 { - return pushAlertAuthorizationRequest( - did, - "DELETE", - ); -} diff --git a/src/test/notificationApiAuth.test.ts b/src/test/notificationApiAuth.test.ts index fe727530..b091b711 100644 --- a/src/test/notificationApiAuth.test.ts +++ b/src/test/notificationApiAuth.test.ts @@ -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", () => {