Skip to content

Send new events and identity metadata to the abuse service #3288

@jrf0110

Description

@jrf0110

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:

  1. A new POST /api/events envelope endpoint that takes a batch of typed cloud events.
  2. 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

  • New reportEvents (or equivalent) helper added next to the existing classifyRequest / reportCost / reportAuthEvent helpers, hitting POST /api/events.
  • Each of the 17 event types is emitted from at least the most obvious call site in the cloud codebase. Test coverage matches the existing abuse-service helpers' coverage style.
  • AuthEventPayload type extended with the new optional fields; the existing signup and signin emit paths populate every field they have in-scope.
  • No regressions to the three existing endpoints (/api/classify, /api/usage/cost, /api/auth-event).
  • All new code paths swallow errors and never block user-facing flows, matching the existing pattern.

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions