Align AlertSearch authorization uploads with the wakeup-service contract: UTC-day delegated JWTs and required notifyHourUtc/notifyMinuteUtc.

This commit is contained in:
Jose Olarte III
2026-09-10 22:07:00 +08:00
parent 2afe748292
commit 4fcc9a40d0
8 changed files with 218 additions and 22 deletions
+6 -6
View File
@@ -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 * Not authentication JWTs, not alertSearch cursor ULIDs, and not the native
* background prefetch pool (`mintBackgroundJwtTokenPool`). * background prefetch pool (`mintBackgroundJwtTokenPool`).
*/ */
export interface DelegatedNotificationJwtWindow { 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; sequence: number;
/** Calendar date of this slot, YYYY-MM-DD in the user's timezone. */ /** Calendar date of this slot, YYYY-MM-DD in UTC. */
localDate: string; utcDay: string;
/** Unix seconds at 00:00:00 of this local day. */ /** Unix seconds at 00:00:00Z of this UTC day. */
nbf: number; 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; exp: number;
} }
+1 -1
View File
@@ -8,7 +8,7 @@
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as * - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
* `Authorization: Bearer` for interactive API calls (`accessToken` / * `Authorization: Bearer` for interactive API calls (`accessToken` /
* `getHeaders`). Identifies the requester DID. * `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. * `mintDelegatedNotificationJwtBatch` for notify-api / wakeup-service.
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification * - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
* plugin prefetch. Unrelated to alertSearch and to the delegated batch. * plugin prefetch. Unrelated to alertSearch and to the delegated batch.
+101
View File
@@ -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);
});
});
+16 -12
View File
@@ -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): * JWT kinds (do not mix):
* - Authentication JWT: short-lived `accessToken` / `getHeaders` Bearer for the * - Authentication JWT: short-lived `accessToken` / `getHeaders` Bearer for the
* setup request itself (not generated here). * setup request itself (not generated here).
* - Delegated notification JWT: this module. Signed like other Endorser JWTs * - Delegated notification JWT: this module. Signed like other Endorser JWTs
* (`createEndorserJwtForDid`). `nbf`/`exp` are that local day's bounds. * (`createEndorserJwtForDid`). `nbf`/`exp` are that UTC day's bounds.
* Sequence is array order: index 0 / sequence 1 = today. * Sequence is array order: index 0 / sequence 1 = the UTC calendar day of `now`.
* - Native background pool: `mintBackgroundJwtTokenPool` — unchanged, unused here. * - Native background pool: `mintBackgroundJwtTokenPool` — unchanged, unused here.
* - alertSearch cursor ULID: server record id, not a signed token. * - alertSearch cursor ULID: server record id, not a signed token.
* *
* Timezone: device IANA zone via Luxon `DateTime.local().zoneName` (same source * 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. * 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. * UTC-day [nbf, exp) windows for sequence 1..count.
* DST is handled by Luxon startOf('day') in the given IANA zone. * `timeZone` is accepted for call-site compatibility and is not used for bounds.
*/ */
export function buildDelegatedNotificationJwtWindows( export function buildDelegatedNotificationJwtWindows(
count: number = DELEGATED_NOTIFICATION_JWT_COUNT, count: number = DELEGATED_NOTIFICATION_JWT_COUNT,
timeZone?: string, timeZone?: string,
now: Date = new Date(), now: Date = new Date(),
): DelegatedNotificationJwtWindow[] { ): DelegatedNotificationJwtWindow[] {
const zone = resolveUserTimeZone(timeZone); if (timeZone !== undefined) {
const todayStart = DateTime.fromJSDate(now, { zone }).startOf("day"); resolveUserTimeZone(timeZone);
}
const todayStart = DateTime.fromJSDate(now, { zone: "utc" }).startOf("day");
const windows: DelegatedNotificationJwtWindow[] = []; const windows: DelegatedNotificationJwtWindow[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const dayStart = todayStart.plus({ days: i }).startOf("day"); const dayStart = todayStart.plus({ days: i });
const nextStart = dayStart.plus({ days: 1 }).startOf("day"); const nextStart = dayStart.plus({ days: 1 });
windows.push({ windows.push({
sequence: i + 1, sequence: i + 1,
localDate: dayStart.toFormat("yyyy-LL-dd"), utcDay: dayStart.toFormat("yyyy-LL-dd"),
nbf: Math.floor(dayStart.toSeconds()), nbf: Math.floor(dayStart.toSeconds()),
exp: Math.floor(nextStart.toSeconds()), exp: Math.floor(nextStart.toSeconds()),
}); });
@@ -104,7 +108,7 @@ export async function mintDelegatedNotificationJwtBatch(
iat: mintedAtEpoch, iat: mintedAtEpoch,
nbf: window.nbf, nbf: window.nbf,
exp: window.exp, exp: window.exp,
jti: `${did}#delegated-notify#${window.localDate}`, jti: `${did}#delegated-notify#${window.utcDay}`,
}); });
tokens.push({ ...window, jwt }); tokens.push({ ...window, jwt });
} }
@@ -4,6 +4,7 @@ import {
type AlertAuthorizationDependencies, type AlertAuthorizationDependencies,
uploadAlertSearchAuthorization, uploadAlertSearchAuthorization,
} from "./alertAuthorization"; } from "./alertAuthorization";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
jest.mock("@/libs/crypto/vc", () => ({ jest.mock("@/libs/crypto/vc", () => ({
ETHR_DID_PREFIX: "did:ethr:", ETHR_DID_PREFIX: "did:ethr:",
@@ -36,7 +37,7 @@ function createMintedBatch(): DelegatedNotificationJwtBatch {
{ length: DELEGATED_NOTIFICATION_JWT_COUNT }, { length: DELEGATED_NOTIFICATION_JWT_COUNT },
(_, index) => ({ (_, index) => ({
sequence: index + 1, sequence: index + 1,
localDate: new Date(Date.UTC(2026, 8, index + 1)) utcDay: new Date(Date.UTC(2026, 8, index + 1))
.toISOString() .toISOString()
.slice(0, 10), .slice(0, 10),
nbf: 1_788_220_800 + index * 86_400, nbf: 1_788_220_800 + index * 86_400,
@@ -132,6 +133,8 @@ describe("uploadAlertSearchAuthorization", () => {
const body = JSON.parse(String(init.body)) as { const body = JSON.parse(String(init.body)) as {
batchId: string; batchId: string;
notifyHourUtc: number;
notifyMinuteUtc: number;
timezone: string; timezone: string;
jwts: Array<{ jwts: Array<{
sequence: number; sequence: number;
@@ -141,7 +144,16 @@ describe("uploadAlertSearchAuthorization", () => {
jwt: string; jwt: string;
}>; }>;
}; };
const expectedNotify = alertSearchNotifyTimeUtcFromLocalNineAm(TIME_ZONE);
expect(body.batchId).toBe(BATCH_ID); 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.timezone).toBe(TIME_ZONE);
expect(body.jwts).toHaveLength(DELEGATED_NOTIFICATION_JWT_COUNT); expect(body.jwts).toHaveLength(DELEGATED_NOTIFICATION_JWT_COUNT);
expect(body.jwts.map((entry) => entry.sequence)).toEqual( expect(body.jwts.map((entry) => entry.sequence)).toEqual(
@@ -155,7 +167,7 @@ describe("uploadAlertSearchAuthorization", () => {
); );
expect(body.jwts[0]).toEqual({ expect(body.jwts[0]).toEqual({
sequence: 1, sequence: 1,
day: createMintedBatch().tokens[0]!.localDate, day: createMintedBatch().tokens[0]!.utcDay,
nbf: createMintedBatch().tokens[0]!.nbf, nbf: createMintedBatch().tokens[0]!.nbf,
exp: createMintedBatch().tokens[0]!.exp, exp: createMintedBatch().tokens[0]!.exp,
jwt: createMintedBatch().tokens[0]!.jwt, jwt: createMintedBatch().tokens[0]!.jwt,
@@ -2,6 +2,7 @@ import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificat
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt"; import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc"; import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { mintDelegatedNotificationJwtBatch } from "@/libs/delegatedNotificationJwt"; import { mintDelegatedNotificationJwtBatch } from "@/libs/delegatedNotificationJwt";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig"; import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
import { import {
getActiveNotificationDid, getActiveNotificationDid,
@@ -12,6 +13,8 @@ import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
export interface AlertAuthorizationRequestBody { export interface AlertAuthorizationRequestBody {
batchId: string; batchId: string;
notifyHourUtc: number;
notifyMinuteUtc: number;
timezone: string; timezone: string;
jwts: Array<{ jwts: Array<{
sequence: number; sequence: number;
@@ -82,12 +85,16 @@ function buildRequestBody(
); );
} }
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(batch.timeZone);
return { return {
batchId, batchId,
notifyHourUtc: notifyTime.notifyHourUtc,
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
timezone: batch.timeZone, timezone: batch.timeZone,
jwts: batch.tokens.map((slot) => ({ jwts: batch.tokens.map((slot) => ({
sequence: slot.sequence, sequence: slot.sequence,
day: slot.localDate, day: slot.utcDay,
nbf: slot.nbf, nbf: slot.nbf,
exp: slot.exp, exp: slot.exp,
jwt: slot.jwt, jwt: slot.jwt,
@@ -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 });
});
});
@@ -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,
};
}