From bf48b205f2130c2ffc56f19ce683bdc19c558bf0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 21:11:36 -0400 Subject: [PATCH] feat(recovery): deliver recovery notices through the adapter, and cancel from a session /recovery/verify returns the opened notices as deliveries to a credentialed caller that lists account_notice_email / account_notice_sms in x-seamless-auth-delivery-kinds; the manifest marks the route notices: true. Callers that do not list the kinds still get the fail-closed 503, so an older adapter never receives a kind it would drop. The recovery code carries purpose account_recovery. GET /recovery/requests and DELETE /recovery/requests/:id let a signed-in owner see and cancel open requests without the token. Updates @seamless-auth/types to 0.31.0. Closes #499 --- .changeset/recovery-notice-delivery.md | 10 + adapter-manifest.json | 4 +- docs/account-recovery.md | 44 +- docs/extending.md | 7 + openapi.json | 1819 ++++++++++++++++- package-lock.json | 8 +- package.json | 2 +- src/controllers/accountRecovery.ts | 73 +- src/controllers/recoveryCodes.ts | 3 +- src/generated/api.ts | 1389 ++++++++++++- src/lib/adapterManifest.ts | 7 + src/lib/externalDelivery.ts | 15 + src/routes/accountRecovery.routes.ts | 56 +- src/schemas/accountRecovery.ts | 27 + src/schemas/adapterManifest.responses.ts | 3 + src/services/accountNotices.ts | 60 +- src/services/accountRecoveryService.ts | 21 + tests/factories/accountRecoveryStore.ts | 12 + .../recovery/accountRecovery.spec.ts | 133 +- tests/postgres/accountRecovery.spec.ts | 29 + tests/setup/mocks.ts | 1 + tests/unit/lib/adapterManifest.spec.ts | 18 + tests/unit/services/accountNotices.spec.ts | 78 +- 23 files changed, 3692 insertions(+), 127 deletions(-) create mode 100644 .changeset/recovery-notice-delivery.md diff --git a/.changeset/recovery-notice-delivery.md b/.changeset/recovery-notice-delivery.md new file mode 100644 index 0000000..4062862 --- /dev/null +++ b/.changeset/recovery-notice-delivery.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': patch +--- + +Account recovery works with adapter-owned delivery, and a signed-in owner can cancel it. + +- `POST /recovery/verify` returns the "recovery opened" notices as `deliveries` (`account_notice_email` / `account_notice_sms`, with the `cancelUrl`) to a credentialed caller that owns delivery and lists those kinds in the new `x-seamless-auth-delivery-kinds` header. A caller that owns delivery but does not list them still gets `503 recovery_notice_failed`, since an older adapter would drop a kind it does not know. The adapter manifest marks the route `notices: true`. +- The recovery code from `POST /recovery/start` carries `purpose: "account_recovery"` under external delivery. +- `GET /recovery/requests` and `DELETE /recovery/requests/:id` (access session) list and cancel the account's open recovery requests without the token from the notice. +- Updates `@seamless-auth/types` to 0.31.0. diff --git a/adapter-manifest.json b/adapter-manifest.json index a47c9ee..5eda92a 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -192,9 +192,11 @@ "credential": "preAuth", "issues": "session" }, + { "method": "GET", "path": "/recovery/requests", "credential": "access" }, + { "method": "DELETE", "path": "/recovery/requests/{id}", "credential": "access" }, { "method": "POST", "path": "/recovery/start", "credential": "preAuth", "delivery": true }, { "method": "GET", "path": "/recovery/status", "credential": "preAuth" }, - { "method": "POST", "path": "/recovery/verify", "credential": "preAuth" }, + { "method": "POST", "path": "/recovery/verify", "credential": "preAuth", "notices": true }, { "method": "POST", "path": "/refresh", "credential": "refresh", "issues": "session" }, { "method": "POST", "path": "/registration/phone", "credential": "access", "delivery": true }, { "method": "POST", "path": "/registration/phone/verify", "credential": "access" }, diff --git a/docs/account-recovery.md b/docs/account-recovery.md index d37d47a..aa72f68 100644 --- a/docs/account-recovery.md +++ b/docs/account-recovery.md @@ -70,16 +70,19 @@ opens, with a link that cancels it. Off by default. Turn it on with `ACCOUNT_RECOVERY` (`account_recovery`), whose `delay` is `24h` unless set (`1h` to `30d`). While it is off, every route below answers -`403 { "error": "account_recovery_disabled" }`, and `GET /system-config/public` reports +`403 { "error": "account_recovery_disabled" }` (except cancelling, and the session routes for +open requests), and `GET /system-config/public` reports `accountRecovery` so sign-in screens offer it only where it works. -| Route | Auth | What it does | -| ------------------------- | ---------------------------- | --------------------------------------------------------- | -| `POST /recovery/start` | pre-auth token from `/login` | `{ channel: "email" \| "phone" }`, sends a six-digit code | -| `POST /recovery/verify` | the same attempt | `{ code }`, opens the request and starts the delay | -| `GET /recovery/status` | the same attempt | `{ state, requestedAt, completesAt }` | -| `POST /recovery/cancel` | none | `{ cancelToken }` from the notice | -| `POST /recovery/complete` | the same attempt, once ready | Signs out every session and issues a recovery session | +| Route | Auth | What it does | +| ------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | +| `POST /recovery/start` | pre-auth token from `/login` | `{ channel: "email" \| "phone" }`, sends a six-digit code | +| `POST /recovery/verify` | the same attempt | `{ code }`, opens the request and starts the delay | +| `GET /recovery/status` | the same attempt | `{ state, requestedAt, completesAt }` | +| `POST /recovery/cancel` | none | `{ cancelToken }` from the notice | +| `POST /recovery/complete` | the same attempt, once ready | Signs out every session and issues a recovery session | +| `GET /recovery/requests` | access session | `{ requests: [{ id, channel, state, requestedAt, completesAt }] }`, the open ones | +| `DELETE /recovery/requests/:id` | access session | Cancels one of them without the token | ### The flow @@ -120,7 +123,9 @@ that code and leave the open request as it was. `POST /recovery/cancel` with the token from the notice cancels the request whether it is `pending` or `ready`. It needs no session, because the owner may be signed out everywhere, and it answers the same `200` whether or not the token matched, so it is not an oracle. -Cancelling from a signed-in session without the token is a follow-up. +A signed-in owner can also see open requests with `GET /recovery/requests` and cancel one +with `DELETE /recovery/requests/:id`, without the token. Both work while recovery is turned +off, and a request id from another account answers `404`. ### Notes @@ -129,14 +134,19 @@ Cancelling from a signed-in session without the token is a follow-up. the OTP flow rate limits. - A pre-auth token for an identifier with no account gets the answers a real account with no verified address gets, and nothing is stored or sent for it. -- The opening notice needs direct delivery: the API's own email or SMS transport. The - delivery contract has no kind for account notices yet, so a credentialed caller that - owns delivery (external delivery with a service token) on `/recovery/verify` gets the - `503`, with each notice recorded as `not_sent_external_delivery`. A deployment that - delivers codes through its adapter can still offer recovery, as long as the API has a - working transport for the notice and the adapter does not ask for external delivery on - verify. The delivery header alone does not suppress notices, so a stranger cannot - recover an account silently. +- Under external delivery the code arrives with `purpose: "account_recovery"`, so an + adapter can word it as a recovery warning rather than a sign-in code. +- The opening notice goes out directly, through the API's own email or SMS transport, + unless the caller owns delivery. A credentialed caller that owns delivery (external + delivery with a service token) gets the notices back only if it lists their kinds in + `x-seamless-auth-delivery-kinds` (`account_notice_email`, `account_notice_sms`): they + arrive as `deliveries` on the `/recovery/verify` response, each with `notice: +"account_recovery_opened"` and the `cancelUrl`, and count as notifying the owner. A + caller that owns delivery but lists neither kind gets the `503`, with each notice + recorded as `not_sent_external_delivery`, because an adapter that predates the kinds + would drop them. The adapter manifest marks the route `notices: true`. The delivery + header alone does not suppress notices, so a stranger cannot recover an account + silently, and the cancel link is never written to the audit event. - Audit events: `account_recovery_started`, `account_recovery_verified` (with each notice's outcome), `account_recovery_cancelled`, `account_recovery_completed` (with the number of sessions revoked) and `account_recovery_failed` (a wrong code, a failed delivery, or a request voided because the diff --git a/docs/extending.md b/docs/extending.md index 59d9229..1f026bf 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -37,6 +37,13 @@ its own channel. See [`src/lib/externalDelivery.ts`](../src/lib/externalDelivery - For local development without a service token, set `ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true`. The flag is ignored when `NODE_ENV=production`. +Kinds that older adapters do not know are only returned to a caller that names them in +`x-seamless-auth-delivery-kinds` (a comma separated list of `kind` values). Today that covers +the owner notices for account recovery (`account_notice_email`, `account_notice_sms`), which +`POST /recovery/verify` returns as a `deliveries` array; routes that can do this are marked +`notices: true` in the adapter manifest. A caller that does not name them gets direct +delivery, or a refusal where a notice is required. + This is how a server adapter integrates its own email/SMS stack without the API baking in a provider. See the delivery payload shapes in [`src/schemas/generic.responses.ts`](../src/schemas/generic.responses.ts) (`AuthDeliverySchema`) diff --git a/openapi.json b/openapi.json index 1fa0ada..8582679 100644 --- a/openapi.json +++ b/openapi.json @@ -44,7 +44,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -53,7 +54,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -75,6 +77,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -183,7 +224,7 @@ "/recovery/verify": { "post": { "summary": "Verify an account recovery code", - "description": "Opens the request started on this attempt, which completes after the configured delay, and tells every verified address how to cancel it. A code for a request that is already open resumes it on this attempt. Five wrong codes void the code. Answers 503 and voids the request when no verified address could be notified.", + "description": "Opens the request started on this attempt, which completes after the configured delay, and tells every verified address how to cancel it. A code for a request that is already open resumes it on this attempt. Five wrong codes void the code. Answers 503 and voids the request when no verified address could be notified. A credentialed caller that owns delivery and lists `account_notice_email` / `account_notice_sms` in `x-seamless-auth-delivery-kinds` gets the notices back as `deliveries` to send; one that does not list them gets the 503.", "tags": ["Recovery"], "security": [{ "bearerAuth": [] }], "requestBody": { @@ -202,7 +243,12 @@ "description": "HTTP 200", "content": { "application/json": { - "example": { "state": null, "requestedAt": null, "completesAt": null }, + "example": { + "state": null, + "requestedAt": null, + "completesAt": null, + "deliveries": [null] + }, "schema": { "type": "object", "properties": { @@ -211,7 +257,92 @@ "enum": ["pending", "ready", "cancelled", "completed"] }, "requestedAt": { "type": "string", "nullable": true, "format": "date-time" }, - "completesAt": { "type": "string", "nullable": true, "format": "date-time" } + "completesAt": { "type": "string", "nullable": true, "format": "date-time" }, + "deliveries": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] + } + ] + } + } }, "required": ["state", "requestedAt", "completesAt"] } @@ -455,7 +586,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -464,7 +596,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -486,6 +619,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -705,6 +877,321 @@ } } }, + "/recovery/requests": { + "get": { + "summary": "List open account recovery requests", + "description": "The signed-in account's pending recovery requests, so the owner can see one they did not start without waiting for the notice.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "requests": [null] }, + "schema": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "channel": { "type": "string", "enum": ["email", "phone"] }, + "state": { + "type": "string", + "enum": ["pending", "ready", "cancelled", "completed"] + }, + "requestedAt": { "type": "string" }, + "completesAt": { "type": "string" } + }, + "required": ["id", "channel", "state", "requestedAt", "completesAt"] + } + } + }, + "required": ["requests"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/recovery/requests/{id}": { + "delete": { + "summary": "Cancel an account recovery request from a session", + "description": "Cancels one of the signed-in account's pending recovery requests without the token from the notice. Answers 404 for a request that is not open or not this account's.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "format": "uuid" }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/.well-known/seamless-adapter.json": { "get": { "summary": "Routes a server adapter exposes and what it does with each", @@ -775,7 +1262,8 @@ }, "required": ["pick"] }, - "delivery": { "type": "boolean", "enum": [true] } + "delivery": { "type": "boolean", "enum": [true] }, + "notices": { "type": "boolean", "enum": [true] } }, "required": ["method", "path", "credential"] } @@ -1736,7 +2224,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -1745,7 +2234,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -1767,6 +2257,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -2520,7 +3049,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -2529,7 +3059,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -2551,6 +3082,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -3408,7 +3978,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -3417,7 +3988,10 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { + "anyOf": [{ "type": "string" }, { "type": "number" }] + }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -3439,6 +4013,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -4314,7 +4927,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -4323,7 +4937,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -4345,6 +4960,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -6296,7 +6950,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6305,7 +6960,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6324,9 +6980,48 @@ "properties": { "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, "to": { "type": "string" }, - "signInUrl": { "type": "string" } + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } }, - "required": ["kind", "to", "signInUrl"] + "required": ["kind", "notice", "to", "text"] } ] } @@ -6477,7 +7172,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6486,7 +7182,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6508,6 +7205,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -6778,7 +7514,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6787,7 +7524,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6809,6 +7547,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -6883,7 +7660,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6892,7 +7670,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -6914,6 +7693,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -8319,7 +9137,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8328,7 +9147,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8350,6 +9170,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -8473,7 +9332,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8482,7 +9342,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8504,6 +9365,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -8653,7 +9553,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8662,7 +9563,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8684,6 +9586,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -8774,7 +9715,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8783,7 +9725,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8805,6 +9748,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -8963,7 +9945,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8972,7 +9955,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -8994,6 +9978,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11036,7 +12059,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11045,7 +12069,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11067,6 +12092,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11182,7 +12246,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11191,7 +12256,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11213,6 +12279,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11300,7 +12405,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11309,7 +12415,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11331,6 +12438,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11418,7 +12564,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11427,7 +12574,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11446,9 +12594,48 @@ "properties": { "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, "to": { "type": "string" }, - "signInUrl": { "type": "string" } + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } }, - "required": ["kind", "to", "signInUrl"] + "required": ["kind", "notice", "to", "text"] } ] } @@ -11523,7 +12710,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11532,7 +12720,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11554,6 +12743,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11628,7 +12856,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11637,7 +12866,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11659,6 +12889,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11733,7 +13002,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11742,7 +13012,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11764,6 +13035,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11838,7 +13148,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11847,7 +13158,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11869,6 +13181,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -11943,7 +13294,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11952,7 +13304,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -11974,6 +13327,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -12954,7 +14346,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -12963,7 +14356,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -12985,6 +14379,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -13111,7 +14544,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -13120,7 +14554,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -13142,6 +14577,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -13486,7 +14960,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -13495,7 +14970,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -13517,6 +14993,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -13594,7 +15109,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -13603,7 +15119,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -13625,6 +15142,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -17921,7 +19477,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -17930,7 +19487,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -17952,6 +19510,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -18818,7 +20415,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -18827,7 +20425,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -18849,6 +20448,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } @@ -18952,7 +20590,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_email"] }, "to": { "type": "string" }, - "token": { "type": "string" } + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -18961,7 +20600,8 @@ "properties": { "kind": { "type": "string", "enum": ["otp_sms"] }, "to": { "type": "string" }, - "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } }, "required": ["kind", "to", "token"] }, @@ -18983,6 +20623,45 @@ "signInUrl": { "type": "string" } }, "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" } + }, + "required": ["kind", "notice", "to", "text"] } ] } diff --git a/package-lock.json b/package-lock.json index 8cbccdc..8ad2d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.30.0", + "@seamless-auth/types": "^0.31.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -3093,9 +3093,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.30.0.tgz", - "integrity": "sha512-1MM/jBSG901HS35pLCNhEcOjA1KYLcBIxQZHYyexOOQ/7R8VNjxlmxG0L26VPV+FhA7Rkq5sBIifCLbR+g+0zA==", + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.31.0.tgz", + "integrity": "sha512-3XcwiseZWXaus7QxDMkgHvHPRF+4qwYVl78KGLVTf+ktAN9cKhBp2zbW6jJ1/aEN7Zd08Gy+4C/CRS57MYb2TQ==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index f28dbd7..2857406 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.30.0", + "@seamless-auth/types": "^0.31.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", diff --git a/src/controllers/accountRecovery.ts b/src/controllers/accountRecovery.ts index 1424213..2e06a23 100644 --- a/src/controllers/accountRecovery.ts +++ b/src/controllers/accountRecovery.ts @@ -7,14 +7,20 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; +import { acceptedDeliveryKinds, canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { RecoveryChannel } from '../models/accountRecoveryRequests.js'; -import { sendAccountNotice, verifiedAddresses } from '../services/accountNotices.js'; +import { + ownerWasNotified, + sendAccountNotice, + verifiedAddresses, +} from '../services/accountNotices.js'; import { cancelRecoveryByToken, + cancelRecoveryForUser, completeRecovery, findRecoveryForAttempt, isAccountRecoveryAvailable, + listOpenRecoveries, RecoveryStatus, recoveryStatus, startRecovery, @@ -127,8 +133,8 @@ export const startAccountRecovery = async (req: Request, res: Response) => { ? { delivery: channel === 'email' - ? { kind: 'otp_email', to, token: code } - : { kind: 'otp_sms', to, token: code }, + ? { kind: 'otp_email', to, token: code, purpose: 'account_recovery' } + : { kind: 'otp_sms', to, token: code, purpose: 'account_recovery' }, } : {}), }); @@ -157,7 +163,12 @@ async function openedNotice(channel: RecoveryChannel, completesAt: Date, cancelT `cancelled, recovery completes at ${completesAt.toISOString()}, signs out every ` + `session and lets them enroll a new passkey. If this was not you, cancel it: ${link}`; - return { subject: `Account recovery requested for your ${name} account`, text }; + return { + name: 'account_recovery_opened' as const, + subject: `Account recovery requested for your ${name} account`, + text, + cancelUrl: link, + }; } /** @@ -201,13 +212,15 @@ export const verifyAccountRecovery = async (req: Request, res: Response) => { return res.status(200).json(serializeRecoveryStatus(status)); } - const notices = await sendAccountNotice( + const external = await canReturnExternalDelivery(req); + const outcome = await sendAccountNotice( user, await openedNotice(request.channel, status.completesAt, result.cancelToken), - { external: await canReturnExternalDelivery(req) }, + { external, returnable: external ? acceptedDeliveryKinds(req) : undefined }, ); + const notices = outcome.results; - if (!notices.some((notice) => notice.status === 'sent')) { + if (!ownerWasNotified(outcome)) { await voidUnannouncedRecovery(request); await AuthEventService.log({ userId: user.id, @@ -237,7 +250,10 @@ export const verifyAccountRecovery = async (req: Request, res: Response) => { }, }); - return res.status(200).json(serializeRecoveryStatus(status)); + return res.status(200).json({ + ...serializeRecoveryStatus(status), + ...(outcome.deliveries.length > 0 ? { deliveries: outcome.deliveries } : {}), + }); }; export const getAccountRecoveryStatus = async (req: Request, res: Response) => { @@ -282,6 +298,42 @@ export const cancelAccountRecovery = async (req: Request, res: Response) => { return res.status(200).json({ message: RECOVERY_CANCEL_MESSAGE }); }; +/** + * The account's open recovery requests, for a signed-in owner. Not gated on the feature + * being available, for the same reason as cancelling. + */ +export const listAccountRecoveryRequests = async (req: Request, res: Response) => { + const { user } = req as AuthenticatedRequest; + const requests = await listOpenRecoveries(user.id); + + return res.status(200).json({ + requests: requests.map((request) => ({ + id: request.id, + channel: request.channel, + ...serializeRecoveryStatus(recoveryStatus(request)), + })), + }); +}; + +/** Lets a signed-in owner cancel a request without the token from the notice. */ +export const cancelAccountRecoveryRequest = async (req: Request, res: Response) => { + const { user } = req as AuthenticatedRequest; + const { id } = req.params as { id: string }; + + if (!(await cancelRecoveryForUser(user.id, id))) { + return res.status(404).json({ error: 'recovery_not_found' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_cancelled', + req, + metadata: { requestId: id, via: 'session' }, + }); + + return res.status(200).json({ message: 'Recovery request cancelled.' }); +}; + /** * Completes a request whose delay has passed: signs out every session, tells every * verified address, and issues a recovery session that can only enroll a new factor. @@ -306,9 +358,10 @@ export const completeAccountRecovery = async (req: Request, res: Response) => { } const name = await appName(); - const notices = await sendAccountNotice( + const { results: notices } = await sendAccountNotice( user, { + name: 'account_recovery_completed', subject: `Your ${name} account was recovered`, text: `Your ${name} account was recovered and every session on it was signed out. ` + diff --git a/src/controllers/recoveryCodes.ts b/src/controllers/recoveryCodes.ts index b3ef03c..0139cd4 100644 --- a/src/controllers/recoveryCodes.ts +++ b/src/controllers/recoveryCodes.ts @@ -104,9 +104,10 @@ export const redeemRecoveryCodeLogin = async (req: Request, res: Response) => { const { remaining } = await getRecoveryCodeStatus(user.id); const { app_name: name } = await getSystemConfig(); - const notices = await sendAccountNotice( + const { results: notices } = await sendAccountNotice( user, { + name: 'recovery_code_used', subject: `A recovery code was used on your ${name} account`, text: `A recovery code was used to sign in to your ${name} account (${remaining} left). ` + diff --git a/src/generated/api.ts b/src/generated/api.ts index ca639b1..db39c1d 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -64,12 +64,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -83,6 +87,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -208,7 +239,7 @@ export interface paths { put?: never; /** * Verify an account recovery code - * @description Opens the request started on this attempt, which completes after the configured delay, and tells every verified address how to cancel it. A code for a request that is already open resumes it on this attempt. Five wrong codes void the code. Answers 503 and voids the request when no verified address could be notified. + * @description Opens the request started on this attempt, which completes after the configured delay, and tells every verified address how to cancel it. A code for a request that is already open resumes it on this attempt. Five wrong codes void the code. Answers 503 and voids the request when no verified address could be notified. A credentialed caller that owns delivery and lists `account_notice_email` / `account_notice_sms` in `x-seamless-auth-delivery-kinds` gets the notices back as `deliveries` to send; one that does not list them gets the 503. */ post: { parameters: { @@ -235,7 +266,10 @@ export interface paths { * @example { * "state": null, * "requestedAt": null, - * "completesAt": null + * "completesAt": null, + * "deliveries": [ + * null + * ] * } */ 'application/json': { @@ -245,6 +279,64 @@ export interface paths { requestedAt: string | null; /** Format: date-time */ completesAt: string | null; + deliveries?: ( + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; + } + )[]; }; }; }; @@ -569,12 +661,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -588,6 +684,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -864,6 +987,363 @@ export interface paths { patch?: never; trace?: never; }; + '/recovery/requests': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List open account recovery requests + * @description The signed-in account's pending recovery requests, so the owner can see one they did not start without waiting for the notice. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "requests": [ + * null + * ] + * } + */ + 'application/json': { + requests: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + channel: 'email' | 'phone'; + /** @enum {string} */ + state: 'pending' | 'ready' | 'cancelled' | 'completed'; + requestedAt: string; + completesAt: string; + }[]; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/recovery/requests/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Cancel an account recovery request from a session + * @description Cancels one of the signed-in account's pending recovery requests without the token from the notice. Answers 404 for a request that is not open or not this account's. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/.well-known/seamless-adapter.json': { parameters: { query?: never; @@ -936,6 +1416,8 @@ export interface paths { }; /** @enum {boolean} */ delivery?: true; + /** @enum {boolean} */ + notices?: true; }[]; }; }; @@ -1560,12 +2042,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -1579,6 +2065,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -2323,12 +2836,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -2342,6 +2859,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -3306,12 +3850,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -3325,6 +3873,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }[]; }; @@ -4138,12 +4713,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -4157,6 +4736,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -6121,12 +6727,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -6140,6 +6750,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -6307,12 +6944,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -6326,6 +6967,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -6621,12 +7289,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -6640,6 +7312,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -6747,12 +7446,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -6763,9 +7466,36 @@ export interface paths { } | { /** @enum {string} */ - kind: 'enrollment_invite_email'; + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; to: string; - signInUrl: string; + text: string; + cancelUrl?: string; }; }; }; @@ -8354,12 +9084,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -8373,6 +9107,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -8498,12 +9259,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -8517,6 +9282,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -8680,12 +9472,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -8699,6 +9495,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -8829,12 +9652,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -8848,6 +9675,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -9028,12 +9882,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -9047,6 +9905,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -10850,12 +11735,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -10869,6 +11758,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11165,12 +12081,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11184,6 +12104,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11295,12 +12242,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11314,6 +12265,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11441,12 +12419,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11460,6 +12442,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11553,12 +12562,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11572,6 +12585,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11681,12 +12721,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11700,6 +12744,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11793,12 +12864,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11812,6 +12887,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -11921,12 +13023,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -11940,6 +13046,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -12033,12 +13166,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -12052,6 +13189,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -13235,12 +14399,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -13254,6 +14422,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -13397,12 +14592,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -13416,6 +14615,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -13823,12 +15049,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -13842,6 +15072,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -13951,12 +15208,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -13970,6 +15231,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -18332,12 +19620,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -18351,6 +19643,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -19174,12 +20493,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -19193,6 +20516,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; @@ -19315,12 +20665,16 @@ export interface paths { kind: 'otp_email'; to: string; token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ kind: 'otp_sms'; to: string; token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; } | { /** @enum {string} */ @@ -19334,6 +20688,33 @@ export interface paths { kind: 'enrollment_invite_email'; to: string; signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed'; + to: string; + text: string; + cancelUrl?: string; }; }; }; diff --git a/src/lib/adapterManifest.ts b/src/lib/adapterManifest.ts index 484855f..743154d 100644 --- a/src/lib/adapterManifest.ts +++ b/src/lib/adapterManifest.ts @@ -211,6 +211,12 @@ export function registerAdapterRoute({ ); } + if (options.notices && !successFields(response, 'deliveries').length) { + throw new Error( + `${label} declares adapter \`notices\` but no success response has \`deliveries\`.`, + ); + } + if (options.body && !options.issues) { throw new Error(`${label} declares an adapter body \`pick\` but issues nothing.`); } @@ -223,6 +229,7 @@ export function registerAdapterRoute({ ...(options.clears ? { clears: options.clears } : {}), ...(options.body ? { body: options.body } : {}), ...(options.delivery ? { delivery: options.delivery } : {}), + ...(options.notices ? { notices: options.notices } : {}), }); } diff --git a/src/lib/externalDelivery.ts b/src/lib/externalDelivery.ts index cdab262..6b558df 100644 --- a/src/lib/externalDelivery.ts +++ b/src/lib/externalDelivery.ts @@ -13,6 +13,7 @@ const logger = getLogger('externalDelivery'); const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; const SERVICE_TOKEN_HEADER = 'x-seamless-service-token'; +const DELIVERY_KINDS_HEADER = 'x-seamless-auth-delivery-kinds'; const UNCREDENTIALED_OPT_IN = 'ALLOW_UNCREDENTIALED_DELIVERY_SECRETS'; function extractBearerToken(headerValue: string | undefined): string | null { @@ -27,6 +28,20 @@ function extractBearerToken(headerValue: string | undefined): string | null { return headerValue.trim() || null; } +/** + * The delivery kinds a caller says it can deliver, for kinds an older adapter would not + * know. A route only hands such a payload to a caller that named its kind, so an adapter + * that would drop it never receives it. + */ +export function acceptedDeliveryKinds(req: Request): ReadonlySet { + return new Set( + (req.get(DELIVERY_KINDS_HEADER) ?? '') + .split(',') + .map((kind) => kind.trim()) + .filter(Boolean), + ); +} + export function wantsExternalDelivery(req: Request) { return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; } diff --git a/src/routes/accountRecovery.routes.ts b/src/routes/accountRecovery.routes.ts index 5f823f1..8c70650 100644 --- a/src/routes/accountRecovery.routes.ts +++ b/src/routes/accountRecovery.routes.ts @@ -6,8 +6,10 @@ import { cancelAccountRecovery, + cancelAccountRecoveryRequest, completeAccountRecovery, getAccountRecoveryStatus, + listAccountRecoveryRequests, startAccountRecovery, verifyAccountRecovery, } from '../controllers/accountRecovery.js'; @@ -21,10 +23,13 @@ import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; import { AccountRecoveryCancelRequestSchema, + AccountRecoveryRequestIdParamSchema, + AccountRecoveryRequestsResponseSchema, AccountRecoveryStartRequestSchema, AccountRecoveryStartResponseSchema, AccountRecoveryStatusSchema, AccountRecoveryVerifyRequestSchema, + AccountRecoveryVerifyResponseSchema, } from '../schemas/accountRecovery.js'; import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { RecoverySessionResponseSchema } from '../schemas/recoveryCodes.js'; @@ -59,7 +64,7 @@ accountRecoveryRouter.post( accountRecoveryRouter.post( '/verify', { - adapter: { credential: 'preAuth' }, + adapter: { credential: 'preAuth', notices: true }, auth: 'ephemeral', decoy: decoyVerifyAccountRecovery, middleware: [otpIpLimiter, otpIdentityLimiter], @@ -68,12 +73,15 @@ accountRecoveryRouter.post( 'Opens the request started on this attempt, which completes after the configured ' + 'delay, and tells every verified address how to cancel it. A code for a request ' + 'that is already open resumes it on this attempt. Five wrong codes void the code. ' + - 'Answers 503 and voids the request when no verified address could be notified.', + 'Answers 503 and voids the request when no verified address could be notified. A ' + + 'credentialed caller that owns delivery and lists `account_notice_email` / ' + + '`account_notice_sms` in `x-seamless-auth-delivery-kinds` gets the notices back as ' + + '`deliveries` to send; one that does not list them gets the 503.', tags: ['Recovery'], schemas: { body: AccountRecoveryVerifyRequestSchema, response: { - 200: AccountRecoveryStatusSchema, + 200: AccountRecoveryVerifyResponseSchema, 401: ErrorSchema, 403: ErrorSchema, 503: ErrorSchema, @@ -153,4 +161,46 @@ accountRecoveryRouter.post( completeAccountRecovery, ); +accountRecoveryRouter.get( + '/requests', + { + auth: 'access', + summary: 'List open account recovery requests', + description: + "The signed-in account's pending recovery requests, so the owner can see one they " + + 'did not start without waiting for the notice.', + tags: ['Recovery'], + schemas: { + response: { + 200: AccountRecoveryRequestsResponseSchema, + 401: ErrorSchema, + 403: ErrorSchema, + }, + }, + }, + listAccountRecoveryRequests, +); + +accountRecoveryRouter.delete( + '/requests/:id', + { + auth: 'access', + summary: 'Cancel an account recovery request from a session', + description: + "Cancels one of the signed-in account's pending recovery requests without the token " + + "from the notice. Answers 404 for a request that is not open or not this account's.", + tags: ['Recovery'], + schemas: { + params: AccountRecoveryRequestIdParamSchema, + response: { + 200: MessageSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + }, + cancelAccountRecoveryRequest, +); + export default accountRecoveryRouter.router; diff --git a/src/schemas/accountRecovery.ts b/src/schemas/accountRecovery.ts index fbf2a67..1e1b7e1 100644 --- a/src/schemas/accountRecovery.ts +++ b/src/schemas/accountRecovery.ts @@ -4,6 +4,14 @@ * See LICENSE file in the project root for full license information */ +import { + AccountRecoveryChannelSchema, + AccountRecoveryStateSchema, + AccountRecoveryStatusSchema, + AuthDeliverySchema, +} from '@seamless-auth/types'; +import { z } from 'zod'; + export { AccountRecoveryCancelRequestSchema, AccountRecoveryStartRequestSchema, @@ -11,3 +19,22 @@ export { AccountRecoveryStatusSchema, AccountRecoveryVerifyRequestSchema, } from '@seamless-auth/types'; + +export const AccountRecoveryRequestSchema = z.object({ + id: z.string().uuid(), + channel: AccountRecoveryChannelSchema, + state: AccountRecoveryStateSchema, + requestedAt: z.string(), + completesAt: z.string(), +}); + +export const AccountRecoveryRequestsResponseSchema = z.object({ + requests: z.array(AccountRecoveryRequestSchema), +}); + +export const AccountRecoveryRequestIdParamSchema = z.object({ id: z.string().uuid() }); + +/** `deliveries` only for a caller that named the notice kinds it delivers. */ +export const AccountRecoveryVerifyResponseSchema = AccountRecoveryStatusSchema.extend({ + deliveries: z.array(AuthDeliverySchema).optional(), +}); diff --git a/src/schemas/adapterManifest.responses.ts b/src/schemas/adapterManifest.responses.ts index 8f23b59..073a40d 100644 --- a/src/schemas/adapterManifest.responses.ts +++ b/src/schemas/adapterManifest.responses.ts @@ -16,6 +16,9 @@ export const AdapterManifestRouteSchema = z.object({ clears: z.array(AdapterHeldSchema).optional(), body: z.object({ pick: z.array(z.string()) }).optional(), delivery: z.literal(true).optional(), + // The route can hand owner notices back as `deliveries` to a caller that names their + // kinds in `x-seamless-auth-delivery-kinds`. Without that header they are sent directly. + notices: z.literal(true).optional(), }); export const AdapterManifestSchema = z.object({ diff --git a/src/services/accountNotices.ts b/src/services/accountNotices.ts index fee3dc7..d706791 100644 --- a/src/services/accountNotices.ts +++ b/src/services/accountNotices.ts @@ -4,6 +4,8 @@ * See LICENSE file in the project root for full license information */ +import type { AccountNoticeName, AuthDeliveryInstruction } from '@seamless-auth/types'; + import getLogger from '../utils/logger.js'; import { DeliveryError } from './deliveryError.js'; import type { IdentityNoticeStatus } from './identityChangeService.js'; @@ -19,13 +21,47 @@ export interface NoticeRecipient { } export interface AccountNotice { + name: AccountNoticeName; subject: string; text: string; + cancelUrl?: string; } export interface AccountNoticeResult { channel: 'email' | 'phone'; - status: IdentityNoticeStatus; + status: IdentityNoticeStatus | 'returned_for_external_delivery'; +} + +export interface AccountNoticeOutcome { + results: AccountNoticeResult[]; + /** For the caller to deliver; they can carry a credential, so never log them. */ + deliveries: AuthDeliveryInstruction[]; +} + +/** Whether the owner was told, or the notice was handed to a caller that delivers it. */ +export function ownerWasNotified({ results }: AccountNoticeOutcome) { + return results.some( + ({ status }) => status === 'sent' || status === 'returned_for_external_delivery', + ); +} + +function asDelivery( + notice: AccountNotice, + channel: 'email' | 'phone', + to: string, +): AuthDeliveryInstruction { + const cancelUrl = notice.cancelUrl ? { cancelUrl: notice.cancelUrl } : {}; + + return channel === 'email' + ? { + kind: 'account_notice_email', + notice: notice.name, + to, + subject: notice.subject, + text: notice.text, + ...cancelUrl, + } + : { kind: 'account_notice_sms', notice: notice.name, to, text: notice.text, ...cancelUrl }; } /** The account's verified addresses only: an unverified one may not be the owner's. */ @@ -41,27 +77,35 @@ export function verifiedAddresses(user: NoticeRecipient) { * account. A notice that fails to send does not fail the request it reports on; the * outcome belongs in that request's audit event. * - * `external` is for a credentialed caller that owns delivery. The delivery contract has - * no kind for account notices, so such a caller cannot be handed one, and sending - * directly would bypass it. + * `external` is for a credentialed caller that owns delivery, so the API does not send + * for it. A route that can return notices passes the kinds that caller accepts as + * `returnable`; a notice of any other kind is not sent at all. */ export async function sendAccountNotice( user: NoticeRecipient, notice: AccountNotice, - { external }: { external: boolean }, -): Promise { + { external, returnable }: { external: boolean; returnable?: ReadonlySet }, +): Promise { const { email, phone } = verifiedAddresses(user); const targets: Array<['email' | 'phone', string | null]> = [ ['email', email], ['phone', phone], ]; const results: AccountNoticeResult[] = []; + const deliveries: AuthDeliveryInstruction[] = []; for (const [channel, to] of targets) { if (!to) continue; if (external) { - results.push({ channel, status: 'not_sent_external_delivery' }); + const delivery = asDelivery(notice, channel, to); + + if (returnable?.has(delivery.kind)) { + deliveries.push(delivery); + results.push({ channel, status: 'returned_for_external_delivery' }); + } else { + results.push({ channel, status: 'not_sent_external_delivery' }); + } continue; } @@ -79,5 +123,5 @@ export async function sendAccountNotice( } } - return results; + return { results, deliveries }; } diff --git a/src/services/accountRecoveryService.ts b/src/services/accountRecoveryService.ts index c5f60af..6c1a4db 100644 --- a/src/services/accountRecoveryService.ts +++ b/src/services/accountRecoveryService.ts @@ -270,6 +270,27 @@ export async function cancelRecoveryByToken(cancelToken: string) { return cancelled === 1 ? request : null; } +/** The account's open requests, oldest first, for the owner to review from a session. */ +export async function listOpenRecoveries(userId: string) { + return AccountRecoveryRequest.findAll({ + where: { userId, state: 'pending' }, + order: [['requestedAt', 'ASC']], + }); +} + +/** + * Cancels one of the user's open requests from a signed-in session, without the token. + * Scoped to the user, so an id belonging to another account matches nothing. + */ +export async function cancelRecoveryForUser(userId: string, requestId: string) { + const [cancelled] = await AccountRecoveryRequest.update( + { state: 'cancelled', cancelledAt: new Date(), codeHash: null, codeAttemptId: null }, + { where: { id: requestId, userId, state: 'pending' } }, + ); + + return cancelled === 1; +} + /** * Whether self-service recovery can be used. Off in phishing-resistant-only mode as well * as when disabled: it proves an email address or phone number, which a deployment that diff --git a/tests/factories/accountRecoveryStore.ts b/tests/factories/accountRecoveryStore.ts index 227296a..b528d78 100644 --- a/tests/factories/accountRecoveryStore.ts +++ b/tests/factories/accountRecoveryStore.ts @@ -73,6 +73,18 @@ export function installAccountRecoveryStore() { }, ); + (AccountRecoveryRequest.findAll as any).mockImplementation( + async ({ where, order }: { where: Where; order?: [string, string][] }) => { + const found = rows.filter((row) => matches(row, where)); + const [key, direction] = order?.[0] ?? []; + if (key) { + const sign = direction === 'ASC' ? 1 : -1; + found.sort((a, b) => sign * (+(a[key] ?? 0) - +(b[key] ?? 0))); + } + return found; + }, + ); + (AccountRecoveryRequest.update as any).mockImplementation( async (fields: Row, { where }: { where: Where }) => { const found = rows.filter((row) => matches(row, where)); diff --git a/tests/integration/recovery/accountRecovery.spec.ts b/tests/integration/recovery/accountRecovery.spec.ts index 2e77907..ee13c34 100644 --- a/tests/integration/recovery/accountRecovery.spec.ts +++ b/tests/integration/recovery/accountRecovery.spec.ts @@ -209,6 +209,7 @@ describe('POST /recovery/start', () => { kind: 'otp_email', to: 'owner@example.com', token: expect.stringMatching(/^\d{6}$/), + purpose: 'account_recovery', }); expect(sendOTPEmail).not.toHaveBeenCalled(); }); @@ -220,7 +221,11 @@ describe('POST /recovery/start', () => { .set('x-seamless-service-token', await mintInternalServiceToken()) .send({ channel: 'phone' }); - expect(res.body.delivery).toMatchObject({ kind: 'otp_sms', to: '+14155552671' }); + expect(res.body.delivery).toMatchObject({ + kind: 'otp_sms', + to: '+14155552671', + purpose: 'account_recovery', + }); }); it('answers 500 when the code cannot be delivered', async () => { @@ -303,6 +308,82 @@ describe('POST /recovery/verify', () => { ); }); + it('returns the notices to a credentialed caller that delivers their kinds', async () => { + const token = await mintInternalServiceToken(); + const started = await request(app) + .post('/recovery/start') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', token) + .send({ channel: 'email' }); + + const res = await request(app) + .post('/recovery/verify') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', token) + .set('x-seamless-auth-delivery-kinds', 'account_notice_email, account_notice_sms') + .send({ code: started.body.delivery.token }); + + expect(res.status).toBe(200); + expect(rows[0].state).toBe('pending'); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + expect(res.body.deliveries).toEqual([ + expect.objectContaining({ + kind: 'account_notice_email', + notice: 'account_recovery_opened', + to: 'owner@example.com', + cancelUrl: expect.stringMatching(/^https:\/\/app\.example\.com\/recovery\/cancel#.+/), + }), + expect.objectContaining({ + kind: 'account_notice_sms', + notice: 'account_recovery_opened', + to: '+14155552671', + }), + ]); + + const logged = (AuthEventService.log as any).mock.calls.find( + ([event]: [{ type: string }]) => event.type === 'account_recovery_verified', + )[0]; + expect(logged.metadata.notices).toEqual([ + { channel: 'email', status: 'returned_for_external_delivery' }, + { channel: 'phone', status: 'returned_for_external_delivery' }, + ]); + expect(JSON.stringify(logged.metadata)).not.toContain('/recovery/cancel#'); + }); + + it('still voids the request for a credentialed caller that lists no notice kind', async () => { + const token = await mintInternalServiceToken(); + const started = await request(app) + .post('/recovery/start') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', token) + .send({ channel: 'email' }); + + const res = await request(app) + .post('/recovery/verify') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', token) + .set('x-seamless-auth-delivery-kinds', 'otp_email') + .send({ code: started.body.delivery.token }); + + expect(res.status).toBe(503); + expect(res.body.deliveries).toBeUndefined(); + expect(rows[0].state).toBe('void'); + }); + + it('sends directly, and returns nothing, when an uncredentialed caller lists the kinds', async () => { + await request(app).post('/recovery/start').send({ channel: 'email' }); + + const res = await request(app) + .post('/recovery/verify') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-auth-delivery-kinds', 'account_notice_email,account_notice_sms') + .send({ code: sentCode() }); + + expect(res.status).toBe(200); + expect(res.body.deliveries).toBeUndefined(); + expect(sendNoticeEmail).toHaveBeenCalled(); + }); + it('voids the request when every notice fails to send', async () => { (sendNoticeEmail as any).mockRejectedValueOnce(new DeliveryError('down', new Error('down'))); (sendNoticeSms as any).mockRejectedValueOnce(new DeliveryError('down', new Error('down'))); @@ -550,3 +631,53 @@ describe('decoys', () => { expect(sendNoticeEmail).not.toHaveBeenCalled(); }); }); + +describe('open requests from a signed-in session', () => { + it('lists the open requests and lets the owner cancel one without the token', async () => { + expect((await startAndVerify('email')).status).toBe(200); + + const listed = await request(app).get('/recovery/requests'); + + expect(listed.status).toBe(200); + expect(listed.body.requests).toEqual([ + expect.objectContaining({ id: rows[0].id, channel: 'email', state: 'pending' }), + ]); + + const cancelled = await request(app).delete(`/recovery/requests/${rows[0].id}`); + + expect(cancelled.status).toBe(200); + expect(rows[0].state).toBe('cancelled'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'account_recovery_cancelled', + metadata: { requestId: rows[0].id, via: 'session' }, + }), + ); + expect((await request(app).get('/recovery/requests')).body.requests).toEqual([]); + }); + + it("will not cancel another account's request", async () => { + expect((await startAndVerify('email')).status).toBe(200); + principal = realUser({ id: 'user-2' }); + + const res = await request(app).delete(`/recovery/requests/${rows[0].id}`); + + expect(res.status).toBe(404); + expect(rows[0].state).toBe('pending'); + }); + + it('answers 404 for a request that is no longer open', async () => { + expect((await startAndVerify('email')).status).toBe(200); + await request(app).delete(`/recovery/requests/${rows[0].id}`); + + expect((await request(app).delete(`/recovery/requests/${rows[0].id}`)).status).toBe(404); + }); + + it('still works after account recovery is turned off', async () => { + expect((await startAndVerify('email')).status).toBe(200); + enabled({ account_recovery: { enabled: false, delay: '1h' } }); + + expect((await request(app).get('/recovery/requests')).body.requests).toHaveLength(1); + expect((await request(app).delete(`/recovery/requests/${rows[0].id}`)).status).toBe(200); + }); +}); diff --git a/tests/postgres/accountRecovery.spec.ts b/tests/postgres/accountRecovery.spec.ts index 814c9cf..fbab716 100644 --- a/tests/postgres/accountRecovery.spec.ts +++ b/tests/postgres/accountRecovery.spec.ts @@ -5,8 +5,10 @@ import { AccountRecoveryRequest } from '../../src/models/accountRecoveryRequests import { getSequelize, initializeModels } from '../../src/models/index.js'; import { cancelRecoveryByToken, + cancelRecoveryForUser, completeRecovery, findRecoveryForAttempt, + listOpenRecoveries, startRecovery, supersedeOtherRecoveries, verifyRecovery, @@ -90,6 +92,33 @@ describe('account recovery against Postgres', () => { expect((await AccountRecoveryRequest.findByPk(third.request.id))?.state).toBe('pending'); }); + it('lists and cancels open requests for their own account only', async () => { + const owner = randomUUID(); + const other = randomUUID(); + for (const id of [owner, other]) { + await getSequelize().query( + `INSERT INTO users (id, email, roles, created_at, updated_at) + VALUES (:id, :email, '{user}', now(), now())`, + { replacements: { id, email: `recovery-${id}@example.com` } }, + ); + } + const opened = await verifyRecovery( + owner, + 'attempt-l', + await startRecovery(owner, 'email', 'attempt-l'), + 3600, + ); + if (opened.outcome !== 'opened') throw new Error('not opened'); + + expect((await listOpenRecoveries(owner)).map((row) => row.id)).toEqual([opened.request.id]); + expect(await listOpenRecoveries(other)).toEqual([]); + + expect(await cancelRecoveryForUser(other, opened.request.id)).toBe(false); + expect(await cancelRecoveryForUser(owner, opened.request.id)).toBe(true); + expect(await cancelRecoveryForUser(owner, opened.request.id)).toBe(false); + expect(await listOpenRecoveries(owner)).toEqual([]); + }); + it('completes once the delay has passed and revokes sessions', async () => { const owner = randomUUID(); await getSequelize().query( diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index ab29c0e..76dae7b 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -121,6 +121,7 @@ vi.mock('../../src/models/accountRecoveryRequests.js', () => ({ AccountRecoveryRequest: { create: vi.fn(), findOne: vi.fn(async () => null), + findAll: vi.fn(async () => []), update: vi.fn(async () => [0]), }, })); diff --git a/tests/unit/lib/adapterManifest.spec.ts b/tests/unit/lib/adapterManifest.spec.ts index 180fb9f..e984395 100644 --- a/tests/unit/lib/adapterManifest.spec.ts +++ b/tests/unit/lib/adapterManifest.spec.ts @@ -125,6 +125,24 @@ describe('registerAdapterRoute', () => { ); }); + it('refuses notices on a route whose response cannot carry deliveries', () => { + expect(() => register({ auth: 'access', adapter: { notices: true } })).toThrow( + /no success response has `deliveries`/, + ); + }); + + it('records notices on a route whose response carries deliveries', () => { + register({ + auth: 'access', + adapter: { notices: true }, + response: { 200: Message.extend({ deliveries: z.array(z.unknown()).optional() }) }, + }); + + expect(getAdapterManifest().routes).toEqual([ + { method: 'POST', path: '/thing', credential: 'access', notices: true }, + ]); + }); + it('refuses a body pick on a route that issues nothing', () => { expect(() => register({ adapter: { body: { pick: ['message'] } } })).toThrow(/issues nothing/); }); diff --git a/tests/unit/services/accountNotices.spec.ts b/tests/unit/services/accountNotices.spec.ts index 47db164..a6f8537 100644 --- a/tests/unit/services/accountNotices.spec.ts +++ b/tests/unit/services/accountNotices.spec.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { sendAccountNotice, verifiedAddresses } from '../../../src/services/accountNotices.js'; +import { + ownerWasNotified, + sendAccountNotice, + verifiedAddresses, +} from '../../../src/services/accountNotices.js'; import { DeliveryError } from '../../../src/services/deliveryError.js'; import { sendNoticeEmail, sendNoticeSms } from '../../../src/services/messagingService.js'; -const notice = { subject: 'Subject', text: 'Body' }; +const notice = { + name: 'account_recovery_opened' as const, + subject: 'Subject', + text: 'Body', + cancelUrl: 'https://app.example.com/recovery/cancel#t', +}; const owner = { email: 'owner@example.com', emailVerified: true, @@ -24,7 +33,7 @@ describe('verifiedAddresses', () => { describe('sendAccountNotice', () => { it('sends to every verified address', async () => { - const results = await sendAccountNotice(owner, notice, { external: false }); + const { results } = await sendAccountNotice(owner, notice, { external: false }); expect(sendNoticeEmail).toHaveBeenCalledWith('owner@example.com', 'Subject', 'Body'); expect(sendNoticeSms).toHaveBeenCalledWith('+14155552671', 'Body'); @@ -37,7 +46,7 @@ describe('sendAccountNotice', () => { it('records a delivery failure without failing', async () => { (sendNoticeSms as any).mockRejectedValueOnce(new DeliveryError('down', new Error('down'))); - const results = await sendAccountNotice(owner, notice, { external: false }); + const { results } = await sendAccountNotice(owner, notice, { external: false }); expect(results).toEqual([ { channel: 'email', status: 'sent' }, @@ -52,12 +61,67 @@ describe('sendAccountNotice', () => { }); it('sends nothing to a caller that owns delivery, and says so', async () => { - const results = await sendAccountNotice(owner, notice, { external: true }); + const outcome = await sendAccountNotice(owner, notice, { external: true }); expect(sendNoticeEmail).not.toHaveBeenCalled(); - expect(results).toEqual([ - { channel: 'email', status: 'not_sent_external_delivery' }, + expect(outcome).toEqual({ + results: [ + { channel: 'email', status: 'not_sent_external_delivery' }, + { channel: 'phone', status: 'not_sent_external_delivery' }, + ], + deliveries: [], + }); + expect(ownerWasNotified(outcome)).toBe(false); + }); + + it('hands a notice back only in the kinds the caller accepts', async () => { + const outcome = await sendAccountNotice(owner, notice, { + external: true, + returnable: new Set(['account_notice_email']), + }); + + expect(sendNoticeEmail).not.toHaveBeenCalled(); + expect(outcome.results).toEqual([ + { channel: 'email', status: 'returned_for_external_delivery' }, { channel: 'phone', status: 'not_sent_external_delivery' }, ]); + expect(outcome.deliveries).toEqual([ + { + kind: 'account_notice_email', + notice: 'account_recovery_opened', + to: 'owner@example.com', + subject: 'Subject', + text: 'Body', + cancelUrl: 'https://app.example.com/recovery/cancel#t', + }, + ]); + expect(ownerWasNotified(outcome)).toBe(true); + }); + + it('builds an SMS notice without a subject, and leaves out an absent cancel link', async () => { + const { deliveries } = await sendAccountNotice( + { ...owner, emailVerified: false }, + { name: 'recovery_code_used', subject: 'Subject', text: 'Body' }, + { external: true, returnable: new Set(['account_notice_sms']) }, + ); + + expect(deliveries).toEqual([ + { + kind: 'account_notice_sms', + notice: 'recovery_code_used', + to: '+14155552671', + text: 'Body', + }, + ]); + }); + + it('never returns a notice to a caller sending directly, even if it lists kinds', async () => { + const outcome = await sendAccountNotice(owner, notice, { + external: false, + returnable: new Set(['account_notice_email', 'account_notice_sms']), + }); + + expect(outcome.deliveries).toEqual([]); + expect(sendNoticeEmail).toHaveBeenCalled(); }); });