Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ assistant features:
`packages/worker/src/email/inbound.ts` rejects routed mail for unverified
accounts right after username routing (`setReject` plus a bounded `rejected`
email delivery event); nothing is stored.

Platform suspension (`users.suspended_at`, set by admins from `/admin/users`)
follows the same chokepoint pattern and also fails closed: browser session
resolution treats a suspended session as signed out (`readAuthenticatedAppUser`
/ `loadSessionInfo`), `handleMcpRequest` rejects with a `403 account_suspended`
JSON response after the verification gate, and both email directions reject
(inbound with a bounded `account-suspension` rejection event, outbound with an
error). See the "Abuse controls" section of [`security.md`](../security.md).

- **Email capabilities**: every capability in the MCP `email` domain calls
`requireVerifiedEmailAccountUser`
(`packages/worker/src/mcp/capabilities/email/require-verified-user.ts`) as
Expand Down
24 changes: 24 additions & 0 deletions docs/contributing/architecture/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ those caps. All other resources use the ordinary `planLimits.max` numbers.
| `repo_sessions` | 2,000 |
| `secrets` | 10,000 |
| `storage_bytes` | 100 GiB |
| `execute_calls_per_day` | 500,000 |
| `outbound_fetches_per_day` | 2,000,000 |

## Compute rate limits

`execute_calls_per_day` and `outbound_fetches_per_day` are daily-counter
resources (same mechanism as `email_sends_per_day`, consumed atomically with
`consumeDailyEntitlement`). They close the metering → enforcement loop for the
two compute surfaces `usage-metering.md` already observes:

- **Execute calls** are consumed at the top of the MCP `execute` tool handler
(`packages/worker/src/mcp/tools/execute.ts`) before any bundling or sandbox
work, so over-limit calls cost nothing. The `EntitlementLimitError` propagates
as a structured MCP error.
- **Outbound fetches** are consumed at the top of `executeGatewayFetch`
(`packages/worker/src/mcp/fetch-gateway.ts`), which every sandbox fetch passes
through, before secret expansion. `FetchGatewayProps.email` carries the acting
user's account email for plan lookup; when a caller cannot carry one (OpenAPI
provider requests, package runtime), the gateway reverse-resolves the account
via `findUserAccountByStableUserId` so the caller's real plan binds. Genuinely
accountless synthetic contexts resolve to `max`, whose limit is still finite.

Both consume only when the context has a `userId`, matching the usage-metering
rule that events without an owning user are skipped.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Schema history

Expand Down
29 changes: 29 additions & 0 deletions docs/contributing/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,35 @@ guarded by a bearer secret comparison.
`Cf-Access-Jwt-Assertion` verification; production restore is a graduated
prepare → typed confirmation → Workflow path, never a single click.

## Abuse controls (suspension, email pause, compute quotas)

One bad actor can poison shared platform identity — every user sends mail from
one platform domain through one Cloudflare Email Sending account, and every
sandbox fetch leaves through the same Worker egress. Three controls bound that
blast radius:

- **Platform suspension (`users.suspended_at`).** An admin-set kill switch,
distinct from a community ban (which only blocks community-surface actions).
Enforced fail-closed at every chokepoint: browser session resolution
(`readAuthenticatedAppUser` / `loadSessionInfo` treat a suspended session as
signed out), MCP bearer auth (`handleMcpRequest` returns a 403
`account_suspended` response, mirroring the email-verification gate), and both
email directions (inbound storage rejects with a bounded `account-suspension`
rejection event; outbound send throws). Set and cleared through the audited
`suspend_user` / `unsuspend_user` actions on `POST /admin/users.json`.
- **Automatic outbound-email pause (`users.email_outbound_paused_at`).** The
delivery queue evaluates provider delivery events
(`packages/worker/src/email/outbound-abuse.ts`): one spam complaint, or five
or more bounced sends within a UTC day, pauses that account's outbound email
and notifies admin accounts through the transactional sender. The pause write
is idempotent (only transitions NULL), the send path rejects while paused, and
the audited `resume_email_outbound` admin action clears it after review. The
`/admin/insights` "Email delivery health" chart shows platform-wide outcome
trends so reputation trouble is visible before providers act on it.
- **Compute quotas.** `execute_calls_per_day` and `outbound_fetches_per_day`
entitlements bound sandbox compute and egress volume per user per day (see
[`architecture/entitlements.md`](./architecture/entitlements.md)).

