425 lines
15 KiB
TypeScript
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;
|