Compare commits

..

1 Commits

Author SHA1 Message Date
0c400b8797 fix spacing in a doc diagram 2026-08-30 11:07:36 -06:00
18 changed files with 14 additions and 1178 deletions

View File

@@ -14,24 +14,24 @@ End-to-end flow when testing New Activity / silent wake on a physical Android ph
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ Mac (localhost) │ ◄───────────── │ ngrok edge
│ notification- │ tunnel │ (public HTTPS URL)
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
│ notification- │ tunnel │ (public HTTPS URL) │
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ fetch
│ POST /notifications/refresh │ POST /notifications/register
│ │ fetch
│ POST /notifications/refresh │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for-
│ │ time-pwa (Capacitor
│ │ Android on device)
│ │ crowd-funder-for- │
│ │ time-pwa (Capacitor │
│ │ Android on device) │
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (local schedule replace)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
└─────────────────────┘ └──────────────────────┘
```

View File

@@ -64,30 +64,6 @@
>
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"
@@ -376,10 +352,6 @@ 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() ?? "";
@@ -485,36 +457,6 @@ 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();

View File

@@ -1,5 +0,0 @@
/**
* Delegated authorization JWTs for notification-wakeup-service via notify-api.
* Distinct from the native background prefetch pool in backgroundJwt.ts.
*/
export const DELEGATED_NOTIFICATION_JWT_COUNT = 100;

View File

@@ -1,125 +0,0 @@
/**
* alertSearch response item and envelope types.
*
* These buckets are new to this app. Item shapes are taken from the endorser-ch
* SELECT lists for alertSearch, not from similarly named existing report types.
*
* Existing types that were considered and not reused:
* - GenericCredWrapper — claims lack a `claim` body; extra jwt columns differ.
* - GiveSummaryRecord / OfferSummaryRecord — those use `jwtId` and give/offer
* summary fields; alertSearch jwt rows use `id` and jwt table columns.
* - PlanSummaryAndPreviousClaim — `/plansLastUpdatedBetween` wraps `{ plan,
* wrappedClaimBefore }`; alertSearch `trackedPlanUpdates` are plan_claim rows.
* - PlanSummaryRecord — overlapping plan fields, but the app type is a subset
* (missing fulfillsLinkConfirmed, result*, etc.) and required fields differ.
* - UserProfile — partner nearby rows include `updatedAt` / `rowId` and omit
* embedding flags that UserProfile models.
*/
/**
* Server-issued ULID on a stored JWT/plan record, used as alertSearch afterId /
* beforeId. Not an authentication JWT and not a delegated notification JWT.
*/
export type AlertSearchCursorUlid = string;
/**
* JWT row from endorser `jwtsWithDidAfterId` (no claim body).
* Cursor field: `id`.
*/
export interface AlertSearchClaimRecord {
id: AlertSearchCursorUlid;
issuedAt: string;
issuer: string;
subject?: string;
claimType?: string;
handleId?: string;
fromEntity?: string;
toEntity?: string;
}
/**
* JWT row from `jwtsForUserPlanContributions` and
* `jwtsGiveActionOfferForPlanHandleIds`. `claim` is the jwt table TEXT
* (canonical JSON string); alertSearch does not JSON.parse it.
* Cursor field: `id`.
*/
export interface AlertSearchJwtWithClaimRecord extends AlertSearchClaimRecord {
claim?: string;
}
/**
* plan_claim row from `plansLastUpdatedBetween` and `plansByLocationAfterId`.
* Cursor field: `jwtId` (not `id`).
*/
export interface AlertSearchPlanRecord {
handleId: string;
jwtId: AlertSearchCursorUlid;
issuerDid?: string;
agentDid?: string;
fulfillsLinkConfirmed?: boolean | number;
fulfillsPlanClaimId?: string;
fulfillsPlanHandleId?: string;
name?: string;
description?: string;
image?: string;
endTime?: string;
startTime?: string;
locLat?: number;
locLon?: number;
resultDescription?: string;
resultIdentifier?: string;
url?: string;
}
/**
* user_profile row from partner `profilesByLocationAfterDate`.
* Profiles have no JWT `id`; partner paging uses dates decoded from cursor ULIDs.
*/
export interface AlertSearchProfileRecord {
rowId?: number;
issuerDid: string;
updatedAt?: string;
description: string;
locLat?: number;
locLon?: number;
locLat2?: number;
locLon2?: number;
}
export interface EndorserAlertSearchData {
claims: AlertSearchClaimRecord[];
personalPlanContributions: AlertSearchJwtWithClaimRecord[];
trackedPlanUpdates: AlertSearchPlanRecord[];
trackedPlanClaims: AlertSearchJwtWithClaimRecord[];
plansNearby: AlertSearchPlanRecord[];
}
export interface PartnerAlertSearchData {
profilesNearby: AlertSearchProfileRecord[];
}
/**
* Endorser GET/POST /api/v2/report/alertSearch body.
* Per-bucket SQL hitLimit is not currently copied onto this envelope.
* Timeouts may set `userMessage` instead.
*/
export interface EndorserAlertSearchResponse {
data: EndorserAlertSearchData;
userMessage?: string;
}
/**
* Partner GET/POST /api/partner/alertSearch body.
*/
export interface PartnerAlertSearchResponse {
data: PartnerAlertSearchData;
userMessage?: string;
}
/**
* Union of the six alertSearch buckets for a future combined daily run.
* Not returned by a single server endpoint today.
*/
export interface CombinedAlertSearchData
extends EndorserAlertSearchData,
PartnerAlertSearchData {}

View File

@@ -1,29 +0,0 @@
/**
* Batch of per-UTC-day delegated JWTs for notify-api / wakeup-service.
*
* Not authentication JWTs, not alertSearch cursor ULIDs, and not the native
* background prefetch pool (`mintBackgroundJwtTokenPool`).
*/
export interface DelegatedNotificationJwtWindow {
/** 1-based; 1 is the UTC calendar day of `now`. */
sequence: number;
/** Calendar date of this slot, YYYY-MM-DD in UTC. */
utcDay: string;
/** Unix seconds at 00:00:00Z of this UTC day. */
nbf: number;
/** Unix seconds at 00:00:00Z of the following UTC day. */
exp: number;
}
export interface DelegatedNotificationJwtSlot
extends DelegatedNotificationJwtWindow {
jwt: string;
}
export interface DelegatedNotificationJwtBatch {
did: string;
timeZone: string;
mintedAtEpoch: number;
tokens: DelegatedNotificationJwtSlot[];
}

View File

@@ -1,6 +1,4 @@
export * from "./alertSearch";
export * from "./claims";
export * from "./delegatedNotificationJwt";
export * from "./claims-result";
export * from "./common";
export * from "./deepLinks";

View File

@@ -1,67 +0,0 @@
/**
* Typed alertSearch API contract only. No HTTP client yet.
*
* Hosts: use DEFAULT_ENDORSER_API_SERVER and DEFAULT_PARTNER_API_SERVER from
* `@/constants/app`. Do not duplicate those constants here.
*
* JWT kinds (do not mix these):
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
* `Authorization: Bearer` for interactive API calls (`accessToken` /
* `getHeaders`). Identifies the requester DID.
* - Delegated notification JWT: 100 per-UTC-day tokens from
* `mintDelegatedNotificationJwtBatch` for notify-api / wakeup-service.
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
* ULID). `afterId` means ids strictly greater than that ULID; `beforeId`
* means strictly less. First daily run omits afterId. `beforeId` is for
* pagination within a run. These are not auth JWTs and not signed tokens.
*
* Truncation: each endorser bucket query uses a server hit-limit (typically
* 50). That flag is not currently returned on the alertSearch JSON envelope.
* Timeouts may add `userMessage`. A later caller must still paginate with
* beforeId when a bucket may be incomplete.
*/
import type {
AlertSearchCursorUlid,
CombinedAlertSearchData,
EndorserAlertSearchResponse,
PartnerAlertSearchResponse,
} from "@/interfaces/alertSearch";
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
export interface AlertSearchLocationBBox {
minLocLat: number;
maxLocLat: number;
minLocLon: number;
maxLocLon: number;
}
/**
* Query/body params accepted by endorser and partner alertSearch (GET or POST).
* GET is the planned daily method; the server also accepts POST.
*/
export interface AlertSearchRequestParams {
afterId?: AlertSearchCursorUlid;
beforeId?: AlertSearchCursorUlid;
afterDate?: string;
beforeDate?: string;
location?: AlertSearchLocationBBox;
minLocLat?: number;
maxLocLat?: number;
minLocLon?: number;
maxLocLon?: number;
planHandleIds?: string[];
planIds?: string[];
handleIds?: string[];
}
export type {
AlertSearchCursorUlid,
CombinedAlertSearchData,
EndorserAlertSearchResponse,
PartnerAlertSearchResponse,
};

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);
});
});

View File

@@ -1,117 +0,0 @@
/**
* Mint 100 delegated notification JWTs (one UTC calendar day each) for notify-api.
*
* JWT kinds (do not mix):
* - Authentication JWT: short-lived `accessToken` / `getHeaders` Bearer for the
* setup request itself (not generated here).
* - Delegated notification JWT: this module. Signed like other Endorser JWTs
* (`createEndorserJwtForDid`). `nbf`/`exp` are that UTC day's bounds.
* Sequence is array order: index 0 / sequence 1 = the UTC calendar day of `now`.
* - Native background pool: `mintBackgroundJwtTokenPool` — unchanged, unused here.
* - alertSearch cursor ULID: server record id, not a signed token.
*
* Timezone: device IANA zone via Luxon `DateTime.local().zoneName` (same source
* as project create/edit). Stored on the batch for the wakeup-service optional
* `timezone` field; it does not change the UTC-day JWT windows. Pass `timeZone`
* to override.
*
* Passkey (JWANT) identities cannot carry per-day nbf/exp; minting throws.
*/
import { DateTime } from "luxon";
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import type {
DelegatedNotificationJwtBatch,
DelegatedNotificationJwtSlot,
DelegatedNotificationJwtWindow,
} from "@/interfaces/delegatedNotificationJwt";
import { isFromPasskey } from "@/libs/crypto/vc";
import { createEndorserJwtForDid } from "@/libs/endorserServer";
import { retrieveAccountMetadata } from "@/libs/util";
export function resolveUserTimeZone(timeZone?: string): string {
const zone = timeZone ?? DateTime.local().zoneName ?? undefined;
if (!zone) {
throw new Error("Could not determine the user's timezone.");
}
const probe = DateTime.now().setZone(zone);
if (!probe.isValid) {
throw new Error(
"Invalid timezone for delegated notification JWTs: " + zone,
);
}
return zone;
}
/**
* UTC-day [nbf, exp) windows for sequence 1..count.
* `timeZone` is accepted for call-site compatibility and is not used for bounds.
*/
export function buildDelegatedNotificationJwtWindows(
count: number = DELEGATED_NOTIFICATION_JWT_COUNT,
timeZone?: string,
now: Date = new Date(),
): DelegatedNotificationJwtWindow[] {
if (timeZone !== undefined) {
resolveUserTimeZone(timeZone);
}
const todayStart = DateTime.fromJSDate(now, { zone: "utc" }).startOf("day");
const windows: DelegatedNotificationJwtWindow[] = [];
for (let i = 0; i < count; i++) {
const dayStart = todayStart.plus({ days: i });
const nextStart = dayStart.plus({ days: 1 });
windows.push({
sequence: i + 1,
utcDay: dayStart.toFormat("yyyy-LL-dd"),
nbf: Math.floor(dayStart.toSeconds()),
exp: Math.floor(nextStart.toSeconds()),
});
}
return windows;
}
export function delegatedNotificationJwtStrings(
batch: DelegatedNotificationJwtBatch,
): string[] {
return batch.tokens.map((slot) => slot.jwt);
}
export async function mintDelegatedNotificationJwtBatch(
did: string,
options?: { timeZone?: string; now?: Date },
): Promise<DelegatedNotificationJwtBatch> {
if (!did) {
throw new Error("A DID is required to mint delegated notification JWTs.");
}
const account = await retrieveAccountMetadata(did);
if (isFromPasskey(account)) {
throw new Error(
"Delegated notification JWTs with per-day nbf/exp require a local signing key. Passkey JWANT tokens cannot carry those claims.",
);
}
const timeZone = resolveUserTimeZone(options?.timeZone);
const now = options?.now ?? new Date();
const mintedAtEpoch = Math.floor(now.getTime() / 1000);
const windows = buildDelegatedNotificationJwtWindows(
DELEGATED_NOTIFICATION_JWT_COUNT,
timeZone,
now,
);
const tokens: DelegatedNotificationJwtSlot[] = [];
for (const window of windows) {
const jwt = await createEndorserJwtForDid(did, {
iss: did,
iat: mintedAtEpoch,
nbf: window.nbf,
exp: window.exp,
jti: `${did}#delegated-notify#${window.utcDay}`,
});
tokens.push({ ...window, jwt });
}
return { did, timeZone, mintedAtEpoch, tokens };
}

