Skip to content

Commit ac10ba8

Browse files
committed
chore: merge main into release for new releases
2 parents 2d4ac3c + ee8de8f commit ac10ba8

File tree

42 files changed

+846
-116
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+846
-116
lines changed

apps/api/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,23 @@
44
"version": "0.0.1",
55
"author": "",
66
"dependencies": {
7-
"@prisma/client": "^6.13.0",
8-
"prisma": "^6.13.0",
97
"@aws-sdk/client-s3": "^3.859.0",
108
"@aws-sdk/s3-request-presigner": "^3.859.0",
119
"@nestjs/common": "^11.0.1",
1210
"@nestjs/config": "^4.0.2",
1311
"@nestjs/core": "^11.0.1",
1412
"@nestjs/platform-express": "^11.1.5",
1513
"@nestjs/swagger": "^11.2.0",
16-
"@trycompai/db": "^1.3.7",
14+
"@prisma/client": "^6.13.0",
15+
"@trycompai/db": "^1.3.17",
1716
"archiver": "^7.0.1",
1817
"axios": "^1.12.2",
1918
"better-auth": "^1.3.27",
2019
"class-transformer": "^0.5.1",
2120
"class-validator": "^0.14.2",
21+
"dotenv": "^17.2.3",
2222
"jose": "^6.0.12",
23+
"prisma": "^6.13.0",
2324
"reflect-metadata": "^0.2.2",
2425
"rxjs": "^7.8.1",
2526
"swagger-ui-express": "^5.0.1",

apps/api/src/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PeopleModule } from './people/people.module';
99
import { DevicesModule } from './devices/devices.module';
1010
import { DeviceAgentModule } from './device-agent/device-agent.module';
1111
import { awsConfig } from './config/aws.config';
12+
import { betterAuthConfig } from './config/better-auth.config';
1213
import { HealthModule } from './health/health.module';
1314
import { OrganizationModule } from './organization/organization.module';
1415
import { PoliciesModule } from './policies/policies.module';
@@ -23,7 +24,8 @@ import { TaskTemplateModule } from './framework-editor/task-template/task-templa
2324
imports: [
2425
ConfigModule.forRoot({
2526
isGlobal: true,
26-
load: [awsConfig],
27+
// .env file is loaded manually in main.ts before NestJS starts
28+
load: [awsConfig, betterAuthConfig],
2729
validationOptions: {
2830
allowUnknown: true,
2931
abortEarly: true,

apps/api/src/auth/hybrid-auth.guard.ts

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,32 @@ import {
44
Injectable,
55
UnauthorizedException,
66
} from '@nestjs/common';
7+
import { ConfigService } from '@nestjs/config';
78
import { db } from '@trycompai/db';
89
import { createRemoteJWKSet, jwtVerify } from 'jose';
910
import { ApiKeyService } from './api-key.service';
11+
import type { BetterAuthConfig } from '../config/better-auth.config';
1012
import { AuthenticatedRequest } from './types';
1113

1214
@Injectable()
1315
export class HybridAuthGuard implements CanActivate {
14-
constructor(private readonly apiKeyService: ApiKeyService) {}
16+
private readonly betterAuthUrl: string;
17+
18+
constructor(
19+
private readonly apiKeyService: ApiKeyService,
20+
private readonly configService: ConfigService,
21+
) {
22+
const betterAuthConfig =
23+
this.configService.get<BetterAuthConfig>('betterAuth');
24+
this.betterAuthUrl =
25+
betterAuthConfig?.url || process.env.BETTER_AUTH_URL || '';
26+
27+
if (!this.betterAuthUrl) {
28+
console.warn(
29+
'[HybridAuthGuard] BETTER_AUTH_URL not configured. JWT authentication will fail.',
30+
);
31+
}
32+
}
1533

1634
async canActivate(context: ExecutionContext): Promise<boolean> {
1735
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
@@ -61,20 +79,71 @@ export class HybridAuthGuard implements CanActivate {
6179
authHeader: string,
6280
): Promise<boolean> {
6381
try {
82+
// Validate BETTER_AUTH_URL is configured
83+
if (!this.betterAuthUrl) {
84+
console.error(
85+
'[HybridAuthGuard] BETTER_AUTH_URL environment variable is not set',
86+
);
87+
throw new UnauthorizedException(
88+
'Authentication configuration error: BETTER_AUTH_URL not configured',
89+
);
90+
}
91+
6492
// Extract token from "Bearer <token>"
6593
const token = authHeader.substring(7);
6694

67-
// Create JWKS for token verification using Better Auth endpoint
68-
const JWKS = createRemoteJWKSet(
69-
new URL(`${process.env.BETTER_AUTH_URL}/api/auth/jwks`),
70-
);
95+
const jwksUrl = `${this.betterAuthUrl}/api/auth/jwks`;
7196

72-
// Verify JWT token
73-
const { payload } = await jwtVerify(token, JWKS, {
74-
issuer: process.env.BETTER_AUTH_URL,
75-
audience: process.env.BETTER_AUTH_URL,
97+
// Create JWKS for token verification using Better Auth endpoint
98+
// Use shorter cache time to handle key rotation better
99+
const JWKS = createRemoteJWKSet(new URL(jwksUrl), {
100+
cacheMaxAge: 60000, // 1 minute cache (default is 5 minutes)
101+
cooldownDuration: 10000, // 10 seconds cooldown before refetching
76102
});
77103

104+
// Verify JWT token with automatic retry on key mismatch
105+
let payload;
106+
try {
107+
payload = (
108+
await jwtVerify(token, JWKS, {
109+
issuer: this.betterAuthUrl,
110+
audience: this.betterAuthUrl,
111+
})
112+
).payload;
113+
} catch (verifyError: any) {
114+
// If we get a key mismatch error, retry with a fresh JWKS fetch
115+
if (
116+
verifyError.code === 'ERR_JWKS_NO_MATCHING_KEY' ||
117+
verifyError.message?.includes('no applicable key found') ||
118+
verifyError.message?.includes('JWKSNoMatchingKey')
119+
) {
120+
console.log(
121+
'[HybridAuthGuard] Key mismatch detected, fetching fresh JWKS and retrying...',
122+
);
123+
124+
// Create a fresh JWKS instance with no cache to force immediate fetch
125+
const freshJWKS = createRemoteJWKSet(new URL(jwksUrl), {
126+
cacheMaxAge: 0, // No cache - force fresh fetch
127+
cooldownDuration: 0, // No cooldown - allow immediate retry
128+
});
129+
130+
// Retry verification with fresh keys
131+
payload = (
132+
await jwtVerify(token, freshJWKS, {
133+
issuer: this.betterAuthUrl,
134+
audience: this.betterAuthUrl,
135+
})
136+
).payload;
137+
138+
console.log(
139+
'[HybridAuthGuard] Successfully verified token with fresh JWKS',
140+
);
141+
} else {
142+
// Re-throw if it's not a key mismatch error
143+
throw verifyError;
144+
}
145+
}
146+
78147
// Extract user information from JWT payload (user data is directly in payload for Better Auth JWT)
79148
const userId = payload.id as string;
80149
const userEmail = payload.email as string;
@@ -112,6 +181,41 @@ export class HybridAuthGuard implements CanActivate {
112181
return true;
113182
} catch (error) {
114183
console.error('JWT verification failed:', error);
184+
185+
// Provide more helpful error messages
186+
if (error instanceof Error) {
187+
// Connection errors
188+
if (
189+
error.message.includes('ECONNREFUSED') ||
190+
error.message.includes('fetch failed')
191+
) {
192+
console.error(
193+
`[HybridAuthGuard] Cannot connect to Better Auth JWKS endpoint at ${this.betterAuthUrl}/api/auth/jwks`,
194+
);
195+
console.error(
196+
'[HybridAuthGuard] Make sure BETTER_AUTH_URL is set correctly and the Better Auth server is running',
197+
);
198+
throw new UnauthorizedException(
199+
`Cannot connect to authentication service. Please check BETTER_AUTH_URL configuration.`,
200+
);
201+
}
202+
203+
// Key mismatch errors should have been handled by retry logic above
204+
// If we still get one here, it means the retry also failed (token truly invalid)
205+
if (
206+
(error as any).code === 'ERR_JWKS_NO_MATCHING_KEY' ||
207+
error.message.includes('no applicable key found') ||
208+
error.message.includes('JWKSNoMatchingKey')
209+
) {
210+
console.error(
211+
'[HybridAuthGuard] Token key not found even after fetching fresh JWKS. Token may be from a different environment or truly invalid.',
212+
);
213+
throw new UnauthorizedException(
214+
'Authentication token is invalid. Please log out and log back in to refresh your session.',
215+
);
216+
}
217+
}
218+
115219
throw new UnauthorizedException('Invalid or expired JWT token');
116220
}
117221
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { registerAs } from '@nestjs/config';
2+
import { z } from 'zod';
3+
4+
const betterAuthConfigSchema = z.object({
5+
url: z.string().url('BETTER_AUTH_URL must be a valid URL'),
6+
});
7+
8+
export type BetterAuthConfig = z.infer<typeof betterAuthConfigSchema>;
9+
10+
export const betterAuthConfig = registerAs(
11+
'betterAuth',
12+
(): BetterAuthConfig => {
13+
const url = process.env.BETTER_AUTH_URL;
14+
15+
if (!url) {
16+
throw new Error('BETTER_AUTH_URL environment variable is required');
17+
}
18+
19+
const config = { url };
20+
21+
// Validate configuration at startup
22+
const result = betterAuthConfigSchema.safeParse(config);
23+
24+
if (!result.success) {
25+
throw new Error(
26+
`Better Auth configuration validation failed: ${result.error.issues
27+
.map((e) => `${e.path.join('.')}: ${e.message}`)
28+
.join(', ')}`,
29+
);
30+
}
31+
32+
return result.data;
33+
},
34+
);

apps/api/src/main.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,24 @@ import { NestFactory } from '@nestjs/core';
44
import type { OpenAPIObject } from '@nestjs/swagger';
55
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
66
import * as express from 'express';
7+
import { config } from 'dotenv';
8+
import path from 'path';
79
import { AppModule } from './app.module';
810
import { existsSync, mkdirSync, writeFileSync } from 'fs';
9-
import path from 'path';
11+
12+
// Load .env file from apps/api directory before anything else
13+
// This ensures .env values override any shell environment variables
14+
// __dirname in compiled code is dist/src, so go up two levels to apps/api
15+
const envPath = path.join(__dirname, '..', '..', '.env');
16+
if (existsSync(envPath)) {
17+
config({ path: envPath, override: true });
18+
} else {
19+
// Fallback: try current working directory (when run from apps/api)
20+
const cwdEnvPath = path.join(process.cwd(), '.env');
21+
if (existsSync(cwdEnvPath)) {
22+
config({ path: cwdEnvPath, override: true });
23+
}
24+
}
1025

1126
async function bootstrap(): Promise<void> {
1227
const app: INestApplication = await NestFactory.create(AppModule);

apps/app/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"dependencies": {
55
"@ai-sdk/anthropic": "^2.0.0",
66
"@ai-sdk/groq": "^2.0.0",
7-
"@ai-sdk/openai": "^2.0.0",
7+
"@ai-sdk/openai": "^2.0.65",
88
"@ai-sdk/provider": "^2.0.0",
99
"@ai-sdk/react": "^2.0.60",
1010
"@ai-sdk/rsc": "^1.0.0",
@@ -51,7 +51,7 @@
5151
"@tiptap/extension-table-row": "^3.4.4",
5252
"@trigger.dev/react-hooks": "4.0.6",
5353
"@trigger.dev/sdk": "4.0.6",
54-
"@trycompai/db": "^1.3.7",
54+
"@trycompai/db": "^1.3.17",
5555
"@trycompai/email": "workspace:*",
5656
"@types/canvas-confetti": "^1.9.0",
5757
"@types/react-syntax-highlighter": "^15.5.13",

apps/app/src/actions/organization/invite-member.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { ActionResponse } from '../types';
88

99
const inviteMemberSchema = z.object({
1010
email: z.string().email(),
11-
role: z.enum(['owner', 'admin', 'auditor', 'employee']),
11+
role: z.enum(['owner', 'admin', 'auditor', 'employee', 'contractor']),
1212
});
1313

1414
export const inviteMember = authActionClient

apps/app/src/actions/policies/publish-all.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,10 @@ export const publishAllPoliciesAction = authActionClient
104104
where: {
105105
organizationId: parsedInput.organizationId,
106106
isActive: true,
107-
role: {
108-
contains: Role.employee,
109-
},
107+
OR: [
108+
{ role: { contains: Role.employee } },
109+
{ role: { contains: Role.contractor } },
110+
],
110111
},
111112
include: {
112113
user: {

apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow
9393
const canRemove = !isOwner;
9494

9595
const isEmployee = currentRoles.includes('employee');
96+
const isContractor = currentRoles.includes('contractor');
9697

9798
const handleDialogItemSelect = () => {
9899
focusRef.current = dropdownTriggerRef.current;
@@ -142,7 +143,7 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow
142143
<div className="min-w-0 flex-1 gap-2">
143144
<div className="flex items-center flex-wrap gap-1.5">
144145
<span className="truncate text-sm font-medium">{memberName}</span>
145-
{isEmployee && (
146+
{(isEmployee || isContractor) && (
146147
<Link
147148
href={`/${orgId}/people/${memberId}`}
148149
className="text-xs text-blue-600 hover:underline flex-shrink-0"

apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,7 @@ export function MultiRoleCombobox({
111111
selectedRoles.length > 0 ? `${selectedRoles.length} selected` : placeholder || 'Select role(s)';
112112

113113
const filteredRoles = availableRoles.filter((role) => {
114-
const label = (() => {
115-
switch (role.value) {
116-
case 'admin':
117-
return 'Admin';
118-
case 'auditor':
119-
return 'Auditor';
120-
case 'employee':
121-
return 'Employee';
122-
case 'contractor':
123-
return 'Contractor';
124-
case 'owner':
125-
return 'Owner';
126-
default:
127-
return role.value;
128-
}
129-
})();
114+
const label = getRoleLabel(role.value);
130115
return label.toLowerCase().includes(searchTerm.toLowerCase());
131116
});
132117

0 commit comments

Comments
 (0)