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
7 changes: 4 additions & 3 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,10 @@ The connection-role migration preserves a sole eligible connection, prefers an u
- `O11Y_SERVICE_URL` - URL for the observability (O11Y) service. [SERVER]
- `O11Y_KILO_GATEWAY_CLIENT_SECRET` - Client secret for the O11Y Kilo Gateway. `[SECRET]`
- `BOUNCER_URL` - Bouncer Worker URL. Defaults to `https://bouncer.kiloapps.io` in production. Every inference endpoint awaits one decide immediately before its provider request. [SERVER]
- Decide has a 500 ms budget. Only a verdict with `enforced: true` rejects: `rate_limited` and `spend_limited` return 429 `rate_limit_exceeded` with `retry-after` and `retry-after-ms`, `restricted` returns 403 `account_restricted`. A timeout, error, non-2xx, unknown shape, or any other verdict sends the request. Verdict flags are logged server-side and never returned to the client.
- Signup calls `/api/v1/signup-decide` only for new users, after account resolution and before Stripe customer creation. Existing sign-in and provider linking never consume signup quotas. The call uses `INTERNAL_API_SECRET` and a 500 ms budget.
- Development skips admission. Missing or invalid IPs, missing configuration, timeouts, transport errors, HTTP errors, invalid JSON, and unknown response shapes fail open.
- Decide has a 1,000 ms budget. Only a verdict with `enforced: true` rejects: `rate_limited` and `spend_limited` return 429 `rate_limit_exceeded` with `retry-after` and `retry-after-ms`, `restricted` returns 403 `account_restricted`. A timeout, error, non-2xx, unknown shape, or any other verdict sends the request. Verdict flags are logged server-side and never returned to the client.
- Signup calls `/api/v1/signup-decide` only for new users, after account resolution and before Stripe customer creation. Existing sign-in and provider linking never consume signup quotas. The call uses `INTERNAL_API_SECRET` and a 3,000 ms budget.
- Development skips admission. In other environments, signup requires a usable IP and a validated Bouncer verdict. Missing configuration, timeouts, transport errors, HTTP errors, invalid JSON, and unknown response shapes fail closed with `SIGNUP-UNAVAILABLE`.
- An unavailable verdict creates no Stripe customer, user, or auth provider. Web signup shows a retry message. Native token exchange returns HTTP 503 and releases the email-code reservation so the same code can be retried.
- Only a validated enforced `signup_rate_limited` response returns `SIGNUP-RATE-LIMITED`. Cloud accepts at most five merged IP/JA4 flags, including `signup:ja4`, and never exposes internal flags to the user.
- Signup sends `{ operationId, ip, ja4? }`. JA4 comes only from the original request's `x-vercel-ja4-digest` header, not the Cloud-to-Bouncer TLS fingerprint. It is trimmed, lowercased, and bounded to `^[a-z0-9_]{1,128}$`; missing or invalid values are omitted whole, never truncated, and IP admission still runs. `signupOperationId` hashes the existing normalized email with SHA-256 and prefixes the digest with `signup:`.
- The identity helper lives in `packages/web-shared/src/lib/bouncer/signup.ts`. Live admission and historical import must use the same helper.
Expand Down
25 changes: 14 additions & 11 deletions apps/web/src/app/api/auth/native/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,13 +734,14 @@ describe('POST /api/auth/native/token', () => {
expect(mockConsumeSignInCode).not.toHaveBeenCalled();
});