View File

@@ -1,50 +0,0 @@
jest.mock("@/constants/app", () => ({
DEFAULT_NOTIFY_API_SERVER: "https://notify-api.timesafari.app",
}));
import {
getNotificationDebugOverrideHeaders,
NGROK_SKIP_BROWSER_WARNING_HEADER,
NGROK_SKIP_BROWSER_WARNING_VALUE,
setBackendBaseUrl,
} from "./NotificationDebugConfig";
const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
describe("getNotificationDebugOverrideHeaders", () => {
const memory = new Map<string, string>();
beforeEach(() => {
memory.clear();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => {
memory.set(key, value);
},
removeItem: (key: string) => {
memory.delete(key);
},
},
});
});
it("adds the ngrok skip header when the debug backend override is set", () => {
setBackendBaseUrl("https://detail-frown-machine.ngrok-free.dev");
expect(getNotificationDebugOverrideHeaders()).toEqual({
[NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE,
});
expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBe(
"https://detail-frown-machine.ngrok-free.dev",
);
});
it("omits the ngrok skip header for the default/production notification API", () => {
setBackendBaseUrl("");
expect(getNotificationDebugOverrideHeaders()).toEqual({});
expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBeUndefined();
});
});

View File

@@ -10,10 +10,6 @@ const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
const STORAGE_KEY_TEST_MODE = "notificationDebug.testMode";
const STORAGE_KEY_BYPASS_AUTH = "notificationDebug.bypassAuth";
/** Free-ngrok interstitial bypass; only sent when the debug backend override is set. */
export const NGROK_SKIP_BROWSER_WARNING_HEADER = "ngrok-skip-browser-warning";
export const NGROK_SKIP_BROWSER_WARNING_VALUE = "true";
/** Trim whitespace, drop trailing slash; empty input becomes null. */
export function normalizeNotificationBackendUrl(url: string): string | null {
const trimmed = url.trim();
@@ -117,16 +113,3 @@ export function getNotificationApiBaseUrl(): string {
DEFAULT_NOTIFY_API_SERVER
);
}
/**
* Extra headers for notification API calls when the Debug Panel backend URL
* override is set. Production/default hosts do not get this header.
*/
export function getNotificationDebugOverrideHeaders(): Record<string, string> {
if (!getBackendBaseUrl()) {
return {};
}
return {
[NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE,
};
}

View File

@@ -42,10 +42,6 @@ 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;
@@ -169,30 +165,6 @@ 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" });
},

