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
24 changes: 24 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GROQ_API_KEY=

# Browserbase — the cloud browser that powers Browser Automations.
# REQUIRED for the browser-automation feature (connect, analyze, runs).
BROWSERBASE_API_KEY=
BROWSERBASE_PROJECT_ID=
# Optional model overrides (defaults are sensible; no need to set these).
# BROWSERBASE_CUA_MODEL: screenshot-based navigation agent. Defaults to
# "openai/gpt-5.6-terra" (needs OPENAI_API_KEY; falls back to Claude if it's
# missing). Set to "openai/gpt-5.6-sol" for max accuracy, or an "anthropic/…"
# computer-use model to use Claude instead.
# BROWSERBASE_STAGEHAND_MODEL: model behind page reading / pass-fail verdicts —
# keep this a general model (Claude), NOT a computer-use model.
BROWSERBASE_CUA_MODEL=
BROWSERBASE_STAGEHAND_MODEL=

# 1Password service account token (starts with "ops_") for storing browser-
# automation logins in a Comp-managed vault.
# Enables the "Sign in for me" password path: Comp stores the login and signs in
# on its own (including generating 2FA codes) for the first sign-in and for
# scheduled runs.
# Optional — when unset, that path is unavailable and users sign in manually in
# the live browser each time (no unattended re-login).
# Create one at 1Password → Developer → Service Accounts (needs vault-create access).
OP_SERVICE_ACCOUNT_TOKEN=

# Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset)
CATALYST_OTLP_TOKEN=
CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "0.0.1",
"author": "",
"dependencies": {
"@1password/sdk": "0.4.0",
"@ai-sdk/anthropic": "^3.0.75",
"@ai-sdk/groq": "^3.0.38",
"@ai-sdk/openai": "^3.0.62",
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/audit/audit-log.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const SENSITIVE_KEYS = new Set([
'credentials',
'privateKey',
'private_key',
// Authenticator (TOTP) material — a reusable MFA secret. Must never land in
// the audit log in cleartext (browser-connection credential endpoints accept
// `totpSeed`; the runtime carries `totpCode`).
'totpSeed',
'totpCode',
]);

export const RESOURCE_TO_ENTITY_TYPE: Record<
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/audit/audit-log.redaction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// buildChanges -> constants.ts imports enums from @db; provide minimal stubs so
// the util can be exercised in isolation without a real Prisma client.
jest.mock('@db', () => ({
AuditLogEntityType: {},
CommentEntityType: {},
}));

import { buildChanges } from './audit-log.utils';

describe('audit log redaction (buildChanges)', () => {
it('redacts the TOTP seed like other secrets, and keeps non-secret fields', () => {
const changes = buildChanges(
{
username: 'user@example.com',
password: 'hunter2',
totpSeed: 'JBSWY3DPEHPK3PXP',
},
null,
{},
);

expect(changes).not.toBeNull();
// The authenticator seed is a reusable MFA secret — must be redacted like
// the password, never written to the audit log in cleartext.
expect(changes?.totpSeed.current).toBe('[REDACTED]');
expect(changes?.password.current).toBe('[REDACTED]');
// A non-secret identifier is still recorded for a useful audit trail.
expect(changes?.username.current).toBe('user@example.com');
// Belt and braces: the raw seed must not appear anywhere in the payload.
expect(JSON.stringify(changes)).not.toContain('JBSWY3DPEHPK3PXP');
});

it('redacts a runtime totpCode as well', () => {
const changes = buildChanges({ totpCode: '123456' }, null, {});
expect(changes?.totpCode.current).toBe('[REDACTED]');
});
});
271 changes: 271 additions & 0 deletions apps/api/src/browserbase/browser-auth-profile.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { BrowserbaseSessionService } from './browserbase-session.service';
import { BrowserAuthProfileService } from './browser-auth-profile.service';

Expand All @@ -10,15 +11,21 @@ jest.mock('@db', () => ({
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
browserbaseContext: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
deleteMany: jest.fn(),
},
browserAutomation: {
findMany: jest.fn(),
},
},
}));

