Add a manual debug-panel action to mint and PUT the 100-day AlertSearch authorization batch.

Reuse the existing delegated JWT minting and notification API auth so a signed-in ethr identity can upload the batch to the configured notify backend without changing production defaults or sending it automatically.
This commit is contained in:
Jose Olarte III
2026-09-02 15:34:03 +08:00
parent d5bcdbae3f
commit 34a8c51d2f
6 changed files with 551 additions and 3 deletions
@@ -64,6 +64,30 @@
>
Register Token Now
</button>
<button
class="w-full text-md bg-gradient-to-b from-cyan-500 to-cyan-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onUploadAlertAuthorization"
>
Upload AlertSearch Authorization
</button>
<p
v-if="alertAuthorizationStatus"
class="text-xs rounded px-3 py-2 border"
:class="
alertAuthorizationStatus.ok
? 'text-emerald-900 bg-emerald-50 border-emerald-200'
: 'text-rose-900 bg-rose-50 border-rose-200'
"
role="status"
>
{{ alertAuthorizationStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
Manually mints and uploads 100 delegated day JWTs. Requires an active
did:ethr identity and JWT authentication; Test Mode is not used.
</p>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
@@ -352,6 +376,10 @@ const fcmToken = ref<string | null>(NotificationDebugService.getFcmToken());
const activeBackendUrl = ref(NotificationDebugService.getActiveBackendUrl());
const realWakeupStatus = ref<{ ok: boolean; message: string } | null>(null);
const alertAuthorizationStatus = ref<{
ok: boolean;
message: string;
} | null>(null);
const truncatedFcmToken = computed(() => {
const t = fcmToken.value?.trim() ?? "";
@@ -457,6 +485,36 @@ async function onRegisterToken(): Promise<void> {
});
}
async function onUploadAlertAuthorization(): Promise<void> {
alertAuthorizationStatus.value = null;
await withBusy(async () => {
const result =
await NotificationDebugService.uploadAlertSearchAuthorization();
alertAuthorizationStatus.value = result.ok
? {
ok: true,
message: [
`Uploaded ${result.jwtCount} JWTs (HTTP ${result.status}).`,
`Batch ${result.batchId}.`,
`${result.timezone}: ${result.firstDay} through ${result.lastDay}.`,
result.message,
]
.filter(Boolean)
.join(" "),
}
: {
ok: false,
message: [
`AlertSearch authorization upload failed: ${result.errorMessage}`,
result.status != null ? `(HTTP ${result.status})` : undefined,
result.errorCode ? `Code: ${result.errorCode}.` : undefined,
]
.filter(Boolean)
.join(" "),
};
});
}
async function onBackendRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerBackendRefresh();
@@ -42,6 +42,10 @@ import {
import { truncateFcmTokenForLog } from "./notificationLog";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
import {
uploadAlertSearchAuthorization as uploadAlertSearchAuthorizationBatch,
type AlertAuthorizationUploadResult,
} from "./alertAuthorization";
type PendingNotificationInfo = {
identifier: string;
@@ -165,6 +169,30 @@ export const NotificationDebugService = {
await reregisterFcmTokenNow();
},
async uploadAlertSearchAuthorization(): Promise<AlertAuthorizationUploadResult> {
logNotification("AlertSearch authorization upload requested");
const result = await uploadAlertSearchAuthorizationBatch();
if (result.ok) {
logNotification("AlertSearch authorization upload succeeded", {
batchId: result.batchId,
timezone: result.timezone,
jwtCount: result.jwtCount,
firstDay: result.firstDay,
lastDay: result.lastDay,
status: result.status,
});
} else {
logNotification(
`AlertSearch authorization upload failed: ${result.errorMessage}`,
{
...(result.status != null ? { status: result.status } : {}),
...(result.errorCode ? { errorCode: result.errorCode } : {}),
},
);
}
return result;
},
async triggerBackendRefresh(): Promise<void> {
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
},
@@ -0,0 +1,229 @@
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
import {
type AlertAuthorizationDependencies,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
jest.mock("@/libs/crypto/vc", () => ({
ETHR_DID_PREFIX: "did:ethr:",
}));
jest.mock("@/libs/delegatedNotificationJwt", () => ({
mintDelegatedNotificationJwtBatch: jest.fn(),
}));
jest.mock("./NotificationDebugConfig", () => ({
getNotificationApiBaseUrl: jest.fn(),
}));
jest.mock("./notificationApiAuth", () => ({
getActiveNotificationDid: jest.fn(),
getNotificationApiHeaders: jest.fn(),
httpAuthErrorMessage: (status: number) => `HTTP ${status}`,
}));
jest.mock("./notificationApiDebugMode", () => ({
shouldBypassNotificationAuth: jest.fn(),
}));
const ACTIVE_DID = `did:ethr:0x${"0".repeat(40)}`;
const TIME_ZONE = "America/Denver";
const BATCH_ID = "batch-fixture";
function createMintedBatch(): DelegatedNotificationJwtBatch {
return {
did: ACTIVE_DID,
timeZone: TIME_ZONE,
mintedAtEpoch: 1_788_220_800,
tokens: Array.from(
{ length: DELEGATED_NOTIFICATION_JWT_COUNT },
(_, index) => ({
sequence: index + 1,
localDate: 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}`,
}),
),
};
}
function createResponse(
status: number,
body: Record<string, unknown>,
): Response {
return {
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockResolvedValue(body),
} as unknown as Response;
}
function createDependencies(overrides?: {
did?: string | null;
bypassAuth?: boolean;
response?: Response;
}): {
dependencies: AlertAuthorizationDependencies;
fetchMock: jest.Mock;
mintMock: jest.Mock;
authMock: jest.Mock;
} {
const fetchMock = jest
.fn()
.mockResolvedValue(overrides?.response ?? createResponse(200, {}));
const mintMock = jest.fn().mockResolvedValue(createMintedBatch());
const authMock = jest.fn().mockResolvedValue({
ok: true,
authenticated: true,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer access-token-fixture",
},
});
return {
dependencies: {
getActiveDid: jest
.fn()
.mockResolvedValue(
overrides && "did" in overrides ? overrides.did : ACTIVE_DID,
),
isAuthBypassEnabled: jest
.fn()
.mockReturnValue(overrides?.bypassAuth ?? false),
mintBatch: mintMock,
getAuthHeaders: authMock,
getBaseUrl: () => "https://notification-backend.invalid",
createBatchId: () => BATCH_ID,
fetch: fetchMock,
},
fetchMock,
mintMock,
authMock,
};
}
describe("uploadAlertSearchAuthorization", () => {
it("uploads the existing 100-token batch with authenticated PUT semantics", async () => {
const { dependencies, fetchMock, mintMock, authMock } =
createDependencies();
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toMatchObject({
ok: true,
batchId: BATCH_ID,
timezone: TIME_ZONE,
jwtCount: DELEGATED_NOTIFICATION_JWT_COUNT,
});
expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID);
expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://notification-backend.invalid/notifications/alert-authorization",
);
expect(init.method).toBe("PUT");
expect(init.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer access-token-fixture",
});
const body = JSON.parse(String(init.body)) as {
batchId: string;
timezone: string;
jwts: Array<{
sequence: number;
day: string;
nbf: number;
exp: number;
jwt: string;
}>;
};
expect(body.batchId).toBe(BATCH_ID);
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]!.localDate,
nbf: createMintedBatch().tokens[0]!.nbf,
exp: createMintedBatch().tokens[0]!.exp,
jwt: createMintedBatch().tokens[0]!.jwt,
});
});
it("does not log delegated JWT contents", async () => {
const logSpy = jest.spyOn(console, "log").mockImplementation();
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
const errorSpy = jest.spyOn(console, "error").mockImplementation();
const { dependencies } = createDependencies();
await uploadAlertSearchAuthorization(dependencies);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
});
it("rejects unsupported identities before minting or network submission", async () => {
const { dependencies, fetchMock, mintMock, authMock } = createDependencies({
did: "did:peer:unsupported",
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
errorMessage:
"AlertSearch authorization requires an active did:ethr identity.",
});
expect(mintMock).not.toHaveBeenCalled();
expect(authMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects notification auth bypass before minting or submission", async () => {
const { dependencies, fetchMock, mintMock } = createDependencies({
bypassAuth: true,
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result.ok).toBe(false);
expect(mintMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces non-2xx responses using only safe response fields", async () => {
const { dependencies } = createDependencies({
response: createResponse(422, {
code: "INVALID_BATCH",
message: "Batch validation failed.",
jwt: "must-not-be-returned",
}),
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
status: 422,
errorCode: "INVALID_BATCH",
errorMessage: "Batch validation failed.",
});
expect(JSON.stringify(result)).not.toContain("must-not-be-returned");
});
});
@@ -0,0 +1,219 @@
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { mintDelegatedNotificationJwtBatch } from "@/libs/delegatedNotificationJwt";
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
import {
getActiveNotificationDid,
getNotificationApiHeaders,
httpAuthErrorMessage,
} from "./notificationApiAuth";
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
export interface AlertAuthorizationRequestBody {
batchId: string;
timezone: string;
jwts: Array<{
sequence: number;
day: string;
nbf: number;
exp: number;
jwt: string;
}>;
}
export type AlertAuthorizationUploadResult =
| {
ok: true;
status: number;
batchId: string;
timezone: string;
jwtCount: number;
firstDay: string;
lastDay: string;
message?: 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<DelegatedNotificationJwtBatch>;
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,
getAuthHeaders: getNotificationApiHeaders,
getBaseUrl: getNotificationApiBaseUrl,
createBatchId,
fetch: globalThis.fetch.bind(globalThis),
};
function buildRequestBody(
batchId: string,
batch: DelegatedNotificationJwtBatch,
): AlertAuthorizationRequestBody {
if (batch.tokens.length !== DELEGATED_NOTIFICATION_JWT_COUNT) {
throw new Error(
`Expected ${DELEGATED_NOTIFICATION_JWT_COUNT} delegated JWTs, received ${batch.tokens.length}.`,
);
}
return {
batchId,
timezone: batch.timeZone,
jwts: batch.tokens.map((slot) => ({
sequence: slot.sequence,
day: slot.localDate,
nbf: slot.nbf,
exp: slot.exp,
jwt: slot.jwt,
})),
};
}
async function readSafeResponseDetail(
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 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);
return {
...(message ? { message: message.trim() } : {}),
...(errorCode ? { errorCode: errorCode.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);
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 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),
},
);
const detail = await readSafeResponseDetail(response);
if (!response.ok) {
const fallback =
response.status === 401 || response.status === 403
? httpAuthErrorMessage(response.status)
: `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
errorMessage: detail.message || fallback,
...(detail.errorCode ? { errorCode: detail.errorCode } : {}),
};
}
return {
ok: true,
status: response.status,
batchId,
timezone: body.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 {
ok: false,
errorMessage: error instanceof Error ? error.message : "Upload failed.",
};
}
}
+5
View File
@@ -35,6 +35,11 @@ export {
export { NotificationService, registerToken } from "./NotificationService";
export { NativeNotificationService } from "./NativeNotificationService";
export { WebPushNotificationService } from "./WebPushNotificationService";
export { uploadAlertSearchAuthorization } from "./alertAuthorization";
export type {
AlertAuthorizationRequestBody,
AlertAuthorizationUploadResult,
} from "./alertAuthorization";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export {
@@ -20,7 +20,7 @@ export type NotificationApiHeadersResult =
}
| {
ok: false;
reason: "no_active_did" | "missing_token";
reason: "no_active_did" | "missing_token" | "identity_changed";
message: string;
};
@@ -28,7 +28,7 @@ const DEBUG_HEADERS: Record<string, string> = {
"Content-Type": "application/json",
};
async function resolveActiveDid(): Promise<string | null> {
export async function getActiveNotificationDid(): Promise<string | null> {
try {
const service = PlatformServiceFactory.getInstance();
const row = await service.dbGetOneRow(
@@ -71,9 +71,11 @@ function logDebugUnauthenticatedNotificationRequest(): void {
/**
* Resolve headers for notification API requests.
* @param kind Optional request kind for structured logs.
* @param expectedDid Reject if the active identity changed before token minting.
*/
export async function getNotificationApiHeaders(
kind?: NotificationRequestKind,
expectedDid?: string,
): Promise<NotificationApiHeadersResult> {
if (shouldBypassNotificationAuth()) {
logAuthBypassEnabled();
@@ -83,7 +85,7 @@ export async function getNotificationApiHeaders(
return { ok: true, authenticated: false, headers: { ...DEBUG_HEADERS } };
}
const did = await resolveActiveDid();
const did = await getActiveNotificationDid();
if (!did) {
return {
ok: false,
@@ -91,6 +93,13 @@ export async function getNotificationApiHeaders(
message: "no active identity (cannot authenticate)",
};
}
if (expectedDid && did !== expectedDid) {
return {
ok: false,
reason: "identity_changed",
message: "active identity changed before authentication",
};
}
const headers = await getHeaders(did);
if (!hasBearerToken(headers)) {