From 0010e67109179373c1ad8f0c09e2806c2d5e0348 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:55:51 +0800 Subject: [PATCH 01/19] feat(account): refuse every write for an abuse-paused account An account whose sending is paused with pause class `abuse` is now read-only. One enforcement point per surface: - /v1: a Huma middleware (httpapi/read_only.go) classifies every operation (method rule; exceptions: validateTemplate reads, deleteAccount trash stays allowed, getInfo public) and refuses writes with 403 account_read_only. Uncached PK lookup per write; a failed lookup fails closed with 503. Covers every credential kind, since all resolve to a principal owned by the account. - legacy mux: a gorilla middleware (agent/read_only.go) for the dashboard account routes and OAuth consent; the HITL magic links and the internal external-principal attach check in their handlers. - GET /v1/account reports read_only; the operator pause readback prints read_only. - notifications.support_email (falls back to reply_to) names the support contact in the message; the reason text is never shown. Spec-walk test classifies every operation against an independent derivation and drives each one over HTTP as a read-only account. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- api/openapi.yaml | 10 +- cmd/e2a/main.go | 1 + cmd/e2a/sending_policy.go | 4 + config.example.yaml | 6 + docs/api.md | 1 + internal/agent/api.go | 6 + internal/agent/hitl_magic_api.go | 27 ++ internal/agent/provisioning_api.go | 12 + internal/agent/read_only.go | 148 ++++++++ internal/agent/read_only_internal_test.go | 66 ++++ internal/agent/read_only_test.go | 217 +++++++++++ internal/apiserver/apiserver.go | 10 + internal/apiserver/read_only_db_test.go | 160 +++++++++ internal/config/config.go | 25 ++ internal/config/config_test.go | 51 +++ internal/httpapi/account.go | 4 + internal/httpapi/error_catalog.go | 4 + internal/httpapi/errors.go | 2 +- internal/httpapi/httpapi.go | 12 + internal/httpapi/read_only.go | 244 +++++++++++++ internal/httpapi/read_only_test.go | 339 ++++++++++++++++++ internal/identity/account_read_only_test.go | 74 ++++ internal/identity/account_trash.go | 34 ++ internal/sendingpolicy/account_pause_admin.go | 8 + .../sendingpolicy/account_read_only_test.go | 44 +++ 25 files changed, 1507 insertions(+), 2 deletions(-) create mode 100644 internal/agent/read_only.go create mode 100644 internal/agent/read_only_internal_test.go create mode 100644 internal/agent/read_only_test.go create mode 100644 internal/apiserver/read_only_db_test.go create mode 100644 internal/httpapi/read_only.go create mode 100644 internal/httpapi/read_only_test.go create mode 100644 internal/identity/account_read_only_test.go create mode 100644 internal/sendingpolicy/account_read_only_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 9256bdc09..d20cfa880 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -186,6 +186,9 @@ components: description: When a trashed account becomes eligible for permanent purge. Absent for a live account. format: date-time type: string + read_only: + description: "True while the account is read-only because its sending is paused pending an abuse review: every write is refused with 403 account_read_only, while reads and moving the account to the trash keep working. False otherwise. Absent when the deployment does not report it or its state is unavailable." + type: boolean restored_at: description: "When the account was last restored from the trash. Absent if it never was. API keys and domain verification do not survive a trash: keys must be re-created and domains re-verified after a restore." format: date-time @@ -2008,9 +2011,14 @@ components: additionalProperties: true properties: code: - description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status." + description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, account_read_only, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed), account_read_only (403, the account is read-only because its sending is paused pending an abuse review: every write is refused while reads and moving the account to the trash keep working; retrying will not succeed until an operator resumes the account — contact support). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status." type: string x-e2a-error-contracts: + account_read_only: + family: auth + retryable: false + statuses: + - 403 address_in_trash: family: state retryable: false diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index f30833b28..5d12f2f10 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -1032,6 +1032,7 @@ func main() { SenderIdentity: senderEnqueuer, ManagedUnsubscribeIssuer: managedUnsubscribeIssuer, AgentSuppressionAddedHook: agent.AgentSuppressionAddedHook(webhookOutbox), + SupportContact: cfg.Notifications.SupportContact(), // River is the sole webhook delivery engine: the /test + redelivery // endpoints insert a delivery row directly (bypassing the outbox drain), // so they must enqueue the River job themselves or the row never delivers. diff --git a/cmd/e2a/sending_policy.go b/cmd/e2a/sending_policy.go index e6ec05f4d..b0e8b864a 100644 --- a/cmd/e2a/sending_policy.go +++ b/cmd/e2a/sending_policy.go @@ -424,6 +424,10 @@ func printAccountPause(stdout io.Writer, rec sendingpolicy.AccountPauseRecord) { fmt.Fprintf(stdout, "account_status: %s\n", rec.AccountStatus) fmt.Fprintf(stdout, "sending_state: %s\n", rec.State) fmt.Fprintf(stdout, "pause_class: %s\n", rec.PauseClass) + // An abuse pause also freezes every customer write + // (docs/design/account-read-only.md); say so, so the operator is never + // surprised by it. + fmt.Fprintf(stdout, "read_only: %v\n", rec.ReadOnly()) if rec.Reason != "" { fmt.Fprintf(stdout, "reason: %s\n", rec.Reason) } diff --git a/config.example.yaml b/config.example.yaml index ad48bc537..0fb4de543 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -208,6 +208,12 @@ notifications: # (webhook mail emits no Reply-To; approval mail emits one pointing at # its own sender, preserving its long-standing behaviour). # reply_to: "support@your-company.example" + # + # Optional support address customers are told to contact when their + # account is read-only (sending paused for an abuse review): it appears in + # the account_read_only API error. Falls back to reply_to; with neither + # set the error says "contact support" without an address. + # support_email: "support@your-company.example" # Outbound delivery is always asynchronous and at-least-once. The send API # durably persists the message and enqueues a River job in one transaction, diff --git a/docs/api.md b/docs/api.md index 0198ad9cf..b63b73d98 100644 --- a/docs/api.md +++ b/docs/api.md @@ -322,6 +322,7 @@ retryable ones (the per-row retry notes in the table below are authoritative). | `sending_paused` | 403 | **Experimental.** Outbound sending is paused for the account by the platform abuse controls. Nothing was queued; queued mail is held until an operator resumes. | | `external_sending_not_enabled` | 403 | **Experimental.** The account may not send to one or more of the To/Cc/Bcc recipients through its sending identity. Nothing was queued. `error.details` (`ExternalSendingNotEnabledDetails`) lists the allowed destinations and the dashboard recovery URL; retrying the same request will not succeed. | | `registration_refused` | 403 | The sign-in identity (login subject or email) belongs to a recently deleted or abuse-closed account and cannot register a new account or restore the old one. Retrying will not succeed; see the privacy policy for the hold periods. | +| `account_read_only` | 403 | The account is read-only: its sending is paused pending an abuse review, so every write on every surface (REST, dashboard, MCP tools, OAuth consent) is refused. Reads, exports, sign-in/out and moving the account to the trash (`DELETE /v1/account` without `permanent`) keep working. Retrying will not succeed until an operator resumes the account; contact support. `GET /v1/account` reports the state as `read_only: true`. | | **Validation** | | | | `invalid_request` | 400 / 422 | The canonical input-validation code — malformed (400) or semantically invalid (422). `error.details` carries the per-field list. | | `invalid_cursor` | 400 | Bad pagination cursor — drop it and re-fetch from the start. | diff --git a/internal/agent/api.go b/internal/agent/api.go index d419d7dac..f09e23959 100644 --- a/internal/agent/api.go +++ b/internal/agent/api.go @@ -172,6 +172,8 @@ type API struct { store *identity.Store sender *outbound.Sender unsubscribeIssuer ManagedUnsubscribeIssuer + // supportContact is named in account_read_only refusals (read_only.go). + supportContact string // screen runs outbound content screening (Slice 5). Stateless heuristics // engine; mirrors the relay's inbound piguard engine. screen *piguard.Engine @@ -703,6 +705,10 @@ func (a *API) RegisterRoutes(r *mux.Router) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) }) + // Read-only accounts: the single enforcement point for this router's + // account-write routes (read_only.go). Runs on matched routes only. + r.Use(a.legacyReadOnlyMiddleware) + // Internal machine-to-machine endpoint: the external limits // provisioner (hosted billing sidecar) calls this to bust the // in-process limits cache for a given user immediately after it diff --git a/internal/agent/hitl_magic_api.go b/internal/agent/hitl_magic_api.go index 4a2e4d4e0..29f0ca6c8 100644 --- a/internal/agent/hitl_magic_api.go +++ b/internal/agent/hitl_magic_api.go @@ -131,6 +131,9 @@ func (a *API) handleApproveMagicLinkPost(w http.ResponseWriter, r *http.Request) "This message no longer exists.") return } + if a.refuseMagicIfReadOnly(w, r, userID, approvaltoken.ActionApprove) { + return + } a.magicApprove(w, r, claims.MessageID, userID, agentID) } @@ -151,6 +154,9 @@ func (a *API) handleRejectMagicLinkPost(w http.ResponseWriter, r *http.Request) "This message no longer exists.") return } + if a.refuseMagicIfReadOnly(w, r, userID, approvaltoken.ActionReject) { + return + } reason := strings.TrimSpace(r.FormValue("reason")) // `reason` is the ONLY caller-authored free text on either magic-link // route, and it is persisted verbatim to messages.rejection_reason. Both @@ -965,3 +971,24 @@ func firstRecipient(rs []string) string { } return rs[0] } + +// refuseMagicIfReadOnly refuses a magic-link approve/reject for a read-only +// account (docs/design/account-read-only.md) with a human page instead of +// the JSON envelope, and fails closed when the state cannot be read. It is +// the magic-link surface's one read-only check: the links are authorized by +// their token, not by an account principal, so the /v1 guard never sees them. +func (a *API) refuseMagicIfReadOnly(w http.ResponseWriter, r *http.Request, userID, action string) bool { + ro, err := a.store.AccountReadOnly(r.Context(), userID) + if err != nil { + log.Printf("[hitl] magic-link read-only check failed: %v", err) + writeMagicMessage(w, http.StatusServiceUnavailable, pageTitleForAction(action, "Try again"), + "We could not check this account right now. Try again in a moment.") + return true + } + if ro { + writeMagicMessage(w, http.StatusForbidden, pageTitleForAction(action, "Account is read-only"), + identity.AccountReadOnlyMessage(a.supportContact)) + return true + } + return false +} diff --git a/internal/agent/provisioning_api.go b/internal/agent/provisioning_api.go index bca60bc59..2b2ac7f93 100644 --- a/internal/agent/provisioning_api.go +++ b/internal/agent/provisioning_api.go @@ -249,6 +249,18 @@ func (a *API) handleAttachExternalPrincipal(w http.ResponseWriter, r *http.Reque return } + // Read-only accounts (sending paused for abuse) take no new sign-in + // principals. The account is named in the signed body, so this surface + // checks here rather than in the legacy route middleware. + if ro, err := a.store.AccountReadOnly(r.Context(), req.UserID); err != nil { + log.Printf("[api] attach external principal read-only check failed: %v", err) + writeProvisionError(w, http.StatusServiceUnavailable, "internal_error") + return + } else if ro { + writeProvisionError(w, http.StatusForbidden, identity.AccountReadOnlyCode) + return + } + created, err := a.store.AttachExternalPrincipal(r.Context(), req.Issuer, req.ExternalRef, req.UserID) switch { case errors.Is(err, identity.ErrExternalPrincipalConflict): diff --git a/internal/agent/read_only.go b/internal/agent/read_only.go new file mode 100644 index 000000000..77935618e --- /dev/null +++ b/internal/agent/read_only.go @@ -0,0 +1,148 @@ +package agent + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gorilla/mux" + "github.com/tokencanopy/e2a/internal/identity" +) + +// Read-only accounts on the legacy (non-/v1) surface +// (docs/design/account-read-only.md). The /v1 surface enforces read-only in +// its own middleware (internal/httpapi/read_only.go); this is the single +// enforcement point for the gorilla/mux routes RegisterRoutes owns: the +// dashboard's legacy account routes and OAuth consent. Two token-authorized +// surfaces that are not account-principal requests check in their handlers: +// the HITL magic links (hitl_magic_api.go) and the internal external-principal +// attach (provisioning_api.go). + +// legacyRouteAccess classifies a legacy route's writes. +type legacyRouteAccess int + +const ( + // legacyAccountWrite changes the signed-in account's state; refused for + // a read-only account. + legacyAccountWrite legacyRouteAccess = iota + 1 + // legacyExempt is not refused here, for the reason recorded with it. + legacyExempt +) + +// legacyWriteRoutes classifies every non-GET route RegisterRoutes registers, +// keyed "METHOD path-template". TestLegacyWriteRoutesAreClassified walks the +// router so a new write route without an entry fails CI. +var legacyWriteRoutes = map[string]legacyRouteAccess{ + // Dashboard account routes (session cookie). + "PATCH /api/auth/me": legacyAccountWrite, + "PUT /api/dashboard/agents/{email}": legacyAccountWrite, + "DELETE /api/dashboard/agents/{email}": legacyAccountWrite, + "POST /api/keys": legacyAccountWrite, + "DELETE /api/keys/{id}": legacyAccountWrite, + // Consent mints a new OAuth grant (agent or account scope) for the + // signed-in account. + "POST /oauth2/consent": legacyAccountWrite, + + // Sign-out only ends the caller's own session. + "POST /api/auth/logout": legacyExempt, + // Anonymous, IP-limited support channel — it is how a paused customer + // reaches the operator. + "POST /api/feedback": legacyExempt, + // Credential exchange and revocation: the tokens minted here resolve to + // principals the /v1 guard refuses for writes, and revoking only removes + // authority. + "POST /oauth2/token": legacyExempt, + "POST /oauth2/revoke": legacyExempt, + "POST /agent/identity": legacyExempt, + // Anonymous dynamic client registration: the client is not tied to any + // account until consent, which is refused above. + "POST /oauth2/register": legacyExempt, + // Operator machine-to-machine endpoints (HMAC). The attach endpoint + // refuses a read-only target account in its handler (the account is named + // in the signed body). + "POST /api/internal/limits/invalidate": legacyExempt, + "POST /api/internal/users/provision": legacyExempt, + "POST /api/internal/users/external-principals/attach": legacyExempt, +} + +// SetSupportContact sets the support address named in account_read_only +// errors on the legacy surface. +func (a *API) SetSupportContact(contact string) { a.supportContact = contact } + +// writeAccountReadOnly writes the canonical 403 account_read_only envelope +// (the same shape and message as /v1). +func (a *API) writeAccountReadOnly(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{ + "code": identity.AccountReadOnlyCode, + "message": identity.AccountReadOnlyMessage(a.supportContact), + }, + }) +} + +// accountReadOnly reports whether userID is read-only. ok=false means the +// response has been written (the lookup failed; fail closed with 503). +func (a *API) accountReadOnly(w http.ResponseWriter, r *http.Request, userID string) (readOnly, ok bool) { + ro, err := a.store.AccountReadOnly(r.Context(), userID) + if err != nil { + log.Printf("[api] read-only check failed: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{ + "code": "auth_unavailable", + "message": "account state is temporarily unavailable; retry", + }, + }) + return false, false + } + return ro, true +} + +// refuseIfReadOnly writes the refusal and returns true when userID is +// read-only (or its state could not be read). For the token-authorized +// handlers that are not principal requests. +func (a *API) refuseIfReadOnly(w http.ResponseWriter, r *http.Request, userID string) bool { + ro, ok := a.accountReadOnly(w, r, userID) + if !ok { + return true + } + if ro { + a.writeAccountReadOnly(w) + return true + } + return false +} + +// legacyReadOnlyMiddleware is the gorilla middleware enforcing read-only on +// the legacy account-write routes. Reads and exempt routes pass untouched; +// an unauthenticated request falls through so the handler emits its own 401. +func (a *API) legacyReadOnlyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + route := mux.CurrentRoute(r) + if route == nil { + next.ServeHTTP(w, r) + return + } + tmpl, err := route.GetPathTemplate() + if err != nil || legacyWriteRoutes[r.Method+" "+tmpl] != legacyAccountWrite { + next.ServeHTTP(w, r) + return + } + p, err := a.authenticatePrincipal(r) + if err != nil || p == nil || p.User == nil { + next.ServeHTTP(w, r) + return + } + if a.refuseIfReadOnly(w, r, p.User.ID) { + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/agent/read_only_internal_test.go b/internal/agent/read_only_internal_test.go new file mode 100644 index 000000000..9292ced19 --- /dev/null +++ b/internal/agent/read_only_internal_test.go @@ -0,0 +1,66 @@ +package agent + +import ( + "net/http" + "sort" + "testing" + + "github.com/gorilla/mux" + "github.com/tokencanopy/e2a/internal/auth" + "github.com/tokencanopy/e2a/internal/config" + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/usage" +) + +// TestLegacyWriteRoutesAreClassified walks every route RegisterRoutes +// registers (with the dashboard and OIDC routes enabled) and requires each +// non-GET/HEAD method to carry an explicit read-only classification, so a new +// legacy write route cannot silently bypass read-only enforcement. +func TestLegacyWriteRoutesAreClassified(t *testing.T) { + relay := outbound.NewSMTPRelay(&config.OutboundSMTPConfig{}) + userAuth := auth.NewUserAuth(&config.OAuthConfig{}, nil, false) + a := NewAPI(nil, outbound.NewSender(relay, "test.e2a.dev"), relay, userAuth, usage.NewNoopUsageTracker(), + "e2a.dev", "test.e2a.dev", "agents.e2a.dev", "", false) + r := mux.NewRouter() + a.RegisterRoutes(r) + + seen := map[string]bool{} + var unclassified []string + err := r.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + tmpl, err := route.GetPathTemplate() + if err != nil { + return nil + } + methods, err := route.GetMethods() + if err != nil { + unclassified = append(unclassified, "ANY "+tmpl+" (route without a method restriction)") + return nil + } + for _, m := range methods { + if m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions { + continue + } + key := m + " " + tmpl + seen[key] = true + if _, ok := legacyWriteRoutes[key]; !ok { + unclassified = append(unclassified, key) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(unclassified) + for _, k := range unclassified { + t.Errorf("legacy write route %s is not classified in legacyWriteRoutes (read_only.go)", k) + } + for k := range legacyWriteRoutes { + if !seen[k] { + t.Errorf("legacyWriteRoutes classifies %q, which RegisterRoutes does not register (stale entry)", k) + } + } + if len(seen) < 10 { + t.Fatalf("walk saw only %d write routes; the router walk is broken", len(seen)) + } +} diff --git a/internal/agent/read_only_test.go b/internal/agent/read_only_test.go new file mode 100644 index 000000000..88b581e14 --- /dev/null +++ b/internal/agent/read_only_test.go @@ -0,0 +1,217 @@ +package agent_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/agent" + "github.com/tokencanopy/e2a/internal/approvaltoken" + "github.com/tokencanopy/e2a/internal/auth" + "github.com/tokencanopy/e2a/internal/config" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" + "github.com/tokencanopy/e2a/internal/testutil" + "github.com/tokencanopy/e2a/internal/usage" +) + +// Read-only accounts on the legacy surface (docs/design/account-read-only.md), +// against real Postgres: the dashboard's legacy account routes and OAuth +// consent refuse writes for an abuse-paused account, reads and sign-out keep +// working, and other pause classes change nothing. + +func setPause(t *testing.T, store *identity.Store, userID, state, class string) { + t.Helper() + ctx := context.Background() + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO account_sending_controls (user_id, state, reason, actor, pause_class) + VALUES ($1, $2, 'synthetic', 'test', $3) + ON CONFLICT (user_id) DO UPDATE SET state = $2, pause_class = $3`, userID, state, class) + return err + }); err != nil { + t.Fatalf("set pause: %v", err) + } +} + +func setupLegacyReadOnlyAPI(t *testing.T) (*httptest.Server, *identity.Store) { + t.Helper() + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + smtpRelay := outbound.NewSMTPRelay(&config.OutboundSMTPConfig{}) + sender := outbound.NewSender(smtpRelay, "test.e2a.dev") + userAuth := auth.NewUserAuth(&config.OAuthConfig{}, store, false) + api := agent.NewAPI(store, sender, smtpRelay, userAuth, usage.NewNoopUsageTracker(), "e2a.dev", "test.e2a.dev", "agents.e2a.dev", "", false) + api.SetSupportContact("help@example.test") + router := mux.NewRouter() + api.RegisterRoutes(router) + server := httptest.NewServer(router) + t.Cleanup(server.Close) + return server, store +} + +func legacyDo(t *testing.T, method, url, session, body string) (int, string, string) { + t.Helper() + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: session}) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&env) + return resp.StatusCode, env.Error.Code, env.Error.Message +} + +func TestLegacyAccountWritesRefusedForAnAbusePausedAccount(t *testing.T) { + server, store := setupLegacyReadOnlyAPI(t) + ctx := context.Background() + user, err := store.CreateOrGetUser(ctx, "legacy-ro@example.test", "RO", "sub-legacy-ro") + if err != nil { + t.Fatal(err) + } + ag, err := store.CreateAgent(ctx, "ro-bot@agents.e2a.dev", "agents.e2a.dev", "RO", "", "cloud", user.ID) + if err != nil { + t.Fatal(err) + } + key, err := store.CreateAPIKey(ctx, user.ID, "existing", nil) + if err != nil { + t.Fatal(err) + } + session, err := store.CreateUserSession(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + setPause(t, store, user.ID, "paused", "abuse") + + writes := []struct{ method, path, body string }{ + {http.MethodPatch, "/api/auth/me", `{"name":"Brand Name Inc"}`}, + {http.MethodPut, "/api/dashboard/agents/" + ag.EmailAddress(), `{"name":"Brand Name Support"}`}, + {http.MethodDelete, "/api/dashboard/agents/" + ag.EmailAddress(), ``}, + {http.MethodPost, "/api/keys", `{"name":"new"}`}, + {http.MethodDelete, "/api/keys/" + key.ID, ``}, + {http.MethodPost, "/oauth2/consent", ``}, + } + for _, w := range writes { + status, code, msg := legacyDo(t, w.method, server.URL+w.path, session, w.body) + if status != http.StatusForbidden || code != "account_read_only" { + t.Errorf("%s %s = %d %q, want 403 account_read_only", w.method, w.path, status, code) + continue + } + if !strings.Contains(msg, "help@example.test") || strings.Contains(msg, "synthetic") { + t.Errorf("%s %s message %q: want the support contact and never the pause reason", w.method, w.path, msg) + } + } + // Nothing changed underneath the refusals. + if got, err := store.GetAgentByEmail(ctx, ag.EmailAddress()); err != nil || got == nil || got.Name != "RO" { + t.Fatalf("agent changed or vanished under a refused write: %+v err=%v", got, err) + } + keys, err := store.ListAPIKeys(ctx, user.ID, 100, time.Time{}, "") + if err != nil || len(keys) != 1 { + t.Fatalf("api keys after refused writes = %d (err %v), want the one existing key", len(keys), err) + } + + // Reads and sign-out keep working. + if status, _, _ := legacyDo(t, http.MethodGet, server.URL+"/api/auth/me", session, ""); status != http.StatusOK { + t.Errorf("GET /api/auth/me = %d, want 200", status) + } + if status, _, _ := legacyDo(t, http.MethodGet, server.URL+"/api/keys", session, ""); status != http.StatusOK { + t.Errorf("GET /api/keys = %d, want 200", status) + } + // Sign-out (which redirects) is not refused, and it ends the session. + if status, code, _ := legacyDo(t, http.MethodPost, server.URL+"/api/auth/logout", session, ""); code == "account_read_only" || status == http.StatusForbidden { + t.Errorf("POST /api/auth/logout = %d %q, want it allowed", status, code) + } + if status, _, _ := legacyDo(t, http.MethodGet, server.URL+"/api/auth/me", session, ""); status != http.StatusUnauthorized { + t.Errorf("GET /api/auth/me after sign-out = %d, want 401 (the session must be gone)", status) + } +} + +func TestLegacyAccountWritesUnaffectedByOtherPauseClasses(t *testing.T) { + server, store := setupLegacyReadOnlyAPI(t) + ctx := context.Background() + user, err := store.CreateOrGetUser(ctx, "legacy-op@example.test", "OP", "sub-legacy-op") + if err != nil { + t.Fatal(err) + } + session, err := store.CreateUserSession(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + for _, class := range []string{"operator", "billing", "system"} { + setPause(t, store, user.ID, "paused", class) + if status, code, _ := legacyDo(t, http.MethodPatch, server.URL+"/api/auth/me", session, `{"name":"n-`+class+`"}`); status != http.StatusOK { + t.Errorf("%s pause: PATCH /api/auth/me = %d %q, want 200", class, status, code) + } + } + // A resume after an abuse pause lifts read-only at once. + setPause(t, store, user.ID, "paused", "abuse") + if status, code, _ := legacyDo(t, http.MethodPatch, server.URL+"/api/auth/me", session, `{"name":"paused"}`); code != "account_read_only" { + t.Fatalf("abuse pause: PATCH = %d %q, want account_read_only", status, code) + } + setPause(t, store, user.ID, "active", "abuse") + if status, code, _ := legacyDo(t, http.MethodPatch, server.URL+"/api/auth/me", session, `{"name":"resumed"}`); status != http.StatusOK { + t.Fatalf("after resume: PATCH = %d %q, want 200", status, code) + } +} + +func TestAttachExternalPrincipalRefusesAReadOnlyAccount(t *testing.T) { + server, store := setupAttachAPI(t, attachTestIssuer) + ctx := context.Background() + user, err := store.BootstrapUser(ctx, "attach-ro@example.com") + if err != nil { + t.Fatal(err) + } + setPause(t, store, user.ID, "paused", "abuse") + resp := attachRequest(t, server, attachTestSecret, attachBody(t, attachTestIssuer, "principal-ro", user.ID)) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("attach to a read-only account = %d, want 403", resp.StatusCode) + } + if got := decodeAttachJSON(t, resp)["error"]; got != "account_read_only" { + t.Fatalf("attach error = %q, want account_read_only", got) + } + // An operator pause does not block it. + setPause(t, store, user.ID, "paused", "operator") + resp = attachRequest(t, server, attachTestSecret, attachBody(t, attachTestIssuer, "principal-ro", user.ID)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("attach under an operator pause = %d, want 201", resp.StatusCode) + } +} + +func TestMagicLinksRefuseAReadOnlyAccount(t *testing.T) { + server, store, signer, _ := setupMagicLinkAPI(t) + a, userID := prepareHITLAgent(t, store, "magic-ro") + msg := issuePending(t, store, a.ID) + setPause(t, store, userID, "paused", "abuse") + + for _, action := range []string{approvaltoken.ActionApprove, approvaltoken.ActionReject} { + tok, _ := signer.Sign(msg.ID, action, time.Now().Add(time.Hour)) + resp := postForm(t, server.URL+"/v1/"+action, map[string]string{"t": tok, "reason": "x"}) + body := readBody(t, resp) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusForbidden || !strings.Contains(body, "read-only") { + t.Errorf("magic %s on a read-only account = %d, want 403 read-only page; body: %s", action, resp.StatusCode, body) + } + } + got, _ := store.GetOutboundMessageForUser(context.Background(), msg.ID, userID) + if got.Status != identity.MessageStatusPendingReview { + t.Fatalf("held message status = %q after refused magic links, want still pending", got.Status) + } +} diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index 97b16c479..d59a67a1a 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -103,6 +103,10 @@ type Params struct { // slice is wired by the caller. AgentSuppressionAddedHook identity.AgentSuppressionTxHook ManagedUnsubscribeIssuer agent.ManagedUnsubscribeIssuer + + // SupportContact is the support address named in the account_read_only + // error (config notifications.SupportContact()). Optional. + SupportContact string } // SenderIdentityEnqueuer is the slice of *senderidentity.Manager apiserver @@ -124,6 +128,7 @@ type SenderIdentityEnqueuer interface { func BuildDeps(p Params) httpapi.Deps { if p.API != nil { p.API.SetManagedUnsubscribeIssuer(p.ManagedUnsubscribeIssuer) + p.API.SetSupportContact(p.SupportContact) } var rampSnapshot func(context.Context, string, string, time.Time) (sendramp.Snapshot, error) if p.Pool != nil { @@ -324,6 +329,11 @@ func BuildDeps(p Params) httpapi.Deps { }, EnqueueSenderProvision: enqueueSenderProvisionFunc(p), + // Read-only accounts (sending paused for abuse): the /v1 guard + // consults the control row on every write, uncached. + AccountReadOnly: p.Store.AccountReadOnly, + SupportContact: p.SupportContact, + SharedDomain: p.SharedDomain, PublicURL: p.PublicURL, APIURL: p.APIURL, diff --git a/internal/apiserver/read_only_db_test.go b/internal/apiserver/read_only_db_test.go new file mode 100644 index 000000000..0a30fd985 --- /dev/null +++ b/internal/apiserver/read_only_db_test.go @@ -0,0 +1,160 @@ +package apiserver_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/sendingpolicy" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// Read-only accounts over real HTTP against the full production composition +// (testutil.TestServer: apiserver.BuildDeps + the legacy mux) and real +// Postgres, with the pause applied the way an operator applies it +// (`-pause-account-sending -pause-class abuse` → sendingpolicy.SetAccountPause). +// docs/design/account-read-only.md. + +func roDo(t *testing.T, method, url, key string, body any) (int, map[string]any) { + t.Helper() + var rdr *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } else { + rdr = bytes.NewReader(nil) + } + req, _ := http.NewRequest(method, url, rdr) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +func operatorPause(t *testing.T, module *sendingpolicy.Module, userID, class string, paused bool) { + t.Helper() + if _, err := module.SetAccountPause(context.Background(), sendingpolicy.AccountPauseChange{ + AccountID: userID, Paused: paused, Class: class, Actor: "test-operator", Reason: "synthetic review reason", + }); err != nil { + t.Fatalf("SetAccountPause(%s, paused=%v): %v", class, paused, err) + } +} + +func TestReadOnlyAccountOverHTTP(t *testing.T) { + pool := testutil.TestDB(t) + ts := testutil.TestServer(t, pool) + ctx := context.Background() + base := ts.HTTPServer.URL + module := sendingpolicy.NewModule(pool, sendingpolicy.Secrets{}) + + user, err := ts.Store.CreateOrGetUser(ctx, "ro-http@example.test", "RO", "sub-ro-http") + if err != nil { + t.Fatal(err) + } + ag, err := ts.Store.CreateAgent(ctx, "ro-http-bot@agents.localhost", "agents.localhost", "RO Bot", "", "cloud", user.ID) + if err != nil { + t.Fatal(err) + } + accountKey, err := ts.Store.CreateAPIKey(ctx, user.ID, "account", nil) + if err != nil { + t.Fatal(err) + } + agentKey, err := ts.Store.CreateScopedAPIKey(ctx, user.ID, "agent", identity.ScopeAgent, ag.ID, nil) + if err != nil { + t.Fatal(err) + } + agentPath := base + "/v1/agents/" + strings.ReplaceAll(ag.EmailAddress(), "@", "%40") + + // Writable before the pause. + if status, body := roDo(t, http.MethodPost, base+"/v1/contacts", accountKey.PlaintextKey, map[string]any{"address": "before@example.test"}); status != http.StatusCreated { + t.Fatalf("create contact before the pause = %d %v", status, body) + } + if status, body := roDo(t, http.MethodGet, base+"/v1/account", accountKey.PlaintextKey, nil); status != 200 || body["read_only"] != false { + t.Fatalf("GET /v1/account before the pause = %d read_only=%v, want 200 false", status, body["read_only"]) + } + + // An operator-class pause refuses only sending; writes keep working. + operatorPause(t, module, user.ID, sendingpolicy.PauseClassOperator, true) + if status, body := roDo(t, http.MethodPatch, agentPath, accountKey.PlaintextKey, map[string]any{"name": "Renamed Under Operator Pause"}); status != 200 { + t.Fatalf("rename under an operator pause = %d %v, want 200", status, body) + } + + // The abuse pause makes the account read-only, effective immediately. + operatorPause(t, module, user.ID, sendingpolicy.PauseClassAbuse, true) + + type call struct { + method, url string + body any + } + writes := []call{ + {http.MethodPost, base + "/v1/agents", map[string]any{"email": "brand-support@agents.localhost"}}, + {http.MethodPatch, agentPath, map[string]any{"name": "Brand Support"}}, + {http.MethodDelete, agentPath + "?confirm=DELETE", nil}, + {http.MethodPost, agentPath + "/messages", map[string]any{"to": []string{"x@example.test"}, "subject": "s", "text": "t"}}, + {http.MethodPost, base + "/v1/domains", map[string]any{"domain": "brand.example.test"}}, + {http.MethodPost, base + "/v1/account/api-keys", map[string]any{"name": "k"}}, + {http.MethodPost, base + "/v1/webhooks", map[string]any{"url": "https://hooks.example.test/x", "events": []string{"email.received"}}}, + {http.MethodPost, base + "/v1/contacts", map[string]any{"address": "after@example.test"}}, + {http.MethodPost, base + "/v1/templates", map[string]any{"name": "n", "subject": "s", "body": "b"}}, + {http.MethodPut, agentPath + "/protection", map[string]any{}}, + {http.MethodPost, base + "/v1/account/sending-access/request", map[string]any{"use_case": "x"}}, + } + for _, key := range []string{accountKey.PlaintextKey, agentKey.PlaintextKey} { + for _, c := range writes { + status, body := roDo(t, c.method, c.url, key, c.body) + e, _ := body["error"].(map[string]any) + if status != http.StatusForbidden || e["code"] != "account_read_only" { + t.Errorf("%s %s = %d %v, want 403 account_read_only", c.method, c.url, status, body) + continue + } + if msg, _ := e["message"].(string); strings.Contains(msg, "synthetic review reason") { + t.Errorf("%s %s leaked the operator reason: %q", c.method, c.url, msg) + } + } + } + + // Nothing the refused writes named was changed. + got, err := ts.Store.GetAgentByEmail(ctx, ag.EmailAddress()) + if err != nil || got == nil || got.Name != "Renamed Under Operator Pause" { + t.Fatalf("agent changed under refused writes: %+v err=%v", got, err) + } + + // Reads keep working, for both scopes. + reads := []string{base + "/v1/account", agentPath, agentPath + "/messages", base + "/v1/account/export", base + "/v1/contacts"} + for _, u := range reads { + if status, body := roDo(t, http.MethodGet, u, accountKey.PlaintextKey, nil); status != 200 { + t.Errorf("GET %s = %d %v, want 200", u, status, body) + } + } + if status, body := roDo(t, http.MethodGet, base+"/v1/account", agentKey.PlaintextKey, nil); status != 200 || body["read_only"] != true { + t.Errorf("GET /v1/account (agent scope) = %d read_only=%v, want 200 true", status, body["read_only"]) + } + + // A resume lifts read-only on the next request. + operatorPause(t, module, user.ID, "", false) + if status, body := roDo(t, http.MethodPost, base+"/v1/contacts", accountKey.PlaintextKey, map[string]any{"address": "resumed@example.test"}); status != http.StatusCreated { + t.Fatalf("write after resume = %d %v, want 201", status, body) + } + + // Pause again: the permanent erase stays held, and the trash stays open. + operatorPause(t, module, user.ID, sendingpolicy.PauseClassAbuse, true) + if status, body := roDo(t, http.MethodDelete, base+"/v1/account?confirm=DELETE&permanent=true", accountKey.PlaintextKey, nil); status != http.StatusConflict { + t.Fatalf("permanent account delete while read-only = %d %v, want 409 erase_held", status, body) + } else if e, _ := body["error"].(map[string]any); e["code"] != "erase_held" { + t.Fatalf("permanent account delete code = %v, want erase_held", e["code"]) + } + status, body := roDo(t, http.MethodDelete, base+"/v1/account?confirm=DELETE", accountKey.PlaintextKey, nil) + if status != 200 || body["mode"] != "trash" { + t.Fatalf("account trash while read-only = %d %v, want 200 mode=trash", status, body) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 79678c72a..e5961d4fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -372,6 +372,22 @@ type NotificationsConfig struct { // default when from_address is itself a real mailbox. Override with // E2A_NOTIFICATIONS_REPLY_TO. ReplyTo string `yaml:"reply_to"` + // SupportEmail is the support address customers are told to contact + // when their account is read-only (sending paused for an abuse review — + // the account_read_only error and the dashboard banner). Optional: when + // empty the message falls back to reply_to, and with neither set it says + // "contact support" without an address. Override with + // E2A_NOTIFICATIONS_SUPPORT_EMAIL. + SupportEmail string `yaml:"support_email"` +} + +// SupportContact is the support address named in customer-facing account +// state messages: support_email, else reply_to, else empty. +func (n NotificationsConfig) SupportContact() string { + if n.SupportEmail != "" { + return n.SupportEmail + } + return n.ReplyTo } // InboundConfig selects the inbound processing model (inbound-message-pipeline- @@ -865,6 +881,9 @@ func Load(path string) (*Config, error) { if v := os.Getenv("E2A_NOTIFICATIONS_REPLY_TO"); v != "" { cfg.Notifications.ReplyTo = v } + if v := os.Getenv("E2A_NOTIFICATIONS_SUPPORT_EMAIL"); v != "" { + cfg.Notifications.SupportEmail = v + } if v := os.Getenv("E2A_METRICS_ENABLED"); v != "" { if b, err := strconv.ParseBool(v); err == nil { cfg.Metrics.Enabled = b @@ -1136,6 +1155,12 @@ func (c *Config) Validate() error { return fmt.Errorf("config: notifications.reply_to must be a bare email address (got %q)", v) } } + if v := c.Notifications.SupportEmail; v != "" { + addr, err := mail.ParseAddress(v) + if err != nil || addr.Address != v { + return fmt.Errorf("config: notifications.support_email must be a bare email address (got %q)", v) + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 505613ef2..0bbc28e63 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1175,3 +1175,54 @@ func loadConfigFromYAML(t *testing.T, yaml string) *Config { } return cfg } + +func TestNotificationsSupportContact(t *testing.T) { + load := func(t *testing.T, yaml string) (*Config, error) { + t.Helper() + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(cfgPath, []byte(yaml), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + return Load(cfgPath) + } + t.Run("support_email wins over reply_to", func(t *testing.T) { + cfg, err := load(t, "notifications:\n reply_to: replies@notify.example\n support_email: help@notify.example\n") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Notifications.SupportContact(); got != "help@notify.example" { + t.Errorf("SupportContact = %q, want support_email", got) + } + }) + t.Run("falls back to reply_to, then empty", func(t *testing.T) { + cfg, err := load(t, "notifications:\n reply_to: replies@notify.example\n") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Notifications.SupportContact(); got != "replies@notify.example" { + t.Errorf("SupportContact = %q, want reply_to fallback", got) + } + cfg, err = load(t, "{}\n") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Notifications.SupportContact(); got != "" { + t.Errorf("SupportContact = %q, want empty", got) + } + }) + t.Run("env override", func(t *testing.T) { + t.Setenv("E2A_NOTIFICATIONS_SUPPORT_EMAIL", "help@env.example") + cfg, err := load(t, "{}\n") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Notifications.SupportEmail != "help@env.example" { + t.Errorf("SupportEmail = %q, want env override", cfg.Notifications.SupportEmail) + } + }) + t.Run("rejects a display-name address", func(t *testing.T) { + if _, err := load(t, "notifications:\n support_email: \"Help \"\n"); err == nil { + t.Fatal("Load accepted a non-bare support_email") + } + }) +} diff --git a/internal/httpapi/account.go b/internal/httpapi/account.go index 3d79fb664..077d116e6 100644 --- a/internal/httpapi/account.go +++ b/internal/httpapi/account.go @@ -44,6 +44,9 @@ type AccountView struct { // restore so a client can show a one-time notice. DeletedAt *time.Time `json:"deleted_at,omitempty" doc:"When the account was moved to the trash. Absent for a live account."` PurgeAfter *time.Time `json:"purge_after,omitempty" doc:"When a trashed account becomes eligible for permanent purge. Absent for a live account."` + // ReadOnly is true while the account is read-only (sending paused for an + // abuse review): every write is refused with 403 account_read_only. + ReadOnly *bool `json:"read_only,omitempty" doc:"True while the account is read-only because its sending is paused pending an abuse review: every write is refused with 403 account_read_only, while reads and moving the account to the trash keep working. False otherwise. Absent when the deployment does not report it or its state is unavailable."` RestoredAt *time.Time `json:"restored_at,omitempty" doc:"When the account was last restored from the trash. Absent if it never was. API keys and domain verification do not survive a trash: keys must be re-created and domains re-verified after a restore."` } @@ -366,6 +369,7 @@ func (s *Server) handleGetMyLimits(ctx context.Context, _ *struct{}) (*accountOu Usage: usage, UpgradeURL: caps.UpgradeURL, SendingAccess: s.accountSendingAccess(ctx, user.ID), + ReadOnly: s.accountReadOnlyView(ctx, user.ID), DeletedAt: user.DeletedAt, PurgeAfter: user.PurgeAfter(), RestoredAt: user.RestoredAt, diff --git a/internal/httpapi/error_catalog.go b/internal/httpapi/error_catalog.go index fbd421b69..1792d6d01 100644 --- a/internal/httpapi/error_catalog.go +++ b/internal/httpapi/error_catalog.go @@ -26,6 +26,10 @@ var errorCodeCatalog = []errorCodeContract{ // An identity held by a live identity tombstone (a recently deleted or // abuse-closed account) cannot register or be restored. {Code: "registration_refused", Status: "403", Family: "auth"}, + // The account is read-only: its sending is paused pending an abuse + // review, so every write is refused (reads and the account trash keep + // working) until an operator resumes it. + {Code: "account_read_only", Status: "403", Family: "auth"}, {Code: "invalid_request", Status: "400 / 422", Family: "validation", DetailsSchema: "ValidationErrorDetails"}, {Code: "invalid_cursor", Status: "400", Family: "validation"}, {Code: "invalid_filter", Status: "400", Family: "validation"}, diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go index 9a2de266d..cce886ab9 100644 --- a/internal/httpapi/errors.go +++ b/internal/httpapi/errors.go @@ -54,7 +54,7 @@ type ErrorEnvelope struct { // ErrorBody is the inner object of the envelope. type ErrorBody struct { - Code string `json:"code" doc:"Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` + Code string `json:"code" doc:"Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, account_read_only, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed), account_read_only (403, the account is read-only because its sending is paused pending an abuse review: every write is refused while reads and moving the account to the trash keep working; retrying will not succeed until an operator resumes the account — contact support). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."` Message string `json:"message" doc:"Human-readable explanation. Not for branching — use code."` Details any `json:"details,omitempty" doc:"Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved."` RequestID string `json:"request_id" doc:"Echoes the X-Request-Id response header so a failing call is greppable in logs."` diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 9d4c114cd..50ee98c75 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -404,6 +404,15 @@ type Deps struct { // restricted cookie was short-lived), and an erase expires it (maxAge < 0). WriteSessionCookie func(w http.ResponseWriter, token string, maxAge time.Duration) + // AccountReadOnly reports whether the account is read-only (sending + // paused with pause class abuse). The readOnlyGuard middleware consults it + // on every write; nil disables read-only enforcement (minimal test + // setups). Production wires identity.Store.AccountReadOnly. + AccountReadOnly func(ctx context.Context, userID string) (bool, error) + // SupportContact is the support address named in the account_read_only + // message. Optional; empty says "contact support" without an address. + SupportContact string + // events (delivery log). EventQuery carries the filters + cursor // position; the closures bind the events pool in main. ListEvents func(ctx context.Context, q EventQuery) ([]agent.EventView, error) @@ -631,6 +640,9 @@ func New(deps Deps) *Server { // RateLimit-* headers on the response and short-circuit a 429 before the // handler. Registered once; applies to every operation. api.UseMiddleware(s.rateLimit) + // Read-only accounts: the single /v1 enforcement point that refuses every + // write for an account paused for abuse (read_only.go). + api.UseMiddleware(s.readOnlyGuard) s.registerOperations() s.applyAuthenticationNullability() // Post-registration document passes, in order: drop the phantom diff --git a/internal/httpapi/read_only.go b/internal/httpapi/read_only.go new file mode 100644 index 000000000..1f5abab59 --- /dev/null +++ b/internal/httpapi/read_only.go @@ -0,0 +1,244 @@ +package httpapi + +import ( + "context" + "log" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/tokencanopy/e2a/internal/identity" +) + +// Read-only accounts (docs/design/account-read-only.md). +// +// An account whose sending is paused with pause class `abuse` is read-only: +// no write of any kind succeeds on /v1, whatever the credential (API key of +// either scope, dashboard session, OAuth access token, agent access token, +// delegated token — they all resolve to a principal owned by the account). +// Reads keep working, and so does moving the account to the trash (the +// permanent erase stays 409 erase_held). Other pause classes refuse only +// sending. +// +// This middleware is the ONLY enforcement point on /v1. Handlers do not check +// read-only themselves; every operation is classified in operationAccess +// below, and TestReadOnlySpecWalk walks the committed OpenAPI document so an +// operation added without a classification fails CI. + +// opAccess classifies what an operation does to account state. +type opAccess int + +const ( + // accessRead changes no account state. Never refused for a read-only + // account, and never pays the read-only lookup. + accessRead opAccess = iota + 1 + // accessWrite changes account state. Refused with 403 account_read_only + // for a read-only account. + accessWrite + // accessWriteAllowedReadOnly changes state but stays available to a + // read-only account by product decision (the account trash). + accessWriteAllowedReadOnly + // accessPublic is unauthenticated; there is no account to consult. + accessPublic +) + +// operationAccess classifies every /v1 operation. The rule is the HTTP +// method — GET/HEAD read, everything else writes — with exactly three kinds of +// exception, each listed with its reason: +// +// - validateTemplate is a POST that only validates a draft and stores +// nothing (read); +// - deleteAccount is the one write a read-only account keeps: moving the +// account to the trash (the handler still refuses permanent=true with 409 +// erase_held while any pause applies); +// - getInfo is public. +// +// Keep this table exhaustive: TestReadOnlySpecWalk fails for an operation in +// the spec that is missing here, and for any entry that disagrees with the +// method rule and its exceptions. +var operationAccess = map[string]opAccess{ + // account + "getAccount": accessRead, + "exportAccount": accessRead, + "deleteAccount": accessWriteAllowedReadOnly, + "getAccountMetrics": accessRead, + "listApiKeys": accessRead, + "createApiKey": accessWrite, + "deleteApiKey": accessWrite, + "getSendingAccessRequest": accessRead, + "createSendingAccessRequest": accessWrite, + "listSuppressions": accessRead, + "deleteSuppression": accessWrite, + + // agents + "listAgents": accessRead, + "createAgent": accessWrite, + "getAgent": accessRead, + "updateAgent": accessWrite, + "deleteAgent": accessWrite, + "restoreAgent": accessWrite, + "testAgent": accessWrite, + "getAgentMetrics": accessRead, + "getAgentProtection": accessRead, + "putAgentProtection": accessWrite, + + // agent suppressions + "listAgentSuppressions": accessRead, + "createAgentSuppression": accessWrite, + "deleteAgentSuppression": accessWrite, + + // engagements (outreach) + "listEngagements": accessRead, + "getEngagement": accessRead, + "upsertEngagement": accessWrite, + "deleteEngagement": accessWrite, + + // conversations + "listConversations": accessRead, + "getConversation": accessRead, + + // messages + "listMessages": accessRead, + "getMessage": accessRead, + "getMessageLifecycle": accessRead, + "getAttachment": accessRead, + "sendMessage": accessWrite, + "replyToMessage": accessWrite, + "forwardMessage": accessWrite, + "updateMessage": accessWrite, + "deleteMessage": accessWrite, + "restoreMessage": accessWrite, + + // reviews (HITL, both directions) + "listReviews": accessRead, + "getReview": accessRead, + "approveReview": accessWrite, + "rejectReview": accessWrite, + + // contacts + "listContacts": accessRead, + "getContact": accessRead, + "createContact": accessWrite, + "updateContact": accessWrite, + "deleteContact": accessWrite, + "importContacts": accessWrite, + "deleteImportBatch": accessWrite, + + // domains + "listDomains": accessRead, + "getDomain": accessRead, + "registerDomain": accessWrite, + "verifyDomain": accessWrite, + "deleteDomain": accessWrite, + + // events + "listEvents": accessRead, + "getEvent": accessRead, + "redeliverEvent": accessWrite, + + // templates + "listTemplates": accessRead, + "getTemplate": accessRead, + "createTemplate": accessWrite, + "updateTemplate": accessWrite, + "deleteTemplate": accessWrite, + "validateTemplate": accessRead, + "listStarterTemplates": accessRead, + "getStarterTemplate": accessRead, + + // webhooks + "listWebhooks": accessRead, + "getWebhook": accessRead, + "listWebhookDeliveries": accessRead, + "createWebhook": accessWrite, + "updateWebhook": accessWrite, + "deleteWebhook": accessWrite, + "rotateWebhookSecret": accessWrite, + "testWebhook": accessWrite, + + // deployment discovery + "getInfo": accessPublic, +} + +// classifyOperation returns the operation's access class. An operation +// missing from operationAccess (which TestReadOnlySpecWalk forbids) falls +// back to the method rule, so an unclassified write is refused rather than +// let through. +func classifyOperation(op *huma.Operation) opAccess { + if a, ok := operationAccess[op.OperationID]; ok { + return a + } + switch op.Method { + case http.MethodGet, http.MethodHead: + return accessRead + default: + return accessWrite + } +} + +// accountReadOnlyError is the canonical 403 account_read_only envelope. +func (s *Server) accountReadOnlyError() *ErrorEnvelope { + return NewError(http.StatusForbidden, "account_read_only", identity.AccountReadOnlyMessage(s.deps.SupportContact)) +} + +// readOnlyGuard is the Huma middleware that refuses writes for a read-only +// account. Reads and public operations pass without any lookup. For a write +// it resolves the principal (reusing the per-request auth memo) and consults +// the account's control row on every request — no cache, so a pause or a +// resume applies to the next request. An unauthenticated or +// auth-unavailable request falls through so the handler emits its canonical +// 401/503. A failed read-only lookup fails closed: 503, and the write does +// not run. +func (s *Server) readOnlyGuard(ctx huma.Context, next func(huma.Context)) { + op := ctx.Operation() + if op == nil || s.deps.AccountReadOnly == nil { + next(ctx) + return + } + switch classifyOperation(op) { + case accessWrite: + default: + next(ctx) + return + } + p := principalFromContext(ctx.Context()) + if p == nil { + r := RequestFromContext(ctx.Context()) + if r == nil { + next(ctx) + return + } + resolved, err := s.resolvePrincipal(r) + if err != nil { + next(ctx) + return + } + p = resolved + ctx = huma.WithContext(ctx, withPrincipal(ctx.Context(), p)) + } + readOnly, err := s.deps.AccountReadOnly(ctx.Context(), p.User.ID) + if err != nil { + log.Printf("[httpapi] read-only check for %s failed: %v", op.OperationID, err) + writeEnvelope(ctx, NewError(http.StatusServiceUnavailable, "auth_unavailable", + "account state is temporarily unavailable; retry")) + return + } + if readOnly { + writeEnvelope(ctx, s.accountReadOnlyError()) + return + } + next(ctx) +} + +// accountReadOnlyView reports the read-only flag for GET /v1/account, or nil +// when the deployment does not wire the check or it could not be read (the +// field is then omitted rather than guessed). +func (s *Server) accountReadOnlyView(ctx context.Context, userID string) *bool { + if s.deps.AccountReadOnly == nil { + return nil + } + ro, err := s.deps.AccountReadOnly(ctx, userID) + if err != nil { + return nil + } + return &ro +} diff --git a/internal/httpapi/read_only_test.go b/internal/httpapi/read_only_test.go new file mode 100644 index 000000000..7f3cae719 --- /dev/null +++ b/internal/httpapi/read_only_test.go @@ -0,0 +1,339 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "sync/atomic" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/tokencanopy/e2a/internal/identity" + "gopkg.in/yaml.v3" +) + +// Read-only accounts (docs/design/account-read-only.md): the spec walk. +// +// Expected classes are derived INDEPENDENTLY of operationAccess: the method +// rule (GET/HEAD read, everything else write) plus the exceptions below, each +// a deliberate product decision. The production table must agree with this +// derivation for every operation in the committed spec, and the live server +// must behave accordingly — so flipping one entry of operationAccess, or +// adding an operation without classifying it, fails this test. +var readOnlyExpectedExceptions = map[string]opAccess{ + // POST that validates a draft template and stores nothing. + "validateTemplate": accessRead, + // Moving the account to the trash stays available to a read-only account + // (the permanent erase is refused separately with 409 erase_held). + "deleteAccount": accessWriteAllowedReadOnly, + // Public deployment discovery: no principal, no account. + "getInfo": accessPublic, +} + +type roSpecOp struct { + ID, Method, Path string +} + +func loadSpecOperations(t *testing.T) []roSpecOp { + t.Helper() + raw, err := os.ReadFile(specGoldenPath) + if err != nil { + t.Fatal(err) + } + var spec struct { + Paths map[string]map[string]struct { + OperationID string `yaml:"operationId"` + } `yaml:"paths"` + } + if err := yaml.Unmarshal(raw, &spec); err != nil { + t.Fatal(err) + } + var ops []roSpecOp + for path, item := range spec.Paths { + for method, op := range item { + m := strings.ToUpper(method) + switch m { + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + default: + continue + } + if op.OperationID == "" { + t.Fatalf("%s %s has no operationId", m, path) + } + ops = append(ops, roSpecOp{ID: op.OperationID, Method: m, Path: path}) + } + } + sort.Slice(ops, func(i, j int) bool { return ops[i].ID < ops[j].ID }) + if len(ops) < 50 { + t.Fatalf("spec walk found only %d operations; the spec parse is broken", len(ops)) + } + return ops +} + +func expectedAccess(op roSpecOp) opAccess { + if a, ok := readOnlyExpectedExceptions[op.ID]; ok { + return a + } + if op.Method == http.MethodGet || op.Method == http.MethodHead { + return accessRead + } + return accessWrite +} + +func (a opAccess) String() string { + switch a { + case accessRead: + return "read" + case accessWrite: + return "write" + case accessWriteAllowedReadOnly: + return "write-allowed-while-read-only" + case accessPublic: + return "public" + } + return fmt.Sprintf("opAccess(%d)", int(a)) +} + +// TestReadOnlyClassificationCoversTheSpec: every operation in the committed +// spec is classified explicitly, the class matches the method rule and its +// named exceptions, and the table carries no stale entries. +func TestReadOnlyClassificationCoversTheSpec(t *testing.T) { + ops := loadSpecOperations(t) + inSpec := map[string]bool{} + for _, op := range ops { + inSpec[op.ID] = true + got, ok := operationAccess[op.ID] + if !ok { + t.Errorf("%s (%s %s) is not classified in operationAccess: add it as read or write (read_only.go)", op.ID, op.Method, op.Path) + continue + } + if want := expectedAccess(op); got != want { + t.Errorf("%s (%s %s) is classified %s, want %s (method rule + readOnlyExpectedExceptions)", op.ID, op.Method, op.Path, got, want) + } + } + for id := range operationAccess { + if !inSpec[id] { + t.Errorf("operationAccess classifies %q, which is not an operation in the spec (stale entry)", id) + } + } + for id := range readOnlyExpectedExceptions { + if !inSpec[id] { + t.Errorf("readOnlyExpectedExceptions names %q, which is not an operation in the spec", id) + } + } +} + +// readOnlyTestServer builds the real /v1 server with a principal +// authenticator keyed on the bearer and an AccountReadOnly stub reporting the +// given state. Every other dependency is nil: a write refused by the guard +// never reaches its handler, and a request that does reach a handler with a +// nil dependency is caught by the recover wrapper (status 599) — the walk only +// asserts what the guard did, not what the handler returned. +func readOnlyTestServer(t *testing.T, state func(userID string) (bool, error), calls *atomic.Int64) *httptest.Server { + t.Helper() + deps := Deps{ + PrincipalAuthenticator: func(r *http.Request) (*identity.Principal, error) { + switch r.Header.Get("Authorization") { + case "Bearer account": + return &identity.Principal{User: &identity.User{ID: "u_ro", Email: "owner@example.test"}, Scope: identity.ScopeAccount}, nil + case "Bearer agent": + // An agent access token / agent-scoped key / delegated token + // all resolve to a principal owned by the same account. + return &identity.Principal{User: &identity.User{ID: "u_ro", Email: "owner@example.test"}, Scope: identity.ScopeAgent, AgentID: "ro-bot@example.test"}, nil + } + return nil, errors.New("unauthorized") + }, + AccountReadOnly: func(ctx context.Context, userID string) (bool, error) { + calls.Add(1) + return state(userID) + }, + SupportContact: "help@example.test", + } + s := New(deps) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + w.WriteHeader(599) + } + }() + s.ServeHTTP(w, r) + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv +} + +var specParamValues = map[string]string{ + "{email}": "ro-bot%40example.test", + "{address}": "someone%40example.test", + "{domain}": "example.test", + "{id}": "x_1", + "{index}": "0", + "{batch_id}": "imp_1", + "{alias}": "welcome", +} + +func concretePath(t *testing.T, specPath string) string { + t.Helper() + p := specParamPattern.ReplaceAllStringFunc(specPath, func(param string) string { + v, ok := specParamValues[param] + if !ok { + t.Fatalf("no test value for path parameter %s in %s", param, specPath) + } + return v + }) + return p +} + +func doReadOnlyRequest(t *testing.T, srv *httptest.Server, op roSpecOp, bearer string) (int, string) { + t.Helper() + url := srv.URL + concretePath(t, op.Path) + if op.Method == http.MethodDelete { + url += "?confirm=DELETE" + } + var body *bytes.Reader + if op.Method == http.MethodGet || op.Method == http.MethodHead { + body = bytes.NewReader(nil) + } else { + body = bytes.NewReader([]byte(`{}`)) + } + req, err := http.NewRequest(op.Method, url, body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+bearer) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", op.Method, url, err) + } + defer resp.Body.Close() + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&env) + return resp.StatusCode, env.Error.Code +} + +// TestReadOnlySpecWalk drives EVERY operation in the committed spec against +// the live router as a read-only account, with both an account-scoped and an +// agent-scoped principal: every write answers 403 account_read_only, every +// read, public operation and allowlisted write passes the guard untouched — +// and a read never even pays the read-only lookup. +func TestReadOnlySpecWalk(t *testing.T) { + ops := loadSpecOperations(t) + var calls atomic.Int64 + srv := readOnlyTestServer(t, func(string) (bool, error) { return true, nil }, &calls) + + for _, bearer := range []string{"account", "agent"} { + for _, op := range ops { + op := op + t.Run(bearer+"/"+op.ID, func(t *testing.T) { + before := calls.Load() + status, code := doReadOnlyRequest(t, srv, op, bearer) + consulted := calls.Load() > before + switch expectedAccess(op) { + case accessWrite: + if status != http.StatusForbidden || code != "account_read_only" { + t.Fatalf("%s %s as a read-only account = %d %q, want 403 account_read_only", op.Method, op.Path, status, code) + } + default: + if code == "account_read_only" { + t.Fatalf("%s %s (%s) was refused as read-only", op.Method, op.Path, expectedAccess(op)) + } + if consulted { + t.Fatalf("%s %s (%s) consulted the read-only state; only writes may pay that lookup", op.Method, op.Path, expectedAccess(op)) + } + } + }) + } + } +} + +// TestReadOnlyGuardLetsWritesThroughForAWritableAccount: the same walk for an +// account that is NOT read-only never produces account_read_only (the guard +// consults the state instead of refusing blanket), and a write for an +// unauthenticated caller falls through to the handler's canonical 401. +func TestReadOnlyGuardLetsWritesThroughForAWritableAccount(t *testing.T) { + ops := loadSpecOperations(t) + var calls atomic.Int64 + srv := readOnlyTestServer(t, func(string) (bool, error) { return false, nil }, &calls) + for _, op := range ops { + if expectedAccess(op) != accessWrite { + continue + } + status, code := doReadOnlyRequest(t, srv, op, "account") + if code == "account_read_only" { + t.Errorf("%s %s refused a writable account (%d)", op.Method, op.Path, status) + } + } + if calls.Load() == 0 { + t.Fatal("no write consulted the read-only state") + } + + op := roSpecOp{ID: "deleteApiKey", Method: http.MethodDelete, Path: "/v1/account/api-keys/{id}"} + before := calls.Load() + status, code := doReadOnlyRequest(t, srv, op, "nobody") + if status != http.StatusUnauthorized || code != "unauthorized" { + t.Fatalf("unauthenticated write = %d %q, want the handler's 401 unauthorized", status, code) + } + if calls.Load() != before { + t.Fatal("an unauthenticated write consulted the read-only state") + } +} + +// TestReadOnlyGuardFailsClosed: when the read-only state cannot be read, a +// write is refused with a retryable 503 and never runs; a read is unaffected. +func TestReadOnlyGuardFailsClosed(t *testing.T) { + var calls atomic.Int64 + srv := readOnlyTestServer(t, func(string) (bool, error) { return false, errors.New("db down") }, &calls) + + status, code := doReadOnlyRequest(t, srv, roSpecOp{ID: "createContact", Method: http.MethodPost, Path: "/v1/contacts"}, "account") + if status != http.StatusServiceUnavailable || code != "auth_unavailable" { + t.Fatalf("write with an unreadable account state = %d %q, want 503 auth_unavailable", status, code) + } + status, code = doReadOnlyRequest(t, srv, roSpecOp{ID: "getAccount", Method: http.MethodGet, Path: "/v1/account"}, "account") + if status == http.StatusServiceUnavailable && code == "auth_unavailable" { + t.Fatalf("a read failed on the read-only lookup (%d %q); reads must not consult it", status, code) + } +} + +// TestAccountReadOnlyErrorMessage: the message names the state and the way +// out, carries the configured support contact, and falls back cleanly. +func TestAccountReadOnlyErrorMessage(t *testing.T) { + with := identity.AccountReadOnlyMessage("help@example.test") + for _, want := range []string{"sending is paused", "abuse review", "read-only", "help@example.test"} { + if !strings.Contains(with, want) { + t.Errorf("message %q lacks %q", with, want) + } + } + without := identity.AccountReadOnlyMessage("") + if !strings.Contains(without, "Contact support to appeal.") || strings.Contains(without, "()") { + t.Errorf("message without a contact = %q", without) + } +} + +// TestClassifyOperationFallsBackToTheMethodRule: an operation missing from the +// table (which the coverage test forbids) is still refused when it writes. +func TestClassifyOperationFallsBackToTheMethodRule(t *testing.T) { + cases := map[string]opAccess{ + http.MethodGet: accessRead, http.MethodHead: accessRead, + http.MethodPost: accessWrite, http.MethodPut: accessWrite, + http.MethodPatch: accessWrite, http.MethodDelete: accessWrite, + } + for method, want := range cases { + if got := classifyOperation(&huma.Operation{OperationID: "notInTheTable", Method: method}); got != want { + t.Errorf("unclassified %s = %s, want %s", method, got, want) + } + } +} diff --git a/internal/identity/account_read_only_test.go b/internal/identity/account_read_only_test.go new file mode 100644 index 000000000..7f5bcbe16 --- /dev/null +++ b/internal/identity/account_read_only_test.go @@ -0,0 +1,74 @@ +package identity_test + +import ( + "context" + "testing" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// AccountReadOnly is true for exactly one control state: paused with pause +// class abuse. Every other class keeps today's behaviour (only sending is +// refused), a resume lifts read-only at once even though the class is kept, +// and an account with no control row is writable. +func TestAccountReadOnlyOnlyForAnAbusePause(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + user, err := store.CreateOrGetUser(ctx, "ro@example.test", "RO", "sub-ro") + if err != nil { + t.Fatal(err) + } + check := func(label string, want bool) { + t.Helper() + got, err := store.AccountReadOnly(ctx, user.ID) + if err != nil { + t.Fatalf("%s: AccountReadOnly: %v", label, err) + } + if got != want { + t.Fatalf("%s: AccountReadOnly = %v, want %v", label, got, want) + } + } + check("no control row", false) + + set := func(state, class string) { + t.Helper() + if _, err := pool.Exec(ctx, ` + INSERT INTO account_sending_controls (user_id, state, reason, actor, pause_class) + VALUES ($1, $2, 'synthetic', 'test', $3) + ON CONFLICT (user_id) DO UPDATE SET state = $2, pause_class = $3`, + user.ID, state, class); err != nil { + t.Fatalf("set control %s/%s: %v", state, class, err) + } + } + for _, class := range []string{"operator", "billing", "system"} { + set("paused", class) + check("paused/"+class, false) + } + set("paused", "abuse") + check("paused/abuse", true) + // A resume keeps the abuse class as history; it must not keep the + // account read-only. + set("active", "abuse") + check("resumed after abuse", false) + + other, err := store.CreateOrGetUser(ctx, "ro-other@example.test", "RO2", "sub-ro-other") + if err != nil { + t.Fatal(err) + } + set("paused", "abuse") + if got, err := store.AccountReadOnly(ctx, other.ID); err != nil || got { + t.Fatalf("another account's abuse pause leaked: got %v err %v", got, err) + } +} + +func TestAccountReadOnlyReportsAQueryFailure(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := store.AccountReadOnly(ctx, "usr_x"); err == nil { + t.Fatal("AccountReadOnly with a dead context returned no error; the guard could not fail closed") + } +} diff --git a/internal/identity/account_trash.go b/internal/identity/account_trash.go index 2c664ad2b..ee7963cba 100644 --- a/internal/identity/account_trash.go +++ b/internal/identity/account_trash.go @@ -473,6 +473,40 @@ func (s *Store) AccountSendingPaused(ctx context.Context, userID string) (bool, return paused, err } +// AccountReadOnly reports whether the account is read-only: its sending is +// paused with pause class 'abuse' (docs/design/account-read-only.md). Every +// write on every customer surface is refused for such an account; reads, sign +// in/out and the account trash stay available. Other pause classes (operator, +// billing, system) refuse only sending. A missing control row is not +// read-only. The read is a single primary-key lookup with no cache, so an +// operator pause or resume takes effect on the very next request. +func (s *Store) AccountReadOnly(ctx context.Context, userID string) (bool, error) { + var readOnly bool + err := s.pool.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM account_sending_controls + WHERE user_id = $1 AND state = 'paused' AND pause_class = 'abuse')`, userID, + ).Scan(&readOnly) + return readOnly, err +} + +// AccountReadOnlyCode is the machine-checked error code every surface emits +// when it refuses a write for a read-only account. +const AccountReadOnlyCode = "account_read_only" + +// AccountReadOnlyMessage is the customer-facing message of the +// account_read_only error, shared by every surface that emits it (/v1, the +// legacy dashboard routes, OAuth consent, the HITL magic links, the internal +// principal attach). It names the state and the way out, never the operator's +// reason. supportContact is optional. +func AccountReadOnlyMessage(supportContact string) string { + contact := "Contact support to appeal." + if supportContact != "" { + contact = "Contact support (" + supportContact + ") to appeal." + } + return "sending is paused for this account pending an abuse review, and the account is read-only: " + + "reads still work, but no changes can be made until the review is complete. " + contact +} + // userPurgeBatch bounds one janitor pass of PurgeDeletedUsers. var userPurgeBatch = 20 diff --git a/internal/sendingpolicy/account_pause_admin.go b/internal/sendingpolicy/account_pause_admin.go index 85689dbe1..c57c0e142 100644 --- a/internal/sendingpolicy/account_pause_admin.go +++ b/internal/sendingpolicy/account_pause_admin.go @@ -204,6 +204,14 @@ func (m *Module) InspectAccountPause(ctx context.Context, accountID string) (Acc return rec, nil } +// ReadOnly reports whether the record describes a read-only account: sending +// paused with pause class abuse. It mirrors identity.Store.AccountReadOnly, +// which the request guards consult; TestAccountPauseReadbackReportsReadOnly +// pins the two against each other. +func (r AccountPauseRecord) ReadOnly() bool { + return r.State == "paused" && r.PauseClass == PauseClassAbuse +} + func nullIfEmpty(v string) any { if v == "" { return nil diff --git a/internal/sendingpolicy/account_read_only_test.go b/internal/sendingpolicy/account_read_only_test.go new file mode 100644 index 000000000..88ffede97 --- /dev/null +++ b/internal/sendingpolicy/account_read_only_test.go @@ -0,0 +1,44 @@ +package sendingpolicy_test + +import ( + "testing" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/sendingpolicy" +) + +// TestAccountPauseReadbackReportsReadOnly pins the operator readback's +// read_only flag against identity.Store.AccountReadOnly (what the request +// guards consult) across every pause class and a resume, applied through the +// operator command's own path (docs/design/account-read-only.md). +func TestAccountPauseReadbackReportsReadOnly(t *testing.T) { + f := newFixture(t) + m := sendingpolicy.NewPolicyModule(f.pool, f.secrets(), sendingpolicy.PolicySourceConfig, sendingpolicy.DisabledPolicy()) + store := identity.NewStore(f.pool) + + check := func(label string, rec sendingpolicy.AccountPauseRecord, user string, want bool) { + t.Helper() + guard, err := store.AccountReadOnly(f.ctx, user) + if err != nil { + t.Fatalf("%s: AccountReadOnly: %v", label, err) + } + if rec.ReadOnly() != want || guard != want { + t.Fatalf("%s: readback ReadOnly()=%v guard=%v, want %v", label, rec.ReadOnly(), guard, want) + } + } + for _, class := range []string{sendingpolicy.PauseClassOperator, sendingpolicy.PauseClassBilling, sendingpolicy.PauseClassSystem, sendingpolicy.PauseClassAbuse} { + user := f.user("standard") + rec, err := m.SetAccountPause(f.ctx, sendingpolicy.AccountPauseChange{ + AccountID: user, Paused: true, Class: class, Actor: "op", Reason: "synthetic", + }) + if err != nil { + t.Fatalf("pause %s: %v", class, err) + } + check("paused/"+class, rec, user, class == sendingpolicy.PauseClassAbuse) + rec, err = m.SetAccountPause(f.ctx, sendingpolicy.AccountPauseChange{AccountID: user, Actor: "op", Reason: "cleared"}) + if err != nil { + t.Fatalf("resume %s: %v", class, err) + } + check("resumed/"+class, rec, user, false) + } +} From 52311f75bd1ed13b1a74b8d1c87077ee39769171 Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:02:09 +0800 Subject: [PATCH 02/19] feat(clients): account_read_only across SDKs, CLI and MCP - regenerate both SDK bases (ErrorBody vocabulary, AccountView.read_only) - TS + Python map account_read_only to the permission family, never retryable - CLI: new frozen exit code 10 (READ_ONLY) with guidance in the top-level error rendering, and whoami prints the read-only state - MCP: per-tool mutating classification (tools/mutating.ts) pinned against the registered tools and the annotations; the /v1 guard stays the enforcement point, and a refused tool call surfaces [account_read_only] with structured code; whoami explains read_only Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- AGENTS.md | 3 +- cli/README.md | 1 + cli/src/__tests__/exit.test.ts | 10 ++ cli/src/__tests__/format-error.test.ts | 16 ++ cli/src/__tests__/whoami.test.ts | 19 +++ cli/src/bin/e2a.ts | 6 + cli/src/commands/whoami.ts | 9 ++ cli/src/exit.ts | 9 ++ mcp/src/tools/agents.ts | 2 +- mcp/src/tools/mutating.ts | 140 ++++++++++++++++++ mcp/tests/tools.test.ts | 66 +++++++++ sdks/python/src/e2a/v1/errors.py | 5 + .../e2a/v1/generated/models/account_view.py | 6 +- .../src/e2a/v1/generated/models/error_body.py | 2 +- sdks/python/tests/test_v1_errors.py | 9 ++ sdks/typescript/src/v1/errors.ts | 5 + .../src/v1/generated/models/AccountView.ts | 10 ++ .../src/v1/generated/models/ErrorBody.ts | 2 +- sdks/typescript/test/v1/errors.test.ts | 6 + 19 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 mcp/src/tools/mutating.ts diff --git a/AGENTS.md b/AGENTS.md index cdf6dd499..2e4ff5b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -313,7 +313,8 @@ manually on every API change even though the template won't remind you. (`cli/src/exit.ts`) are a frozen contract** — 0 ok, 1 transient, 2 usage, 3 held-for-review, 4 auth, 5 permanent request error, 6 timeout, 7 send-outcome, 8 warn (`doctor` warnings only), 9 config (`doctor` found a - definite configuration failure). Add new codes, never renumber. + definite configuration failure), 10 read-only (`account_read_only`: the + account is frozen for an abuse review). Add new codes, never renumber. - **MCP server** (`mcp/`): inbox tools over the REST API; hosted HTTP transport (image `ghcr.io/tokencanopy/e2a-mcp-http`). **npm publishing is retired** (`@e2a/mcp-server` frozen at 0.5.0) — do not configure a trusted diff --git a/cli/README.md b/cli/README.md index ecbc77dd2..06830b83b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -519,3 +519,4 @@ only added to. | `7` | A persisted send failed or returned an unrecognized outcome — do not retry; inspect the returned message id | | `8` | Diagnostics (`doctor`) completed with warnings only — nothing broken | | `9` | Diagnostics (`doctor`) found a definite configuration failure — do not retry; fix the reported configuration | +| `10` | The account is read-only (`account_read_only`): sending is paused pending an abuse review, so every write is refused — do not retry or rotate keys; contact support | diff --git a/cli/src/__tests__/exit.test.ts b/cli/src/__tests__/exit.test.ts index 2df143e1d..9b275c471 100644 --- a/cli/src/__tests__/exit.test.ts +++ b/cli/src/__tests__/exit.test.ts @@ -23,6 +23,15 @@ describe("API error exit classification", () => { }); }); +describe("account_read_only exit classification", () => { + it("maps account_read_only to its own READ_ONLY code, not AUTH or REQUEST", () => { + // The credential is valid (not AUTH) and the invocation is fine (not + // REQUEST): the account itself is frozen for an abuse review, so no write + // succeeds until an operator resumes it. + expect(exitCodeForAPIError({ code: "account_read_only", retryable: false })).toBe(EXIT.READ_ONLY); + }); +}); + describe("exit code contract", () => { it("published values are frozen — add codes, never renumber", () => { expect(EXIT.OK).toBe(0); @@ -35,5 +44,6 @@ describe("exit code contract", () => { expect(EXIT.SEND_OUTCOME).toBe(7); expect(EXIT.WARN).toBe(8); expect(EXIT.CONFIG).toBe(9); + expect(EXIT.READ_ONLY).toBe(10); }); }); diff --git a/cli/src/__tests__/format-error.test.ts b/cli/src/__tests__/format-error.test.ts index bfef9d880..8b781d141 100644 --- a/cli/src/__tests__/format-error.test.ts +++ b/cli/src/__tests__/format-error.test.ts @@ -61,4 +61,20 @@ describe("formatError", () => { it("handles a non-E2AError throw without crashing", () => { expect(formatError(new Error("boom"))).toBe("Error: boom\n"); }); + + it("renders account_read_only with read-only guidance and no retry advice", () => { + const err = new E2AError({ + code: "account_read_only", + message: "sending is paused for this account pending an abuse review, and the account is read-only", + status: 403, + retryable: false, + }); + const out = formatError(err); + expect(out).toContain("[account_read_only]"); + expect(out).toContain("read-only while its sending is paused for an abuse review"); + expect(out).toContain("reads (whoami, messages, listen) still work"); + expect(out.toLowerCase()).toContain("do not retry"); + expect(out).toContain("contact support"); + expect(out).not.toContain("e2a sending-access request"); + }); }); diff --git a/cli/src/__tests__/whoami.test.ts b/cli/src/__tests__/whoami.test.ts index 059d75e16..3a170dde1 100644 --- a/cli/src/__tests__/whoami.test.ts +++ b/cli/src/__tests__/whoami.test.ts @@ -87,6 +87,25 @@ describe("whoami command", () => { expect(output).not.toContain("restored:"); }); + it("says the account is read-only when readOnly is set", async () => { + mockAccountGet.mockResolvedValue(makeAccount({ readOnly: true })); + const { whoami } = await import("../commands/whoami.js"); + await whoami({}); + + const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join(""); + expect(output).toContain("read-only: yes"); + expect(output).toContain("abuse review"); + }); + + it("says nothing about read-only for a writable account", async () => { + mockAccountGet.mockResolvedValue(makeAccount({ readOnly: false })); + const { whoami } = await import("../commands/whoami.js"); + await whoami({}); + + const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join(""); + expect(output).not.toContain("read-only"); + }); + it("emits raw JSON with --json", async () => { const account = makeAccount(); mockAccountGet.mockResolvedValue(account); diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index 54ba1eaf2..a5ed9515c 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -883,6 +883,12 @@ function formatError(err: unknown): string { " request approval with: e2a sending-access request --use-case --recipients --volume \n" + " do not retry this request as-is — the same recipients will refuse again.\n"; } + if (err instanceof E2AError && err.code === "account_read_only") { + out += + " this account is read-only while its sending is paused for an abuse review.\n" + + " reads (whoami, messages, listen) still work; no change will succeed until the review is complete.\n" + + " do not retry or rotate keys — contact support to appeal.\n"; + } return out; } diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts index 25bb74ff4..4a56c040e 100644 --- a/cli/src/commands/whoami.ts +++ b/cli/src/commands/whoami.ts @@ -41,6 +41,15 @@ export async function whoami(opts: WhoamiOptions): Promise { if (account.restoredAt) { process.stdout.write(`restored: ${account.restoredAt.toISOString()} (from trash)\n`); } + // Read-only accounts: sending is paused for an abuse review and every write + // is refused (account_read_only, exit 10). Say so up front so a preflight + // does not go on to attempt writes that cannot succeed. + if (account.readOnly) { + process.stdout.write( + "read-only: yes (sending is paused pending an abuse review; reads still work, " + + "every change is refused — contact support)\n", + ); + } // Beta, additive: `sending_access` is omitted entirely on a deployment that // doesn't run this control, so say nothing rather than printing a diff --git a/cli/src/exit.ts b/cli/src/exit.ts index 413fe1d46..f2f724ab1 100644 --- a/cli/src/exit.ts +++ b/cli/src/exit.ts @@ -45,10 +45,19 @@ export const EXIT = { * REQUEST (5) because nothing about the invocation itself was wrong. */ CONFIG: 9, + /** + * The account is read-only (`account_read_only`): its sending is paused + * pending an abuse review, so every write is refused. Distinct from AUTH + * (the credential is fine) and REQUEST (nothing about the invocation was + * wrong): no write will succeed until an operator resumes the account, so + * wrappers must neither retry nor rotate keys — contact support. + */ + READ_ONLY: 10, } as const; export function exitCodeForAPIError(error: { code: string; retryable: boolean }): number { if (error.code === "unauthorized" || error.code === "forbidden") return EXIT.AUTH; + if (error.code === "account_read_only") return EXIT.READ_ONLY; return error.retryable ? EXIT.ERROR : EXIT.REQUEST; } diff --git a/mcp/src/tools/agents.ts b/mcp/src/tools/agents.ts index e47b81888..532588031 100644 --- a/mcp/src/tools/agents.ts +++ b/mcp/src/tools/agents.ts @@ -51,7 +51,7 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { title: "Get the authenticated account's identity", annotations: { readOnlyHint: true }, description: - "Use first when starting work on e2a to learn WHO you are: the authenticated user (email), the credential's scope (`account` or `agent`), and your plan + usage limits. For an agent-scoped credential it also returns `agent_email` — the single agent that credential IS. Account-scoped credentials own many agents; discover them with `list_agents`. This is identity, not an agent — it never guesses a 'default' agent. When the deployment restricts external sending, the optional `sending_access` object reports whether this account may email external recipients (`enforcement_applies`, `shared_external_approved`, `paid_external_sending_entitled`, `owner_recipient_verified`).", + "Use first when starting work on e2a to learn WHO you are: the authenticated user (email), the credential's scope (`account` or `agent`), and your plan + usage limits. For an agent-scoped credential it also returns `agent_email` — the single agent that credential IS. Account-scoped credentials own many agents; discover them with `list_agents`. This is identity, not an agent — it never guesses a 'default' agent. When the deployment restricts external sending, the optional `sending_access` object reports whether this account may email external recipients (`enforcement_applies`, `shared_external_approved`, `paid_external_sending_entitled`, `owner_recipient_verified`). `read_only: true` means the account is frozen while its sending is paused for an abuse review: read tools keep working, and every tool that changes anything (send, create, update, delete, approve, …) fails with `account_read_only` — do not retry those; the account owner must contact support.", inputSchema: strictInputSchema({}), }, async () => runTool(() => client.whoami()), diff --git a/mcp/src/tools/mutating.ts b/mcp/src/tools/mutating.ts new file mode 100644 index 000000000..fd4962b11 --- /dev/null +++ b/mcp/src/tools/mutating.ts @@ -0,0 +1,140 @@ +// Per-tool `mutating` flag — the MCP surface's read-only classification +// (docs/design/account-read-only.md). +// +// An account whose sending is paused for an abuse review is READ-ONLY: every +// write on every surface is refused with 403 `account_read_only`. Every MCP +// tool call goes through the /v1 REST API with the caller's own credential, +// so the enforcement point for MCP is the server's /v1 read-only guard — the +// MCP server keeps no account state it could let go stale, and a pause or a +// resume applies to the very next tool call. A refused call surfaces like any +// other API error: `e2a error [account_read_only]: …` in the text and +// `{ code: "account_read_only", retryable: false, status: 403 }` in +// structuredContent. +// +// This map records which tools that guard will refuse (mutating: the tool +// calls a /v1 write operation) and which stay available (reads). It is the +// contract an agent can rely on — "while read-only, exactly these tools keep +// working" — and it is pinned by tests: every registered tool must be in +// exactly one set, and the sets must agree with the MCP annotations +// (a readOnlyHint tool never mutates; a mutating tool is never readOnlyHint). + +/** Tools that call a /v1 write operation — refused for a read-only account. */ +export const MUTATING_TOOLS: ReadonlySet = new Set([ + // agents + "create_agent", + "update_agent", + "update_protection", + "delete_agent", + "restore_agent", + // messages + "send_message", + "send_email", + "reply_to_message", + "forward_message", + "update_message_labels", + "delete_message", + "restore_message", + // reviews (both directions) + "approve_review", + "reject_review", + "approve_pending_message", + "reject_pending_message", + "approve_message", + "reject_message", + // domains + "register_domain", + "verify_domain", + "delete_domain", + // webhooks + events + "create_webhook", + "update_webhook", + "delete_webhook", + "rotate_webhook_secret", + "test_webhook", + "redeliver_event", + // templates + "create_template", + "update_template", + "delete_template", + // api keys + "create_api_key", + "delete_api_key", + // contacts + outreach + "create_contact", + "update_contact", + "delete_contact", + "import_contacts", + "delete_contact_import", + "set_outreach_contact", + "delete_outreach_contact", + // suppressions + "delete_suppression", + "create_agent_suppression", + "delete_agent_suppression", +]); + +/** Tools that only read — they keep working for a read-only account. */ +export const NON_MUTATING_TOOLS: ReadonlySet = new Set([ + "whoami", + "list_agents", + "get_agent", + "get_protection", + "list_messages", + // get_message is not readOnlyHint (fetching marks an inbound message read), + // but it calls the GET read operation, which a read-only account keeps. + "get_message", + "get_message_lifecycle", + "get_attachment", + "get_attachment_data", + "list_conversations", + "get_conversation", + "list_reviews", + "get_review", + "list_pending_messages", + "get_pending_message", + "list_domains", + "get_domain", + "list_webhooks", + "get_webhook", + "list_webhook_deliveries", + "list_events", + "get_event", + "list_templates", + "get_template", + // validate_template POSTs a draft for validation and stores nothing. + "validate_template", + "list_starter_templates", + "get_starter_template", + "list_api_keys", + "list_contacts", + "get_contact", + "list_outreach_contacts", + "get_outreach_contact", + "list_suppressions", + "list_agent_suppressions", + "get_agent_metrics", + "get_account_metrics", +]); + +/** True when `name` calls a /v1 write (refused while the account is read-only). */ +export function isMutatingTool(name: string): boolean { + return MUTATING_TOOLS.has(name); +} + +/** + * Drift guard: every registered tool is classified exactly once, and nothing + * classified is unregistered. Call from a test with the real registered names. + */ +export function assertMutatingClassificationComplete(registered: Iterable): void { + const reg = new Set(registered); + const unclassified = [...reg].filter((n) => !MUTATING_TOOLS.has(n) && !NON_MUTATING_TOOLS.has(n)); + const doubled = [...reg].filter((n) => MUTATING_TOOLS.has(n) && NON_MUTATING_TOOLS.has(n)); + const phantom = [...MUTATING_TOOLS, ...NON_MUTATING_TOOLS].filter((n) => !reg.has(n)); + const problems: string[] = []; + if (unclassified.length) problems.push(`unclassified: ${unclassified.join(", ")}`); + if (doubled.length) problems.push(`both mutating and non-mutating: ${doubled.join(", ")}`); + if (phantom.length) problems.push(`classified but not registered: ${phantom.join(", ")}`); + if (problems.length) { + throw new Error(`MCP read-only classification out of sync — ${problems.join("; ")}`); + } +} diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index e97f0df94..66dcbfe9b 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -12,6 +12,7 @@ import { import type { McpClient } from "../src/client.js"; import { buildServer } from "../src/server.js"; import { ADMIN_TOOLS, assertToolTiersComplete, toolNamesForScope, RUNTIME_TOOLS } from "../src/tools/tiers.js"; +import { assertMutatingClassificationComplete, MUTATING_TOOLS, NON_MUTATING_TOOLS } from "../src/tools/mutating.js"; import { messageSummaryViewForTool, registerMessageTools } from "../src/tools/messages.js"; import { registerAgentTools } from "../src/tools/agents.js"; import { registerDomainTools } from "../src/tools/domains.js"; @@ -622,6 +623,34 @@ describe("e2a MCP server", () => { expect(() => assertToolTiersComplete(names)).not.toThrow(); }); + it("every registered tool is classified mutating or not (read-only drift guard)", () => { + // Same true registered set as the tier guard above: an unclassified tool + // would leave agents unable to know whether it works while the account + // is read-only (docs/design/account-read-only.md). + const names: string[] = []; + const recorder = { + registerTool: (name: string) => { + names.push(name); + return undefined; + }, + } as unknown as McpServer; + const stub = makeStubClient(); + registerMessageTools(recorder, stub); + registerAgentTools(recorder, stub); + registerDomainTools(recorder, stub); + registerReviewTools(recorder, stub); + registerWebhookTools(recorder, stub); + registerEventTools(recorder, stub); + registerTemplateTools(recorder, stub); + registerApiKeyTools(recorder, stub); + registerContactTools(recorder, stub); + registerSuppressionTools(recorder, stub); + registerMetricsTools(recorder, stub); + registerLegacyTools(recorder, stub); + expect(() => assertMutatingClassificationComplete(names)).not.toThrow(); + expect(() => assertMutatingClassificationComplete([...names, "brand_new_tool"])).toThrow(/unclassified: brand_new_tool/); + }); + it("unrecognized scope falls back to the runtime tier (least privilege)", () => { expect(toolNamesForScope("bogus")).toBe(RUNTIME_TOOLS); expect(toolNamesForScope("")).toBe(RUNTIME_TOOLS); @@ -1136,6 +1165,43 @@ describe("e2a MCP server", () => { expect(byName.get("get_message")?.readOnlyHint, "get_message not read-only").toBe(false); }); + it("the mutating flag agrees with the annotations", async () => { + const { tools } = await client.listTools(); // account scope → full surface + for (const t of tools) { + if (MUTATING_TOOLS.has(t.name)) { + expect(t.annotations?.readOnlyHint ?? false, `${t.name} mutates, so it cannot be readOnlyHint`).toBe(false); + } + if (t.annotations?.readOnlyHint === true) { + expect(NON_MUTATING_TOOLS.has(t.name), `${t.name} is readOnlyHint, so it must be non-mutating`).toBe(true); + } + } + }); + + it("a mutating tool refused for a read-only account surfaces account_read_only", async () => { + // The /v1 guard is the MCP surface's enforcement point: the tool call + // reaches the API with the caller's credential and the 403 comes back as + // a non-retryable tool error an agent can branch on. + (stub.send as ReturnType).mockRejectedValueOnce( + new E2AError({ + code: "account_read_only", + message: + "sending is paused for this account pending an abuse review, and the account is read-only: reads still work, but no changes can be made until the review is complete. Contact support to appeal.", + status: 403, + retryable: false, + }), + ); + const res = await client.callTool({ + name: "send_message", + arguments: { to: ["x@example.com"], subject: "s", text: "b" }, + }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ text: string }>)[0]?.text ?? ""; + expect(text).toContain("[account_read_only]"); + expect(text).toContain("read-only"); + expect(text).not.toContain("(retryable)"); + expect(res.structuredContent).toMatchObject({ code: "account_read_only", retryable: false, status: 403 }); + }); + it("send_message forwards args to client.send", async () => { await client.callTool({ name: "send_message", diff --git a/sdks/python/src/e2a/v1/errors.py b/sdks/python/src/e2a/v1/errors.py index 6ab8e3df3..48a9501db 100644 --- a/sdks/python/src/e2a/v1/errors.py +++ b/sdks/python/src/e2a/v1/errors.py @@ -204,6 +204,11 @@ def is_retryable_status(status: int) -> bool: # deleted or closed account and cannot register or be restored (account # trash / purge). Not retryable — the same identity will refuse again. "registration_refused": (E2APermissionError, False), + # 403 — the account is read-only: its sending is paused pending an abuse + # review, so every write is refused (reads and moving the account to the + # trash keep working). Not retryable until an operator resumes the + # account; contact support. GET /v1/account reports it as read_only. + "account_read_only": (E2APermissionError, False), # 404/410 family — also covers *_not_found via the suffix check in _resolve. "not_found": (E2ANotFoundError, False), "gone": (E2ANotFoundError, False), diff --git a/sdks/python/src/e2a/v1/generated/models/account_view.py b/sdks/python/src/e2a/v1/generated/models/account_view.py index abedcfc21..1fad544c5 100644 --- a/sdks/python/src/e2a/v1/generated/models/account_view.py +++ b/sdks/python/src/e2a/v1/generated/models/account_view.py @@ -18,7 +18,7 @@ import json from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr from typing import Any, ClassVar, Dict, List, Optional from e2a.v1.generated.models.account_user_view import AccountUserView from e2a.v1.generated.models.limits_caps_view import LimitsCapsView @@ -36,6 +36,7 @@ class AccountView(BaseModel): limits: LimitsCapsView plan_code: StrictStr purge_after: Optional[datetime] = Field(default=None, description="When a trashed account becomes eligible for permanent purge. Absent for a live account.") + read_only: Optional[StrictBool] = Field(default=None, description="True while the account is read-only because its sending is paused pending an abuse review: every write is refused with 403 account_read_only, while reads and moving the account to the trash keep working. False otherwise. Absent when the deployment does not report it or its state is unavailable.") restored_at: Optional[datetime] = Field(default=None, description="When the account was last restored from the trash. Absent if it never was. API keys and domain verification do not survive a trash: keys must be re-created and domains re-verified after a restore.") scope: StrictStr = Field(description="Credential scope. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: account, agent.") sending_access: Optional[SendingAccessView] = Field(default=None, description="External sending access eligibility (beta). Booleans only; describes what the account may do, not a promise that a given send passes pause, quota, content or domain checks. Omitted when the deployment does not enable external sending access, or when its state is unavailable.") @@ -43,7 +44,7 @@ class AccountView(BaseModel): usage: LimitsUsageView user: AccountUserView additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["agent_email", "deleted_at", "limits", "plan_code", "purge_after", "restored_at", "scope", "sending_access", "upgrade_url", "usage", "user"] + __properties: ClassVar[List[str]] = ["agent_email", "deleted_at", "limits", "plan_code", "purge_after", "read_only", "restored_at", "scope", "sending_access", "upgrade_url", "usage", "user"] model_config = ConfigDict( populate_by_name=True, @@ -120,6 +121,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "limits": LimitsCapsView.from_dict(obj["limits"]) if obj.get("limits") is not None else None, "plan_code": obj.get("plan_code"), "purge_after": obj.get("purge_after"), + "read_only": obj.get("read_only"), "restored_at": obj.get("restored_at"), "scope": obj.get("scope"), "sending_access": SendingAccessView.from_dict(obj["sending_access"]) if obj.get("sending_access") is not None else None, diff --git a/sdks/python/src/e2a/v1/generated/models/error_body.py b/sdks/python/src/e2a/v1/generated/models/error_body.py index 89a8f21e7..df24f1dab 100644 --- a/sdks/python/src/e2a/v1/generated/models/error_body.py +++ b/sdks/python/src/e2a/v1/generated/models/error_body.py @@ -26,7 +26,7 @@ class ErrorBody(BaseModel): """ ErrorBody """ # noqa: E501 - code: StrictStr = Field(description="Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status.") + code: StrictStr = Field(description="Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, account_read_only, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed), account_read_only (403, the account is read-only because its sending is paused pending an abuse review: every write is refused while reads and moving the account to the trash keep working; retrying will not succeed until an operator resumes the account — contact support). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status.") details: Optional[Dict[str, Any]] = Field(default=None, description="Optional structured context, polymorphic by code. Treat it as an open object keyed off code; unknown codes and fields must be preserved.") message: StrictStr = Field(description="Human-readable explanation. Not for branching — use code.") request_id: StrictStr = Field(description="Echoes the X-Request-Id response header so a failing call is greppable in logs.") diff --git a/sdks/python/tests/test_v1_errors.py b/sdks/python/tests/test_v1_errors.py index 0a26d35fb..5dbf9cacd 100644 --- a/sdks/python/tests/test_v1_errors.py +++ b/sdks/python/tests/test_v1_errors.py @@ -237,6 +237,15 @@ def test_catalog_family_overrides(): assert isinstance(paused, E2APermissionError) assert paused.retryable is False + # account_read_only: an abuse-paused account refuses every write — + # permission, never retryable until an operator resumes the account. + read_only = from_api_exception( + _exc(403, body='{"error":{"code":"account_read_only","message":"x"}}') + ) + assert isinstance(read_only, E2APermissionError) + assert read_only.retryable is False + assert read_only.code == "account_read_only" + # external_sending_not_enabled: not retryable — nothing was queued and # retrying the same request will not succeed. `.details` carries the open # set of allowed destinations plus an optional dashboard recovery URL. diff --git a/sdks/typescript/src/v1/errors.ts b/sdks/typescript/src/v1/errors.ts index 712532c0a..5c2bb4c8f 100644 --- a/sdks/typescript/src/v1/errors.ts +++ b/sdks/typescript/src/v1/errors.ts @@ -123,6 +123,11 @@ const CODE_TABLE: Record = { // deleted or closed account and cannot register or be restored (account // trash / purge). Not retryable — the same identity will refuse again. registration_refused: { make: mkPermission, retryable: false }, + // The account is read-only: its sending is paused pending an abuse review, + // so every write is refused (reads and moving the account to the trash keep + // working). Not retryable — nothing succeeds until an operator resumes the + // account; contact support. GET /v1/account reports it as read_only. + account_read_only: { make: mkPermission, retryable: false }, // 404 / 410 — the *_not_found suffix family resolves in resolve() below. not_found: { make: mkNotFound, retryable: false }, gone: { make: mkNotFound, retryable: false }, diff --git a/sdks/typescript/src/v1/generated/models/AccountView.ts b/sdks/typescript/src/v1/generated/models/AccountView.ts index 3770eff1d..77069564e 100644 --- a/sdks/typescript/src/v1/generated/models/AccountView.ts +++ b/sdks/typescript/src/v1/generated/models/AccountView.ts @@ -29,6 +29,10 @@ export class AccountView { */ 'purgeAfter'?: Date; /** + * True while the account is read-only because its sending is paused pending an abuse review: every write is refused with 403 account_read_only, while reads and moving the account to the trash keep working. False otherwise. Absent when the deployment does not report it or its state is unavailable. + */ + 'readOnly'?: boolean; + /** * When the account was last restored from the trash. Absent if it never was. API keys and domain verification do not survive a trash: keys must be re-created and domains re-verified after a restore. */ 'restoredAt'?: Date; @@ -79,6 +83,12 @@ export class AccountView { "type": "Date", "format": "date-time" }, + { + "name": "readOnly", + "baseName": "read_only", + "type": "boolean", + "format": "" + }, { "name": "restoredAt", "baseName": "restored_at", diff --git a/sdks/typescript/src/v1/generated/models/ErrorBody.ts b/sdks/typescript/src/v1/generated/models/ErrorBody.ts index 9a853ea4e..a9172268f 100644 --- a/sdks/typescript/src/v1/generated/models/ErrorBody.ts +++ b/sdks/typescript/src/v1/generated/models/ErrorBody.ts @@ -14,7 +14,7 @@ import { HttpFile } from '../http/http.js'; export class ErrorBody { /** - * Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account\'s sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status. + * Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, account_read_only, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed), account_read_only (403, the account is read-only because its sending is paused pending an abuse review: every write is refused while reads and moving the account to the trash keep working; retrying will not succeed until an operator resumes the account — contact support). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account\'s sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status. */ 'code': string; /** diff --git a/sdks/typescript/test/v1/errors.test.ts b/sdks/typescript/test/v1/errors.test.ts index 277391244..e7c445b01 100644 --- a/sdks/typescript/test/v1/errors.test.ts +++ b/sdks/typescript/test/v1/errors.test.ts @@ -172,6 +172,12 @@ describe("code-first class selection (F2)", () => { E2APermissionError, ); expect(toE2AError({ status: 403, code: "sending_paused", message: "x" }).retryable).toBe(false); + // account_read_only: an abuse-paused account refuses every write — + // PERMISSION, never retryable until an operator resumes the account. + const readOnly = toE2AError({ status: 403, code: "account_read_only", message: "x" }); + expect(readOnly).toBeInstanceOf(E2APermissionError); + expect(readOnly.retryable).toBe(false); + expect(readOnly.code).toBe("account_read_only"); // external_sending_not_enabled: PERMISSION (not quota), never retryable — // the account may not send to one or more recipients through its shared // sending identity, and nothing was queued. From 0ce140c8d83280bae47027036dc59b35d3a8eb0a Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:06:17 +0800 Subject: [PATCH 03/19] feat(web): read-only banner and disabled writes for abuse-paused accounts A persistent, non-dismissible banner in the app shell ("Your account is read-only while sending is paused for abuse review. Contact support.") driven by GET /v1/account read_only through the shared limits SWR entry. The request helper turns a 403 account_read_only into that copy; the Create key and Add domain controls are disabled and Create inbox is hidden while read-only. The API error remains the backstop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW --- web/src/app/(app)/AppLayoutClient.tsx | 2 + web/src/app/(app)/api-keys/page.test.tsx | 59 ++++++++++++++++ web/src/app/(app)/api-keys/page.tsx | 8 ++- web/src/app/(app)/domains/page.tsx | 11 ++- web/src/app/(app)/inboxes/page.tsx | 5 +- .../app/components/ReadOnlyBanner.test.tsx | 68 +++++++++++++++++++ web/src/app/components/ReadOnlyBanner.tsx | 46 +++++++++++++ .../components/hooks/useAccountReadOnly.ts | 17 +++++ web/src/app/components/onboarding/api.test.ts | 27 ++++++++ web/src/app/components/onboarding/api.ts | 15 +++- web/src/lib/readOnly.test.ts | 16 +++++ web/src/lib/readOnly.ts | 30 ++++++++ 12 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 web/src/app/components/ReadOnlyBanner.test.tsx create mode 100644 web/src/app/components/ReadOnlyBanner.tsx create mode 100644 web/src/app/components/hooks/useAccountReadOnly.ts create mode 100644 web/src/lib/readOnly.test.ts create mode 100644 web/src/lib/readOnly.ts diff --git a/web/src/app/(app)/AppLayoutClient.tsx b/web/src/app/(app)/AppLayoutClient.tsx index 81d6cdc45..34b8e9c65 100644 --- a/web/src/app/(app)/AppLayoutClient.tsx +++ b/web/src/app/(app)/AppLayoutClient.tsx @@ -8,6 +8,7 @@ import { SWRProvider } from "../components/swr/SWRProvider"; import { PendingPollingOwner } from "../components/swr/PendingPollingOwner"; import { SignInLinks } from "../components/SignInLinks"; import { RestoredNotice } from "../components/RestoredNotice"; +import { ReadOnlyBanner } from "../components/ReadOnlyBanner"; import { Sidebar } from "../components/loft/Sidebar"; const FOCUSABLE_SELECTOR = @@ -273,6 +274,7 @@ export default function AppLayout({
+ {children}
diff --git a/web/src/app/(app)/api-keys/page.test.tsx b/web/src/app/(app)/api-keys/page.test.tsx index 7229970a5..c25bfb6ea 100644 --- a/web/src/app/(app)/api-keys/page.test.tsx +++ b/web/src/app/(app)/api-keys/page.test.tsx @@ -302,3 +302,62 @@ describe("API keys — agent scope", () => { expect(screen.getByText("bot@acme.io")).toBeInTheDocument(); }); }); + +describe("read-only account", () => { + // An account paused for abuse review is read-only: the Create button is + // disabled up front, and a refused create (e.g. a stale page) shows the + // read-only copy instead of the raw envelope. + function stageReadOnly(readOnly: boolean, createBody?: string) { + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === "/v1/account") { + return Promise.resolve({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ + user: { id: "usr_1", email: "owner@example.test" }, + scope: "account", + plan_code: "free", + limits: { max_agents: 3, max_domains: 1, max_messages_month: 3000, max_storage_bytes: 1 }, + usage: { agents: 0, domains: 0, messages_month: 0, storage_bytes: 0 }, + upgrade_url: "", + read_only: readOnly, + }), + ), + }); + } + if (url === "/v1/account/api-keys" && init?.method === "POST") { + return Promise.resolve({ ok: false, status: 403, text: () => Promise.resolve(createBody ?? "") }); + } + if (url === "/v1/account/api-keys") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) }); + } + if (url === "/v1/agents") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) }); + } + return Promise.resolve({ ok: false, text: () => Promise.resolve("not found") }); + }); + } + + it("disables Create key while the account is read-only", async () => { + stageReadOnly(true); + render(); + const button = await screen.findByRole("button", { name: /create key/i }); + await waitFor(() => expect(button).toBeDisabled()); + expect(button).toHaveAttribute("title", expect.stringMatching(/read-only/i)); + }); + + it("shows the read-only copy when a create is refused with account_read_only", async () => { + stageReadOnly( + false, + JSON.stringify({ error: { code: "account_read_only", message: "sending is paused for this account" } }), + ); + render(); + const button = await screen.findByRole("button", { name: /create key/i }); + await userEvent.click(button); + expect( + await screen.findByText("Your account is read-only while sending is paused for abuse review. Contact support."), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/app/(app)/api-keys/page.tsx b/web/src/app/(app)/api-keys/page.tsx index b38325abb..afcc5f298 100644 --- a/web/src/app/(app)/api-keys/page.tsx +++ b/web/src/app/(app)/api-keys/page.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import type { APIKeyData } from "../../components/types"; import { useAgents } from "../../components/hooks/useAgents"; import { PageShell } from "../../components/loft/PageShell"; +import { useAccountReadOnly } from "../../components/hooks/useAccountReadOnly"; +import { ACCOUNT_READ_ONLY_CONTROL_TITLE, readOnlyMessageFromBody } from "../../../lib/readOnly"; import { Chip } from "@e2a/ui"; type SortKey = "last_used" | "created" | "name"; @@ -52,6 +54,7 @@ function formatExpiresIn(iso: string): { label: string; expired: boolean; immine } export default function APIKeysPage() { + const readOnly = useAccountReadOnly(); const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); const [newKeyName, setNewKeyName] = useState(""); @@ -154,7 +157,7 @@ export default function APIKeysPage() { fetchKeys(); } else { const msg = await res.text(); - setCreateError(msg || `Could not create the key (HTTP ${res.status}).`); + setCreateError(readOnlyMessageFromBody(msg) ?? (msg || `Could not create the key (HTTP ${res.status}).`)); } } catch { setCreateError("Network error — check your connection and try again."); @@ -348,7 +351,8 @@ export default function APIKeysPage() {