From 01f8ad1a8847a60bfbe6847ad46276400af5f5e8 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 11:33:50 +0000 Subject: [PATCH 1/3] fix(users): preserve derived email invariants --- .../backfills/normalized-email/route.test.ts | 68 +++++++++++++++++++ .../api/backfills/normalized-email/route.ts | 15 +++- .../web/src/lib/bot-users/bot-user-service.ts | 4 ++ .../webhook-agent-ingest/src/db/queries.ts | 5 +- 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts diff --git a/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts b/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts new file mode 100644 index 0000000000..57fa498e5b --- /dev/null +++ b/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable drizzle/enforce-delete-with-where */ +import { db } from '@/lib/drizzle'; +import { kilocode_users } from '@kilocode/db'; +import { eq } from 'drizzle-orm'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { softDeleteUser } from '@/lib/user'; +import { normalizedEmailBackfillCandidates } from './route'; + +describe('normalizedEmailBackfillCandidates', () => { + afterEach(async () => { + await db.delete(kilocode_users); + }); + + it('includes users that are missing normalized_email', async () => { + const user = await insertTestUser({ normalized_email: null }); + + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(normalizedEmailBackfillCandidates); + + expect(rows.map(r => r.id)).toContain(user.id); + }); + + it('excludes users that already have normalized_email set', async () => { + const user = await insertTestUser({ normalized_email: 'user@example.com' }); + + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(normalizedEmailBackfillCandidates); + + expect(rows.map(r => r.id)).not.toContain(user.id); + }); + + it('excludes soft-deleted users so the GDPR normalized_email=null invariant is preserved', async () => { + const user = await insertTestUser({ normalized_email: 'user@example.com' }); + + await softDeleteUser(user.id); + const softDeleted = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); + expect(softDeleted[0].normalized_email).toBeNull(); + expect(softDeleted[0].blocked_reason).toMatch(/^soft-deleted at /); + + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(normalizedEmailBackfillCandidates); + + expect(rows.map(r => r.id)).not.toContain(user.id); + }); + + it('still includes users blocked for other reasons', async () => { + const user = await insertTestUser({ + normalized_email: null, + blocked_reason: 'domainblocked', + }); + + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(normalizedEmailBackfillCandidates); + + expect(rows.map(r => r.id)).toContain(user.id); + }); +}); diff --git a/apps/web/src/app/admin/api/backfills/normalized-email/route.ts b/apps/web/src/app/admin/api/backfills/normalized-email/route.ts index de66413c21..d966c97072 100644 --- a/apps/web/src/app/admin/api/backfills/normalized-email/route.ts +++ b/apps/web/src/app/admin/api/backfills/normalized-email/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { db } from '@/lib/drizzle'; import { kilocode_users } from '@kilocode/db'; -import { isNull, count, sql } from 'drizzle-orm'; +import { and, isNull, count, not, or, sql, like } from 'drizzle-orm'; import { normalizeEmail } from '@/lib/utils'; export type NormalizedEmailCountsResponse = { @@ -14,6 +14,15 @@ export type NormalizedEmailBackfillResponse = { remaining: boolean; }; +// Exclude GDPR-deleted users whose derived email values are intentionally cleared. +export const normalizedEmailBackfillCandidates = and( + isNull(kilocode_users.normalized_email), + or( + isNull(kilocode_users.blocked_reason), + not(like(kilocode_users.blocked_reason, 'soft-deleted at %')) + ) +); + export async function GET(): Promise< NextResponse > { @@ -23,7 +32,7 @@ export async function GET(): Promise< const [result] = await db .select({ count: count() }) .from(kilocode_users) - .where(isNull(kilocode_users.normalized_email)); + .where(normalizedEmailBackfillCandidates); return NextResponse.json({ missing: result?.count ?? 0 }); } @@ -43,7 +52,7 @@ export async function POST(): Promise< const rows = await db .select({ id: kilocode_users.id, google_user_email: kilocode_users.google_user_email }) .from(kilocode_users) - .where(isNull(kilocode_users.normalized_email)) + .where(normalizedEmailBackfillCandidates) .limit(BATCH_SIZE); if (rows.length === 0) break; diff --git a/apps/web/src/lib/bot-users/bot-user-service.ts b/apps/web/src/lib/bot-users/bot-user-service.ts index 493069de4e..ba458d1a4b 100644 --- a/apps/web/src/lib/bot-users/bot-user-service.ts +++ b/apps/web/src/lib/bot-users/bot-user-service.ts @@ -5,6 +5,8 @@ import { kilocode_users, organization_memberships, type User } from '@kilocode/d import { eq, and } from 'drizzle-orm'; import { captureException } from '@sentry/nextjs'; import { logExceptInTest, errorExceptInTest } from '@/lib/utils.server'; +import { normalizeEmail } from '@/lib/utils'; +import { extractEmailDomain } from '@/lib/email-domain'; import crypto from 'crypto'; import type { BotType } from './types'; import { generateBotUserId, generateBotUserEmail, getBotDisplayName } from './types'; @@ -53,6 +55,8 @@ async function createBotUser(organizationId: string, botType: BotType): Promise< .values({ id: botId, google_user_email: botEmail, + normalized_email: normalizeEmail(botEmail), + email_domain: extractEmailDomain(botEmail), google_user_name: botName, google_user_image_url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSIyNCIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPg==', // Gray circle placeholder diff --git a/services/webhook-agent-ingest/src/db/queries.ts b/services/webhook-agent-ingest/src/db/queries.ts index 45b9f93abb..a6b1bb2d5a 100644 --- a/services/webhook-agent-ingest/src/db/queries.ts +++ b/services/webhook-agent-ingest/src/db/queries.ts @@ -23,6 +23,7 @@ export type BotUserForToken = { const WEBHOOK_BOT_ID_PREFIX = 'bot-webhook'; const WEBHOOK_BOT_EMAIL_SUFFIX = 'webhook-bot'; const WEBHOOK_BOT_DISPLAY_NAME = 'Webhook Bot'; +const BOT_EMAIL_DOMAIN = 'kilocode.internal'; const BOT_AVATAR_PLACEHOLDER = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSIyNCIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPg=='; @@ -31,7 +32,7 @@ export function generateBotUserId(organizationId: string): string { } export function generateBotUserEmail(organizationId: string): string { - return `${WEBHOOK_BOT_EMAIL_SUFFIX}-${organizationId}@kilocode.internal`; + return `${WEBHOOK_BOT_EMAIL_SUFFIX}-${organizationId}@${BOT_EMAIL_DOMAIN}`; } function generateApiTokenPepper(): string { @@ -145,6 +146,8 @@ export async function ensureBotUserForOrg(db: WorkerDb, orgId: string): Promise< await db.insert(kilocode_users).values({ id: botId, google_user_email: botEmail, + normalized_email: botEmail, + email_domain: BOT_EMAIL_DOMAIN, google_user_name: WEBHOOK_BOT_DISPLAY_NAME, google_user_image_url: BOT_AVATAR_PLACEHOLDER, stripe_customer_id: stripeCustomerId, From 566f8e19075371975a815241e64672be5df782cd Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 11:48:42 +0000 Subject: [PATCH 2/3] fix(users): retain derived deletion tombstones --- .../api/backfills/email-domain/route.test.ts | 21 +++++++++++-- .../admin/api/backfills/email-domain/route.ts | 14 ++------- .../backfills/normalized-email/route.test.ts | 27 +++++++++++++++-- .../api/backfills/normalized-email/route.ts | 30 ++++++++++--------- apps/web/src/lib/user/deleted-email.ts | 13 ++++++++ apps/web/src/lib/user/index.test.ts | 8 ++--- apps/web/src/lib/user/index.ts | 14 +++++---- .../webhook-agent-ingest/src/db/queries.ts | 6 ++-- 8 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/lib/user/deleted-email.ts diff --git a/apps/web/src/app/admin/api/backfills/email-domain/route.test.ts b/apps/web/src/app/admin/api/backfills/email-domain/route.test.ts index bdfcdb57d9..461517bcad 100644 --- a/apps/web/src/app/admin/api/backfills/email-domain/route.test.ts +++ b/apps/web/src/app/admin/api/backfills/email-domain/route.test.ts @@ -33,7 +33,7 @@ describe('emailDomainBackfillCandidates', () => { expect(rows.map(r => r.id)).not.toContain(user.id); }); - it('excludes soft-deleted users so the GDPR email_domain=null invariant is preserved', async () => { + it('does not select newly soft-deleted users because their tombstone domain is stored', async () => { const user = await insertTestUser({ email_domain: 'example.com' }); await softDeleteUser(user.id); @@ -41,7 +41,7 @@ describe('emailDomainBackfillCandidates', () => { .select() .from(kilocode_users) .where(eq(kilocode_users.id, user.id)); - expect(softDeleted[0].email_domain).toBeNull(); + expect(softDeleted[0].email_domain).toBe('deleted.invalid'); expect(softDeleted[0].blocked_reason).toMatch(/^soft-deleted at /); const rows = await db @@ -52,6 +52,23 @@ describe('emailDomainBackfillCandidates', () => { expect(rows.map(r => r.id)).not.toContain(user.id); }); + it('includes legacy soft-deleted users missing a tombstone domain', async () => { + const userId = 'legacy-deleted-user'; + const user = await insertTestUser({ + id: userId, + google_user_email: `deleted+${userId}@deleted.invalid`, + email_domain: null, + blocked_reason: 'soft-deleted at 2026-01-15T12:00:00.000Z', + }); + + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(emailDomainBackfillCandidates); + + expect(rows.map(r => r.id)).toContain(user.id); + }); + it('still includes users blocked for other reasons', async () => { const user = await insertTestUser({ email_domain: null, diff --git a/apps/web/src/app/admin/api/backfills/email-domain/route.ts b/apps/web/src/app/admin/api/backfills/email-domain/route.ts index 8d00e869d6..2a44fb6948 100644 --- a/apps/web/src/app/admin/api/backfills/email-domain/route.ts +++ b/apps/web/src/app/admin/api/backfills/email-domain/route.ts @@ -2,20 +2,10 @@ import { NextResponse } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { db } from '@/lib/drizzle'; import { kilocode_users } from '@kilocode/db'; -import { and, isNull, count, not, or, sql, like } from 'drizzle-orm'; +import { isNull, count, sql } from 'drizzle-orm'; import { extractEmailDomain } from '@/lib/email-domain'; -// Exclude soft-deleted users: softDeleteUser anonymizes them to -// `deleted+@deleted.invalid` and sets `blocked_reason` to a string starting -// with `soft-deleted at`. Filling email_domain for those rows would undo the -// GDPR nulling invariant. -export const emailDomainBackfillCandidates = and( - isNull(kilocode_users.email_domain), - or( - isNull(kilocode_users.blocked_reason), - not(like(kilocode_users.blocked_reason, 'soft-deleted at %')) - ) -); +export const emailDomainBackfillCandidates = isNull(kilocode_users.email_domain); export type EmailDomainCountsResponse = { missing: number; diff --git a/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts b/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts index 57fa498e5b..09a49b4fae 100644 --- a/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts +++ b/apps/web/src/app/admin/api/backfills/normalized-email/route.test.ts @@ -4,6 +4,7 @@ import { kilocode_users } from '@kilocode/db'; import { eq } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { softDeleteUser } from '@/lib/user'; +import { canonicalizeDeletedUserEmail } from '@/lib/user/deleted-email'; import { normalizedEmailBackfillCandidates } from './route'; describe('normalizedEmailBackfillCandidates', () => { @@ -33,7 +34,7 @@ describe('normalizedEmailBackfillCandidates', () => { expect(rows.map(r => r.id)).not.toContain(user.id); }); - it('excludes soft-deleted users so the GDPR normalized_email=null invariant is preserved', async () => { + it('does not select newly soft-deleted users because their tombstone email is stored', async () => { const user = await insertTestUser({ normalized_email: 'user@example.com' }); await softDeleteUser(user.id); @@ -41,7 +42,8 @@ describe('normalizedEmailBackfillCandidates', () => { .select() .from(kilocode_users) .where(eq(kilocode_users.id, user.id)); - expect(softDeleted[0].normalized_email).toBeNull(); + expect(softDeleted[0].google_user_email).toBe(`deleted-${user.id}@deleted.invalid`); + expect(softDeleted[0].normalized_email).toBe(`deleted-${user.id}@deleted.invalid`); expect(softDeleted[0].blocked_reason).toMatch(/^soft-deleted at /); const rows = await db @@ -52,6 +54,27 @@ describe('normalizedEmailBackfillCandidates', () => { expect(rows.map(r => r.id)).not.toContain(user.id); }); + it('selects and canonicalizes legacy plus-addressed deletion tombstones', async () => { + const userId = 'legacy-deleted-user'; + const legacyEmail = `deleted+${userId}@deleted.invalid`; + const user = await insertTestUser({ + id: userId, + google_user_email: legacyEmail, + normalized_email: 'deleted@deleted.invalid', + blocked_reason: 'soft-deleted at 2026-01-15T12:00:00.000Z', + }); + + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(normalizedEmailBackfillCandidates); + + expect(rows.map(r => r.id)).toContain(user.id); + expect(canonicalizeDeletedUserEmail(user.id, legacyEmail)).toBe( + `deleted-${user.id}@deleted.invalid` + ); + }); + it('still includes users blocked for other reasons', async () => { const user = await insertTestUser({ normalized_email: null, diff --git a/apps/web/src/app/admin/api/backfills/normalized-email/route.ts b/apps/web/src/app/admin/api/backfills/normalized-email/route.ts index d966c97072..0aaf90966c 100644 --- a/apps/web/src/app/admin/api/backfills/normalized-email/route.ts +++ b/apps/web/src/app/admin/api/backfills/normalized-email/route.ts @@ -2,8 +2,9 @@ import { NextResponse } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { db } from '@/lib/drizzle'; import { kilocode_users } from '@kilocode/db'; -import { and, isNull, count, not, or, sql, like } from 'drizzle-orm'; +import { isNull, count, or, sql } from 'drizzle-orm'; import { normalizeEmail } from '@/lib/utils'; +import { canonicalizeDeletedUserEmail } from '@/lib/user/deleted-email'; export type NormalizedEmailCountsResponse = { missing: number; @@ -14,13 +15,9 @@ export type NormalizedEmailBackfillResponse = { remaining: boolean; }; -// Exclude GDPR-deleted users whose derived email values are intentionally cleared. -export const normalizedEmailBackfillCandidates = and( +export const normalizedEmailBackfillCandidates = or( isNull(kilocode_users.normalized_email), - or( - isNull(kilocode_users.blocked_reason), - not(like(kilocode_users.blocked_reason, 'soft-deleted at %')) - ) + sql`${kilocode_users.google_user_email} = 'deleted+' || ${kilocode_users.id} || '@deleted.invalid'` ); export async function GET(): Promise< @@ -57,18 +54,23 @@ export async function POST(): Promise< if (rows.length === 0) break; - const updates = rows.map(row => ({ - id: row.id, - normalized_email: normalizeEmail(row.google_user_email), - })); + const updates = rows.map(row => { + const email = canonicalizeDeletedUserEmail(row.id, row.google_user_email); + return { + id: row.id, + google_user_email: email, + normalized_email: normalizeEmail(email), + }; + }); await db.execute(sql` UPDATE ${kilocode_users} - SET normalized_email = email_updates.normalized_email + SET google_user_email = email_updates.google_user_email, + normalized_email = email_updates.normalized_email FROM (VALUES ${sql.join( - updates.map(u => sql`(${u.id}, ${u.normalized_email})`), + updates.map(u => sql`(${u.id}, ${u.google_user_email}, ${u.normalized_email})`), sql`, ` - )}) AS email_updates(id, normalized_email) + )}) AS email_updates(id, google_user_email, normalized_email) WHERE ${kilocode_users.id} = email_updates.id `); diff --git a/apps/web/src/lib/user/deleted-email.ts b/apps/web/src/lib/user/deleted-email.ts new file mode 100644 index 0000000000..42f9f6d026 --- /dev/null +++ b/apps/web/src/lib/user/deleted-email.ts @@ -0,0 +1,13 @@ +const DELETED_USER_EMAIL_DOMAIN = 'deleted.invalid'; + +export function getDeletedUserEmail(userId: string): string { + return `deleted-${userId}@${DELETED_USER_EMAIL_DOMAIN}`; +} + +export function getLegacyDeletedUserEmail(userId: string): string { + return `deleted+${userId}@${DELETED_USER_EMAIL_DOMAIN}`; +} + +export function canonicalizeDeletedUserEmail(userId: string, email: string): string { + return email === getLegacyDeletedUserEmail(userId) ? getDeletedUserEmail(userId) : email; +} diff --git a/apps/web/src/lib/user/index.test.ts b/apps/web/src/lib/user/index.test.ts index e616d917b8..f62bed1a31 100644 --- a/apps/web/src/lib/user/index.test.ts +++ b/apps/web/src/lib/user/index.test.ts @@ -417,9 +417,9 @@ describe('User', () => { const softDeleted = await findUserById(user.id); expect(softDeleted).toBeDefined(); - expect(softDeleted!.google_user_email).toBe(`deleted+${user.id}@deleted.invalid`); - expect(softDeleted!.normalized_email).toBeNull(); - expect(softDeleted!.email_domain).toBeNull(); + expect(softDeleted!.google_user_email).toBe(`deleted-${user.id}@deleted.invalid`); + expect(softDeleted!.normalized_email).toBe(`deleted-${user.id}@deleted.invalid`); + expect(softDeleted!.email_domain).toBe('deleted.invalid'); expect(softDeleted!.google_user_name).toBe('Deleted User'); expect(softDeleted!.google_user_image_url).toBe(''); expect(softDeleted!.hosted_domain).toBeNull(); @@ -1235,7 +1235,7 @@ describe('User', () => { const anonymized = rows.find(row => row.benchEvalName === 'soft-delete-promoter-eval'); const retained = rows.find(row => row.benchEvalName === 'retained-promoter-eval'); - expect(anonymized?.promoterEmail).toBe(`deleted+${promoter.id}@deleted.invalid`); + expect(anonymized?.promoterEmail).toBe(`deleted-${promoter.id}@deleted.invalid`); expect(retained?.promoterEmail).toBe(otherPromoter.google_user_email); }); diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index 7d0be868ec..6843d032c8 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -96,6 +96,7 @@ import { } from '@/lib/ai-gateway/providerHash'; import { normalizeEmail } from '@/lib/utils'; import { extractEmailDomain } from '@/lib/email-domain'; +import { getDeletedUserEmail } from './deleted-email'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/impact/affiliate-events'; import { logImpactReferralDebug } from '@/lib/impact/debug'; import { @@ -790,7 +791,7 @@ export class SoftDeletePreconditionError extends Error { * organization_id references the organization — no direct PII) * * What is scrubbed/deleted: - * - PII on the user row (email, name, avatar, urls) + * - PII on the user row (email replaced with a synthetic tombstone; name, avatar, urls cleared) * - user_auth_provider (auth links with email/avatar) * - enrichment_data (GitHub/LinkedIn/Clay PII) * - user_admin_notes @@ -891,12 +892,13 @@ export async function softDeleteUser(userId: string) { }); // ── 1. Anonymize the user row ──────────────────────────────────────── + const deletedEmail = getDeletedUserEmail(userId); await tx .update(kilocode_users) .set({ - google_user_email: `deleted+${userId}@deleted.invalid`, - normalized_email: null, - email_domain: null, + google_user_email: deletedEmail, + normalized_email: normalizeEmail(deletedEmail), + email_domain: extractEmailDomain(deletedEmail), google_user_name: 'Deleted User', google_user_image_url: '', hosted_domain: null, @@ -1165,7 +1167,7 @@ export async function softDeleteUser(userId: string) { await tx .update(model_eval_ingestions) - .set({ promoted_by_email: `deleted+${userId}@deleted.invalid` }) + .set({ promoted_by_email: deletedEmail }) .where(sql`lower(${model_eval_ingestions.promoted_by_email}) = lower(${originalEmail})`); // Credit campaigns: strip the creator-admin reference. The campaigns @@ -1211,7 +1213,7 @@ export async function softDeleteUser(userId: string) { ); // Also clear events matched by email directly (covers un-enrolled contributors). // Use originalEmail captured before the user row was anonymized — the subquery - // would resolve to the already-overwritten deleted+@deleted.invalid address. + // would resolve to the already-overwritten synthetic deletion address. await tx .update(contributor_champion_events) .set({ github_author_email: null }) diff --git a/services/webhook-agent-ingest/src/db/queries.ts b/services/webhook-agent-ingest/src/db/queries.ts index a6b1bb2d5a..b1857629d5 100644 --- a/services/webhook-agent-ingest/src/db/queries.ts +++ b/services/webhook-agent-ingest/src/db/queries.ts @@ -23,7 +23,9 @@ export type BotUserForToken = { const WEBHOOK_BOT_ID_PREFIX = 'bot-webhook'; const WEBHOOK_BOT_EMAIL_SUFFIX = 'webhook-bot'; const WEBHOOK_BOT_DISPLAY_NAME = 'Webhook Bot'; -const BOT_EMAIL_DOMAIN = 'kilocode.internal'; +const BOT_EMAIL_HOST = 'kilocode.internal'; +// Matches the web app's extractEmailDomain fallback for the internal host. +const BOT_EMAIL_DOMAIN = 'kilocode.internal.invalid'; const BOT_AVATAR_PLACEHOLDER = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSIyNCIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPg=='; @@ -32,7 +34,7 @@ export function generateBotUserId(organizationId: string): string { } export function generateBotUserEmail(organizationId: string): string { - return `${WEBHOOK_BOT_EMAIL_SUFFIX}-${organizationId}@${BOT_EMAIL_DOMAIN}`; + return `${WEBHOOK_BOT_EMAIL_SUFFIX}-${organizationId}@${BOT_EMAIL_HOST}`; } function generateApiTokenPepper(): string { From 2cf3765bd3354d74b01303ac1be8870a91934f4b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 11:54:43 +0000 Subject: [PATCH 3/3] fix(users): align webhook bot email domain --- apps/web/src/lib/email-domain.test.ts | 1 + services/webhook-agent-ingest/src/db/queries.ts | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/email-domain.test.ts b/apps/web/src/lib/email-domain.test.ts index 0341e58aa2..ec71825784 100644 --- a/apps/web/src/lib/email-domain.test.ts +++ b/apps/web/src/lib/email-domain.test.ts @@ -48,6 +48,7 @@ describe('extractEmailDomain', () => { // tldts treats the final label as a public suffix when unknown. expect(extractEmailDomain('alice@host.madeuptld')).toBe('host.madeuptld'); expect(extractEmailDomain('alice@sub.host.madeuptld')).toBe('host.madeuptld'); + expect(extractEmailDomain('bot@kilocode.internal')).toBe('kilocode.internal'); }); it('falls back to `.invalid` when tldts cannot resolve a registrable domain (e.g. IP)', () => { diff --git a/services/webhook-agent-ingest/src/db/queries.ts b/services/webhook-agent-ingest/src/db/queries.ts index b1857629d5..a6b1bb2d5a 100644 --- a/services/webhook-agent-ingest/src/db/queries.ts +++ b/services/webhook-agent-ingest/src/db/queries.ts @@ -23,9 +23,7 @@ export type BotUserForToken = { const WEBHOOK_BOT_ID_PREFIX = 'bot-webhook'; const WEBHOOK_BOT_EMAIL_SUFFIX = 'webhook-bot'; const WEBHOOK_BOT_DISPLAY_NAME = 'Webhook Bot'; -const BOT_EMAIL_HOST = 'kilocode.internal'; -// Matches the web app's extractEmailDomain fallback for the internal host. -const BOT_EMAIL_DOMAIN = 'kilocode.internal.invalid'; +const BOT_EMAIL_DOMAIN = 'kilocode.internal'; const BOT_AVATAR_PLACEHOLDER = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSIyNCIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPg=='; @@ -34,7 +32,7 @@ export function generateBotUserId(organizationId: string): string { } export function generateBotUserEmail(organizationId: string): string { - return `${WEBHOOK_BOT_EMAIL_SUFFIX}-${organizationId}@${BOT_EMAIL_HOST}`; + return `${WEBHOOK_BOT_EMAIL_SUFFIX}-${organizationId}@${BOT_EMAIL_DOMAIN}`; } function generateApiTokenPepper(): string {