diff --git a/.env.example b/.env.example index f7dbe24..531e551 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,11 @@ PORT=3003 # Endorser API base URL (default: https://api.endorser.ch) # ENDORSER_URL=https://api.endorser.ch +# DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch + +# Partner API base URL (default: https://partner-api.endorser.ch) +# PARTNER_URL=https://partner-api.endorser.ch +# DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch # Firebase Admin: inline service account JSON (one line). # If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS). diff --git a/CHANGELOG.md b/CHANGELOG.md index 63cbb31..4ac963c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.6] - 2026.08.27 +### Added +- AlertSearch retrieval layer for Endorser and Partner GET endpoints (pagination, independent cursors; not scheduled yet) + + ## [0.1.5] - 2026.08.26 ### Added - `PUT /notifications/alert-authorization` stores a 100-JWT delegated alert-authorization batch per user (SQLite), authenticated with the current user JWT diff --git a/README.md b/README.md index 1aeef34..e60035a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ cp .env.example .env Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`. Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .` -Optionally set `ENDORSER_URL` if you are not using the default production Endorser API (`https://api.endorser.ch`). +Optionally set `ENDORSER_URL` / `PARTNER_URL` if you are not using the production Endorser (`https://api.endorser.ch`) and Partner (`https://partner-api.endorser.ch`) hosts. Optionally set `NOTIFY_DATA_DIR` if you want the SQLite database somewhere other than `./data`. @@ -20,6 +20,10 @@ pnpm install pnpm run dev ``` +```bash +pnpm test +``` + The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes. On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema. @@ -60,6 +64,15 @@ Authorization: Bearer `timezone` is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`. +### Alert search retrieval + +`retrieveAlertSearch` (not yet wired to the scheduler) GETs: + +- `{ENDORSER_URL}/api/v2/report/alertSearch` +- `{PARTNER_URL}/api/partner/alertSearch` + +The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endorserAfterId` / `partnerAfterId` ULIDs, or omit them on first run. Nearby search uses the alertSearch bbox (`minLocLat`, `maxLocLat`, `minLocLon`, `maxLocLon`). + ## Storage ### Database location @@ -131,5 +144,6 @@ Required environment variables: |---|---| | `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. | | `PORT` | HTTP port (default: `3003`). | -| `ENDORSER_URL` | Endorser API base URL used for auth checks on register, refresh, and alert-authorization (default: `https://api.endorser.ch`). | +| `ENDORSER_URL` | Endorser API base URL used for auth checks and alertSearch (default: `https://api.endorser.ch`). | +| `PARTNER_URL` | Partner API base URL used for alertSearch (default: `https://partner-api.endorser.ch`). | | `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). | diff --git a/package.json b/package.json index 77aa1ec..422ac5d 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "notification-wakeup-service", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "packageManager": "pnpm@11.4.0", "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", - "build": "tsc" + "build": "tsc", + "test": "tsx --test src/alertSearch/*.test.ts" }, "dependencies": { "@peculiar/asn1-ecc": "^2.7.0", diff --git a/src/alertSearch/client.ts b/src/alertSearch/client.ts new file mode 100644 index 0000000..b4e45c7 --- /dev/null +++ b/src/alertSearch/client.ts @@ -0,0 +1,335 @@ +import { errorMessage } from "../util/formatElapsed.js"; +import { maskToken } from "../util/maskToken.js"; +import { + alertSearchUrl, + buildAlertSearchQuery, + type AlertSearchQueryInput, +} from "./params.js"; +import { + ALERT_SEARCH_PAGE_SIZE, + type AlertSearchFailureReason, + type AlertSearchSource, + type EndorserAlertSearchData, + type EndorserAlertSearchResponse, + type PartnerAlertSearchData, + type PartnerAlertSearchResponse, +} from "./types.js"; + +export type FetchLike = ( + input: string, + init?: RequestInit +) => Promise; + +export type AlertSearchHttpConfig = { + fetch?: FetchLike; + timeoutMs?: number; +}; + +export type AlertSearchHttpOk = { + ok: true; + status: number; + body: T; + url: string; +}; + +export type AlertSearchHttpErr = { + ok: false; + reason: AlertSearchFailureReason; + message: string; + status?: number; + url: string; +}; + +export type AlertSearchHttpResult = AlertSearchHttpOk | AlertSearchHttpErr; + +const DEFAULT_TIMEOUT_MS = 15_000; + +export function emptyEndorserData(): EndorserAlertSearchData { + return { + claims: [], + personalPlanContributions: [], + trackedPlanUpdates: [], + trackedPlanClaims: [], + plansNearby: [], + }; +} + +export function emptyPartnerData(): PartnerAlertSearchData { + return { profilesNearby: [] }; +} + +function classifyHttpStatus(status: number): AlertSearchFailureReason { + if (status === 400 || status === 401 || status === 403) return "auth"; + return "http"; +} + +function isAbortError(err: unknown): boolean { + if (err instanceof Error) { + if (err.name === "AbortError" || err.name === "TimeoutError") return true; + if (err.message.toLowerCase().includes("aborted")) return true; + } + return false; +} + +async function getJson( + url: string, + jwt: string, + source: AlertSearchSource, + config: AlertSearchHttpConfig +): Promise> { + const fetchImpl = config.fetch ?? fetch; + const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const headers = { + Authorization: `Bearer ${jwt}`, + Accept: "application/json", + }; + + let response: Response; + try { + response = await fetchImpl(url, { + method: "GET", + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const reason: AlertSearchFailureReason = isAbortError(err) + ? "timeout" + : "network"; + console.error( + "[AlertSearch]", + source, + reason, + url, + "jwt suffix=" + maskToken(jwt) + ":", + errorMessage(err) + ); + return { ok: false, reason, message: errorMessage(err), url }; + } + + let parsed: unknown; + try { + parsed = await response.json(); + } catch (err) { + if (!response.ok) { + const reason = classifyHttpStatus(response.status); + return { + ok: false, + reason, + message: `HTTP ${response.status}`, + status: response.status, + url, + }; + } + return { + ok: false, + reason: "malformed", + message: errorMessage(err), + status: response.status, + url, + }; + } + + if (!response.ok) { + const reason = classifyHttpStatus(response.status); + return { + ok: false, + reason, + message: `HTTP ${response.status}`, + status: response.status, + url, + }; + } + + return { ok: true, status: response.status, body: parsed, url }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function asObjectArray(value: unknown): Record[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: Record[] = []; + for (const item of value) { + if (!isRecord(item)) return undefined; + out.push(item); + } + return out; +} + +export function parseEndorserAlertSearchResponse( + body: unknown +): EndorserAlertSearchResponse | undefined { + if (!isRecord(body) || !isRecord(body.data)) return undefined; + const data = body.data; + const claims = asObjectArray(data.claims); + const personalPlanContributions = asObjectArray( + data.personalPlanContributions + ); + const trackedPlanUpdates = asObjectArray(data.trackedPlanUpdates); + const trackedPlanClaims = asObjectArray(data.trackedPlanClaims); + const plansNearby = asObjectArray(data.plansNearby); + if ( + claims === undefined || + personalPlanContributions === undefined || + trackedPlanUpdates === undefined || + trackedPlanClaims === undefined || + plansNearby === undefined + ) { + return undefined; + } + const parsed: EndorserAlertSearchResponse = { + data: { + claims: claims as EndorserAlertSearchResponse["data"]["claims"], + personalPlanContributions: + personalPlanContributions as EndorserAlertSearchResponse["data"]["personalPlanContributions"], + trackedPlanUpdates: + trackedPlanUpdates as EndorserAlertSearchResponse["data"]["trackedPlanUpdates"], + trackedPlanClaims: + trackedPlanClaims as EndorserAlertSearchResponse["data"]["trackedPlanClaims"], + plansNearby: + plansNearby as EndorserAlertSearchResponse["data"]["plansNearby"], + }, + }; + if (typeof body.userMessage === "string") { + parsed.userMessage = body.userMessage; + } + return parsed; +} + +export function parsePartnerAlertSearchResponse( + body: unknown +): PartnerAlertSearchResponse | undefined { + if (!isRecord(body) || !isRecord(body.data)) return undefined; + const profilesNearby = asObjectArray(body.data.profilesNearby); + if (profilesNearby === undefined) return undefined; + const parsed: PartnerAlertSearchResponse = { + data: { + profilesNearby: + profilesNearby as PartnerAlertSearchResponse["data"]["profilesNearby"], + }, + }; + if (typeof body.userMessage === "string") { + parsed.userMessage = body.userMessage; + } + return parsed; +} + +export async function fetchEndorserAlertSearchPage(input: { + baseUrl: string; + jwt: string; + query: AlertSearchQueryInput; + config?: AlertSearchHttpConfig; +}): Promise> { + const url = alertSearchUrl( + input.baseUrl, + "/api/v2/report/alertSearch", + buildAlertSearchQuery(input.query) + ); + const raw = await getJson(url, input.jwt, "endorser", input.config ?? {}); + if (!raw.ok) return raw; + const parsed = parseEndorserAlertSearchResponse(raw.body); + if (parsed === undefined) { + return { + ok: false, + reason: "malformed", + message: "Endorser alertSearch response is missing required data buckets.", + status: raw.status, + url, + }; + } + return { ok: true, status: raw.status, body: parsed, url }; +} + +export async function fetchPartnerAlertSearchPage(input: { + baseUrl: string; + jwt: string; + query: AlertSearchQueryInput; + config?: AlertSearchHttpConfig; +}): Promise> { + const url = alertSearchUrl( + input.baseUrl, + "/api/partner/alertSearch", + buildAlertSearchQuery(input.query) + ); + const raw = await getJson(url, input.jwt, "partner", input.config ?? {}); + if (!raw.ok) return raw; + const parsed = parsePartnerAlertSearchResponse(raw.body); + if (parsed === undefined) { + return { + ok: false, + reason: "malformed", + message: "Partner alertSearch response is missing profilesNearby.", + status: raw.status, + url, + }; + } + return { ok: true, status: raw.status, body: parsed, url }; +} + +export function endorserBucketHitPageLimit( + data: EndorserAlertSearchData +): boolean { + return ( + data.claims.length >= ALERT_SEARCH_PAGE_SIZE || + data.personalPlanContributions.length >= ALERT_SEARCH_PAGE_SIZE || + data.trackedPlanUpdates.length >= ALERT_SEARCH_PAGE_SIZE || + data.trackedPlanClaims.length >= ALERT_SEARCH_PAGE_SIZE || + data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE + ); +} + +export function partnerBucketHitPageLimit( + data: PartnerAlertSearchData +): boolean { + return data.profilesNearby.length >= ALERT_SEARCH_PAGE_SIZE; +} + +export function minEndorserBeforeId( + data: EndorserAlertSearchData +): string | undefined { + const ids: string[] = []; + const takeJwtIdBuckets: Array<{ length: number; jwtIds: Array }> = [ + { + length: data.claims.length, + jwtIds: data.claims.map((r) => r.id), + }, + { + length: data.personalPlanContributions.length, + jwtIds: data.personalPlanContributions.map((r) => r.id), + }, + { + length: data.trackedPlanClaims.length, + jwtIds: data.trackedPlanClaims.map((r) => r.id), + }, + { + length: data.trackedPlanUpdates.length, + jwtIds: data.trackedPlanUpdates.map((r) => r.jwtId), + }, + { + length: data.plansNearby.length, + jwtIds: data.plansNearby.map((r) => r.jwtId), + }, + ]; + for (const bucket of takeJwtIdBuckets) { + if (bucket.length < ALERT_SEARCH_PAGE_SIZE) continue; + for (const id of bucket.jwtIds) { + if (typeof id === "string" && id.length > 0) ids.push(id); + } + } + if (ids.length === 0) return undefined; + ids.sort(); + return ids[0]; +} + +export function minPartnerBeforeDate( + data: PartnerAlertSearchData +): string | undefined { + if (data.profilesNearby.length < ALERT_SEARCH_PAGE_SIZE) return undefined; + const dates = data.profilesNearby + .map((p) => p.updatedAt) + .filter((d): d is string => typeof d === "string" && d.length > 0); + if (dates.length === 0) return undefined; + dates.sort(); + return dates[0]; +} diff --git a/src/alertSearch/index.ts b/src/alertSearch/index.ts new file mode 100644 index 0000000..ebc7fed --- /dev/null +++ b/src/alertSearch/index.ts @@ -0,0 +1,30 @@ +export { + ALERT_SEARCH_PAGE_SIZE, + ENDORSER_ALERT_SEARCH_PATH, + PARTNER_ALERT_SEARCH_PATH, +} from "./types.js"; +export type { + AlertSearchCursorUlid, + AlertSearchFailureReason, + AlertSearchLocationBBox, + AlertSearchQueryOutcome, + CombinedAlertSearchData, + EndorserAlertSearchData, + EndorserAlertSearchResponse, + PartnerAlertSearchData, + PartnerAlertSearchResponse, +} from "./types.js"; +export { + alertSearchUrl, + buildAlertSearchQuery, + isAlertSearchCursorUlid, + isCompleteLocationBBox, +} from "./params.js"; +export { + fetchEndorserAlertSearchPage, + fetchPartnerAlertSearchPage, + parseEndorserAlertSearchResponse, + parsePartnerAlertSearchResponse, +} from "./client.js"; +export { retrieveAlertSearch } from "./retrieve.js"; +export type { RetrieveAlertSearchInput, RetrieveAlertSearchResult } from "./retrieve.js"; diff --git a/src/alertSearch/params.test.ts b/src/alertSearch/params.test.ts new file mode 100644 index 0000000..902c965 --- /dev/null +++ b/src/alertSearch/params.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + alertSearchUrl, + buildAlertSearchQuery, + isAlertSearchCursorUlid, +} from "./params.js"; +import { + ENDORSER_ALERT_SEARCH_PATH, + PARTNER_ALERT_SEARCH_PATH, +} from "./types.js"; + +const LOCATION = { + minLocLat: 40.7, + maxLocLat: 40.8, + minLocLon: -74.1, + maxLocLon: -74.0, +}; + +describe("alertSearch request construction", () => { + it("omits afterId on first run (does not send 0 or empty)", () => { + const query = buildAlertSearchQuery({}); + assert.equal(query.has("afterId"), false); + assert.equal(query.has("beforeId"), false); + const endorser = alertSearchUrl( + "https://api.endorser.ch", + ENDORSER_ALERT_SEARCH_PATH, + query + ); + const partner = alertSearchUrl( + "https://partner-api.endorser.ch", + PARTNER_ALERT_SEARCH_PATH, + query + ); + assert.equal(endorser, "https://api.endorser.ch/api/v2/report/alertSearch"); + assert.equal( + partner, + "https://partner-api.endorser.ch/api/partner/alertSearch" + ); + }); + + it("rejects non-ULID afterId including 0", () => { + assert.equal(isAlertSearchCursorUlid("0"), false); + assert.equal(isAlertSearchCursorUlid(""), false); + const query = buildAlertSearchQuery({ afterId: "0" }); + assert.equal(query.has("afterId"), false); + }); + + it("includes a subsequent afterId ULID", () => { + const afterId = "01H00000000000000000000001"; + const query = buildAlertSearchQuery({ afterId }); + assert.equal(query.get("afterId"), afterId); + }); + + it("builds independent Endorser vs Partner URLs and cursors", () => { + const endorserAfter = "01H0000000000000000000000A"; + const partnerAfter = "01H0000000000000000000000B"; + const endorser = alertSearchUrl( + "https://api.endorser.ch", + ENDORSER_ALERT_SEARCH_PATH, + buildAlertSearchQuery({ afterId: endorserAfter }) + ); + const partner = alertSearchUrl( + "https://partner-api.endorser.ch", + PARTNER_ALERT_SEARCH_PATH, + buildAlertSearchQuery({ afterId: partnerAfter }) + ); + assert.match(endorser, /afterId=01H0000000000000000000000A/); + assert.match(partner, /afterId=01H0000000000000000000000B/); + assert.equal(endorser.includes("0000000B"), false); + assert.equal(partner.includes("0000000A"), false); + assert.equal(endorser.includes("/api/v2/report/alertSearch"), true); + assert.equal(partner.includes("/api/partner/alertSearch"), true); + }); + + it("flattens location bbox for GET query params", () => { + const query = buildAlertSearchQuery({ location: LOCATION }); + assert.equal(query.get("minLocLat"), "40.7"); + assert.equal(query.get("maxLocLat"), "40.8"); + assert.equal(query.get("minLocLon"), "-74.1"); + assert.equal(query.get("maxLocLon"), "-74"); + assert.equal(query.has("location"), false); + }); + + it("JSON-encodes planHandleIds", () => { + const query = buildAlertSearchQuery({ + planHandleIds: ["plan-1", "plan-2"], + }); + assert.equal(query.get("planHandleIds"), JSON.stringify(["plan-1", "plan-2"])); + }); +}); diff --git a/src/alertSearch/params.ts b/src/alertSearch/params.ts new file mode 100644 index 0000000..71003f1 --- /dev/null +++ b/src/alertSearch/params.ts @@ -0,0 +1,82 @@ +import type { AlertSearchCursorUlid, AlertSearchLocationBBox } from "./types.js"; + +/** Crockford ULID, same regex as endorser-ch alert-search.service.js */ +const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/; + +export function isAlertSearchCursorUlid( + value: unknown +): value is AlertSearchCursorUlid { + return typeof value === "string" && ULID_REGEX.test(value); +} + +/** + * Only a complete bbox is sent. Partial boxes are omitted rather than guessing. + * Shape matches endorser-ch `alertSearchParamsFromRequest` (not the app's + * eastLong/westLong BoundingBox). + */ +export function isCompleteLocationBBox( + value: unknown +): value is AlertSearchLocationBBox { + if (value === null || typeof value !== "object") return false; + const box = value as Record; + return ( + typeof box.minLocLat === "number" && + Number.isFinite(box.minLocLat) && + typeof box.maxLocLat === "number" && + Number.isFinite(box.maxLocLat) && + typeof box.minLocLon === "number" && + Number.isFinite(box.minLocLon) && + typeof box.maxLocLon === "number" && + Number.isFinite(box.maxLocLon) + ); +} + +export type AlertSearchQueryInput = { + afterId?: string; + beforeId?: string; + afterDate?: string; + beforeDate?: string; + location?: AlertSearchLocationBBox; + planHandleIds?: string[]; +}; + +/** + * GET query params for alertSearch. + * Omits afterId/beforeId unless they are valid ULIDs (never sends "0"). + * Location is flattened to minLocLat/maxLocLat/minLocLon/maxLocLon for GET. + */ +export function buildAlertSearchQuery( + input: AlertSearchQueryInput +): URLSearchParams { + const params = new URLSearchParams(); + + if (isAlertSearchCursorUlid(input.afterId)) { + params.set("afterId", input.afterId); + } + if (isAlertSearchCursorUlid(input.beforeId)) { + params.set("beforeId", input.beforeId); + } + if (typeof input.afterDate === "string" && input.afterDate.length > 0) { + params.set("afterDate", input.afterDate); + } + if (typeof input.beforeDate === "string" && input.beforeDate.length > 0) { + params.set("beforeDate", input.beforeDate); + } + if (input.location !== undefined && isCompleteLocationBBox(input.location)) { + params.set("minLocLat", String(input.location.minLocLat)); + params.set("maxLocLat", String(input.location.maxLocLat)); + params.set("minLocLon", String(input.location.minLocLon)); + params.set("maxLocLon", String(input.location.maxLocLon)); + } + if (input.planHandleIds !== undefined && input.planHandleIds.length > 0) { + params.set("planHandleIds", JSON.stringify(input.planHandleIds)); + } + + return params; +} + +export function alertSearchUrl(baseUrl: string, path: string, query: URLSearchParams): string { + const base = baseUrl.replace(/\/+$/, ""); + const qs = query.toString(); + return qs.length > 0 ? `${base}${path}?${qs}` : `${base}${path}`; +} diff --git a/src/alertSearch/retrieve.test.ts b/src/alertSearch/retrieve.test.ts new file mode 100644 index 0000000..70eb462 --- /dev/null +++ b/src/alertSearch/retrieve.test.ts @@ -0,0 +1,360 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + fetchEndorserAlertSearchPage, + fetchPartnerAlertSearchPage, + type FetchLike, +} from "./client.js"; +import { retrieveAlertSearch } from "./retrieve.js"; +import { ALERT_SEARCH_PAGE_SIZE } from "./types.js"; + +const JWT = "delegated.jwt.token"; +const ENDORSER_BASE = "https://api.endorser.ch"; +const PARTNER_BASE = "https://partner-api.endorser.ch"; +const AFTER_E = "01H0000000000000000000000A"; +const AFTER_P = "01H0000000000000000000000B"; + +function emptyEndorserBody(overrides?: Record) { + return { + data: { + claims: [], + personalPlanContributions: [], + trackedPlanUpdates: [], + trackedPlanClaims: [], + plansNearby: [], + ...overrides, + }, + }; +} + +function emptyPartnerBody(overrides?: Record) { + return { + data: { + profilesNearby: [], + ...overrides, + }, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function captureFetch( + handler: (url: string, init?: RequestInit) => Response | Promise +): { fetch: FetchLike; urls: string[]; auths: string[] } { + const urls: string[] = []; + const auths: string[] = []; + const fetchImpl: FetchLike = async (input, init) => { + urls.push(input); + auths.push(new Headers(init?.headers).get("Authorization") ?? ""); + assert.equal(init?.method, "GET"); + return handler(input, init); + }; + return { fetch: fetchImpl, urls, auths }; +} + +function ulid(n: number): string { + return `01H${String(n).padStart(23, "0")}`; +} + +describe("alertSearch HTTP pages", () => { + it("sends the delegated JWT as Bearer on Endorser GET", async () => { + const cap = captureFetch(() => jsonResponse(emptyEndorserBody())); + await fetchEndorserAlertSearchPage({ + baseUrl: ENDORSER_BASE, + jwt: JWT, + query: {}, + config: { fetch: cap.fetch }, + }); + assert.equal(cap.auths[0], "Bearer delegated.jwt.token"); + assert.equal(cap.urls[0], `${ENDORSER_BASE}/api/v2/report/alertSearch`); + }); + + it("sends the delegated JWT as Bearer on Partner GET", async () => { + const cap = captureFetch(() => jsonResponse(emptyPartnerBody())); + await fetchPartnerAlertSearchPage({ + baseUrl: PARTNER_BASE, + jwt: JWT, + query: {}, + config: { fetch: cap.fetch }, + }); + assert.equal(cap.auths[0], "Bearer delegated.jwt.token"); + assert.equal(cap.urls[0], `${PARTNER_BASE}/api/partner/alertSearch`); + }); + + it("classifies auth failure", async () => { + const cap = captureFetch(() => + jsonResponse({ error: "Request must include a valid Authorization JWT" }, 400) + ); + const result = await fetchEndorserAlertSearchPage({ + baseUrl: ENDORSER_BASE, + jwt: JWT, + query: {}, + config: { fetch: cap.fetch }, + }); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.reason, "auth"); + }); + + it("classifies network failure", async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError("fetch failed"); + }; + const result = await fetchPartnerAlertSearchPage({ + baseUrl: PARTNER_BASE, + jwt: JWT, + query: {}, + config: { fetch: fetchImpl }, + }); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.reason, "network"); + }); + + it("classifies malformed Endorser envelope", async () => { + const cap = captureFetch(() => jsonResponse({ data: { hitLimit: true } })); + const result = await fetchEndorserAlertSearchPage({ + baseUrl: ENDORSER_BASE, + jwt: JWT, + query: {}, + config: { fetch: cap.fetch }, + }); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.reason, "malformed"); + }); + + it("classifies timeout", async () => { + const fetchImpl: FetchLike = async () => { + const err = new Error("The operation was aborted"); + err.name = "TimeoutError"; + throw err; + }; + const result = await fetchEndorserAlertSearchPage({ + baseUrl: ENDORSER_BASE, + jwt: JWT, + query: {}, + config: { fetch: fetchImpl }, + }); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.reason, "timeout"); + }); +}); + +describe("retrieveAlertSearch", () => { + it("first run omits afterId on both hosts", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse(emptyPartnerBody()); + } + return jsonResponse(emptyEndorserBody()); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.endorser.outcome, "empty"); + assert.equal(result.partner.outcome, "empty"); + assert.equal(result.empty, true); + assert.equal(cap.urls.length, 2); + for (const url of cap.urls) { + assert.equal(url.includes("afterId="), false); + } + }); + + it("passes independent afterId cursors", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse(emptyPartnerBody()); + } + return jsonResponse(emptyEndorserBody()); + }); + await retrieveAlertSearch({ + jwt: JWT, + endorserAfterId: AFTER_E, + partnerAfterId: AFTER_P, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + const endorserUrl = cap.urls.find((u) => u.includes("/api/v2/report/")) ?? ""; + const partnerUrl = cap.urls.find((u) => u.includes("/api/partner/")) ?? ""; + assert.equal(endorserUrl.includes(AFTER_E), true); + assert.equal(endorserUrl.includes(AFTER_P), false); + assert.equal(partnerUrl.includes(AFTER_P), true); + assert.equal(partnerUrl.includes(AFTER_E), false); + }); + + it("paginates Endorser with beforeId from the limited bucket", async () => { + const page1Ids = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) => + ulid(100 + i) + ); + const minId = [...page1Ids].sort()[0]; + let endorserPages = 0; + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse(emptyPartnerBody()); + } + endorserPages += 1; + if (endorserPages === 1) { + assert.equal(url.includes("beforeId="), false); + return jsonResponse( + emptyEndorserBody({ + claims: page1Ids.map((id) => ({ + id, + issuedAt: "2026-01-01T00:00:00Z", + issuer: "did:ethr:0x1", + })), + }) + ); + } + assert.equal(url.includes(`beforeId=${minId}`), true); + return jsonResponse( + emptyEndorserBody({ + claims: [ + { + id: ulid(1), + issuedAt: "2026-01-01T00:00:00Z", + issuer: "did:ethr:0x1", + }, + ], + }) + ); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.endorser.outcome, "success"); + assert.equal(result.endorser.pageCount, 2); + assert.equal(result.data.claims.length, ALERT_SEARCH_PAGE_SIZE + 1); + }); + + it("paginates Partner with beforeDate independently", async () => { + const times = Array.from( + { length: ALERT_SEARCH_PAGE_SIZE }, + (_, i) => `2026-01-01T00:${String(i).padStart(2, "0")}:00.000Z` + ); + const minTime = [...times].sort()[0]; + let partnerPages = 0; + const cap = captureFetch((url) => { + if (url.includes("/api/v2/report/")) { + return jsonResponse(emptyEndorserBody()); + } + partnerPages += 1; + if (partnerPages === 1) { + assert.equal(url.includes("beforeDate="), false); + return jsonResponse( + emptyPartnerBody({ + profilesNearby: times.map((updatedAt, i) => ({ + issuerDid: `did:ethr:0x${i}`, + description: "p", + updatedAt, + rowId: i, + })), + }) + ); + } + assert.equal(decodeURIComponent(url).includes(`beforeDate=${minTime}`), true); + return jsonResponse(emptyPartnerBody()); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.partner.pageCount, 2); + assert.equal(result.partner.outcome, "success"); + }); + + it("returns success with empty buckets", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + return jsonResponse(emptyEndorserBody()); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.empty, true); + assert.equal(result.endorser.outcome, "empty"); + assert.equal(result.partner.outcome, "empty"); + assert.ok("claims" in result.data); + assert.ok("profilesNearby" in result.data); + }); + + it("preserves all six result categories", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse( + emptyPartnerBody({ + profilesNearby: [ + { issuerDid: "did:ethr:0x2", description: "near", extra: true }, + ], + }) + ); + } + return jsonResponse({ + data: { + claims: [{ id: ulid(1), issuedAt: "t", issuer: "did:ethr:0x1", extra: 1 }], + personalPlanContributions: [ + { id: ulid(2), issuedAt: "t", issuer: "did:ethr:0x1", claim: "{}" }, + ], + trackedPlanUpdates: [{ handleId: "h", jwtId: ulid(3) }], + trackedPlanClaims: [ + { id: ulid(4), issuedAt: "t", issuer: "did:ethr:0x1" }, + ], + plansNearby: [{ handleId: "p", jwtId: ulid(5), locLat: 1 }], + }, + }); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.endorser.outcome, "success"); + assert.equal(result.partner.outcome, "success"); + assert.equal(result.data.claims[0]?.extra, 1); + assert.equal(result.data.profilesNearby[0]?.extra, true); + assert.equal(result.data.personalPlanContributions.length, 1); + assert.equal(result.data.trackedPlanUpdates.length, 1); + assert.equal(result.data.trackedPlanClaims.length, 1); + assert.equal(result.data.plansNearby.length, 1); + }); + + it("classifies truncation userMessage as incomplete pagination", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + return jsonResponse({ + data: { + claims: [], + personalPlanContributions: [], + trackedPlanUpdates: [], + trackedPlanClaims: [], + plansNearby: [], + }, + userMessage: + "Some data was not available in this search. Check the detail screens for the full set of data.", + }); + }); + const result = await retrieveAlertSearch({ + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, + config: { fetch: cap.fetch }, + }); + assert.equal(result.endorser.outcome, "pagination"); + assert.equal(result.endorser.reason, "pagination"); + }); +}); diff --git a/src/alertSearch/retrieve.ts b/src/alertSearch/retrieve.ts new file mode 100644 index 0000000..aedd3fa --- /dev/null +++ b/src/alertSearch/retrieve.ts @@ -0,0 +1,342 @@ +import { + DEFAULT_ENDORSER_API_SERVER, + DEFAULT_PARTNER_API_SERVER, + ENDORSER_URL, + PARTNER_URL, +} from "../env.js"; +import { + emptyEndorserData, + emptyPartnerData, + endorserBucketHitPageLimit, + fetchEndorserAlertSearchPage, + fetchPartnerAlertSearchPage, + minEndorserBeforeId, + minPartnerBeforeDate, + partnerBucketHitPageLimit, + type AlertSearchHttpConfig, +} from "./client.js"; +import type { AlertSearchQueryInput } from "./params.js"; +import type { + AlertSearchFailureReason, + AlertSearchLocationBBox, + AlertSearchQueryOutcome, + CombinedAlertSearchData, + EndorserAlertSearchData, + PartnerAlertSearchData, +} from "./types.js"; + +const DEFAULT_MAX_PAGES = 20; + +const TRUNCATION_HINT = "Some data was not available in this search"; + +export type AlertSearchSourceResult = { + outcome: AlertSearchQueryOutcome; + reason?: AlertSearchFailureReason; + message?: string; + userMessage?: string; + pageCount: number; + data: TData; +}; + +export type RetrieveAlertSearchInput = { + /** Delegated notification JWT for this scheduled day. Not the setup session JWT. */ + jwt: string; + /** Endorser cursor from a previous successful daily run. Omit on first run. */ + endorserAfterId?: string; + /** Partner cursor from a previous successful daily run. Independent of Endorser. */ + partnerAfterId?: string; + /** User-selected nearby-search bbox (alertSearch minLoc* / maxLoc* shape). */ + location?: AlertSearchLocationBBox; + planHandleIds?: string[]; + endorserBaseUrl?: string; + partnerBaseUrl?: string; + maxPages?: number; + config?: AlertSearchHttpConfig; +}; + +export type RetrieveAlertSearchResult = { + data: CombinedAlertSearchData; + empty: boolean; + endorser: AlertSearchSourceResult; + partner: AlertSearchSourceResult; +}; + +function recordKey(row: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = row[key]; + if (typeof value === "string" && value.length > 0) return `${key}:${value}`; + if (typeof value === "number") return `${key}:${value}`; + } + return undefined; +} + +function mergeRows>( + existing: T[], + incoming: T[], + keys: string[] +): T[] { + const seen = new Set(); + const out: T[] = []; + for (const row of [...existing, ...incoming]) { + const key = recordKey(row, keys); + if (key !== undefined) { + if (seen.has(key)) continue; + seen.add(key); + } + out.push(row); + } + return out; +} + +function mergeEndorser( + acc: EndorserAlertSearchData, + page: EndorserAlertSearchData +): EndorserAlertSearchData { + return { + claims: mergeRows(acc.claims, page.claims, ["id"]), + personalPlanContributions: mergeRows( + acc.personalPlanContributions, + page.personalPlanContributions, + ["id"] + ), + trackedPlanUpdates: mergeRows(acc.trackedPlanUpdates, page.trackedPlanUpdates, [ + "jwtId", + "handleId", + ]), + trackedPlanClaims: mergeRows(acc.trackedPlanClaims, page.trackedPlanClaims, [ + "id", + ]), + plansNearby: mergeRows(acc.plansNearby, page.plansNearby, ["jwtId", "handleId"]), + }; +} + +function mergePartner( + acc: PartnerAlertSearchData, + page: PartnerAlertSearchData +): PartnerAlertSearchData { + return { + profilesNearby: mergeRows(acc.profilesNearby, page.profilesNearby, [ + "rowId", + "issuerDid", + "updatedAt", + ]), + }; +} + +function endorserEmpty(data: EndorserAlertSearchData): boolean { + return ( + data.claims.length === 0 && + data.personalPlanContributions.length === 0 && + data.trackedPlanUpdates.length === 0 && + data.trackedPlanClaims.length === 0 && + data.plansNearby.length === 0 + ); +} + +function partnerEmpty(data: PartnerAlertSearchData): boolean { + return data.profilesNearby.length === 0; +} + +async function retrieveEndorserPages(input: { + jwt: string; + afterId?: string; + location?: AlertSearchLocationBBox; + planHandleIds?: string[]; + baseUrl: string; + maxPages: number; + config?: AlertSearchHttpConfig; +}): Promise> { + let data = emptyEndorserData(); + let beforeId: string | undefined; + let userMessage: string | undefined; + let pageCount = 0; + + for (let page = 0; page < input.maxPages; page++) { + const query: AlertSearchQueryInput = { + afterId: input.afterId, + beforeId, + location: input.location, + planHandleIds: input.planHandleIds, + }; + const result = await fetchEndorserAlertSearchPage({ + baseUrl: input.baseUrl, + jwt: input.jwt, + query, + config: input.config, + }); + pageCount += 1; + if (!result.ok) { + return { + outcome: result.reason, + reason: result.reason, + message: result.message, + userMessage, + pageCount, + data, + }; + } + if (result.body.userMessage) { + userMessage = userMessage + ? `${userMessage} ${result.body.userMessage}` + : result.body.userMessage; + } + if (result.body.userMessage?.includes(TRUNCATION_HINT)) { + data = mergeEndorser(data, result.body.data); + return { + outcome: "pagination", + reason: "pagination", + message: result.body.userMessage, + userMessage, + pageCount, + data, + }; + } + data = mergeEndorser(data, result.body.data); + if (!endorserBucketHitPageLimit(result.body.data)) { + return { + outcome: endorserEmpty(data) ? "empty" : "success", + userMessage, + pageCount, + data, + }; + } + const nextBefore = minEndorserBeforeId(result.body.data); + if (nextBefore === undefined) { + return { + outcome: "pagination", + reason: "pagination", + message: + "Endorser page was at the server row limit but no beforeId ULID could be derived.", + userMessage, + pageCount, + data, + }; + } + beforeId = nextBefore; + } + + return { + outcome: "pagination", + reason: "pagination", + message: `Endorser alertSearch stopped after ${input.maxPages} pages.`, + userMessage, + pageCount, + data, + }; +} + +async function retrievePartnerPages(input: { + jwt: string; + afterId?: string; + location?: AlertSearchLocationBBox; + baseUrl: string; + maxPages: number; + config?: AlertSearchHttpConfig; +}): Promise> { + let data = emptyPartnerData(); + let beforeDate: string | undefined; + let userMessage: string | undefined; + let pageCount = 0; + + for (let page = 0; page < input.maxPages; page++) { + const query: AlertSearchQueryInput = { + afterId: input.afterId, + beforeDate, + location: input.location, + }; + const result = await fetchPartnerAlertSearchPage({ + baseUrl: input.baseUrl, + jwt: input.jwt, + query, + config: input.config, + }); + pageCount += 1; + if (!result.ok) { + return { + outcome: result.reason, + reason: result.reason, + message: result.message, + userMessage, + pageCount, + data, + }; + } + if (result.body.userMessage) { + userMessage = userMessage + ? `${userMessage} ${result.body.userMessage}` + : result.body.userMessage; + } + data = mergePartner(data, result.body.data); + if (!partnerBucketHitPageLimit(result.body.data)) { + return { + outcome: partnerEmpty(data) ? "empty" : "success", + userMessage, + pageCount, + data, + }; + } + const nextBefore = minPartnerBeforeDate(result.body.data); + if (nextBefore === undefined) { + return { + outcome: "pagination", + reason: "pagination", + message: + "Partner page was at the server row limit but no beforeDate could be derived.", + userMessage, + pageCount, + data, + }; + } + beforeDate = nextBefore; + } + + return { + outcome: "pagination", + reason: "pagination", + message: `Partner alertSearch stopped after ${input.maxPages} pages.`, + userMessage, + pageCount, + data, + }; +} + +/** + * Query Endorser and Partner alertSearch independently with the same delegated JWT. + * Does not persist cursors; the caller supplies afterId values. + */ +export async function retrieveAlertSearch( + input: RetrieveAlertSearchInput +): Promise { + const endorserBaseUrl = + input.endorserBaseUrl ?? ENDORSER_URL ?? DEFAULT_ENDORSER_API_SERVER; + const partnerBaseUrl = + input.partnerBaseUrl ?? PARTNER_URL ?? DEFAULT_PARTNER_API_SERVER; + const maxPages = input.maxPages ?? DEFAULT_MAX_PAGES; + + const [endorser, partner] = await Promise.all([ + retrieveEndorserPages({ + jwt: input.jwt, + afterId: input.endorserAfterId, + location: input.location, + planHandleIds: input.planHandleIds, + baseUrl: endorserBaseUrl, + maxPages, + config: input.config, + }), + retrievePartnerPages({ + jwt: input.jwt, + afterId: input.partnerAfterId, + location: input.location, + baseUrl: partnerBaseUrl, + maxPages, + config: input.config, + }), + ]); + + const data: CombinedAlertSearchData = { + ...endorser.data, + ...partner.data, + }; + const empty = endorserEmpty(endorser.data) && partnerEmpty(partner.data); + return { data, empty, endorser, partner }; +} diff --git a/src/alertSearch/types.ts b/src/alertSearch/types.ts new file mode 100644 index 0000000..9c05207 --- /dev/null +++ b/src/alertSearch/types.ts @@ -0,0 +1,109 @@ +/** + * Raw alertSearch contract types, aligned with endorser-ch and the app + * `interfaces/alertSearch` module. These are API envelopes, not a digest model. + */ + +/** Server-issued ULID on a stored JWT/plan record. Not an auth or delegated JWT. */ +export type AlertSearchCursorUlid = string; + +export type AlertSearchLocationBBox = { + minLocLat: number; + maxLocLat: number; + minLocLon: number; + maxLocLon: number; +}; + +export type AlertSearchClaimRecord = { + id: AlertSearchCursorUlid; + issuedAt: string; + issuer: string; + subject?: string; + claimType?: string; + handleId?: string; + fromEntity?: string; + toEntity?: string; + [key: string]: unknown; +}; + +export type AlertSearchJwtWithClaimRecord = AlertSearchClaimRecord & { + claim?: string; +}; + +export type 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; + [key: string]: unknown; +}; + +export type AlertSearchProfileRecord = { + rowId?: number; + issuerDid: string; + updatedAt?: string; + description: string; + locLat?: number; + locLon?: number; + locLat2?: number; + locLon2?: number; + [key: string]: unknown; +}; + +export type EndorserAlertSearchData = { + claims: AlertSearchClaimRecord[]; + personalPlanContributions: AlertSearchJwtWithClaimRecord[]; + trackedPlanUpdates: AlertSearchPlanRecord[]; + trackedPlanClaims: AlertSearchJwtWithClaimRecord[]; + plansNearby: AlertSearchPlanRecord[]; +}; + +export type PartnerAlertSearchData = { + profilesNearby: AlertSearchProfileRecord[]; +}; + +export type CombinedAlertSearchData = EndorserAlertSearchData & + PartnerAlertSearchData; + +export type EndorserAlertSearchResponse = { + data: EndorserAlertSearchData; + userMessage?: string; +}; + +export type PartnerAlertSearchResponse = { + data: PartnerAlertSearchData; + userMessage?: string; +}; + +export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch"; +export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch"; + +/** Matches endorser-ch / partner DEFAULT_LIMIT. Not a JSON envelope field. */ +export const ALERT_SEARCH_PAGE_SIZE = 50; + +export type AlertSearchFailureReason = + | "auth" + | "timeout" + | "network" + | "malformed" + | "pagination" + | "http"; + +export type AlertSearchSource = "endorser" | "partner"; + +export type AlertSearchQueryOutcome = + | "success" + | "empty" + | AlertSearchFailureReason; diff --git a/src/env.ts b/src/env.ts index dc2d113..3e3cce2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -2,6 +2,20 @@ import { config } from "dotenv"; config(); +/** Production Endorser host (same default as the app DEFAULT_ENDORSER_API_SERVER). */ +export const DEFAULT_ENDORSER_API_SERVER = "https://api.endorser.ch"; + +/** Production Partner host (same default as the app DEFAULT_PARTNER_API_SERVER). */ +export const DEFAULT_PARTNER_API_SERVER = "https://partner-api.endorser.ch"; + /** Base URL for the Endorser API server. */ export const ENDORSER_URL = - process.env.ENDORSER_URL ?? "https://api.endorser.ch"; + process.env.ENDORSER_URL ?? + process.env.DEFAULT_ENDORSER_API_SERVER ?? + DEFAULT_ENDORSER_API_SERVER; + +/** Base URL for the Partner API server. Separate from Endorser. */ +export const PARTNER_URL = + process.env.PARTNER_URL ?? + process.env.DEFAULT_PARTNER_API_SERVER ?? + DEFAULT_PARTNER_API_SERVER; diff --git a/tsconfig.json b/tsconfig.json index 63c6b32..f2c08b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "sourceMap": true }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] }