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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/extension/entrypoints/sidepanel/agents-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const ExtensionAgentsProvider = ({

const userWebConnection = createUserWebConnection({
getAuthToken: async () => {
const tokenResult = await trpcClient.activeSessions.getToken.mutate();
const tokenResult = await trpcClient.activeSessions.createWebTicket.mutate();
return tokenResult.token;
},
lifecycleHooks: createBrowserLifecycleHooks(),
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/tests/e2e/agents-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ export const mockAgentsApi = async (
return { result: { data: { favorites: [], lastSelected: null } } };
}

if (proc === 'activeSessions.getToken') {
if (proc === 'activeSessions.createWebTicket' || proc === 'activeSessions.getToken') {
return {
result: { data: { expiresAt: 1_700_000_000, token: 'mock-ingest-token' } },
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ vi.mock('@/lib/user-web-connection-lifecycle', () => ({
vi.mock('@/lib/trpc', () => ({
trpcClient: {
activeSessions: {
getToken: {
createWebTicket: {
mutate: mocks.mutate,
},
getToken: {
query: mocks.query,
},
},
Expand All @@ -63,7 +65,7 @@ describe('UserWebConnectionProvider', () => {
mocks.createUserWebConnection.mockClear();
});

it('mints the ingest ticket via the getToken mutation', async () => {
it('mints the ingest ticket via the createWebTicket mutation', async () => {
const holder: { renderer?: TestRenderer.ReactTestRenderer } = {};
await act(() => {
holder.renderer = TestRenderer.create(createElement(UserWebConnectionProvider, null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ vi.mock('@/lib/user-web-connection-lifecycle', () => ({
vi.mock('@/lib/trpc', () => ({
trpcClient: {
activeSessions: {
getToken: {
createWebTicket: {
mutate: mocks.mutate,
},
getToken: {
query: mocks.query,
},
},
Expand All @@ -49,7 +51,7 @@ describe('UserWebConnectionProvider', () => {
mocks.query.mockClear();
});

it('mints the ingest ticket via the getToken mutation (not query)', async () => {
it('mints the ingest ticket via the createWebTicket mutation (not the getToken query)', async () => {
const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = {
current: undefined,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function UserWebConnectionProvider({ children }: Readonly<UserWebConnecti
connectionRef.current ??= createUserWebConnection({
websocketUrl: `${SESSION_INGEST_WS_URL}/api/user/web`,
getAuthToken: async () => {
const result = await trpcClient.activeSessions.getToken.mutate();
const result = await trpcClient.activeSessions.createWebTicket.mutate();
return result.token;
},
lifecycleHooks: createNativeUserWebConnectionLifecycleHooks(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi
sharedConnectionRef.current = createUserWebConnection({
websocketUrl: `${SESSION_INGEST_WS_URL}/api/user/web`,
getAuthToken: async () => {
const result = await trpcClient.activeSessions.getToken.mutate();
const result = await trpcClient.activeSessions.createWebTicket.mutate();
return result.token;
},
lifecycleHooks: createBrowserLifecycleHooks(),
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/routers/active-sessions-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe('active-sessions-router', () => {
jest.restoreAllMocks();
});

describe('getToken', () => {
describe('web ticket minting', () => {
it('mints a one-use ticket and returns { token, expiresAt }', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ticket: 'ticket-abc', expiresAt: 1_700_000_060 }), {
Expand All @@ -92,6 +92,22 @@ describe('active-sessions-router', () => {
expect((init.headers as Record<string, string>).Authorization).toContain('Bearer ');
});

it('mints the same ticket through the createWebTicket mutation', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ticket: 'ticket-abc', expiresAt: 1_700_000_060 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);

const caller = await createCallerForUser(regularUser.id);

await expect(caller.activeSessions.createWebTicket()).resolves.toEqual({
token: 'ticket-abc',
expiresAt: 1_700_000_060,
});
});

it('throws PRECONDITION_FAILED when the worker returns a non-2xx response', async () => {
jest
.spyOn(global, 'fetch')
Expand Down
109 changes: 63 additions & 46 deletions apps/web/src/routers/active-sessions-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,56 +228,73 @@ function throwOrgContextFailure(error: unknown): never {
});
}

export const activeSessionsRouter = createTRPCRouter({
/**
* Mint a one-use web ticket from Session Ingest for the authenticated user.
* The returned `token` is the opaque ticket; `expiresAt` is the Unix-seconds
* expiry from the worker body. A missing worker URL or a non-2xx mint
* response fails fast with PRECONDITION_FAILED rather than hanging.
*/
getToken: baseProcedure.mutation(async ({ ctx }) => {
if (!SESSION_INGEST_WORKER_URL) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session ingest is not configured',
});
}
/**
* Mint a one-use web ticket from Session Ingest for the given user. The
* returned `token` is the opaque ticket; `expiresAt` is the Unix-seconds
* expiry from the worker body. A missing worker URL or a non-2xx mint
* response fails fast with PRECONDITION_FAILED rather than hanging.
*/
async function mintWebTicket(userId: string): Promise<{ token: string; expiresAt: number }> {
if (!SESSION_INGEST_WORKER_URL) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session ingest is not configured',
});
}

const token = generateInternalServiceToken(ctx.user.id);
const url = `${SESSION_INGEST_WORKER_URL}/api/user/web-ticket`;
const token = generateInternalServiceToken(userId);
const url = `${SESSION_INGEST_WORKER_URL}/api/user/web-ticket`;

let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
} catch (error) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session ingest is not configured',
cause: error,
});
}

let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
} catch (error) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session ingest is not configured',
cause: error,
});
}
if (!response.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session ingest is not configured',
});
}

if (!response.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session ingest is not configured',
});
}
const raw = await response.json();
const parsed = webTicketResponseSchema.safeParse(raw);
if (!parsed.success) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Invalid ticket response from session ingest',
cause: parsed.error,
});
}
return { token: parsed.data.ticket, expiresAt: parsed.data.expiresAt };
}

const raw = await response.json();
const parsed = webTicketResponseSchema.safeParse(raw);
if (!parsed.success) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Invalid ticket response from session ingest',
cause: parsed.error,
});
}
return { token: parsed.data.ticket, expiresAt: parsed.data.expiresAt };
}),
export const activeSessionsRouter = createTRPCRouter({
/**
* Mint a web ticket. This is the path forward: minting is not idempotent,
* so it belongs on a mutation.
*/
createWebTicket: baseProcedure.mutation(({ ctx }) => mintWebTicket(ctx.user.id)),

/**
* TODO: remove once no shipped client calls this. Superseded by
* `createWebTicket`. Store builds and installed extensions cannot update in
* step with the server, so the procedure has to stay a query: tRPC answers a
* query-shaped call to a mutation with 405, and fails the whole batch with
* 400 "Cannot mix procedure types in call" when it is batched beside a query.
* Drop it when the mobile and extension releases that call `createWebTicket`
* have rolled out and the getToken traffic in Axiom reaches zero.
*/
getToken: baseProcedure.query(({ ctx }) => mintWebTicket(ctx.user.id)),

list: baseProcedure.input(listInputSchema).query(async ({ ctx, input }) => {
const organizationId = input?.organizationId;
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/routers/user-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { eq, inArray } from 'drizzle-orm';
import { insertTestUser } from '@/tests/helpers/user.helper';
import type { User } from '@kilocode/db/schema';
import { sendSignInCodeEmail } from '@/lib/email';
import {
sendAccountDeletionConfirmationEmail,
sendAccountDeletionSupportNotification,
} from '@/lib/email';
import { performGdprRemoval } from '@/lib/user/gdpr-removal';
import { assertUserCanBeSoftDeleted, SoftDeletePreconditionError } from '@/lib/user';

Expand All @@ -19,6 +23,8 @@ jest.mock('@/lib/email', () => {
return {
...actual,
sendSignInCodeEmail: jest.fn(),
sendAccountDeletionConfirmationEmail: jest.fn(),
sendAccountDeletionSupportNotification: jest.fn(),
};
});

Expand All @@ -35,6 +41,8 @@ jest.mock('@/lib/user', () => {
});

const mockSendSignInCodeEmail = jest.mocked(sendSignInCodeEmail);
const mockSendDeletionConfirmation = jest.mocked(sendAccountDeletionConfirmationEmail);
const mockSendDeletionSupportNotification = jest.mocked(sendAccountDeletionSupportNotification);
const mockPerformGdprRemoval = jest.mocked(performGdprRemoval);
const mockAssertUserCanBeSoftDeleted = jest.mocked(assertUserCanBeSoftDeleted);

Expand Down Expand Up @@ -1035,12 +1043,16 @@ describe('user router - account deletion', () => {
});

mockSendSignInCodeEmail.mockReset();
mockSendDeletionConfirmation.mockReset();
mockSendDeletionSupportNotification.mockReset();
mockPerformGdprRemoval.mockReset();
mockAssertUserCanBeSoftDeleted.mockReset();

mockAssertUserCanBeSoftDeleted.mockResolvedValue(undefined);
mockPerformGdprRemoval.mockResolvedValue({ warnings: [] });
mockSendSignInCodeEmail.mockResolvedValue({ sent: false, reason: 'provider_not_configured' });
mockSendDeletionConfirmation.mockResolvedValue({ sent: true });
mockSendDeletionSupportNotification.mockResolvedValue(undefined);
});

afterEach(async () => {
Expand Down Expand Up @@ -1113,6 +1125,26 @@ describe('user router - account deletion', () => {
expect(mockPerformGdprRemoval).not.toHaveBeenCalled();
});

it('no input keeps the legacy request flow: emails only, no removal', async () => {
const caller = await createCallerForUser(deletionUser.id);

const result = await caller.user.requestAccountDeletion();

expect(result).toEqual({ success: true });
expect(mockSendDeletionConfirmation).toHaveBeenCalledWith(deletionUser.google_user_email);
expect(mockSendDeletionSupportNotification).toHaveBeenCalledWith(
deletionUser.google_user_email,
deletionUser.id
);
expect(mockPerformGdprRemoval).not.toHaveBeenCalled();

const [stored] = await db
.select({ requestedAt: kilocode_users.account_deletion_requested_at })
.from(kilocode_users)
.where(eq(kilocode_users.id, deletionUser.id));
expect(stored?.requestedAt).not.toBeNull();
});

it('valid code calls performGdprRemoval once', async () => {
const caller = await createCallerForUser(deletionUser.id);
const { challengeId, devCode } = await caller.user.requestAccountDeletionChallenge();
Expand Down
40 changes: 38 additions & 2 deletions apps/web/src/routers/user-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
SoftDeletePreconditionError,
unlinkAuthProviderFromUser,
} from '@/lib/user';
import { sendSignInCodeEmail } from '@/lib/email';
import {
sendAccountDeletionConfirmationEmail,
sendAccountDeletionSupportNotification,
sendSignInCodeEmail,
} from '@/lib/email';
import {
consumeSignInCode,
createSignInCode,
Expand Down Expand Up @@ -945,11 +949,43 @@ export const userRouter = createTRPCRouter({
}),

requestAccountDeletion: baseProcedure
.input(z.object({ challengeId: z.uuid(), code: z.string().min(1) }))
.input(z.object({ challengeId: z.uuid(), code: z.string().min(1) }).optional())
.mutation(async ({ ctx, input }) => {
const userEmail = ctx.user.google_user_email;
const userId = ctx.user.id;

// TODO: remove this branch, and make the input required again, once no
// shipped client calls this without a challenge. Builds already in the
// stores send no input and a required input answers them with a 400, so
// they keep the old support-ticket flow: it emails the user and support
// and deletes nothing, which is what those builds tell the user happened.
// Drop it when the mobile release that sends { challengeId, code } has
// rolled out and input-less traffic in Axiom reaches zero.
if (!input) {
const lastRequested = ctx.user.account_deletion_requested_at;
if (
lastRequested &&
Date.now() - new Date(lastRequested).getTime() < ACCOUNT_DELETION_COOLDOWN_MS
) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Account deletion already requested. Please wait before trying again.',
});
}

await Promise.all([
sendAccountDeletionConfirmationEmail(userEmail),
sendAccountDeletionSupportNotification(userEmail, userId),
]);

await db
.update(kilocode_users)
.set({ account_deletion_requested_at: new Date().toISOString() })
.where(eq(kilocode_users.id, userId));

return successResult();
}

try {
await assertUserCanBeSoftDeleted(userId);
} catch (error) {
Expand Down