it('retry after DIFFERENT-OAUTH release reserves and settles the same code', async () => {
// First call: DIFFERENT-OAUTH triggers release. The second call must
// re-reserve the same code and settle successfully, proving the release.
it.each([
{ error: 'DIFFERENT-OAUTH' as const, status: 403 },
{ error: 'SIGNUP-UNAVAILABLE' as const, status: 503 },
])('retry after $error reserves and settles the same code', async ({ error, status }) => {
mockCreateOrUpdateUser
.mockResolvedValueOnce({
success: false,
error: 'DIFFERENT-OAUTH',
error,
} as never)
// Second call falls back to the beforeEach success default.
.mockResolvedValueOnce({
Expand All @@ -752,10 +753,9 @@ describe('POST /api/auth/native/token', () => {
const email = 'retry-oauth@example.com';
const code = '654321';

// First attempt: DIFFERENT-OAUTH → release.
const first = await POST(createRequest({ provider: 'email', email, code }));
expect(first.status).toBe(403);
expect(await first.json()).toEqual({ error: 'DIFFERENT-OAUTH' });
expect(first.status).toBe(status);
expect(await first.json()).toEqual({ error });
expect(mockReleaseSignInCode).toHaveBeenCalledWith(email, code, undefined);
expect(mockCommitSignInCode).not.toHaveBeenCalled();
expect(mockConsumeSignInCode).not.toHaveBeenCalled();
Expand Down Expand Up @@ -875,18 +875,21 @@ describe('POST /api/auth/native/token', () => {
});
});

it('returns 403 with the AuthErrorType when createOrUpdateUser fails', async () => {
it.each([
{ error: 'BLOCKED' as const, status: 403 },
{ error: 'SIGNUP-UNAVAILABLE' as const, status: 503 },
])('returns $status for $error without credentials', async ({ error, status }) => {
mockVerifyNativeGoogleIdToken.mockResolvedValue({
sub: 'google-sub-1',
email: 'googleuser@example.com',
});
mockCreateOrUpdateUser.mockResolvedValue({ success: false, error: 'BLOCKED' } as never);
mockCreateOrUpdateUser.mockResolvedValue({ success: false, error });

const response = await POST(createRequest({ provider: 'google', idToken: 'google-id-token' }));
const data = await response.json();

expect(response.status).toBe(403);
expect(data).toEqual({ error: 'BLOCKED' });
expect(response.status).toBe(status);
expect(data).toEqual({ error });
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/app/api/auth/native/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,10 @@ export const POST = withRestTiming('/api/auth/native/token', async (request: Req
);
if (!result.success) {
phase = 'release';
return NextResponse.json({ error: result.error }, { status: 403 });
return NextResponse.json(
{ error: result.error },
{ status: result.error === 'SIGNUP-UNAVAILABLE' ? 503 : 403 }
);
}

if (result.user.blocked_reason) {
Expand Down Expand Up @@ -547,7 +550,10 @@ export const POST = withRestTiming('/api/auth/native/token', async (request: Req
true
);
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 403 });
return NextResponse.json(
{ error: result.error },
{ status: result.error === 'SIGNUP-UNAVAILABLE' ? 503 : 403 }
);
}

if (result.user.blocked_reason) {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/auth/AuthErrorNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ export function AuthErrorNotification({ error }: { error: string }) {
</div>
);

if (error === 'SIGNUP-UNAVAILABLE')
return (
<div data-error-notification role="alert" aria-live="assertive" aria-atomic="true">
<ErrorNotificationBox title="Signup Temporarily Unavailable">
We couldn&apos;t complete your signup right now. Please try again in a few moments.
</ErrorNotificationBox>
</div>
);

if (error === 'EMAIL-ALREADY-USED')
return (
<div data-error-notification role="alert" aria-live="assertive" aria-atomic="true">
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/hooks/useSignInFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const callbackAuthErrors = new Set([
'PROVIDER-ALREADY-LINKED',
'LINKING-FAILED',
'SIGNUP-RATE-LIMITED',
'SIGNUP-UNAVAILABLE',
'EMAIL-ALREADY-USED',
'EMAIL-MUST-BE-LOWERCASE',
'EMAIL-CANNOT-CONTAIN-PLUS',
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/lib/user/anaconda-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, jest, test } from '
import { db } from '@kilocode/web-shared/lib/drizzle';
import { kilocode_users, user_auth_provider } from '@kilocode/db/schema';
import type { createOrUpdateUser as CreateOrUpdateUser } from '@/lib/user';
import type * as BouncerClient from '@kilocode/web-shared/lib/bouncer/client';
import { inArray } from 'drizzle-orm';

jest.mock('@kilocode/web-shared/lib/stripe-client', () => ({
Expand All @@ -11,6 +12,11 @@ jest.mock('@kilocode/web-shared/lib/stripe-client', () => ({
deleteStripeCustomer: jest.fn(async () => {}),
}));

jest.mock('@kilocode/web-shared/lib/bouncer/client', () => ({
...jest.requireActual<typeof BouncerClient>('@kilocode/web-shared/lib/bouncer/client'),
signupDecide: jest.fn(async () => ({ enforced: false, flags: [] })),
}));

jest.mock('@kilocode/web-shared/lib/posthog', () => {
const mockCapture = jest.fn();
return {
Expand Down Expand Up @@ -67,7 +73,12 @@ afterEach(async () => {

describe('Anaconda authentication persistence and tracking', () => {
test('stores a new Anaconda provider and tracks the signup', async () => {
const result = await createOrUpdateUser(anacondaAccount, undefined);
const result = await createOrUpdateUser(
anacondaAccount,
undefined,
false,
new Headers({ 'x-forwarded-for': '203.0.113.70' })
);
expect(result.success).toBe(true);
if (!result.success) return;
createdUserIds.push(result.user.id);
Expand All @@ -88,7 +99,12 @@ describe('Anaconda authentication persistence and tracking', () => {
});

test('tracks a returning Anaconda sign-in', async () => {
const signupResult = await createOrUpdateUser(anacondaAccount, undefined);
const signupResult = await createOrUpdateUser(
anacondaAccount,
undefined,
false,
new Headers({ 'x-forwarded-for': '203.0.113.71' })
);
expect(signupResult.success).toBe(true);
if (!signupResult.success) return;
createdUserIds.push(signupResult.user.id);
Expand Down
118 changes: 96 additions & 22 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@ describe('User', () => {
describe('createOrUpdateUser', () => {
beforeEach(() => {
mockRecordAffiliateAttributionAndQueueParentEvent.mockResolvedValue(null);
mockSignupDecide.mockReset().mockResolvedValue(null);
mockSignupDecide.mockReset().mockResolvedValue({ enforced: false, flags: [] });
jest.mocked(createStripeCustomer).mockClear();
jest.mocked(deleteStripeCustomer).mockClear();
});
Expand Down Expand Up @@ -950,22 +950,6 @@ describe('User', () => {
],
},
},
{
name: 'invalid six-flag rejection',
verdict: {
enforced: true,
code: 'signup_rate_limited',
retryAfterMs: 1_000,
flags: Array.from({ length: 6 }, () => ({
name: 'signup:ja4',
source: 'ja4',
decision: 'throttle',
enforced: true,
until: null,
})),
},
},
{ name: 'unavailable or unknown response', verdict: null },
])('creates the user after Bouncer $name', async ({ verdict }) => {
mockSignupDecide.mockImplementation(actualSignupDecide);
jest.spyOn(global, 'fetch').mockResolvedValue(Response.json(verdict));
Expand All @@ -992,6 +976,93 @@ describe('User', () => {
await db.query.kilocode_users.findFirst({ where: eq(kilocode_users.id, result.user.id) })
).toBeDefined();
});
it.each([
'unknown response',
'invalid six-flag rejection',
'transport failure',
'HTTP failure',
'invalid JSON',
'timeout',
])('prevents Stripe and user creation after Bouncer %s', async mode => {
mockSignupDecide.mockImplementation(request =>
actualSignupDecide(request, { timeoutMs: 30 })
);
const fetchMock = jest.spyOn(global, 'fetch');
switch (mode) {
case 'unknown response':
fetchMock.mockResolvedValue(Response.json(null));
break;
case 'invalid six-flag rejection':
fetchMock.mockResolvedValue(
Response.json({
enforced: true,
code: 'signup_rate_limited',
retryAfterMs: 1_000,
flags: Array.from({ length: 6 }, () => ({
name: 'signup:ja4',
source: 'ja4',
decision: 'throttle',
enforced: true,
until: null,
})),
})
);
break;
case 'transport failure':
fetchMock.mockRejectedValue(new TypeError('fetch failed'));
break;
case 'HTTP failure':
fetchMock.mockResolvedValue(new Response(null, { status: 503 }));
break;
case 'invalid JSON':
fetchMock.mockResolvedValue(new Response('not-json'));
break;
case 'timeout':
fetchMock.mockImplementation(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason));
})
);
break;
}
const email = 'bouncer-unavailable@example.com';
const args = {
google_user_email: email,
google_user_name: 'Bouncer Unavailable',
google_user_image_url: '',
hosted_domain: null,
provider: 'google' as const,
provider_account_id: 'google-bouncer-unavailable',
};
const headers = new Headers({ 'x-forwarded-for': '203.0.113.63' });
const result = await createOrUpdateUser(args, undefined, false, headers);

expect(result).toEqual({ success: false, error: 'SIGNUP-UNAVAILABLE' });
expect(createStripeCustomer).not.toHaveBeenCalled();
expect(deleteStripeCustomer).not.toHaveBeenCalled();
expect(
await db.query.kilocode_users.findFirst({
where: eq(kilocode_users.google_user_email, email),
})
).toBeUndefined();
expect(
await db.query.user_auth_provider.findFirst({
where: eq(user_auth_provider.provider_account_id, args.provider_account_id),
})
).toBeUndefined();

// Retrying after admission recovers must create one account, not a partial duplicate.
fetchMock.mockResolvedValue(Response.json({ enforced: false, flags: [] }));
const retry = await createOrUpdateUser(args, undefined, false, headers);
expect(retry.success).toBe(true);
if (!retry.success) return;
expect(retry.isNew).toBe(true);
expect(createStripeCustomer).toHaveBeenCalledTimes(1);
expect(
await db.query.kilocode_users.findFirst({ where: eq(kilocode_users.id, retry.user.id) })
).toBeDefined();
});

it.each([undefined, '', 'not-a-digest', 'has space', 'a'.repeat(129)])(
'still enforces IP admission with missing or invalid JA4: %s',
Expand Down Expand Up @@ -1069,7 +1140,7 @@ describe('User', () => {
});

it.each([undefined, 'not-an-ip', 'fe80::1%eth0'])(
'fails open without a usable signup IP: %s',
'rejects signup without a usable signup IP: %s',
async ip => {
const result = await createOrUpdateUser(
{
Expand All @@ -1084,7 +1155,8 @@ describe('User', () => {
false,
ip ? new Headers({ 'x-forwarded-for': ip }) : undefined
);
expect(result.success).toBe(true);
expect(result).toEqual({ success: false, error: 'SIGNUP-UNAVAILABLE' });
expect(createStripeCustomer).not.toHaveBeenCalled();
expect(mockSignupDecide).not.toHaveBeenCalled();
}
);
Expand Down Expand Up @@ -1185,7 +1257,8 @@ describe('User', () => {
provider_account_id: 'google-new-admin-candidate',
},
undefined,
false
false,
new Headers({ 'x-forwarded-for': '203.0.113.64' })
);

expect(result.success).toBe(true);
Expand All @@ -1212,7 +1285,8 @@ describe('User', () => {
provider_account_id: 'github-dedup',
},
undefined,
false
false,
new Headers({ 'x-forwarded-for': '203.0.113.65' })
);

expect(result.success).toBe(false);
Expand Down Expand Up @@ -1240,7 +1314,7 @@ describe('User', () => {
},
undefined,
false,
undefined,
new Headers({ 'x-forwarded-for': '203.0.113.66' }),
'impact-click-123'
);

Expand Down
24 changes: 17 additions & 7 deletions apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,19 +590,29 @@ export async function createOrUpdateUser(
const normalizedEmail = normalizeEmail(args.google_user_email);
const operationId = signupOperationId(normalizedEmail);
const bouncerIp = bareIpLiteral(signupIp ?? undefined);
if (!IS_DEVELOPMENT && bouncerIp) {
const verdict = await signupDecide({
operationId,
ip: bouncerIp,
ja4: normalizeJa4(requestHeaders?.get('x-vercel-ja4-digest')),
});
if (!IS_DEVELOPMENT) {
const verdict = bouncerIp
? await signupDecide({
operationId,
ip: bouncerIp,
ja4: normalizeJa4(requestHeaders?.get('x-vercel-ja4-digest')),
})
: null;
console.info('[auth] Bouncer signup admission', {
operationId,
enforced: verdict?.enforced ?? false,
available: verdict !== null,
flags: verdict?.flags ?? [],
});
if (verdict?.enforced) {
if (verdict === null) {
console.info('[auth] Signup creation outcome', {
operationId,
outcome: 'rejected',
reason: bouncerIp ? 'bouncer_unavailable' : 'signup_ip_unavailable',
});
return failureResult('SIGNUP-UNAVAILABLE');
}
if (verdict.enforced) {
console.info('[auth] Signup creation outcome', {
operationId,
outcome: 'rejected',
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/lib/user/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ async function processSSOInternal(
trackingContext
);
if (!res.success) {
if (res.error === 'SIGNUP-RATE-LIMITED' || res.error === 'EMAIL-ALREADY-USED') {
if (
res.error === 'SIGNUP-RATE-LIMITED' ||
res.error === 'SIGNUP-UNAVAILABLE' ||
res.error === 'EMAIL-ALREADY-USED'
) {
return `${SSO_SIGNIN_PATH}?error=${res.error}`;
}
throw new Error(res.error);
Expand Down
Loading
Loading