Expand All @@ -37,6 +44,57 @@ describe('BrowserAuthProfileService', () => {
service = new BrowserAuthProfileService(sessions);
});

describe('tenant guards (cross-org IDOR)', () => {
it('assertContextOwnedByOrg passes when a profile in the org owns the context', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({ id: 'bap_1' });
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.assertContextOwnedByOrg({ organizationId: 'org_1', contextId: 'ctx_1' }),
).resolves.toBeUndefined();
});

it('assertContextOwnedByOrg passes when the org-level context row owns it', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue({ id: 'bbc_1' });
await expect(
service.assertContextOwnedByOrg({ organizationId: 'org_1', contextId: 'ctx_1' }),
).resolves.toBeUndefined();
});

it('assertContextOwnedByOrg rejects a context that belongs to another org', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.assertContextOwnedByOrg({ organizationId: 'org_1', contextId: 'ctx_other' }),
).rejects.toBeInstanceOf(ForbiddenException);
});

it('assertSessionOwnedByOrg returns the contextId when the org owns the session', async () => {
jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue('ctx_1');
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({ id: 'bap_1' });
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.assertSessionOwnedByOrg({ organizationId: 'org_1', sessionId: 'sess_1' }),
).resolves.toBe('ctx_1');
});

it('assertSessionOwnedByOrg rejects a session whose context is another org', async () => {
jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue('ctx_other');
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.assertSessionOwnedByOrg({ organizationId: 'org_1', sessionId: 'sess_x' }),
).rejects.toBeInstanceOf(ForbiddenException);
});

it('assertSessionOwnedByOrg rejects when the session context cannot be resolved', async () => {
jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue(undefined);
await expect(
service.assertSessionOwnedByOrg({ organizationId: 'org_1', sessionId: 'gone' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

it('normalizes hostname and login identity when creating a profile', async () => {
(db.browserAuthProfile.findUnique as jest.Mock).mockResolvedValue(null);
(db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue(null);
Expand Down Expand Up @@ -188,4 +246,217 @@ describe('BrowserAuthProfileService', () => {
where: { organizationId: 'org_1', contextId: '__PENDING__' },
});
});

describe('listProfiles', () => {
it('attaches an automation count per connection by hostname', async () => {
(db.browserAuthProfile.findMany as jest.Mock).mockResolvedValue([
{ id: 'bap_gh', organizationId: 'org_1', hostname: 'github.com' },
{ id: 'bap_aws', organizationId: 'org_1', hostname: 'aws.amazon.com' },
{ id: 'bap_dd', organizationId: 'org_1', hostname: 'datadoghq.com' },
]);
(db.browserAutomation.findMany as jest.Mock).mockResolvedValue([
{ targetUrl: 'https://github.com/acme/repo' },
{ targetUrl: 'https://github.com/acme/other' },
{ targetUrl: 'https://aws.amazon.com/console' },
{ targetUrl: 'not-a-url' }, // malformed -> skipped, not fatal
]);

const result = await service.listProfiles('org_1');

expect(db.browserAutomation.findMany).toHaveBeenCalledWith({
where: { task: { organizationId: 'org_1' } },
select: { targetUrl: true },
});
expect(result.find((p) => p.id === 'bap_gh')?.automationCount).toBe(2);
expect(result.find((p) => p.id === 'bap_aws')?.automationCount).toBe(1);
expect(result.find((p) => p.id === 'bap_dd')?.automationCount).toBe(0);
});
});

describe('updateProfile / deleteProfile', () => {
const existing = {
id: 'bap_1',
organizationId: 'org_1',
hostname: 'app.example.com',
displayName: 'Example',
status: 'verified',
};

beforeEach(() => {
(db.browserAuthProfile.update as jest.Mock).mockImplementation(
({ data }) => ({ ...existing, ...data }),
);
(db.browserAuthProfile.delete as jest.Mock).mockResolvedValue(existing);
});

it('updates only the display name (trimmed)', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
await service.updateProfile({
organizationId: 'org_1',
profileId: 'bap_1',
displayName: ' New name ',
});
expect(db.browserAuthProfile.update).toHaveBeenCalledWith({
where: { id: 'bap_1' },
data: { displayName: 'New name' },
});
});

it('keeps the connection signed in when the URL stays on the same host', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
await service.updateProfile({
organizationId: 'org_1',
profileId: 'bap_1',
url: 'https://app.example.com/account/login',
});
const data = (db.browserAuthProfile.update as jest.Mock).mock.calls[0][0]
.data;
expect(data.lastAuthCheckUrl).toBe(
'https://app.example.com/account/login',
);
expect(data.status).toBeUndefined();
});

it('marks needs_reauth when the URL moves to a different host', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
await service.updateProfile({
organizationId: 'org_1',
profileId: 'bap_1',
url: 'https://other.com/login',
});
const data = (db.browserAuthProfile.update as jest.Mock).mock.calls[0][0]
.data;
expect(data.status).toBe('needs_reauth');
});

it('deletes the profile', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
const result = await service.deleteProfile({
organizationId: 'org_1',
profileId: 'bap_1',
});
expect(db.browserAuthProfile.delete).toHaveBeenCalledWith({
where: { id: 'bap_1' },
});
expect(result.success).toBe(true);
});

it('throws when updating a missing profile', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.updateProfile({
organizationId: 'org_1',
profileId: 'nope',
displayName: 'x',
}),
).rejects.toThrow('not found');
});
});

