diff --git a/src/interfaces/delegatedNotificationJwt.ts b/src/interfaces/delegatedNotificationJwt.ts index d0cf0dd1..a0d436ff 100644 --- a/src/interfaces/delegatedNotificationJwt.ts +++ b/src/interfaces/delegatedNotificationJwt.ts @@ -1,18 +1,18 @@ /** - * Batch of per-local-day delegated JWTs for notify-api / wakeup-service. + * 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 today in the user's timezone. */ + /** 1-based; 1 is the UTC calendar day of `now`. */ sequence: number; - /** Calendar date of this slot, YYYY-MM-DD in the user's timezone. */ - localDate: string; - /** Unix seconds at 00:00:00 of this local day. */ + /** 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:00 of the following local day. */ + /** Unix seconds at 00:00:00Z of the following UTC day. */ exp: number; } diff --git a/src/libs/alertSearch.ts b/src/libs/alertSearch.ts index ab021238..b98b9f2f 100644 --- a/src/libs/alertSearch.ts +++ b/src/libs/alertSearch.ts @@ -8,7 +8,7 @@ * - 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-local-day tokens from + * - 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. diff --git a/src/libs/delegatedNotificationJwt.test.ts b/src/libs/delegatedNotificationJwt.test.ts new file mode 100644 index 00000000..2065e437 --- /dev/null +++ b/src/libs/delegatedNotificationJwt.test.ts @@ -0,0 +1,101 @@ +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/libs/delegatedNotificationJwt.ts b/src/libs/delegatedNotificationJwt.ts index f0d2ed48..0a7142fe 100644 --- a/src/libs/delegatedNotificationJwt.ts +++ b/src/libs/delegatedNotificationJwt.ts @@ -1,17 +1,19 @@ /** - * Mint 100 delegated notification JWTs (one local day each) for notify-api. + * 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 local day's bounds. - * Sequence is array order: index 0 / sequence 1 = today. + * (`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). Not persisted in settings. Pass `timeZone` to override. + * 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. */ @@ -43,23 +45,25 @@ export function resolveUserTimeZone(timeZone?: string): string { } /** - * Local-day [nbf, exp) windows for sequence 1..count. - * DST is handled by Luxon startOf('day') in the given IANA 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[] { - const zone = resolveUserTimeZone(timeZone); - const todayStart = DateTime.fromJSDate(now, { zone }).startOf("day"); + 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 }).startOf("day"); - const nextStart = dayStart.plus({ days: 1 }).startOf("day"); + const dayStart = todayStart.plus({ days: i }); + const nextStart = dayStart.plus({ days: 1 }); windows.push({ sequence: i + 1, - localDate: dayStart.toFormat("yyyy-LL-dd"), + utcDay: dayStart.toFormat("yyyy-LL-dd"), nbf: Math.floor(dayStart.toSeconds()), exp: Math.floor(nextStart.toSeconds()), }); @@ -104,7 +108,7 @@ export async function mintDelegatedNotificationJwtBatch( iat: mintedAtEpoch, nbf: window.nbf, exp: window.exp, - jti: `${did}#delegated-notify#${window.localDate}`, + jti: `${did}#delegated-notify#${window.utcDay}`, }); tokens.push({ ...window, jwt }); } diff --git a/src/services/notifications/alertAuthorization.test.ts b/src/services/notifications/alertAuthorization.test.ts index 60df2de1..88623d83 100644 --- a/src/services/notifications/alertAuthorization.test.ts +++ b/src/services/notifications/alertAuthorization.test.ts @@ -4,6 +4,7 @@ import { type AlertAuthorizationDependencies, uploadAlertSearchAuthorization, } from "./alertAuthorization"; +import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime"; jest.mock("@/libs/crypto/vc", () => ({ ETHR_DID_PREFIX: "did:ethr:", @@ -36,7 +37,7 @@ function createMintedBatch(): DelegatedNotificationJwtBatch { { length: DELEGATED_NOTIFICATION_JWT_COUNT }, (_, index) => ({ sequence: index + 1, - localDate: new Date(Date.UTC(2026, 8, index + 1)) + utcDay: new Date(Date.UTC(2026, 8, index + 1)) .toISOString() .slice(0, 10), nbf: 1_788_220_800 + index * 86_400, @@ -132,6 +133,8 @@ describe("uploadAlertSearchAuthorization", () => { const body = JSON.parse(String(init.body)) as { batchId: string; + notifyHourUtc: number; + notifyMinuteUtc: number; timezone: string; jwts: Array<{ sequence: number; @@ -141,7 +144,16 @@ describe("uploadAlertSearchAuthorization", () => { 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( @@ -155,7 +167,7 @@ describe("uploadAlertSearchAuthorization", () => { ); expect(body.jwts[0]).toEqual({ sequence: 1, - day: createMintedBatch().tokens[0]!.localDate, + day: createMintedBatch().tokens[0]!.utcDay, nbf: createMintedBatch().tokens[0]!.nbf, exp: createMintedBatch().tokens[0]!.exp, jwt: createMintedBatch().tokens[0]!.jwt, diff --git a/src/services/notifications/alertAuthorization.ts b/src/services/notifications/alertAuthorization.ts index 069b6be7..a24e5309 100644 --- a/src/services/notifications/alertAuthorization.ts +++ b/src/services/notifications/alertAuthorization.ts @@ -2,6 +2,7 @@ import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificat 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, @@ -12,6 +13,8 @@ import { shouldBypassNotificationAuth } from "./notificationApiDebugMode"; export interface AlertAuthorizationRequestBody { batchId: string; + notifyHourUtc: number; + notifyMinuteUtc: number; timezone: string; jwts: Array<{ sequence: number; @@ -82,12 +85,16 @@ function buildRequestBody( ); } + 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.localDate, + day: slot.utcDay, nbf: slot.nbf, exp: slot.exp, jwt: slot.jwt, diff --git a/src/services/notifications/alertSearchNotifyTime.test.ts b/src/services/notifications/alertSearchNotifyTime.test.ts new file mode 100644 index 00000000..37776a05 --- /dev/null +++ b/src/services/notifications/alertSearchNotifyTime.test.ts @@ -0,0 +1,25 @@ +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 }); + }); +}); diff --git a/src/services/notifications/alertSearchNotifyTime.ts b/src/services/notifications/alertSearchNotifyTime.ts new file mode 100644 index 00000000..4bcc42fc --- /dev/null +++ b/src/services/notifications/alertSearchNotifyTime.ts @@ -0,0 +1,47 @@ +/** + * 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, + }; +}