## Accepted residual risks and out-of-scope items

These were reviewed and intentionally left as-is for this project. Document any
Expand Down
6 changes: 6 additions & 0 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ Inbound storage is quota-gated per user:
events independently populate `delivery_status` with `delivered`, `deferred`,
`bounced`, `failed`, `rejected`, or `complained`; use
`email_delivery_event_list` for the event history and SMTP details.
- Delivery events feed an automatic abuse pause: one spam complaint, or five or
more bounced sends within a UTC day, pauses outbound sending for the account
(receiving is unaffected). Every user sends from the same platform domain, so
one account's complaints damage delivery for everyone. A paused send fails
with a clear error; contact the operator to have the pause reviewed and
cleared.
- System inbox mail is not gated by a user plan or account-verification state.
It has fixed platform caps and retention: messages are pruned after 90 days
and the stored system inbox is capped so arbitrary sender traffic cannot grow
Expand Down
58 changes: 58 additions & 0 deletions packages/worker/client/routes/admin-insights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,64 @@ function renderDashboard(data: AdminInsightsLoaderData) {
/>
</ChartCard>

<ChartCard
title="Email delivery health"
sub="Outbound delivery outcomes from the provider, last 28 days. Bounces and complaints burn the shared sender domain's reputation."
span={12}
legend={
<ChartLegend
items={[
{ label: 'Delivered', color: chartColor.emerald },
{ label: 'Deferred', color: chartColor.cyan },
{ label: 'Bounced', color: chartColor.amber },
{ label: 'Failed', color: chartColor.fuchsia },
{ label: 'Rejected', color: chartColor.violet },
{ label: 'Complained', color: chartColor.rose },
]}
/>
}
>
<StackedBarChart
id="email-delivery"
ariaLabel="Outbound email delivery outcomes per day"
series={[
{
label: 'Delivered',
color: chartColor.emerald,
values: data.emailDeliveryByDay.map((day) => day.delivered),
},
{
label: 'Deferred',
color: chartColor.cyan,
values: data.emailDeliveryByDay.map((day) => day.deferred),
},
{
label: 'Bounced',
color: chartColor.amber,
values: data.emailDeliveryByDay.map((day) => day.bounced),
},
{
label: 'Failed',
color: chartColor.fuchsia,
values: data.emailDeliveryByDay.map((day) => day.failed),
},
{
label: 'Rejected',
color: chartColor.violet,
values: data.emailDeliveryByDay.map((day) => day.rejected),
},
{
label: 'Complained',
color: chartColor.rose,
values: data.emailDeliveryByDay.map((day) => day.complained),
},
]}
xLabels={data.emailDeliveryByDay.map((day) =>
formatDayLabel(day.day),
)}
/>
</ChartCard>

<ChartCard
title="Auth pulse"
sub="Authentication and account events per day, last 28 days."
Expand Down
138 changes: 137 additions & 1 deletion packages/worker/client/routes/admin-users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,12 @@ export function AdminUsersRoute(handle: Handle) {
// (deep link / filtered out). Prefer the in-list row when present.
let selectedUserFallback: AdminUserListItem | null = null
let message: string | null = null
let actionState: 'idle' | 'assigning' | 'removing' | 'saving-plan' = 'idle'
let actionState:
| 'idle'
| 'assigning'
| 'removing'
| 'saving-plan'
| 'moderating' = 'idle'
let selectedRoleToAssign = 'user' as RoleName
// Draft follows the selected user (see the render body) until the admin
// edits it. Null stored plan values are shown/saved as `free`.
Expand Down Expand Up @@ -559,6 +564,59 @@ export function AdminUsersRoute(handle: Handle) {
}
}

async function submitModerationAction(
action: 'suspend_user' | 'unsuspend_user' | 'resume_email_outbound',
) {
const href = getCurrentHref()
const selectedUser = resolveSelectedUser(href)
if (!selectedUser || actionState !== 'idle') return
actionState = 'moderating'
message = null
handle.update()
try {
// Carry filters + selected so the mutation response refreshes the
// same list window and selectedUser fallback the UI is showing.
const response = await fetch(buildAdminUsersApiRequestUrl(href), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
action,
userId: selectedUser.id,
}),
})
if (response.status === 401) {
window.location.assign('/login')
return
}
const payload = await readJson<
AdminUsersMutationData & { ok?: boolean; error?: string }
>(response)
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || 'Unable to update the account.')
}
applyMutationPayload(payload, href)
lastLoadedDataKey = getDataKey(href)
message =
action === 'suspend_user'
? 'Account suspended.'
: action === 'unsuspend_user'
? 'Account suspension cleared.'
: 'Outbound email resumed.'
status = 'ready'
actionState = 'idle'
handle.update()
} catch (error) {
actionState = 'idle'
message =
error instanceof Error ? error.message : 'Unable to update the account.'
handle.update()
}
}