View File

@@ -1,241 +0,0 @@
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
import {
type AlertAuthorizationDependencies,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
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,
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}`,
}),
),
};
}
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;
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,
});
});
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");
});
});

View File

@@ -1,226 +0,0 @@
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 { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
import {
getActiveNotificationDid,
getNotificationApiHeaders,
httpAuthErrorMessage,
} 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;
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}.`,
);
}
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(batch.timeZone);
return {
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,
})),
};
}
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.",
};
}
}

View File

@@ -1,25 +0,0 @@
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
describe("alertSearchNotifyTimeUtcFromLocalNineAm", () => {
it("converts 09:00 Asia/Manila to 01:00 UTC", () => {
const result = alertSearchNotifyTimeUtcFromLocalNineAm(
"Asia/Manila",
new Date("2026-09-10T16:00:00.000Z"),
);
expect(result).toEqual({ notifyHourUtc: 1, notifyMinuteUtc: 0 });
});
it("returns integers in the UTC hour/minute ranges", () => {
const result = alertSearchNotifyTimeUtcFromLocalNineAm(
"America/Denver",
new Date("2026-01-15T12:00:00.000Z"),
);
expect(Number.isInteger(result.notifyHourUtc)).toBe(true);
expect(Number.isInteger(result.notifyMinuteUtc)).toBe(true);
expect(result.notifyHourUtc).toBeGreaterThanOrEqual(0);
expect(result.notifyHourUtc).toBeLessThanOrEqual(23);
expect(result.notifyMinuteUtc).toBeGreaterThanOrEqual(0);
expect(result.notifyMinuteUtc).toBeLessThanOrEqual(59);
expect(result).toEqual({ notifyHourUtc: 16, notifyMinuteUtc: 0 });
});
});

