Compare commits
1 Commits
notify-api
...
notify-api
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c400b8797 |
@@ -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 │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from "./alertSearch";
|
||||
export * from "./claims";
|
||||
export * from "./delegatedNotificationJwt";
|
||||
export * from "./claims-result";
|
||||
export * from "./common";
|
||||
export * from "./deepLinks";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user