describe('recordSignInAttempt', () => {
it('persists the outcome, detail, and a timestamp, scoped to the org', async () => {
(db.browserAuthProfile.updateMany as jest.Mock).mockResolvedValue({
count: 1,
});

await service.recordSignInAttempt({
organizationId: 'org_1',
profileId: 'bap_1',
outcome: 'needs_2fa',
detail: 'The account requires a two-factor code to sign in.',
});

expect(db.browserAuthProfile.updateMany).toHaveBeenCalledWith({
where: { id: 'bap_1', organizationId: 'org_1' },
data: expect.objectContaining({
lastSignInOutcome: 'needs_2fa',
lastSignInDetail: 'The account requires a two-factor code to sign in.',
lastSignInAt: expect.any(Date),
}),
});
});

it('stores null detail when none is provided', async () => {
(db.browserAuthProfile.updateMany as jest.Mock).mockResolvedValue({
count: 1,
});

await service.recordSignInAttempt({
organizationId: 'org_1',
profileId: 'bap_1',
outcome: 'logged_in',
});

expect(db.browserAuthProfile.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
lastSignInOutcome: 'logged_in',
lastSignInDetail: null,
}),
}),
);
});

it('never throws — a logging write failure cannot break sign-in', async () => {
(db.browserAuthProfile.updateMany as jest.Mock).mockRejectedValue(
new Error('db down'),
);

await expect(
service.recordSignInAttempt({
organizationId: 'org_1',
profileId: 'bap_1',
outcome: 'error',
detail: 'boom',
}),
).resolves.toBeUndefined();
});
});

describe('assertUrlMatchesProfileHostname (credential-safety guard)', () => {
it('passes when the URL host matches the connection hostname', () => {
expect(() =>
service.assertUrlMatchesProfileHostname({
url: 'https://app.example.com/login',
profileHostname: 'app.example.com',
}),
).not.toThrow();
});

it('rejects a URL on a different host', () => {
expect(() =>
service.assertUrlMatchesProfileHostname({
url: 'https://evil.example.net/login',
profileHostname: 'app.example.com',
}),
).toThrow(BadRequestException);
});

it('rejects an unparseable URL', () => {
expect(() =>
service.assertUrlMatchesProfileHostname({
url: 'not a url',
profileHostname: 'app.example.com',
}),
).toThrow(BadRequestException);
});
});

describe('resolveProfileForTarget (explicit profileId host binding)', () => {
it('rejects an explicit profile whose host does not match the target URL', async () => {
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({
id: 'bap_1',
organizationId: 'org_1',
hostname: 'app.example.com',
});

await expect(
service.resolveProfileForTarget({
organizationId: 'org_1',
targetUrl: 'https://evil.example.net/app',
profileId: 'bap_1',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});
Loading
Loading