View File

@@ -1,47 +0,0 @@
/**
* Debug/E2E AlertSearch notify-time conversion only.
* Not a user-facing setting; does not read Daily Reminder or New Activity times.
*/
import { DateTime } from "luxon";
/** Temporary default wall-clock time for AlertSearch authorization uploads. */
export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR = 9;
export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE = 0;
function requireIanaTimeZone(timeZone: string): string {
const probe = DateTime.now().setZone(timeZone);
if (!timeZone || !probe.isValid) {
throw new Error(
"Invalid IANA timezone for AlertSearch notify time: " + timeZone,
);
}
return timeZone;
}
/**
* Convert 09:00 in `timeZone` (IANA) on the calendar day of `now` to UTC hour/minute.
*/
export function alertSearchNotifyTimeUtcFromLocalNineAm(
timeZone: string,
now: Date = new Date(),
): { notifyHourUtc: number; notifyMinuteUtc: number } {
const zone = requireIanaTimeZone(timeZone);
const localNine = DateTime.fromJSDate(now, { zone }).set({
hour: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR,
minute: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE,
second: 0,
millisecond: 0,
});
if (!localNine.isValid) {
throw new Error(
"Could not convert AlertSearch 09:00 local notify time to UTC for zone " +
zone,
);
}
const utc = localNine.toUTC();
return {
notifyHourUtc: utc.hour,
notifyMinuteUtc: utc.minute,
};
}

