Files
crowd-funder-for-time-pwa/src/interfaces/notifyApi.ts
T

425 lines
15 KiB
TypeScript

/**
* 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;