Align AlertSearch authorization uploads with the wakeup-service contract: UTC-day delegated JWTs and required notifyHourUtc/notifyMinuteUtc.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user