/** * Wire payloads for the notify-api (notification-wakeup-service): request * bodies, query parameters, the JWT payloads the app signs for it, and the * bodies it answers with. * * Each type names the route it belongs to and holds the fields that route reads * or writes; a field the app sends and the service ignores is marked "Not read * by the service." The service's code is the source of truth, in * notification-wakeup-service: `src/routes/notifications.ts`, * `src/routes/notifySms.ts`, `src/routes/debug.ts`, `src/middleware/`, and * `src/services/alertAuthorization.ts`. * * Two kinds of JWT travel to the service: * - The Bearer token on each request authenticates the caller, and the service * takes the DID from it; no body carries the DID. On `/notify-sms` that token * also carries an {@link SmsNotificationActionClaim}. * - The delegated JWTs inside an {@link AlertAuthorizationRequestBody} are * stored credentials the service later presents to Endorser and Partner. * They authenticate nothing about the upload that carries them. * * Refusals come in these shapes, by where they are raised: * - {@link NotifyApiUncodedFailure}: a message and no code, from the auth * stages in front of every authenticated route, and from a push * alert-authorization route that fails to store or delete. * - {@link NotificationDeviceRouteFailure}: a sentence under `error`, from * register and refresh. * - {@link DebugSendWakeupResponse} with `success: false`. * - {@link AlertAuthorizationBatchFailure} and {@link NotifySmsFailure}: coded. * * A proxy in front of the service can answer with none of these, or with no * JSON at all. */ // --------------------------------------------------------------------------- // Shared // --------------------------------------------------------------------------- /** * Refusal with a message and no code. The auth stages send it with 401 for a * missing, invalid, or Endorser-rejected Bearer token and 503 when Endorser * cannot be reached; their messages end with a timestamp for finding the server * log. The push alert-authorization routes send it with 500 when storing or * deleting fails. */ export interface NotifyApiUncodedFailure { success: false; message: string; } // --------------------------------------------------------------------------- // Device routes: /notifications/register, /notifications/refresh, /debug // --------------------------------------------------------------------------- /** * Local-test switch on the device routes. `true` with no `Authorization` * header makes the service skip JWT and Endorser checks and file the request * under a synthetic test user. A request carrying a Bearer token is * authenticated normally whatever this says, and no other route reads it. */ export interface NotifyApiTestModeFlag { testMode?: boolean; } /** * Body of `POST /notifications/register`: store this device's FCM token under * the caller's DID, keyed by `deviceId`. Success is a bare 200 with a * plain-text body; a failure to store is a bare 500. */ export interface NotificationRegisterRequest extends NotifyApiTestModeFlag { /** Stable per-install id; trimmed by the service and required non-empty. */ deviceId: string; /** Required non-empty. */ fcmToken: string; /** `Capacitor.getPlatform()`: `"ios"`, `"android"`, or `"web"`. Required non-empty. */ platform: string; /** The DID comes from the Bearer token; a body with a `userId` key is rejected. */ userId?: never; } /** * Body of `POST /notifications/refresh`. The service finds the caller's device * by `deviceId`, or by `fcmToken` when no `deviceId` is sent, and answers 400 * when neither is present and non-empty. When both are sent they must name the * same device, or the answer is 404. */ export type NotificationRefreshRequest = NotifyApiTestModeFlag & { /** Not read by the service. */ platform?: string; } & ( | { deviceId: string; fcmToken?: string } | { deviceId?: string; fcmToken: string } ); /** Success body of `POST /notifications/refresh`. */ export interface NotificationRefreshResponse { shouldNotify: boolean; /** Instants for the device to schedule, as Unix milliseconds. */ nextNotifications: Array<{ timestamp: number }>; } /** * Refusal from `POST /notifications/register` (400) or * `POST /notifications/refresh` (400, or 404 when no device of the caller's * matches). `error` is a sentence such as `"Device not found"`, not a code. */ export interface NotificationDeviceRouteFailure { error: string; } /** * Body of `POST /debug/send-wakeup`, which pushes a WAKEUP_PING to one of the * caller's devices. The `/debug` routes exist only on a service started with * `DEBUG_ENDPOINT` on. */ export interface DebugSendWakeupRequest extends NotifyApiTestModeFlag { /** A token registered under the caller's DID; required non-empty. */ fcmToken: string; /** Not read by the service. */ deviceId?: string; /** Not read by the service. */ platform?: string; } /** * Every answer `POST /debug/send-wakeup` writes itself. A 200 still carries * `success: false` when the push was skipped or failed, so `success`, not the * status, says whether a WAKEUP_PING went out. */ export interface DebugSendWakeupResponse { success: boolean; /** * Why no push went out: `"fcmToken is required"` (400), `"Device not found"` * (404), or with a 200, `"Device was notified within the eligibility * threshold"` or `"FCM send failed"`. */ failureReason?: string; /** Last six characters of the token; absent on the 400. */ fcmTokenSuffix?: string; } // --------------------------------------------------------------------------- // Alert authorization: /notifications/alert-authorization and // /notify-sms/alert-authorization // --------------------------------------------------------------------------- /** * Payload the app signs for one {@link DelegatedAlertJwt}; the signer adds * `iss` (the DID) and `iat`. The service requires the signed `nbf` and `exp` to * equal the values listed beside the JWT. */ export interface DelegatedAlertJwtPayload { /** Unix seconds, at or before the midnight UTC that opens the entry's `day`. */ nbf: number; /** Unix seconds, at or after the midnight UTC that closes the entry's `day`. */ exp: number; } /** One day's delegated credential inside an {@link AlertAuthorizationRequestBody}. */ export interface DelegatedAlertJwt { /** Integer; the batch's values are consecutive, starting anywhere. */ sequence: number; /** UTC calendar day, `YYYY-MM-DD`, distinct across the batch. */ day: string; /** Unix seconds; equal to the signed `nbf`. */ nbf: number; /** Unix seconds; equal to the signed `exp`. */ exp: number; /** Signed by the caller's DID, which must be a `did:ethr`. */ jwt: string; } /** * Body of `PUT /notifications/alert-authorization` (push) and * `POST /notify-sms/alert-authorization` (SMS), which validate it with one * rule. A stored batch replaces the unused inventory the DID holds in that * channel; JWTs already spent stay behind. */ export interface AlertAuthorizationRequestBody { /** Required non-empty; echoed in the response. */ batchId: string; /** UTC hour, integer 0-23. */ notifyHourUtc: number; /** UTC minute, integer 0-59. */ notifyMinuteUtc: number; /** * IANA zone name such as `"America/Denver"`. Validated and stored; no * scheduling decision reads it. */ timezone?: string; /** Exactly 100 entries. */ jwts: DelegatedAlertJwt[]; } /** Success body of storing a batch, on either alert-authorization route. */ export interface AlertAuthorizationResponse { success: true; batchId: string; /** The stored UTC hour; null only for a batch stored without one. */ notifyHourUtc: number | null; /** The stored UTC minute; null only for a batch stored without one. */ notifyMinuteUtc: number | null; timezone: string | null; /** JWTs stored from this batch. */ storedCount: number; /** Unused JWTs the DID holds in this channel once the batch is stored. */ unusedCount: number; } /** Success body of `DELETE` on either alert-authorization route. */ export interface AlertAuthorizationRevokeResponse { success: true; /** Batches removed, spent ones included. */ deletedBatches: number; /** JWTs removed, spent ones included. */ deletedJwts: number; } /** * Coded refusal of a batch (400) on either alert-authorization route: it failed * validation, or the caller's identity cannot hold one. Nothing was stored. */ export interface AlertAuthorizationBatchFailure { success: false; error: | "ALERT_AUTHORIZATION_INVALID_BATCH" | "DELEGATED_JWT_UNSUPPORTED_IDENTITY"; message: string; /** Specific problems, at most 20 plus a count of the rest. */ details: string[]; } /** Refusal from `PUT` or `DELETE /notifications/alert-authorization`. */ export type AlertAuthorizationFailure = | AlertAuthorizationBatchFailure | NotifyApiUncodedFailure; // --------------------------------------------------------------------------- // SMS channel: /notify-sms // --------------------------------------------------------------------------- /** * What a `/notify-sms` action claim authorizes. The service checks `action` * against the route and, for handset actions, checks that `phoneNumber` * normalizes to the same E.164 number the request names. */ export type SmsActionTarget = // Bound to one handset; the number must match the request's. | { action: "register-phone" | "verify-phone" | "delete-phone"; phoneNumber: string; } // Bound to a number only when the request sends `?phoneNumber=`. | { action: "list-phones"; phoneNumber?: string } // Acts on the DID's whole alert authorization; the service reads no number. | { action: "authorize-alert-search" | "revoke-alert-search"; phoneNumber?: undefined; }; /** Actions the service's `requireSmsActionJwt` stage recognizes, one per route. */ export type SmsAction = SmsActionTarget["action"]; /** The `claim` in the Bearer JWT on every `/notify-sms` request. */ export type SmsNotificationActionClaim = { /** Claim namespace; distinct from `https://giftopia.me`, the link in the texts. */ "@context": "https://giftopia.tech"; "@type": "SmsNotificationAction"; } & SmsActionTarget; /** * Payload the app signs as the Bearer JWT on every `/notify-sms` request; the * signer adds `iss`, `iat`, and `exp`. The service refuses a token whose `iat` * is further than `SMS_ACTION_JWT_MAX_AGE_SEC` from its clock or whose `exp` * has passed, and accepts each token once. */ export interface SmsActionJwtPayload { claim: SmsNotificationActionClaim; } /** * Query of `GET /notify-sms/phone`. With `phoneNumber`, the response adds the * DIDs verified on that number, which the service reveals only to a DID that * has verified it too. */ export interface SmsPhoneListQuery { phoneNumber?: string; } /** One of the caller's registrations, as `GET /notify-sms/phone` lists it. */ export interface SmsPhoneRegistration { /** The full E.164 number; these are the caller's own. */ phoneNumber: string; verified: boolean; /** ISO 8601; null until verified. */ verifiedAt: string | null; /** ISO 8601. */ createdAt: string; } /** * Success body of `GET /notify-sms/phone`, oldest registration first. An * identity with no registrations gets an empty list. */ export interface SmsPhoneListResponse { success: true; phones: SmsPhoneRegistration[]; /** The queried number, normalized; present exactly when `?phoneNumber=` was sent. */ phoneNumber?: string; /** * Every DID verified on the queried number, the caller's included; present * exactly when `?phoneNumber=` was sent. */ dids?: string[]; } /** Body of `POST /notify-sms/phone`: record the number, unverified, and text it a code. */ export interface SmsPhoneRegisterRequest { /** E.164; the service normalizes, taking ten bare digits as a US number. */ phoneNumber: string; } /** * Success body of `POST /notify-sms/phone`. `phoneNumber` is masked, such as * `+1555*****23`, so it cannot stand in for the number that was sent. */ export type SmsPhoneRegisterResponse = // The caller had already verified this number; no text was sent. | { success: true; phoneNumber: string; verified: true } | { success: true; phoneNumber: string; verified: false; /** ISO 8601; when the texted code stops working. */ expiresAt: string; /** * The plaintext code, only from a service run with both * `NODE_ENV=test-local` and `SMS_DEV_ECHO_CODE`. */ devCode?: string; }; /** Body of `PUT /notify-sms/phone`: match the texted code. */ export interface SmsPhoneVerifyRequest { phoneNumber: string; /** The six digits as texted. */ code: string; } /** * Success body of `PUT /notify-sms/phone`, also for a number the caller had * already verified. `phoneNumber` is masked. */ export interface SmsPhoneVerifyResponse { success: true; phoneNumber: string; verified: true; } /** * Body, and also query, of `DELETE /notify-sms/phone`. The service reads the * body first and falls back to the query, because proxies may drop DELETE * bodies. */ export interface SmsPhoneDeleteRequest { phoneNumber: string; } /** Success body of `DELETE /notify-sms/phone`; `deleted` is false when the number was not registered. */ export interface SmsPhoneDeleteResponse { success: true; deleted: boolean; } /** Codes of `/notify-sms` refusals that carry nothing beyond the message. */ export type NotifySmsPlainErrorCode = | "SMS_DISABLED" // 503 | "SMS_NOT_CONFIGURED" // 500 | "SMS_PHONE_INVALID" // 400 | "SMS_PHONE_BLOCKED" // 403 | "SMS_PHONE_NOT_VERIFIED_BY_CALLER" // 403 | "SMS_RECIPIENT_NOT_ALLOWED" // 403 | "SMS_CODE_RATE_LIMITED" // 429 | "SMS_CODE_SEND_FAILED" // 502 | "SMS_CODE_EXPIRED" // 400 | "SMS_CODE_ATTEMPTS_EXHAUSTED" // 429 | "SMS_NO_VERIFIED_PHONE" // 409 | "SMS_ALERT_AUTHORIZATION_FAILED" // 500 | "SMS_ALERT_AUTHORIZATION_DELETE_FAILED" // 500 | "SMS_ACTION_JWT_NOT_AUTHENTICATED" // 500 | "SMS_ACTION_JWT_MISSING_CLAIM" // 403 | "SMS_ACTION_JWT_WRONG_ACTION" // 403 | "SMS_ACTION_JWT_PHONE_MISMATCH" // 403 | "SMS_ACTION_JWT_STALE" // 401 | "SMS_ACTION_JWT_EXPIRED" // 401 | "SMS_ACTION_JWT_REPLAYED"; // 401 /** * A coded refusal from any `/notify-sms` route. The auth stages in front of * these routes refuse with a {@link NotifyApiUncodedFailure} instead. */ export type NotifySmsFailure = | { success: false; error: NotifySmsPlainErrorCode; message: string } | { success: false; error: "SMS_CODE_MISMATCH"; // 400 message: string; /** Wrong codes left before this code is cleared. */ attemptsRemaining: number; } | { success: false; error: "SMS_PHONE_DID_LIMIT"; // 409 message: string; /** Most identities one number may carry. */ limit: number; /** Identities other than the caller already verified on the number. */ verifiedCount: number; /** * Every DID verified on the number. Sent only by `PUT`, whose caller has * just proved possession of the handset. */ dids?: string[]; } | AlertAuthorizationBatchFailure;