From e99e52dba30762ad404e782a059f71ccf78eabb2 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 18 Aug 2026 11:49:03 +0200 Subject: [PATCH 1/3] fix(web): issue revocable session viewer tokens --- .../block-blacklisted-domains/route.ts | 3 + apps/web/src/lib/abuse/bulkBlock.ts | 5 + .../lib/session-ingest-client-unset.test.ts | 44 +++++++++ .../web/src/lib/session-ingest-client.test.ts | 65 +++++++++++++ apps/web/src/lib/session-ingest-client.ts | 28 ++++++ .../web/src/lib/user/block-invalidate.test.ts | 92 +++++++++++++++++++ apps/web/src/lib/user/block.ts | 38 +++++++- apps/web/src/lib/user/index.ts | 10 ++ .../lib/user/soft-delete-invalidate.test.ts | 60 ++++++++++++ .../routers/active-sessions-router.test.ts | 22 +++++ 10 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/session-ingest-client-unset.test.ts create mode 100644 apps/web/src/lib/user/block-invalidate.test.ts create mode 100644 apps/web/src/lib/user/soft-delete-invalidate.test.ts diff --git a/apps/web/src/app/admin/api/backfills/block-blacklisted-domains/route.ts b/apps/web/src/app/admin/api/backfills/block-blacklisted-domains/route.ts index 1cce866c1f..d12724519e 100644 --- a/apps/web/src/app/admin/api/backfills/block-blacklisted-domains/route.ts +++ b/apps/web/src/app/admin/api/backfills/block-blacklisted-domains/route.ts @@ -114,6 +114,9 @@ export async function backfillBlockBlacklistedDomainsBatch(params: { ) .returning({ id: kilocode_users.id }); + // This bulk backfill intentionally relies on the session-ingest auth-cache + // TTL plus KV propagation rather than issuing one invalidation request per + // user. Pepper rotation and non-null blocked_reason remain authoritative. totalProcessed += updated.length; if (updated.length > 0) { await revokeGatewayGrantsForBlockedUsers(updated.map(user => user.id)); diff --git a/apps/web/src/lib/abuse/bulkBlock.ts b/apps/web/src/lib/abuse/bulkBlock.ts index 31999b58a1..a03a0d9db7 100644 --- a/apps/web/src/lib/abuse/bulkBlock.ts +++ b/apps/web/src/lib/abuse/bulkBlock.ts @@ -51,6 +51,11 @@ export async function bulkBlockUsers( } const blockedAt = new Date().toISOString(); + // Bulk blocks intentionally do not fan out one invalidation request per + // user. Session-ingest rechecks the authoritative pepper/blocked state when + // its short-lived auth cache expires and KV propagation settles; a bounded + // bulk invalidation job can be added separately if this path needs a tighter + // convergence bound. const updated = await db .update(kilocode_users) .set({ diff --git a/apps/web/src/lib/session-ingest-client-unset.test.ts b/apps/web/src/lib/session-ingest-client-unset.test.ts new file mode 100644 index 0000000000..facd741c9c --- /dev/null +++ b/apps/web/src/lib/session-ingest-client-unset.test.ts @@ -0,0 +1,44 @@ +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +async function loadInvalidateUserAuthCache(config: { + SESSION_INGEST_WORKER_URL: string; + INTERNAL_API_SECRET: string; +}) { + jest.resetModules(); + jest.doMock('@sentry/nextjs', () => ({ + captureException: jest.fn(), + })); + jest.doMock('@/lib/tokens', () => ({ + generateInternalServiceToken: jest.fn(), + })); + jest.doMock('@/lib/config.server', () => config); + const mod = await import('./session-ingest-client'); + return mod.invalidateUserAuthCache; +} + +describe('invalidateUserAuthCache skip-when-unset', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('skips when SESSION_INGEST_WORKER_URL is unset', async () => { + const invalidateUserAuthCache = await loadInvalidateUserAuthCache({ + SESSION_INGEST_WORKER_URL: '', + INTERNAL_API_SECRET: 'internal-secret', + }); + + await expect(invalidateUserAuthCache('usr_blocked')).resolves.toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips when INTERNAL_API_SECRET is unset', async () => { + const invalidateUserAuthCache = await loadInvalidateUserAuthCache({ + SESSION_INGEST_WORKER_URL: 'https://ingest.test.example.com', + INTERNAL_API_SECRET: '', + }); + + await expect(invalidateUserAuthCache('usr_blocked')).resolves.toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/session-ingest-client.test.ts b/apps/web/src/lib/session-ingest-client.test.ts index 958b04e203..2217670d0e 100644 --- a/apps/web/src/lib/session-ingest-client.test.ts +++ b/apps/web/src/lib/session-ingest-client.test.ts @@ -10,6 +10,7 @@ import { unshareSession, fetchSharedSessionMetadata, invalidateOrganizationSessionAccess, + invalidateUserAuthCache, } from './session-ingest-client'; // --------------------------------------------------------------------------- @@ -645,6 +646,70 @@ describe('invalidateOrganizationSessionAccess', () => { }); }); +describe('invalidateUserAuthCache', () => { + beforeEach(() => { + mockFetch.mockReset(); + mockCaptureException.mockReset(); + }); + + it('reports and throws invalidation failures', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: () => Promise.resolve('cache unavailable'), + }); + + await expect(invalidateUserAuthCache('usr_blocked')).rejects.toThrow( + 'User auth invalidation failed: 503 Service Unavailable' + ); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { source: 'session-ingest-client', endpoint: 'invalidate-user-auth' }, + extra: { + kiloUserId: 'usr_blocked', + status: 503, + }, + }) + ); + }); + + it('calls the secret-protected user-auth invalidation endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + await invalidateUserAuthCache('usr_blocked'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://ingest.test.example.com/internal/user-auth/invalidate', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Internal-Secret': 'internal-secret', + }, + body: JSON.stringify({ kiloUserId: 'usr_blocked' }), + signal: expect.any(AbortSignal), + } + ); + }); + + it('sets a 30-second invalidation deadline', async () => { + const signal = new AbortController().signal; + const timeout = jest.spyOn(AbortSignal, 'timeout').mockReturnValue(signal); + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + await invalidateUserAuthCache('usr_blocked'); + + expect(timeout).toHaveBeenCalledWith(30_000); + expect(mockFetch).toHaveBeenCalledWith( + 'https://ingest.test.example.com/internal/user-auth/invalidate', + expect.objectContaining({ signal }) + ); + timeout.mockRestore(); + }); +}); + describe('fetchSessionMessages', () => { beforeEach(() => { mockFetch.mockReset(); diff --git a/apps/web/src/lib/session-ingest-client.ts b/apps/web/src/lib/session-ingest-client.ts index f604122c52..5c04b5e9e0 100644 --- a/apps/web/src/lib/session-ingest-client.ts +++ b/apps/web/src/lib/session-ingest-client.ts @@ -387,6 +387,34 @@ export async function invalidateOrganizationSessionAccess( } } +export async function invalidateUserAuthCache(kiloUserId: string): Promise { + if (!SESSION_INGEST_WORKER_URL || !INTERNAL_API_SECRET) { + return; + } + + const response = await fetch(`${SESSION_INGEST_WORKER_URL}/internal/user-auth/invalidate`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Internal-Secret': INTERNAL_API_SECRET, + }, + body: JSON.stringify({ kiloUserId }), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + await response.text().catch(() => undefined); + const error = new Error( + `User auth invalidation failed: ${response.status} ${response.statusText}` + ); + captureException(error, { + tags: { source: 'session-ingest-client', endpoint: 'invalidate-user-auth' }, + extra: { kiloUserId, status: response.status }, + }); + throw error; + } +} + // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- diff --git a/apps/web/src/lib/user/block-invalidate.test.ts b/apps/web/src/lib/user/block-invalidate.test.ts new file mode 100644 index 0000000000..78bdfb1d88 --- /dev/null +++ b/apps/web/src/lib/user/block-invalidate.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { eq } from 'drizzle-orm'; +import { kilocode_users } from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { invalidateUserAuthCache } from '@/lib/session-ingest-client'; +import { blockUser } from '@/lib/user/block'; + +const mockAfterCallbacks: Array<() => unknown> = []; + +jest.mock('next/server', () => ({ + after: jest.fn((callback: () => unknown) => { + mockAfterCallbacks.push(callback); + }), +})); + +jest.mock('@/lib/session-ingest-client', () => ({ + invalidateUserAuthCache: jest.fn().mockResolvedValue(undefined), +})); + +const mockInvalidateUserAuthCache = jest.mocked(invalidateUserAuthCache); + +describe('blockUser auth-cache invalidation', () => { + beforeEach(() => { + mockInvalidateUserAuthCache.mockReset(); + mockInvalidateUserAuthCache.mockResolvedValue(undefined); + mockAfterCallbacks.length = 0; + }); + + test('invalidates after a successful self-owned block', async () => { + const user = await insertTestUser({ api_token_pepper: 'initial-pepper' }); + + const didBlock = await blockUser({ kiloUserId: user.id, reason: 'manual block' }); + + expect(didBlock).toBe(true); + expect(mockInvalidateUserAuthCache).toHaveBeenCalledWith(user.id); + }); + + test('still returns true when invalidation fails', async () => { + const user = await insertTestUser({ api_token_pepper: 'initial-pepper' }); + mockInvalidateUserAuthCache.mockRejectedValueOnce(new Error('invalidation unavailable')); + + await expect(blockUser({ kiloUserId: user.id, reason: 'manual block' })).resolves.toBe(true); + + const after = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, user.id), + columns: { blocked_reason: true }, + }); + expect(after?.blocked_reason).toBe('manual block'); + expect(mockInvalidateUserAuthCache).toHaveBeenCalledWith(user.id); + }); + + test('does not invalidate when the user was already blocked', async () => { + const user = await insertTestUser({ api_token_pepper: 'initial-pepper' }); + await db + .update(kilocode_users) + .set({ blocked_reason: 'already blocked' }) + .where(eq(kilocode_users.id, user.id)); + + const didBlock = await blockUser({ kiloUserId: user.id, reason: 'second reason' }); + + expect(didBlock).toBe(false); + expect(mockInvalidateUserAuthCache).not.toHaveBeenCalled(); + }); + + test('does not invalidate when the user is missing', async () => { + await expect( + blockUser({ kiloUserId: 'non-existent-user', reason: 'manual block' }) + ).resolves.toBe(false); + expect(mockInvalidateUserAuthCache).not.toHaveBeenCalled(); + }); + + test('defers invalidation until after a provided transaction callback returns', async () => { + const user = await insertTestUser({ api_token_pepper: 'initial-pepper' }); + let transactionCallbackFinished = false; + mockInvalidateUserAuthCache.mockImplementation(async () => { + expect(transactionCallbackFinished).toBe(true); + }); + + const didBlock = await db.transaction(async tx => { + const result = await blockUser({ kiloUserId: user.id, reason: 'tx block', dbOrTx: tx }); + expect(mockInvalidateUserAuthCache).not.toHaveBeenCalled(); + transactionCallbackFinished = true; + return result; + }); + + expect(didBlock).toBe(true); + expect(mockAfterCallbacks).toHaveLength(1); + await mockAfterCallbacks[0]!(); + expect(mockInvalidateUserAuthCache).toHaveBeenCalledWith(user.id); + }); +}); diff --git a/apps/web/src/lib/user/block.ts b/apps/web/src/lib/user/block.ts index dc983ef968..1a380e49a5 100644 --- a/apps/web/src/lib/user/block.ts +++ b/apps/web/src/lib/user/block.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto'; +import { after } from 'next/server'; import { and, eq, isNull, or, inArray } from 'drizzle-orm'; import { kilocode_users, @@ -8,6 +9,8 @@ import { native_attested_keys, } from '@kilocode/db/schema'; import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { invalidateUserAuthCache } from '@/lib/session-ingest-client'; +import { errorExceptInTest } from '@/lib/utils.server'; export type BlockUserParams = { kiloUserId: string; @@ -17,6 +20,28 @@ export type BlockUserParams = { dbOrTx?: typeof db | DrizzleTransaction; }; +async function invalidateUserAuthCacheBestEffort(kiloUserId: string): Promise { + try { + await invalidateUserAuthCache(kiloUserId); + } catch (error) { + errorExceptInTest('Failed to invalidate cached user auth after block', { + kiloUserId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function scheduleUserAuthCacheInvalidation(kiloUserId: string): void { + try { + after(() => invalidateUserAuthCacheBestEffort(kiloUserId)); + } catch (error) { + errorExceptInTest('Failed to schedule cached user auth invalidation after block', { + kiloUserId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** * Block a single user. * @@ -100,8 +125,15 @@ export async function blockUser(params: BlockUserParams): Promise { return true; } - if (executor) { - return run(executor); + const didBlock = executor ? await run(executor) : await db.transaction(tx => run(tx)); + + if (didBlock) { + if (executor) { + scheduleUserAuthCacheInvalidation(params.kiloUserId); + } else { + await invalidateUserAuthCacheBestEffort(params.kiloUserId); + } } - return db.transaction(tx => run(tx)); + + return didBlock; } diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index f3ef858642..ef78ca49f1 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -9,6 +9,8 @@ import { WorkOS } from '@workos-inc/node'; import type { User } from '@kilocode/db/schema'; import { createSoftDeletedBlockedReason } from '@kilocode/db/user-soft-delete'; import { reportAuthEvent, reportEvents } from '@/lib/ai-gateway/abuse-service'; +import { invalidateUserAuthCache } from '@/lib/session-ingest-client'; +import { errorExceptInTest } from '@/lib/utils.server'; import { payment_methods, kilocode_users, @@ -1657,6 +1659,14 @@ export async function softDeleteUser(userId: string) { .where(eq(security_advisor_scans.kilo_user_id, userId)); }); + try { + await invalidateUserAuthCache(userId); + } catch (error) { + errorExceptInTest('Failed to invalidate cached user auth after soft-delete', { + kiloUserId: userId, + error: error instanceof Error ? error.message : String(error), + }); + } void reportEvents({ events: [{ type: 'user.deleted', data: { kilo_user_id: userId } }] }); } diff --git a/apps/web/src/lib/user/soft-delete-invalidate.test.ts b/apps/web/src/lib/user/soft-delete-invalidate.test.ts new file mode 100644 index 0000000000..c8629c3563 --- /dev/null +++ b/apps/web/src/lib/user/soft-delete-invalidate.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { invalidateUserAuthCache } from '@/lib/session-ingest-client'; +import { db } from '@/lib/drizzle'; +import { findUserById, softDeleteUser } from '@/lib/user'; + +jest.mock('@/lib/stripe-client', () => ({ + createStripeCustomer: jest.fn(), + deleteStripeCustomer: jest.fn(), +})); + +jest.mock('@/lib/session-ingest-client', () => ({ + invalidateUserAuthCache: jest.fn().mockResolvedValue(undefined), +})); + +const mockInvalidateUserAuthCache = jest.mocked(invalidateUserAuthCache); + +describe('softDeleteUser auth-cache invalidation', () => { + beforeEach(() => { + mockInvalidateUserAuthCache.mockReset(); + mockInvalidateUserAuthCache.mockResolvedValue(undefined); + }); + + test('invalidates after a successful soft-delete', async () => { + const user = await insertTestUser(); + + await expect(softDeleteUser(user.id)).resolves.toBeUndefined(); + + expect(mockInvalidateUserAuthCache).toHaveBeenCalledWith(user.id); + const deleted = await findUserById(user.id); + expect(deleted?.blocked_reason).toMatch(/^soft-deleted at /); + }); + + test('still succeeds when invalidation fails', async () => { + const user = await insertTestUser(); + mockInvalidateUserAuthCache.mockRejectedValueOnce(new Error('invalidation unavailable')); + + await expect(softDeleteUser(user.id)).resolves.toBeUndefined(); + + expect(mockInvalidateUserAuthCache).toHaveBeenCalledWith(user.id); + const deleted = await findUserById(user.id); + expect(deleted?.blocked_reason).toMatch(/^soft-deleted at /); + }); + + test('does not invalidate when the user is missing', async () => { + await expect(softDeleteUser('non-existent-user')).resolves.toBeUndefined(); + expect(mockInvalidateUserAuthCache).not.toHaveBeenCalled(); + }); + + test('does not invalidate when the anonymization transaction fails', async () => { + const user = await insertTestUser(); + const transaction = jest.spyOn(db, 'transaction').mockImplementationOnce(async () => { + throw new Error('database unavailable'); + }); + + await expect(softDeleteUser(user.id)).rejects.toThrow('database unavailable'); + expect(mockInvalidateUserAuthCache).not.toHaveBeenCalled(); + transaction.mockRestore(); + }); +}); diff --git a/apps/web/src/routers/active-sessions-router.test.ts b/apps/web/src/routers/active-sessions-router.test.ts index f5a9cd910d..464a82e5f3 100644 --- a/apps/web/src/routers/active-sessions-router.test.ts +++ b/apps/web/src/routers/active-sessions-router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, jest, beforeAll, afterEach } from '@jest/globals'; +import jwt from 'jsonwebtoken'; import { TRPCError } from '@trpc/server'; import jwt from 'jsonwebtoken'; import { insertTestUser } from '@/tests/helpers/user.helper'; @@ -72,6 +73,27 @@ describe('active-sessions-router', () => { // rather than a fragile default. }, 15_000); + it('returns a pepper-bearing one-hour user token for session-ingest', async () => { + const caller = await createCallerForUser(regularUser.id); + const before = Math.floor(Date.now() / 1000); + const result = await caller.activeSessions.getToken(); + const { NEXTAUTH_SECRET } = await import('@/lib/config.server'); + const payload = jwt.verify(result.token, NEXTAUTH_SECRET, { + algorithms: ['HS256'], + }) as jwt.JwtPayload & { + kiloUserId: string; + apiTokenPepper: string | null; + }; + + expect(payload.kiloUserId).toBe(regularUser.id); + expect(payload.apiTokenPepper).toBe(regularUser.api_token_pepper); + expect(payload.aud).toBeUndefined(); + expect(payload).not.toHaveProperty('google_user_email'); + expect(payload).not.toHaveProperty('organizationId'); + expect(payload.exp! - payload.iat!).toBe(60 * 60); + expect(payload.exp).toBeGreaterThanOrEqual(before + 60 * 60 - 2); + }); + afterEach(() => { jest.restoreAllMocks(); }); From 3d55be74f06c3808639d1afdb8143df14eb18ab6 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 18 Aug 2026 11:49:09 +0200 Subject: [PATCH 2/3] docs(session-ingest): record token revocation rollout --- SESSION-INGEST-TOKEN-REVOCATION-HANDOFF.md | 265 ++++++ SESSION-INGEST-TOKEN-REVOCATION-PLAN.md | 203 +++++ ...NGEST-TOKEN-REVOCATION-REMEDIATION-PLAN.md | 770 ++++++++++++++++++ 3 files changed, 1238 insertions(+) create mode 100644 SESSION-INGEST-TOKEN-REVOCATION-HANDOFF.md create mode 100644 SESSION-INGEST-TOKEN-REVOCATION-PLAN.md create mode 100644 SESSION-INGEST-TOKEN-REVOCATION-REMEDIATION-PLAN.md diff --git a/SESSION-INGEST-TOKEN-REVOCATION-HANDOFF.md b/SESSION-INGEST-TOKEN-REVOCATION-HANDOFF.md new file mode 100644 index 0000000000..42fef710ef --- /dev/null +++ b/SESSION-INGEST-TOKEN-REVOCATION-HANDOFF.md @@ -0,0 +1,265 @@ +# Session-ingest token revocation handoff + +## Objective + +Fix a GDPR deletion release blocker in `services/session-ingest`: ordinary Kilo JWTs remain usable after a user is blocked and their API token pepper is rotated. The fix must prevent a deleted user from recreating CLI v2 data without adding a PostgreSQL lookup to every session-ingest request. + +Do not weaken the separate deletion-token flow. A purpose-bound deletion token must continue to work for the exact leaf-session delete operation after the user has been blocked, and nowhere else. + +## Confirmed production constraint + +Cloudflare currently reports approximately **135.1 requests/second** for the `session-ingest` Worker. + +An authoritative PostgreSQL lookup in global authentication middleware would therefore add roughly: + +- 135 database queries per second; +- 11.7 million database queries per day. + +That approach is not acceptable, even through Hyperdrive. The high-volume ingest path must not query PostgreSQL once per request merely to validate account revocation state. + +## Current behavior + +The relevant middleware is: + +- `services/session-ingest/src/middleware/kilo-jwt-auth.ts` + +For each request it currently: + +1. Extracts the bearer token, or the WebSocket query token. +2. Verifies the JWT signature, expiry, schema, and audience. +3. Extracts `kiloUserId`. +4. Looks up `user-exists:` in `USER_EXISTS_CACHE` KV. +5. Accepts cached `"1"`; rejects cached `"0"`. +6. On a cache miss, queries `kilocode_users` only for the user ID. +7. Caches existence for 24 hours, or absence for five minutes. + +This verifies only that the retained user row exists. It does not inspect: + +- `kilocode_users.api_token_pepper`; +- `kilocode_users.blocked_reason`; +- the JWT's `apiTokenPepper` claim. + +The deletion flow intentionally retains and anonymizes the `kilocode_users` row. At deletion intake it blocks the user and rotates `api_token_pepper` in: + +- `apps/web/src/lib/user/deletion-queue/deletion-access.ts` + +Final anonymization rotates the pepper again and preserves a soft-deleted blocked reason in: + +- `apps/web/src/lib/user/index.ts` + +Because the row still exists, an old ordinary JWT continues to pass session-ingest authentication. Even after the 24-hour KV entry expires, the database existence check returns true and another positive entry is cached. The JWT can remain usable until its own expiry and can create new CLI v2 session data. + +## Existing deletion-token exception + +The durable deletion queue mints a five-minute token with audience: + +```text +session-ingest:user-deletion +``` + +See: + +- `apps/web/src/lib/user/deletion-queue/handlers/cli-v2.ts` +- `apps/web/src/lib/user/deletion-queue/deletion-constants.ts` +- `packages/worker-utils/src/internal-service-token-audiences.ts` + +The middleware accepts that audience only for: + +```text +DELETE /api/session/:sessionId +``` + +The route then uses the signed `kiloUserId` and calls the dedicated leaf-only deletion path in: + +- `services/session-ingest/src/routes/api.ts` + +That path: + +- selects by both session ID and user ID; +- refuses deletion when the selected session still has a child; +- deletes only the selected leaf; +- clears the per-user access-cache entry; +- clears the session's Durable Object and R2-backed state; +- converges when the PostgreSQL row is already missing. + +The deletion audience must continue to bypass the user's blocked/pepper state because blocking and pepper rotation happen before session deletion. Its authorization must remain limited to the exact route and method above. + +## Important token compatibility issue + +Ordinary user API tokens contain `apiTokenPepper`, but several web-to-session-ingest calls use `generateInternalServiceToken(userId)`, which intentionally contains only `kiloUserId` and token version. + +See: + +- `apps/web/src/lib/tokens.ts` +- `apps/web/src/lib/session-ingest-client.ts` +- `apps/web/src/lib/cloud-agent/session-events.ts` +- `apps/web/src/routers/active-sessions-router.ts` +- `apps/web/src/routers/cli-sessions-v2-router.ts` +- `services/security-auto-analysis/src/token.ts` + +Therefore, simply requiring `payload.apiTokenPepper` on every non-deletion JWT would break legitimate current service calls. Do not silently treat a missing pepper as trusted either: that would create an ambiguous bypass unless this token class receives its own explicit, purpose-bound trust contract. + +Inventory all session-ingest token issuers and classify them before changing acceptance rules. + +## Approaches discussed + +### 1. PostgreSQL lookup in global middleware — rejected + +For every ordinary request, fetch current pepper and blocked state and compare them with the JWT. + +This is straightforward and authoritative, and resembles `verifyKiloBearerAgainstCurrentPepper` in `packages/worker-utils/src/kilo-token-auth.ts`. However, at 135.1 requests/second it would impose an unreasonable database load. Do not implement this approach. + +### 2. Validate only selected mutation routes — considered hacky + +We considered putting an authoritative check only at session creation or WebSocket connection admission. Session creation already writes PostgreSQL, so its insert could theoretically be conditioned on the current user pepper and block state without adding a separate round trip. + +This reduces load, but it distributes authentication/revocation policy into business routes and requires careful reasoning about every path that can create or mutate retained data. The user correctly considered this hacky and weird. Do not adopt it without a strong architectural justification. + +### 3. Cache full authorization state in KV — current pragmatic candidate + +Replace the existence-only cache value with an authorization snapshot containing at least: + +```ts +type CachedUserAuthState = { + pepper: string | null; + blockedReason: string | null; +}; +``` + +For an ordinary token: + +1. Verify JWT signature and reject unexpected audiences locally. +2. Read `user-auth:` from KV. +3. On a cache miss, query PostgreSQL once for `api_token_pepper` and `blocked_reason` and cache the result for a short TTL, tentatively 60 seconds. +4. Accept only when `blockedReason === null` and the cached current pepper equals `payload.apiTokenPepper ?? null`. + +For a deletion-audience token: + +1. Verify the exact deletion audience. +2. Reject it unless the method and path are the exact leaf delete operation. +3. Permit the leaf deletion despite the blocked reason and pepper mismatch. + +This changes the database load from one query per request to approximately one query per active user per cache TTL. It introduces a documented bounded revocation delay. + +Cloudflare KV is eventually consistent, so an explicit overwrite/delete during blocking is useful for reducing the common-case delay but cannot by itself prove immediate global revocation. The design must not claim zero-delay revocation if it relies on KV. + +### 4. Strongly consistent per-user Durable Object authorization cache — clean but larger + +A per-user Durable Object could own the current auth epoch/state. Ordinary requests would consult it instead of PostgreSQL, and the deletion intake path would explicitly revoke the user. + +This provides a coherent, strongly consistent revocation boundary, but it adds a Worker-to-DO RPC on the hot path and materially more lifecycle, deployment, failure, and invalidation machinery. Measure the latency/cost and justify the complexity before choosing it. + +### 5. Short-lived purpose-bound session-ingest tokens — worth evaluating + +A cleaner longer-term boundary may be for clients to exchange their general Kilo token for a short-lived token specifically accepted by session-ingest. The web/auth service validates current user state when minting it; session-ingest then validates it locally until a short expiry. + +This bounds revocation delay without per-request database reads, but it changes client refresh flows, WebSocket reconnect behavior, and all current session-ingest issuers. It may be the best architectural answer, but it is broader than a minimal queue fix. + +## Suggested direction to evaluate first + +Start with the KV authorization-state design, but treat the following as explicit requirements rather than implementation details: + +1. Choose and document the maximum revocation window. +2. Cache the current pepper and blocked state, not merely existence. +3. Ensure cache keys/serialization are versioned so old `"1"` entries cannot be interpreted as authorized under the new scheme. +4. Fail closed on malformed cache values and database errors. +5. Do not place raw JWTs or other credentials in KV, logs, tests, or error output. +6. Decide how purpose-free internal service tokens are migrated or replaced; do not preserve an undocumented missing-pepper bypass. +7. Keep deletion-audience authorization separate and exact. +8. Make the deletion queue wait until the maximum old authorization-cache window has passed before considering CLI v2 deletion complete, then rescan and delete any rows created during the window. +9. Avoid sleeping inside a Vercel invocation. Persist/reschedule the step until its not-before time. +10. Account for the existing `SessionIngestDO` deletion tombstone: after `clear()`, late ingest for that exact user/session DO is rejected and uploaded R2 blobs are cleaned. Verify this remains true through deployment-version skew. + +The wait/rescan barrier is needed because a stale authorization snapshot can permit writes briefly after blocking. Completion must mean the stale-token window has closed and no user-owned CLI v2 rows remain. + +## Questions the implementation owner must resolve + +1. What revocation delay is acceptable: 30 seconds, 60 seconds, or another value? +2. Can the Cloud deletion path call a session-ingest internal invalidation endpoint as a best-effort accelerator without making correctness depend on it? +3. Should ordinary web-to-session-ingest internal calls carry the user's pepper, or should they use a separate explicit service audience plus an internal secret/service binding? +4. Which client paths call session-ingest directly with a long-lived general API token, including CLI and WebSocket connections? +5. Does Cloudflare KV propagation add delay beyond the chosen TTL in the deployed topology, and how should that affect the completion barrier? +6. Is a session-ingest-specific short-lived token worth doing now instead of adding authorization state to KV? +7. Does the queue already persist enough timing state (`blocked_at`, step timing/progress) to express the barrier without a schema change? + +## Files to inspect + +At minimum: + +- `services/session-ingest/src/middleware/kilo-jwt-auth.ts` +- `services/session-ingest/src/middleware/kilo-jwt-auth.test.ts` +- `services/session-ingest/src/app.ts` +- `services/session-ingest/src/routes/api.ts` +- `services/session-ingest/src/routes/api.test.ts` +- `services/session-ingest/src/dos/SessionIngestDO.ts` +- `services/session-ingest/src/dos/SessionAccessCacheDO.ts` +- `services/session-ingest/wrangler.jsonc` +- `packages/worker-utils/src/kilo-token.ts` +- `packages/worker-utils/src/kilo-token-auth.ts` +- `packages/worker-utils/src/internal-service-token-audiences.ts` +- `apps/web/src/lib/tokens.ts` +- `apps/web/src/lib/session-ingest-client.ts` +- `apps/web/src/lib/user/deletion-queue/deletion-access.ts` +- `apps/web/src/lib/user/deletion-queue/handlers/cli-v2.ts` +- `apps/web/src/lib/user/index.ts` +- `packages/db/src/schema.ts` +- `packages/db/AGENTS.md` + +Also inventory all call sites of `generateInternalServiceToken()` that target session-ingest. + +## Required tests + +Authentication tests should cover: + +- ordinary token with matching pepper and unblocked user succeeds; +- stale pepper fails; +- matching pepper with non-null `blocked_reason` fails; +- missing user fails; +- malformed or unknown cached state fails closed; +- legacy existence-cache values do not authorize requests; +- cache miss reads PostgreSQL and writes the expected bounded state; +- cache hit avoids PostgreSQL; +- deletion token succeeds for exact `DELETE /api/session/:sessionId` after blocking; +- deletion token fails for GET/POST/PATCH/PUT on that path; +- deletion token fails for all other `/api` and internal routes; +- a token with another audience is rejected; +- the chosen handling of missing-pepper internal tokens is explicit and tested. + +Deletion convergence tests should cover: + +- blocking rotates pepper and records the barrier start; +- CLI v2 deletion does not terminally succeed before the revocation window closes; +- work is rescheduled rather than sleeping; +- after the barrier, sessions created during the stale window are found and deleted; +- a final rescan proves absence before success; +- late ingest cannot repopulate a cleared SessionIngestDO; +- deletion remains idempotent when PostgreSQL, cache, or DO state is already missing. + +## Verification + +Follow the target checkout's `AGENTS.md`, `services/AGENTS.md`, `packages/db/AGENTS.md`, and applicable repository skills before editing. + +Run the narrowest relevant checks, likely including: + +```sh +pnpm --filter cloudflare-session-ingest test -- src/middleware/kilo-jwt-auth.test.ts src/routes/api.test.ts +pnpm --filter cloudflare-session-ingest typecheck +pnpm --filter cloudflare-session-ingest lint +``` + +Run the focused web deletion-queue tests covering `handlers/cli-v2.ts` and any timing/barrier changes. Use repository-local binaries if pnpm wrappers hang. Finish with formatting of task-owned files and `git diff --check`. + +Do not commit, push, or comment on a PR unless explicitly requested. + +## Acceptance criteria + +The task is complete when: + +- an old ordinary token cannot recreate or mutate CLI v2 data after the documented bounded revocation window; +- no design adds a PostgreSQL query per session-ingest request; +- the database/cache load model is stated and defensible at 135.1 requests/second; +- internal service-token behavior is explicit rather than an accidental missing-pepper bypass; +- the deletion audience remains usable only for exact leaf deletion; +- deletion waits/rescans as needed and does not claim completion while stale authorization can recreate data; +- focused authentication and deletion-convergence tests pass; +- no unrelated worktree changes are overwritten. diff --git a/SESSION-INGEST-TOKEN-REVOCATION-PLAN.md b/SESSION-INGEST-TOKEN-REVOCATION-PLAN.md new file mode 100644 index 0000000000..a959b39516 --- /dev/null +++ b/SESSION-INGEST-TOKEN-REVOCATION-PLAN.md @@ -0,0 +1,203 @@ +# Session-ingest token revocation — implementation plan + +**Checkout**: this worktree. The deletion-queue / `session-ingest:user-deletion` audience described in `SESSION-INGEST-TOKEN-REVOCATION-HANDOFF.md` does not exist here. Do not build it. + +**Goal**: after block or GDPR soft-delete, a stolen ordinary Kilo JWT must not recreate or keep mutating CLI v2 data beyond a documented bounded window, without a PostgreSQL lookup on every session-ingest request. + +## Locked decisions + +1. Keep revocation in `kiloJwtAuthMiddleware`. Do not add a per-request Postgres lookup. +2. Do not change JWT shape. No `aud` migration. No dual-accept of token formats. +3. Classify tokens by whether `apiTokenPepper` is present on the verified payload: + - field **absent** (`undefined`) → internal service token + - field **present** (string or `null`) → ordinary user token + - never coalesce with `??`. `undefined` and `null` are different. +4. Cache `{ pepper, blockedReason }` under a versioned KV key. Ignore legacy `user-exists:` values. +5. GDPR delete stays on the existing pepper-less `generateInternalServiceToken` + `DELETE /api/session/:id` path. +6. `POST /api/session` additionally refuses blocked users. This is required because internal tokens remain valid after block (GDPR delete needs that). +7. Best-effort KV invalidation from web on block / soft-delete. Correctness must not depend on it. +8. No per-user auth DO, no short-lived token exchange, no deletion-queue wait/rescan. + +## Current hole (this checkout) + +`services/session-ingest/src/middleware/kilo-jwt-auth.ts` verifies the JWT, then caches only `user-exists:` (`"1"` for 24h). Soft-delete keeps the `kilocode_users` row, so an old CLI JWT stays valid until its own expiry and can `POST /api/session`. + +GDPR today: + +1. `softDeleteUser` sets `blocked_reason` and rotates `api_token_pepper`. +2. `deleteCliSessionV2Blobs` mints `generateInternalServiceToken(userId)` (no pepper) and `DELETE`s each session. + +That delete only works because middleware ignores pepper and blocked state. Closing the hole must not break that. + +## Token classes + +After `verifyKiloToken(token, secret)` (no audience option): + +| Payload | Class | Accept when | +|---|---|---| +| `apiTokenPepper` is `undefined` | Internal | user row exists | +| `apiTokenPepper` is `string` or `null` | User | user exists, `blockedReason === null`, cached pepper **===** claim (strict, including `null === null`) | +| `aud` present | Rejected already by `verifyKiloToken` | n/a | + +Why this needs no transition window: + +- `generateApiToken` / `generateOrganizationApiToken` always set `apiTokenPepper`. +- `generateInternalServiceToken` never sets it. +- In-flight 1h internal tokens and 5-year CLI tokens keep working on deploy. +- Do not add `audience` to internal tokens in this change. `verifyKiloToken` without `{ audience }` rejects any `aud`, so minting audience first would break every web call. + +Internal tokens ignore pepper and blocked reason. That is an explicit contract, not a missing-field bypass: the field is omitted on purpose by `generateInternalServiceToken`. + +### Residual + +`activeSessions.getToken` returns a pepper-less 1h token to clients (mobile WS). After block, an already-issued `getToken` still authenticates until expiry. It cannot mint a new one if web auth checks pepper. + +Mitigation in this change: `POST /api/session` rejects blocked users, so that token cannot recreate sessions. It can still ingest into sessions not yet deleted, until GDPR `deleteCliSessionV2Blobs` finishes or the 1h token expires. + +Do not change `getToken` shape in this PR. + +## KV cache + +Reuse the existing `USER_EXISTS_CACHE` binding. Do not rename the namespace. + +```ts +const USER_AUTH_CACHE_KEY_PREFIX = 'user-auth:v1:'; +const USER_AUTH_TTL_SECONDS = 60; +const USER_MISSING_TTL_SECONDS = 5 * 60; + +type CachedUserAuthV1 = + | { v: 1; exists: false } + | { v: 1; exists: true; pepper: string | null; blockedReason: string | null }; +``` + +Key: `user-auth:v1:`. + +Rules: + +- Parse with Zod. Legacy `"1"` / `"0"` / unknown JSON → treat as miss, then read Postgres. Never treat them as authorized. +- Cache miss or malformed: `findKiloUserPepper` (already in `packages/worker-utils/src/kilo-token-auth.ts`). Missing row → cache `{ v: 1, exists: false }` for 5 minutes. Present row → cache `{ v: 1, exists: true, pepper, blockedReason }` for 60 seconds. +- **Await** `KV.put`. Do not `void` it. +- Postgres / Hyperdrive error → **503**, fail closed. Do not authorize. +- Do not log tokens, peppers, or Authorization headers. + +Load model: one Postgres read per distinct active user per 60s, not 135 qps. First request after deploy is a miss (new key). Old `user-exists:` entries expire unused. + +Documented revocation window for user tokens: **60s TTL + KV eventual consistency** (treat as ~2 minutes, not 60s). Do not claim immediate global revocation. + +## Middleware algorithm + +File: `services/session-ingest/src/middleware/kilo-jwt-auth.ts` + +1. Extract bearer, or WS `?token=` when `Upgrade: websocket`. +2. `verifyKiloToken(token, secret)` — no audience. Invalid/expired/`aud` → 401. +3. Load cached auth state as above. +4. If `!exists` → 403 `User account not found`. +5. If `payload.apiTokenPepper === undefined` → `c.set('user_id', kiloUserId)` and `next()` (internal). +6. Else if `state.blockedReason !== null` or `state.pepper !== payload.apiTokenPepper` → 403 (same generic error for both; do not leak which). +7. Else authorize. + +Keep applying this middleware to `/api/*` and `/internal/cloud-agent/v1/*`. Cloud-agent routes still also require `X-Internal-Secret`. + +## Create-path block + +File: `services/session-ingest/src/routes/api.ts` — `POST /session` + +Before insert, read `blocked_reason` for `kiloUserId`. If the user is missing or `blocked_reason !== null`, return 403 and do not insert. + +This is a low-volume extra query on session create only. It is required: internal tokens are accepted after block, and without this check `getToken` / any pepper-less JWT could recreate `cli_sessions_v2` rows. + +Do not add blocked checks to ingest/export/delete/share. Ingest into a missing session already 404s. Ingest into a cleared `SessionIngestDO` already returns `reason: 'deleted'` and drops R2 blobs. GDPR delete uses `DELETE` with an internal token and must keep working. + +## Best-effort invalidation + +Add `POST /internal/user-auth/invalidate` next to the existing session-access invalidate route in `app.ts`. + +- Auth: `X-Internal-Secret` via existing `hasValidInternalSecret`. +- Body: `{ kiloUserId: string }`. +- Action: `USER_EXISTS_CACHE.delete('user-auth:v1:' + kiloUserId)`. +- Response: 204. +- Correctness does not depend on this. A lost race can leave a stale unblocked snapshot until TTL. + +Web helper in `apps/web/src/lib/session-ingest-client.ts`, same pattern as `invalidateOrganizationSessionAccess` (secret header, 30s timeout). Fire-and-forget from: + +- `blockUser` in `apps/web/src/lib/user/block.ts` — after the transaction commits, not inside it. Catch + log / Sentry. Do not fail the block. +- `softDeleteUser` in `apps/web/src/lib/user/index.ts` — after the anonymize transaction. Soft-delete does **not** call `blockUser`, so both sites are required. + +Skip the call when `SESSION_INGEST_WORKER_URL` or `INTERNAL_API_SECRET` is unset (local / tests). + +## Out of scope + +- `generateInternalServiceToken` audience +- Changing `getToken` to include pepper +- Deletion-queue files / leaf-only delete / `session-ingest:user-deletion` +- Renaming the KV binding +- Schema migrations +- Per-request Postgres in middleware +- Sleeping or rescheduling GDPR for a cache window + +## Tests + +### `kilo-jwt-auth.test.ts` + +Mock KV + `findKiloUserPepper` / `getWorkerDb`. Cover: + +- user token, matching pepper, unblocked → 200 +- user token, stale pepper → 403 +- user token, matching pepper, blocked → 403 +- user token, `apiTokenPepper: null`, cached pepper `null`, unblocked → 200 +- user token, `apiTokenPepper: null`, cached pepper string → 403 +- missing user → 403 +- malformed KV JSON → miss, then Postgres; do not authorize from the blob +- legacy `"1"` / `"0"` → miss, then Postgres +- cache hit → no Postgres +- cache miss → Postgres + `put` of `user-auth:v1:` with TTL 60 (or 300 if missing) +- await `put` (assert `put` was called before the 200) +- Postgres throw → 503, not 200 +- internal token (`version` + `kiloUserId` only) + unblocked user → 200 +- internal token + blocked user → 200 (GDPR delete) +- internal token + missing user → 403 +- token with `aud` → 401 +- missing Authorization → 401 + +### `api.test.ts` + +- `POST /session` when `blocked_reason` is set → 403, no insert +- `POST /session` when user missing → 403, no insert +- existing create tests still pass for an unblocked user + +### Web + +- `session-ingest-client` invalidate helper: secret header, 204, error path +- `blockUser` / `softDeleteUser`: invalidate called after success; block/delete still succeeds if invalidate throws + +Do not add deletion-queue wait/rescan tests. They belong to a queue that is not in this tree. + +## Verification + +```sh +pnpm --filter cloudflare-session-ingest test -- src/middleware/kilo-jwt-auth.test.ts src/routes/api.test.ts +pnpm --filter cloudflare-session-ingest typecheck +pnpm --filter cloudflare-session-ingest lint +``` + +Plus the focused web tests for the invalidate helper and the `blockUser` / `softDeleteUser` call sites. Format task-owned files. `git diff --check`. + +Do not commit, push, or comment on a PR unless asked. + +## Implementation order + +1. Middleware + cache types + auth tests (closes the CLI JWT hole). +2. `POST /session` blocked check + api tests (closes internal-token recreate). +3. Internal invalidate route. +4. Web helper + `blockUser` / `softDeleteUser` best-effort calls + tests. +5. Typecheck / lint / format of owned files. + +## Acceptance + +- Ordinary user JWT with rotated pepper or non-null `blocked_reason` is rejected after at most the documented KV window. +- No Postgres read on a warm cache hit. +- Pepper-less internal token still deletes sessions after soft-delete. +- Pepper-less internal token cannot create a new session for a blocked user. +- Legacy `"1"` does not authorize. +- Internal missing-pepper behavior is tested as a named class, not accidental. +- `SessionIngestDO.clear()` tombstone behavior is unchanged. diff --git a/SESSION-INGEST-TOKEN-REVOCATION-REMEDIATION-PLAN.md b/SESSION-INGEST-TOKEN-REVOCATION-REMEDIATION-PLAN.md new file mode 100644 index 0000000000..b016144f83 --- /dev/null +++ b/SESSION-INGEST-TOKEN-REVOCATION-REMEDIATION-PLAN.md @@ -0,0 +1,770 @@ +# Session-ingest token revocation — review remediation and staged rollout plan + +## Status and scope + +This plan supersedes the implementation sequencing in +`SESSION-INGEST-TOKEN-REVOCATION-PLAN.md` where the verified review findings below +require changes. It does not supersede the original token-class and KV-state +decisions unless this document says so explicitly. + +The work is split into two independently deployable pull requests: + +1. **PR 1: session-ingest compatibility and enforcement**, implemented from the + clean, current-main worktree: + `/Users/evgeny/.argus/worktrees/E334DA0A-0BCF-4F1C-9457-B7FD59473A30/eshurakov-mighty-hazel` +2. **PR 2: web token issuance and invalidation**, created from `main` only after + PR 1 is merged and deployed successfully. + +The current `eshurakov-serene-cedar` worktree contains an older, uncommitted +combined implementation. Treat it as reference material. Do not cherry-pick or +copy that diff wholesale: it contains the review defects this plan addresses and +is based 46 commits behind the current `main` snapshot used by `mighty-hazel`. + +No client release, database migration, KV namespace migration, or coordinated +flag day is required. + +## Goal + +After a user is blocked or GDPR-soft-deleted: + +- ordinary pepper-bearing Kilo JWTs stop authorizing new HTTP requests to + session-ingest after the documented KV convergence window; +- no production `cli_sessions_v2` insert path can create a row for a blocked or + missing user; +- the session-ingest deployment remains compatible with both the currently + issued pepper-less one-hour viewer tokens and the pepper-bearing viewer tokens + introduced by PR 2; +- internal pepper-less service tokens retain their existing behavior in this + rollout; +- blocking and deletion remain successful if best-effort cache invalidation + fails; +- invalidation is never started inside an uncommitted caller-owned transaction; + and +- normal web, mobile, extension, CLI, Cloud Agent, and internal-service traffic + continues across a rolling deployment. + +## Explicitly accepted residuals + +This is the low-disruption path. The following limitations are intentional and +must remain visible in the PR descriptions and release notes: + +1. `activeSessions.getToken` tokens issued before PR 2 remain pepper-less and + continue to use the internal-token compatibility path until they expire, for + at most one hour after issuance. +2. Authentication for `/api/user/web` and `/api/user/cli` occurs during the + WebSocket handshake. Rotating a pepper does not re-authenticate or close an + already-open socket. An open viewer socket may therefore outlive its JWT and + continue to relay commands until it disconnects. +3. This rollout does not add command-time authorization checks, forced socket + closure, periodic reauthentication, a per-user authorization Durable Object, + or a purpose-bound internal-token migration. +4. Bulk blocking and the blacklisted-domain backfill rely on the authoritative + 60-second auth-cache TTL plus KV propagation. They do not fan out thousands of + best-effort HTTP invalidation requests. +5. A PostgreSQL/Hyperdrive outage that lasts beyond a warm entry's 60-second TTL + causes affected authenticated session-ingest requests to fail closed with + 503. This is the chosen security/availability trade-off. + +Consequently, the bounded-revocation claim in this change applies to new HTTP +admissions and database creation paths. It must not be described as immediate +revocation of already-open WebSockets. + +## Verified review findings and disposition + +| Finding | Disposition | Planned treatment | +|---|---|---| +| 1. KV `put` failure causes a spurious 503 | Fix in PR 1 | Await the write but isolate its failure from the authoritative DB result. | +| 2. Invalidation runs inside a caller transaction | Fix in PR 2 | Use post-response scheduling for caller-owned transactions and direct post-commit best-effort work for self-owned transactions. | +| 3. Bulk block paths do not invalidate | Explicit bounded-risk decision | Rely on TTL for bulk paths; document why per-user fan-out is not added. | +| 4. Two other session insert paths are unguarded | Fix in PR 1 | Add one transaction-aware user-admission helper and use it at all three inserts. | +| 5. Vercel fire-and-forget invalidation can be dropped | Fix in PR 2 | Await or register work with Next `after()`; never leave an untracked promise. | +| 6. The 60-second TTL changes outage behavior | Explicit operational sign-off | Preserve fail-closed behavior, document it, and add deployment monitoring gates. | +| 7. `getToken` exposes a pepper-less client token | Low-disruption fix in PR 2 | Issue a pepper-bearing token with the same one-hour lifetime; accept old tokens and open-socket residuals. | +| 8. Invalidation client duplication | No change | Keep the two explicit helpers because their missing-configuration contracts intentionally differ. | +| 9. Missing-pepper semantics differ across services | Clarify in PR 1 | Add comments at both trust boundaries; do not introduce a premature shared classifier. | +| 10. Internal-secret check is repeated | Fix in PR 1 | Apply the existing middleware to all affected internal routes. | +| 11. Null/undefined style | No change | No correctness or maintenance value. | + +## Compatibility model + +### Token classes during the rollout + +| Token | Before PR 1 | After PR 1 | After PR 2 | +|---|---|---|---| +| Ordinary API token with `apiTokenPepper` | Signature + retained-row existence | Pepper and blocked-state checked through KV | Same | +| Existing `getToken` token without pepper | Accepted | Accepted as the named internal compatibility class | Accepted until its one-hour expiry | +| New `getToken` token | Pepper-less | Pepper-less until PR 2 | Pepper-bearing, one-hour expiry | +| Server-generated internal token without pepper | Accepted | Accepted while the user row exists, regardless of block state | Unchanged | +| Token with an unexpected `aud` | Rejected | Rejected | Rejected | + +### Why session-ingest must deploy first + +PR 1 establishes the verifier that understands the new pepper-bearing viewer +token before PR 2 starts minting it. The old session-ingest middleware would also +accept the new token because it ignores the pepper, so the rollout is technically +bidirectionally compatible. Deploying session-ingest first is still preferable: + +- it makes the intended dependency explicit; +- it closes the unguarded Cloud Agent insert path first; +- it allows production observation of the KV/Hyperdrive behavior before token + issuance changes; +- it ensures the invalidation endpoint exists before web starts calling it; and +- it gives PR 2 a simple rollback path without rolling back the security + enforcement in PR 1. + +## PR 1 — session-ingest compatibility and enforcement + +### Worktree and branch preparation + +Use `eshurakov-mighty-hazel`. Before editing: + +1. Confirm `git status --short` is empty. +2. Confirm the branch is based on the intended current `main`. +3. Read the root, `services/AGENTS.md`, and `packages/db/AGENTS.md` instructions. +4. Read the root and `services/session-ingest/package.json` scripts before + running package commands. +5. Re-inspect every target file on this branch; do not assume the older + `serene-cedar` line numbers still match. + +### PR 1 file scope + +Expected production files: + +- `services/session-ingest/src/middleware/kilo-jwt-auth.ts` +- `services/session-ingest/src/services/user-session-admission.ts` — new shared + admission helper; use a similarly focused existing services directory if the + current branch has a more appropriate established location +- `services/session-ingest/src/routes/api.ts` +- `services/session-ingest/src/routes/cloud-agent-session-scope.ts` +- `services/session-ingest/src/session-ingest-rpc.ts` +- `services/session-ingest/src/app.ts` +- `packages/worker-utils/src/kilo-token-auth.ts` — comment-only clarification + +Expected tests: + +- `services/session-ingest/src/middleware/kilo-jwt-auth.test.ts` +- `services/session-ingest/src/routes/api.test.ts` +- `services/session-ingest/src/routes/cloud-agent-session-scope.test.ts` +- `services/session-ingest/src/session-ingest-rpc.test.ts` +- `services/session-ingest/src/index.test.ts` or the existing app-route test that + owns internal endpoint authentication +- `packages/worker-utils/src/kilo-token-auth.test.ts` only if a behavior assertion + is needed to protect the documented legacy null-pepper semantics + +Do not change `apps/web/**` in PR 1. + +### 1. Replace the existence-only KV value with versioned auth state + +In `kilo-jwt-auth.ts`: + +```ts +const USER_AUTH_CACHE_KEY_PREFIX = 'user-auth:v1:'; +const USER_AUTH_TTL_SECONDS = 60; +const USER_MISSING_TTL_SECONDS = 5 * 60; + +type CachedUserAuthV1 = + | { v: 1; exists: false } + | { v: 1; exists: true; pepper: string | null; blockedReason: string | null }; +``` + +Required behavior: + +1. Read only `user-auth:v1:`. +2. Parse with a strict Zod schema. +3. Treat legacy `user-exists:` keys, literal `"1"`/`"0"`, malformed JSON, + unknown versions, and wrong field types as cache misses. None may authorize. +4. On a miss, call the existing `findKiloUserPepper` helper. +5. Cache a present user's pepper and blocked reason for 60 seconds. +6. Cache a missing user for five minutes. +7. Fail closed with 503 if KV `get`, secret resolution, PostgreSQL, or Hyperdrive + fails before an authoritative state is available. +8. Do not log tokens, peppers, Authorization headers, internal secrets, or cache + payloads. + +### 2. Isolate KV write failure from authorization + +An authoritative DB result is sufficient to decide the current request. A cache +write is an optimization for later requests. + +After constructing `state` from PostgreSQL: + +1. Await `USER_EXISTS_CACHE.put` as required by the platform contract. +2. Catch only the `put` failure locally. +3. Emit a sanitized structured warning containing the operation, user ID, and + safe error message/class, but never the serialized state or pepper. +4. Return the authoritative state whether the `put` succeeds or fails. + +Expected outcomes: + +- present, unblocked matching user + failed `put` → authorize; +- present blocked or mismatched user + failed `put` → 403; +- missing user + failed `put` → 403; +- DB lookup failure → 503; and +- KV `get` failure → 503 rather than an unbounded DB fallback during a KV outage. + +The implementation must not use `void USER_EXISTS_CACHE.put(...)`; the test must +prove that the write was awaited even though its rejection is non-fatal. + +### 3. Preserve and document token classification + +After signature, expiry, schema, and audience verification: + +- `apiTokenPepper === undefined` is the explicit internal compatibility class; +- `apiTokenPepper` present as a string or `null` is an ordinary user token; +- ordinary tokens require `blockedReason === null` and strict pepper equality; +- internal compatibility tokens require an existing user row but ignore pepper + and blocked state; and +- unexpected audiences remain rejected. + +Add a concise code comment at this branch in `kilo-jwt-auth.ts`. Add a matching +comment near `payload.apiTokenPepper ?? null` in +`packages/worker-utils/src/kilo-token-auth.ts` explaining that the shared helper +uses legacy null-pepper comparison semantics and does not perform +session-ingest's internal-token classification. + +Do not add a shared `classifyKiloToken` abstraction in this PR. There is no +second consumer with the same policy. + +### 4. Add one transaction-aware CLI session admission helper + +Create a small shared session-ingest service whose only responsibility is to +decide whether a user may create a `cli_sessions_v2` row. + +Suggested contract: + +```ts +async function canCreateCliSessionForUser( + tx: WorkerDbOrTransaction, + kiloUserId: string +): Promise +``` + +The exact database type should reuse an existing exported repository type rather +than defining a broad local interface. + +Required query semantics: + +1. Select the `kilocode_users` row by ID. +2. Lock the row for the duration of the creation transaction using the existing + Drizzle/PostgreSQL locking pattern (`FOR UPDATE` or the narrowest equivalent + that conflicts with the blocking update). +3. Return true only when the row exists and `blocked_reason IS NULL`. +4. Do not compare a token pepper here. This guard also protects trusted internal + creation paths, and ordinary-token pepper validation remains middleware's + responsibility. +5. Do not reveal whether the user is missing or blocked through public HTTP + responses. + +The guard and insert must execute in the same PostgreSQL transaction. A separate +preflight query is insufficient because blocking could commit between the check +and insert. + +Concurrency invariant: + +- if session creation acquires the user lock first, its row commits before the + block and is part of the pre-block/in-flight state that deletion must clean; +- if blocking acquires the user lock first, session creation observes the + blocked row and refuses the insert; and +- no creation can observe an unblocked row, release that observation, then + insert after the block commits. + +### 5. Apply admission at every production insert site + +#### Public `POST /api/session` + +Wrap the admission check and `cli_sessions_v2` insert in one transaction. +Return the existing generic 403 shape when admission fails. Preserve current +idempotent conflict behavior, event emission, and access-cache warming after the +transaction. + +#### `SessionIngestRPC.createSessionForCloudAgent` + +Run admission at the start of the existing transaction, before attempting the +insert or rebinding an existing row. If admission fails, throw one stable, +non-sensitive domain error. The Cloud Agent caller must treat it as failed +session preparation and must not continue creating execution state as if the +ownership row existed. + +This is the critical bypass closure: Cloud Agent's public `start` authentication +currently verifies token signature/shape but does not compare current pepper or +blocked state before invoking the service-binding RPC. + +#### Cloud Agent scoped child-session creation + +Run admission inside the existing transaction before locking the root session +and before inserting a contained child. Return the same generic 403 response as +the public creation route. Preserve root identity, organization access, and +scope assertions. + +Do not add blocked checks to read, export, delete, share, or ingest routes as +part of this item. + +### 6. Add the internal user-auth invalidation endpoint + +Add `POST /internal/user-auth/invalidate` with: + +- body `{ kiloUserId: string }` validated by Zod; +- `X-Internal-Secret` authentication; +- deletion of `user-auth:v1:` from the existing + `USER_EXISTS_CACHE` namespace; +- 204 on success; +- 400 on invalid input; and +- 401 on a missing or invalid internal secret. + +The endpoint may ship unused in PR 1. That is intentional and enables the safe +deployment order. + +### 7. Reuse the existing internal-secret middleware + +Apply `requireValidInternalSecret` to the exact internal routes that currently +repeat `hasValidInternalSecret`, including: + +- `/internal/session-access/invalidate`; +- `/internal/user-auth/invalidate`; and +- `/internal/session/:sessionId/export`. + +Keep the existing middleware ordering for `/internal/cloud-agent/v1/*`: Kilo JWT +authentication followed by internal-secret authentication. Do not broaden the +secret middleware to public routes or change public 401/404 behavior. + +### 8. PR 1 tests + +#### Middleware tests + +Cover at minimum: + +- matching ordinary pepper + unblocked state → 200; +- stale pepper → 403; +- blocked state → 403; +- present `null` token pepper + `null` stored pepper → 200; +- present `null` token pepper + string stored pepper → 403; +- missing user → 403; +- warm cache hit avoids PostgreSQL; +- malformed and legacy values cause a DB miss and never authorize directly; +- present-user cache miss writes TTL 60; +- missing-user cache miss writes TTL 300; +- `put` remains pending → response remains pending; +- `put` rejects after a successful DB read → the DB-derived allow/deny response + is preserved; +- PostgreSQL throws → 503 and no authorization; +- KV `get` throws → 503 and no PostgreSQL stampede fallback; +- internal compatibility token + existing blocked user → accepted; +- internal compatibility token + missing user → 403; +- unexpected audience → 401; and +- missing/malformed bearer token → 401. + +#### Creation-admission tests + +For each of the three insert sites, cover: + +- existing unblocked user → current create/idempotent behavior; +- existing blocked user → no insert; +- missing user → no insert; and +- admission failure occurs before any post-insert event/cache side effect. + +Add one database-backed concurrency regression test if the repository's current +session-ingest test infrastructure can exercise two real transactions without +substantial harness work. It should demonstrate that a concurrent block and +create serialize on the user row. If the service suite is mock-only, protect the +locking and same-transaction invariant with focused query-chain tests and note +the lack of a real concurrency test in the PR description. + +#### Internal route tests + +Cover valid secret + valid body, invalid secret, missing secret, malformed body, +and exact versioned-key deletion. Ensure route authentication tests prove the +middleware is actually mounted rather than merely unit-testing the helper. + +### 9. PR 1 verification + +Run the narrow service checks first, then the package-wide suite: + +```sh +pnpm --filter cloudflare-session-ingest test -- src/middleware/kilo-jwt-auth.test.ts +pnpm --filter cloudflare-session-ingest test -- src/routes/api.test.ts +pnpm --filter cloudflare-session-ingest test -- src/routes/cloud-agent-session-scope.test.ts +pnpm --filter cloudflare-session-ingest test -- src/session-ingest-rpc.test.ts +pnpm --filter cloudflare-session-ingest test +pnpm --filter cloudflare-session-ingest typecheck +pnpm --filter cloudflare-session-ingest lint +``` + +Also run the narrow `@kilocode/worker-utils` test/typecheck commands declared in +its `package.json` if that package changes. Finish with formatting of only +task-owned files and `git diff --check`. + +Do not report Docker/PostgreSQL unavailability as a code failure. State which +database-backed checks could not run and the remaining concurrency risk. + +### 10. Suggested PR 1 commit boundaries + +1. `fix(session-ingest): cache revocable user auth state` +2. `fix(session-ingest): guard every CLI session insert` +3. `feat(session-ingest): add user auth cache invalidation endpoint` +4. `test(session-ingest): cover revocation failure and compatibility paths` + +Small adjustments are acceptable, but keep production behavior and test-only +changes reviewable rather than combining the entire port into one opaque commit. + +## PR 1 deployment and observation gate + +### Pre-deploy checks + +- PR merged from current `main` with all required checks green. +- The `USER_EXISTS_CACHE`, `HYPERDRIVE`, `NEXTAUTH_SECRET_PROD`, and + `INTERNAL_API_SECRET_PROD` bindings exist in the target environment. +- No secret or pepper values appear in logs or test fixtures. +- The deployment owner acknowledges the 60-second fail-closed outage trade-off. + +### Deploy + +Deploy session-ingest by the normal production process. Do not deploy PR 2 in the +same change window. + +### Observe before PR 2 + +Observe at least one full positive TTL plus KV propagation margin; use a minimum +of several minutes and extend the window if traffic or metrics are sparse. + +Check: + +- total request and WebSocket-handshake success rate; +- 401, 403, and 503 rates split by route class; +- p50/p95/p99 authentication latency; +- Hyperdrive/PostgreSQL query rate and connection errors; +- KV `get`, `put`, and sanitized cache-write failure logs; +- Cloud Agent session-start failures from `createSessionForCloudAgent`; and +- unexpected growth in missing/malformed cache-state misses. + +Expected one-time behavior: + +- legacy `user-exists:` entries are ignored; +- the first request per active user for the new versioned key reads PostgreSQL; +- concurrent first requests may duplicate that read and write because KV is not + a single-flight cache; and +- load settles toward one auth-state lookup per distinct active user per minute. + +### PR 1 rollback + +If availability or load is unacceptable, roll back only the session-ingest +deployment. The new `user-auth:v1:` keys are isolated by prefix and expire on +their own. No schema or client rollback is required because PR 2 has not shipped. + +Do not proceed to PR 2 until PR 1 is healthy. + +## PR 2 — web token issuance and invalidation + +### Branch preparation + +Create PR 2 from `main` after PR 1 has merged and its production deployment has +passed the observation gate. Prefer a fresh worktree. If reusing +`eshurakov-serene-cedar`, first preserve its uncommitted user-owned files, update +it deliberately, and resolve overlaps without overwriting those changes. + +PR 2 depends on the deployed invalidation endpoint but remains safe if an +individual invalidation call fails because the 60-second cache TTL owns +correctness. + +### PR 2 file scope + +Expected production files: + +- `apps/web/src/routers/active-sessions-router.ts` +- `apps/web/src/lib/session-ingest-client.ts` +- `apps/web/src/lib/user/block.ts` +- `apps/web/src/lib/user/index.ts` + +Potential comments/documentation at the direct bulk block paths: + +- `apps/web/src/lib/abuse/bulkBlock.ts` +- `apps/web/src/app/admin/api/backfills/block-blacklisted-domains/route.ts` + +Expected tests: + +- the focused active-sessions token test file; +- `apps/web/src/lib/session-ingest-client.test.ts`; +- the unset-environment helper test if kept separate; +- `apps/web/src/lib/user/block-invalidate.test.ts` or the existing block test + suite; and +- `apps/web/src/lib/user/soft-delete-invalidate.test.ts` or the existing + soft-delete test suite. + +Do not change session-ingest behavior in PR 2 except for a narrowly justified +follow-up discovered during integration verification. + +### 1. Mint a pepper-bearing viewer token + +Change only `activeSessions.getToken` to mint an ordinary user token containing +the current `ctx.user.api_token_pepper`, with an explicit one-hour expiry. + +Requirements: + +- preserve the response shape `{ token }`; +- preserve the one-hour lifetime; +- use the existing `generateApiToken` and `TOKEN_EXPIRY.oneHour` primitives; +- do not change mobile, web, or extension consumers; +- do not change the other `generateInternalServiceToken` calls in + `active-sessions-router.ts`; they are server-side calls and remain part of the + internal compatibility class; and +- do not introduce a new JWT audience or token format in this PR. + +The test must decode/verify the minted token and assert: + +- `kiloUserId` matches the authenticated user; +- `apiTokenPepper` is present, including the distinction between explicit + `null` and an absent field; +- expiry is approximately one hour; and +- no unrelated user fields are added. + +### 2. Add the web invalidation client + +Add `invalidateUserAuthCache(kiloUserId)` next to the existing organization +session-access invalidation helper. + +Requirements: + +- call `POST /internal/user-auth/invalidate`; +- send `X-Internal-Secret` and a JSON content type; +- send only `{ kiloUserId }`; +- use the existing 30-second request deadline pattern; +- capture and throw non-2xx failures using sanitized metadata; +- never log a token, pepper, secret, cookie, or Authorization header; and +- return without a network request when the session-ingest URL or internal + secret is unset, preserving the chosen local/test behavior. + +Keep this explicit helper separate from organization-session invalidation. Their +missing-configuration behavior is intentionally different, so a generic helper +with policy flags would add more concepts than it removes. + +### 3. Make single-user block invalidation post-commit and lifecycle-safe + +`blockUser` has two transaction ownership modes and must handle them separately. + +#### Self-owned transaction + +When `blockUser` opens and awaits its own transaction: + +1. finish the transaction; +2. if the user transitioned to blocked, call a best-effort invalidation wrapper; +3. await that wrapper so the fetch is tracked by the invocation; and +4. catch/log failure inside the wrapper so the successful block is still + returned. + +#### Caller-owned transaction (`dbOrTx`) + +`blockUser` cannot observe the caller's commit. For this mode: + +1. finish the block writes using the provided transaction; +2. if the user transitioned to blocked, register the best-effort invalidation + with Next's `after()` using the established `credits.ts` pattern; +3. in automated tests, execute or capture the callback through the repository's + established test branch/mock because `after()` requires request context; and +4. do not start the invalidation fetch synchronously before returning to the + outer transaction. + +`after()` may still run after a later caller rollback. That produces only an +extra cache miss and authoritative reload of the still-unblocked row; it cannot +incorrectly block the user. The important invariant is that it cannot delete the +cache and allow a pre-commit DB read to repopulate a fresh unblocked value. + +Tests must prove: + +- self-owned block invalidates after transaction success; +- caller-owned block does not invoke invalidation before the outer transaction + callback finishes; +- a rollback does not persist the block; +- invalidation failure does not change the successful block result; and +- already-blocked/missing users do not invalidate. + +Replace the current test that merely asserts invalidation occurred inside a +provided transaction; it encodes the wrong ordering. + +### 4. Make soft-delete invalidation post-commit and lifecycle-safe + +`softDeleteUser` owns and awaits its anonymization transaction. After it commits: + +1. call the same best-effort invalidation wrapper; +2. await the wrapper so Vercel cannot freeze an untracked fetch; +3. catch and report failure without failing completed anonymization; and +4. preserve the existing deletion event behavior outside this remediation's + scope. + +Tests must prove invalidation occurs only after successful anonymization, is not +called when the transaction fails, and cannot turn a completed soft-delete into +an error. + +### 5. Explicitly retain TTL-only behavior for bulk blocking + +Do not issue one invalidation HTTP request per user from `bulkBlockUsers` or the +blacklisted-domain backfill. Those paths can update up to thousands of users, +and an unbounded HTTP/KV fan-out would add a larger availability risk than the +accelerator solves. + +Document near each direct block update that: + +- session-ingest authorization correctness is bounded by the 60-second positive + TTL plus KV propagation; +- these bulk paths intentionally do not accelerate invalidation; +- pepper rotation and non-null `blocked_reason` remain authoritative; and +- a future bulk invalidation endpoint requires measured need and bounded Worker + operation/concurrency limits. + +This resolves the review finding by making the trade-off explicit rather than +silently omitting a presumed required side effect. + +### 6. PR 2 tests + +Cover at minimum: + +- `getToken` returns a pepper-bearing one-hour token; +- existing client code compiles without response-contract changes; +- invalidation helper request URL, method, body, secret header, and timeout; +- invalidation helper non-2xx reporting; +- unset URL and unset secret both skip fetch; +- self-owned block post-commit ordering; +- caller-owned block scheduling and ordering; +- block invalidation failure isolation; +- already-blocked/missing block behavior; +- successful soft-delete post-commit invalidation; +- failed soft-delete does not invalidate; and +- soft-delete invalidation failure isolation. + +Do not add snapshots that merely reproduce implementation details. Prefer +observable ordering and outcome assertions. + +### 7. PR 2 verification + +Read the current `apps/web/package.json` and use its exact scripts. At minimum: + +```sh +pnpm --dir apps/web exec jest --runInBand --no-watchman +pnpm --filter web typecheck +pnpm --filter web lint +``` + +The web Jest environment performs global PostgreSQL cleanup even for apparently +unit-only files. Start/migrate the documented test database when available. If +Docker/PostgreSQL is unavailable, report that environment limitation separately +and still run typecheck, lint, `git diff --check`, and any test suite that can run +without the database. + +Format only task-owned files and finish with `git diff --check`. + +### 8. Suggested PR 2 commit boundaries + +1. `fix(web): issue revocable active-session viewer tokens` +2. `fix(web): invalidate session auth after committed user blocks` +3. `fix(web): invalidate session auth after soft delete` +4. `test(web): cover revocable token and invalidation lifecycle` + +## PR 2 deployment and drain + +### Pre-deploy + +- Confirm PR 1 is still deployed and healthy. +- Confirm the internal invalidation endpoint returns 204 from the deployed web + environment using the configured secret; do not print the secret. +- Confirm every web deployment serving `activeSessions.getToken` is included. +- Confirm the one-hour expiry is explicit in code and tests. + +### Deploy + +Deploy PR 2 through the normal web deployment process. No client-store release +or coordinated mobile/extension update is required because clients treat the +token as opaque and fetch it on connection/recovery. + +### Observe + +Check: + +- `activeSessions.getToken` error rate; +- `/api/user/web` handshake 401/403 rates; +- invalidation endpoint 2xx/4xx/5xx rates and latency; +- logged invalidation failures; +- unexpected mobile, extension, or web reconnect loops; +- session-ingest DB lookup and 503 rates; and +- reports of active-session lists or remote controls failing for unblocked + users. + +### Drain window + +Wait at least one hour after the last web deployment finishes before claiming +that previously issued pepper-less viewer tokens have expired. This drain does +not close already-open WebSockets and must not be represented as doing so. + +### PR 2 rollback + +Rolling back web token issuance is safe because deployed session-ingest continues +to accept pepper-less internal compatibility tokens. Leave PR 1 deployed unless +its own metrics require rollback. The invalidation endpoint may remain unused. + +## End-to-end acceptance criteria + +### Security and correctness + +- A matching ordinary token for an existing unblocked user is accepted. +- An ordinary token with a rotated pepper is rejected after the KV convergence + window. +- An ordinary token for a blocked user is rejected after the KV convergence + window. +- Legacy cache blobs cannot authorize a request. +- A failed KV cache write does not reject a request whose authoritative DB state + permits it. +- DB/KV-read failures fail closed without exposing credentials. +- Pepper-less internal compatibility tokens retain their named behavior. +- A blocked or missing user cannot create a `cli_sessions_v2` row through the + public route, the Cloud Agent RPC, or the scoped child-session route. +- Admission check and insert are serialized in the same transaction. +- Blocking and soft-delete success do not depend on invalidation success. +- Single-user invalidation is not initiated inside an uncommitted caller-owned + transaction and is tracked through completion by await or `after()`. + +### Compatibility + +- PR 1 supports old pepper-less viewer tokens and new pepper-bearing tokens. +- PR 1 can be deployed and rolled back before any web change. +- Existing mobile, extension, and web clients require no code update. +- PR 2 preserves `{ token }` and the one-hour token lifetime. +- Existing server-to-server internal token issuance remains unchanged. +- The accepted already-open WebSocket residual is documented and is not hidden + behind an overbroad revocation claim. + +### Operations + +- The 60-second positive TTL and 503-on-authoritative-read-failure behavior are + explicit in both PR descriptions. +- Production observation separates expected cold versioned-key misses from + errors. +- Neither PR logs tokens, peppers, secrets, cookies, or authentication headers. +- Rollback requires no database migration, KV deletion, or client release. + +## Deferred follow-up: strict WebSocket revocation + +Create a separate design only if product/security requires already-open viewer +sockets to lose mutation ability within the same bounded revocation window. +Evaluate, with measured traffic and latency: + +1. command-time cached authorization checks in `UserConnectionDO`; +2. attaching token class/pepper/expiry metadata to accepted sockets and forcing + periodic reauthentication; +3. a reliable per-user socket-revocation RPC with retries/durable delivery; or +4. short-lived, purpose-bound session-ingest viewer tokens. + +That follow-up must explicitly cover Durable Object hibernation, reconnect +behavior, pending command settlement, mobile background/resume behavior, +extension lifecycle, deploy-version skew, and the load added to KV or another +authorization service. It is not part of either PR in this plan. + +## Final handoff checklist + +Before declaring the remediation complete: + +1. PR 1 is merged, deployed, observed, and its production health gate passed. +2. PR 2 was based on PR 1's merged `main`, not the older combined diff. +3. PR 2 is merged and deployed to every relevant web project. +4. The one-hour legacy viewer-token drain elapsed. +5. Automated checks and any environment-limited checks are reported accurately. +6. PR descriptions include the availability trade-off and WebSocket residual. +7. No unrelated user changes in either worktree were overwritten. +8. No push, deployment, or PR-thread action is performed by an agent without + explicit authorization. From b5e7847d18a027adcaaffb95934f41d214b42fe3 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 18 Aug 2026 15:07:26 +0200 Subject: [PATCH 3/3] fix(web): resolve main rebase import conflict --- apps/web/src/routers/active-sessions-router.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/routers/active-sessions-router.test.ts b/apps/web/src/routers/active-sessions-router.test.ts index 464a82e5f3..1c11b08b67 100644 --- a/apps/web/src/routers/active-sessions-router.test.ts +++ b/apps/web/src/routers/active-sessions-router.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, jest, beforeAll, afterEach } from '@jest/globals'; import jwt from 'jsonwebtoken'; import { TRPCError } from '@trpc/server'; -import jwt from 'jsonwebtoken'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { db } from '@/lib/drizzle'; import { organizations, organization_memberships } from '@kilocode/db/schema';