Overview
The abuse service (abuse.kiloapps.io) recently added a generic event-ingest endpoint and expanded the existing auth-event payload. Cloud already calls /api/classify, /api/auth-event, and /api/usage/cost via apps/web/src/lib/ai-gateway/abuse-service.ts. This issue tracks wiring up the rest of what the abuse service expects.
There are two pieces:
- A new
POST /api/events envelope endpoint that takes a batch of typed cloud events.
- A larger
POST /api/auth-event payload with additional optional identity-metadata fields.
Everything is fire-and-forget from cloud's perspective. Failures are logged but never block the user-facing flow, same convention as the existing abuse-service helpers.
1. New endpoint: POST /api/events
Add a new helper alongside classifyRequest / reportCost / reportAuthEvent in apps/web/src/lib/ai-gateway/abuse-service.ts.
Envelope:
type EventsBatchPayload = {
events: CloudEvent[];
};
type CloudEvent =
| { type: 'user.blocked'; occurred_at?: number; data: UserEventData }
| { type: 'user.unblocked'; occurred_at?: number; data: UserEventData }
| { type: 'user.deleted'; occurred_at?: number; data: { kilo_user_id: string } }
| { type: 'user.email_changed'; occurred_at?: number; data: UserEmailChangedData }
| { type: 'org.member_added'; occurred_at?: number; data: OrgEventData }
| { type: 'org.member_removed'; occurred_at?: number; data: OrgEventData }
| { type: 'org.created'; occurred_at?: number; data: OrgEventData }
| { type: 'org.deleted'; occurred_at?: number; data: OrgEventData }
| { type: 'billing.credit_purchased'; occurred_at?: number; data: BillingCreditPurchasedData }
| { type: 'billing.kilo_pass_changed'; occurred_at?: number; data: BillingKiloPassChangedData }
| { type: 'stripe.payment_method.attached'; occurred_at?: number; data: StripeEventData }
| { type: 'stripe.payment_method.detached'; occurred_at?: number; data: StripeEventData }
| { type: 'stripe.charge.dispute.created'; occurred_at?: number; data: StripeEventData }
| { type: 'stripe.charge.dispute.funds_withdrawn'; occurred_at?: number; data: StripeEventData }
| { type: 'stripe.radar.early_fraud_warning.created'; occurred_at?: number; data: StripeEventData }
| { type: 'stripe.charge.failed'; occurred_at?: number; data: StripeEventData }
| { type: 'stripe.payment_intent.succeeded'; occurred_at?: number; data: StripeEventData };
type UserEventData = {
kilo_user_id: string;
reason?: string | null;
actor_email?: string | null; // who performed the action, if applicable
};
type UserEmailChangedData = {
kilo_user_id: string;
previous_email?: string | null;
email: string; // the new email
};
type OrgEventData = {
kilo_user_id: string;
organization_id: string;
role?: string | null;
plan?: string | null;
has_sso?: boolean;
in_free_trial?: boolean;
};
type BillingCreditPurchasedData = {
kilo_user_id: string;
microdollars_acquired: number;
total_microdollars_acquired?: number; // running lifetime total after this purchase
};
type BillingKiloPassChangedData = {
kilo_user_id: string;
tier?: string | null; // e.g. 'free', 'pro'
status?: string | null; // e.g. 'active', 'cancelled'
streak_months?: number;
};
// Mirror the relevant subset of the Stripe webhook event object
type StripeEventData = {
id?: string;
type?: string;
customer?: string | { id: string } | null;
data?: { object?: { amount?: number; customer?: ...; decline_code?: string; [k: string]: unknown } };
decline_code?: string;
amount?: number;
// Passthrough — additional fields are accepted
};
occurred_at is an optional epoch-ms timestamp; if omitted the abuse service stamps it on receipt.
The endpoint accepts a batch ({ events: [...] }). Cloud can either send one event per call or batch up to a few hundred per call — both work.
Auth & headers
Same as the existing endpoints: send the CF-Access-Client-Id and CF-Access-Client-Secret headers from ABUSE_SERVICE_CF_ACCESS_CLIENT_ID / ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET. Reuse fetchAbuseService(...).
Suggested API
export async function reportEvents(payload: EventsBatchPayload): Promise<void> {
await fetchAbuseService('/api/events', payload, 'events');
}
Where to emit each event
Best-effort mapping; verify each call site matches the cloud codebase's actual flow:
| Event |
Suggested call site |
user.blocked / user.unblocked |
Wherever an admin / automated flow sets the user's blocked state |
user.deleted |
User deletion flow |
user.email_changed |
After an email change is committed |
org.member_added / org.member_removed |
Membership mutations |
org.created / org.deleted |
Org lifecycle mutations |
billing.credit_purchased |
Successful credit purchase (post-Stripe charge or post-grant) |
billing.kilo_pass_changed |
Tier / status / streak transitions |
stripe.* |
The existing Stripe webhook handlers — emit one event per relevant Stripe webhook type, reusing the original event JSON for data |
Cloud is the source of truth for which user is which. Each event payload includes kilo_user_id so the abuse service can route it to the right per-identity record.
Forward-compatibility
The abuse service accepts unknown type values too — it logs them once and moves on. Cloud can ship new event types ahead of the abuse service if needed; nothing breaks.
2. Expanded POST /api/auth-event payload
The existing AuthEventPayload type in apps/web/src/lib/ai-gateway/abuse-service.ts is a strict subset of what the abuse service now accepts. Adding the new fields lets the abuse service do its job without us having to backfill identity metadata via separate events.
All new fields are optional; existing payloads continue to work. Send whatever you have at signup/signin time.
export type AuthEventPayload = {
// --- existing fields (unchanged) ---
kilo_user_id: string;
event_type: 'signup' | 'signin';
email: string;
account_created_at?: string; // ISO 8601, was required, now optional in schema
ip_address?: string | null;
geo_city?: string | null;
geo_country?: string | null;
ja4_digest?: string | null;
user_agent?: string | null;
auth_method?: AuthProviderId | null;
stytch_session_id?: string | null;
// --- NEW: user.* metadata ---
hosted_domain?: string | null; // Google-Workspace-style hosted domain
signup_ip?: string | null; // Stable snapshot, not the current request IP
signup_ja4_digest?: string | null;
signup_geo_country?: string | null;
customer_source?: string | null; // Product/campaign that brought the user in
is_bot?: boolean | null;
is_admin?: boolean | null;
is_blocked?: boolean | null;
completed_welcome_form?: boolean | null;
has_linkedin_url?: boolean | null;
has_github_url?: boolean | null;
has_discord_verified?: boolean | null;
cohorts?: string[] | null; // Cohort membership names
// --- NEW: auth.* metadata ---
has_validation_stytch?: boolean | null;
has_validation_novel_card_with_hold?: boolean | null;
stytch_verdict_action?: string | null;
stytch_is_authentic_device?: boolean | null;
stytch_device_type?: string | null;
stytch_hardware_fingerprint?: string | null;
auth_providers?: string[] | null; // Distinct providers this user has linked
// --- NEW: org.* metadata ---
org_memberships?: Array<{
organization_id: string;
role?: string | null;
plan?: string | null;
has_sso?: boolean | null;
in_free_trial?: boolean | null;
}> | null;
};
Call sites: reportAuthEvent is already invoked from the signup / signin flows (currently in apps/web/src/lib/user.ts per-grep). The change is purely additive — populate the new fields with whatever's available in scope at call time and pass them through.
For signin events specifically: it's worth re-sending the metadata fields that may have changed since signup (is_blocked, is_admin, cohorts, org_memberships, etc.) so the abuse service stays current without needing a separate sync path.
Implementation notes
- All calls remain fire-and-forget. The
fetchAbuseService(...) helper already swallows errors and returns null. New helpers should follow the same pattern.
ABUSE_SERVICE_URL may be unset in non-production. The existing helpers no-op when it isn't configured; new helpers should too.
- Batching is optional. A single-event batch is fine; the abuse service handles either shape. Don't build a queue/buffer unless it falls naturally out of an existing pattern.
occurred_at should be the epoch-ms timestamp of the underlying business event when known (e.g. Stripe webhook.created * 1000), otherwise omit and let the abuse service stamp it.
- Stripe events can pass the relevant slice of the Stripe webhook event object straight through as
data — the abuse-service schema is intentionally permissive (passthrough) on Stripe-shaped data.
Acceptance criteria
References
- Existing client:
apps/web/src/lib/ai-gateway/abuse-service.ts
- Existing call sites:
apps/web/src/lib/user.ts (auth events), apps/web/src/lib/ai-gateway/processUsage.ts (cost), apps/web/src/app/api/openrouter/[...path]/route.ts (classify)
Overview
The abuse service (
abuse.kiloapps.io) recently added a generic event-ingest endpoint and expanded the existing auth-event payload. Cloud already calls/api/classify,/api/auth-event, and/api/usage/costviaapps/web/src/lib/ai-gateway/abuse-service.ts. This issue tracks wiring up the rest of what the abuse service expects.There are two pieces:
POST /api/eventsenvelope endpoint that takes a batch of typed cloud events.POST /api/auth-eventpayload with additional optional identity-metadata fields.Everything is fire-and-forget from cloud's perspective. Failures are logged but never block the user-facing flow, same convention as the existing abuse-service helpers.
1. New endpoint:
POST /api/eventsAdd a new helper alongside
classifyRequest/reportCost/reportAuthEventinapps/web/src/lib/ai-gateway/abuse-service.ts.Envelope:
occurred_atis an optional epoch-ms timestamp; if omitted the abuse service stamps it on receipt.The endpoint accepts a batch (
{ events: [...] }). Cloud can either send one event per call or batch up to a few hundred per call — both work.Auth & headers
Same as the existing endpoints: send the
CF-Access-Client-IdandCF-Access-Client-Secretheaders fromABUSE_SERVICE_CF_ACCESS_CLIENT_ID/ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET. ReusefetchAbuseService(...).Suggested API
Where to emit each event
Best-effort mapping; verify each call site matches the cloud codebase's actual flow:
user.blocked/user.unblockeduser.deleteduser.email_changedorg.member_added/org.member_removedorg.created/org.deletedbilling.credit_purchasedbilling.kilo_pass_changedstripe.*dataCloud is the source of truth for which user is which. Each event payload includes
kilo_user_idso the abuse service can route it to the right per-identity record.Forward-compatibility
The abuse service accepts unknown
typevalues too — it logs them once and moves on. Cloud can ship new event types ahead of the abuse service if needed; nothing breaks.2. Expanded
POST /api/auth-eventpayloadThe existing
AuthEventPayloadtype inapps/web/src/lib/ai-gateway/abuse-service.tsis a strict subset of what the abuse service now accepts. Adding the new fields lets the abuse service do its job without us having to backfill identity metadata via separate events.All new fields are optional; existing payloads continue to work. Send whatever you have at signup/signin time.
Call sites:
reportAuthEventis already invoked from the signup / signin flows (currently inapps/web/src/lib/user.tsper-grep). The change is purely additive — populate the new fields with whatever's available in scope at call time and pass them through.For
signinevents specifically: it's worth re-sending the metadata fields that may have changed since signup (is_blocked,is_admin,cohorts,org_memberships, etc.) so the abuse service stays current without needing a separate sync path.Implementation notes
fetchAbuseService(...)helper already swallows errors and returnsnull. New helpers should follow the same pattern.ABUSE_SERVICE_URLmay be unset in non-production. The existing helpers no-op when it isn't configured; new helpers should too.occurred_atshould be the epoch-ms timestamp of the underlying business event when known (e.g.Stripe webhook.created * 1000), otherwise omit and let the abuse service stamp it.data— the abuse-service schema is intentionally permissive (passthrough) on Stripe-shaped data.Acceptance criteria
reportEvents(or equivalent) helper added next to the existingclassifyRequest/reportCost/reportAuthEventhelpers, hittingPOST /api/events.AuthEventPayloadtype extended with the new optional fields; the existingsignupandsigninemit paths populate every field they have in-scope./api/classify,/api/usage/cost,/api/auth-event).References
apps/web/src/lib/ai-gateway/abuse-service.tsapps/web/src/lib/user.ts(auth events),apps/web/src/lib/ai-gateway/processUsage.ts(cost),apps/web/src/app/api/openrouter/[...path]/route.ts(classify)