const primaryButtonCss = getPrimaryButtonCss()
const secondaryButtonCss = getSecondaryButtonCss()

Expand Down Expand Up @@ -827,6 +885,18 @@ export function AdminUsersRoute(handle: Handle) {
label: 'Plan',
value: selectedUser.plan ?? 'free',
},
{
label: 'Suspended',
value: selectedUser.suspended_at
? formatTimestamp(selectedUser.suspended_at)
: 'No',
},
{
label: 'Outbound email',
value: selectedUser.email_outbound_paused_at
? `Paused ${formatTimestamp(selectedUser.email_outbound_paused_at)}`
: 'Active',
},
{
label: 'Created',
value: formatTimestamp(selectedUser.created_at),
Expand Down Expand Up @@ -944,6 +1014,72 @@ export function AdminUsersRoute(handle: Handle) {
</button>
</div>
</AccountManagementPanel>
<AccountManagementPanel
title="Moderation"
description="Suspension blocks the account at the session, MCP, and email chokepoints. The outbound-email pause is set automatically after spam complaints or repeated bounces."
>
<div
mix={css({
display: 'flex',
gap: spacing.md,
flexWrap: 'wrap',
})}
>
{selectedUser.suspended_at ? (
<button
type="button"
disabled={isMutating}
mix={[
on(
'click',
() => void submitModerationAction('unsuspend_user'),
),
css(primaryButtonCss),
]}
>
{actionState === 'moderating'
? 'Working…'
: 'Clear suspension'}
</button>
) : (
<button
type="button"
disabled={isMutating}
mix={[
on(
'click',
() => void submitModerationAction('suspend_user'),
),
css(secondaryButtonCss),
]}
>
{actionState === 'moderating'
? 'Working…'
: 'Suspend account'}
</button>
)}
{selectedUser.email_outbound_paused_at ? (
<button
type="button"
disabled={isMutating}
mix={[
on(
'click',
() =>
void submitModerationAction(
'resume_email_outbound',
),
),
css(secondaryButtonCss),
]}
>
{actionState === 'moderating'
? 'Working…'
: 'Resume outbound email'}
</button>
) : null}
</div>
</AccountManagementPanel>
<AccountManagementPanel
title="Usage & quotas"
description="Metered usage rollups and entitlement consumption for this account. Warnings appear above 80% of a numeric limit."
Expand Down
10 changes: 10 additions & 0 deletions packages/worker/migrations/0091-user-abuse-controls.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Platform abuse controls on user accounts:
-- - suspended_at: operator-set platform suspension. A suspended account is
-- blocked at the browser session, MCP, and email chokepoints until an
-- admin clears it.
-- - email_outbound_paused_at: automatic outbound-email pause set when
-- provider delivery events report a spam complaint or repeated bounces,
-- protecting the shared user email domain's sender reputation. Cleared
-- by an admin after review.
ALTER TABLE users ADD COLUMN suspended_at TEXT;
ALTER TABLE users ADD COLUMN email_outbound_paused_at TEXT;
Loading
Loading