View File

@@ -17,10 +17,7 @@ export {
getBackendBaseUrl,
getBypassAuth,
getNotificationApiBaseUrl,
getNotificationDebugOverrideHeaders,
getTestMode,
NGROK_SKIP_BROWSER_WARNING_HEADER,
NGROK_SKIP_BROWSER_WARNING_VALUE,
normalizeNotificationBackendUrl,
setBackendBaseUrl,
setBypassAuth,
@@ -38,11 +35,6 @@ 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 {

View File

@@ -7,7 +7,6 @@
import { getHeaders } from "@/libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "@/utils/logger";
import { getNotificationDebugOverrideHeaders } from "./NotificationDebugConfig";
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
import { logNotification } from "./NotificationDebugEvents";
@@ -21,7 +20,7 @@ export type NotificationApiHeadersResult =
}
| {
ok: false;
reason: "no_active_did" | "missing_token" | "identity_changed";
reason: "no_active_did" | "missing_token";
message: string;
};
@@ -29,7 +28,7 @@ const DEBUG_HEADERS: Record<string, string> = {
"Content-Type": "application/json",
};
export async function getActiveNotificationDid(): Promise<string | null> {
async function resolveActiveDid(): Promise<string | null> {
try {
const service = PlatformServiceFactory.getInstance();
const row = await service.dbGetOneRow(
@@ -72,28 +71,19 @@ 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();
if (kind) {
logDebugUnauthenticatedNotificationRequest();
}
return {
ok: true,
authenticated: false,
headers: {
...DEBUG_HEADERS,
...getNotificationDebugOverrideHeaders(),
},
};
return { ok: true, authenticated: false, headers: { ...DEBUG_HEADERS } };
}
const did = await getActiveNotificationDid();
const did = await resolveActiveDid();
if (!did) {
return {
ok: false,
@@ -101,13 +91,6 @@ 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)) {
@@ -128,7 +111,6 @@ export async function getNotificationApiHeaders(
headers: {
"Content-Type": headers["Content-Type"],
Authorization: headers.Authorization,
...getNotificationDebugOverrideHeaders(),
},
};
}