Skip to content

Commit 88702f9

Browse files
authored
Merge branch 'main' into tofik/fix-onboarding-stale-manifest-fk
2 parents d269491 + 6e1ee8e commit 88702f9

7 files changed

Lines changed: 176 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
## [3.66.1](https://github.com/trycompai/comp/compare/v3.66.0...v3.66.1) (2026-06-01)
2+
3+
4+
### Bug Fixes
5+
6+
* **api:** avoid registering duplicated device when the status is flipping to compliant ([b103710](https://github.com/trycompai/comp/commit/b1037104271c5c750bc7a5d111c98d3672b39c40))
7+
* **api:** exclude deactivated owners from acting-user fallback ([ec6a1fa](https://github.com/trycompai/comp/commit/ec6a1fa9beff9fe474dba37785a06bb0ae834ba8))
8+
* **api:** support API key auth on upload-submission endpoint ([9570390](https://github.com/trycompai/comp/commit/957039077d3681a7c90cb1f246a3a5aeb6a3a8a1))
9+
* **app:** fit requirements table columns ([318330e](https://github.com/trycompai/comp/commit/318330ef7b3d55b51152c1eee4c31cc960d8bd9c))
10+
* **app:** keep removed multi-select values in the dropdown ([4dc1a12](https://github.com/trycompai/comp/commit/4dc1a12d4491fef3b0aecbaf10787db21b09559d))
11+
* **app:** prevent duplicate publish all submissions ([8b0b863](https://github.com/trycompai/comp/commit/8b0b863923d8f30fd6be2d7713424cb5935dcca0))
12+
* **app:** resolve archived policy and compliance UI tasks ([d8ce450](https://github.com/trycompai/comp/commit/d8ce45093d87a3f7222fc14123ca88a82043f9c5))
13+
* **app:** resolve small linear tasks ([424d863](https://github.com/trycompai/comp/commit/424d863458ccfd9059611515239e86895fee6c5f))
14+
* **app:** search integrations by name ([4db9154](https://github.com/trycompai/comp/commit/4db91546dfefa9b010bedbc65aedf7a781ce6a0b))
15+
* **app:** warn before invalidating policy acknowledgments ([76e106f](https://github.com/trycompai/comp/commit/76e106f1ab125ddbeaaa9945e2be39185b45230b))
16+
* **device-agent:** make serial number extraction more robust on agent ([85f0b1e](https://github.com/trycompai/comp/commit/85f0b1e52d3f978935fddf6858eec6f6239a4e69))
17+
118
# [3.66.0](https://github.com/trycompai/comp/compare/v3.65.1...v3.66.0) (2026-05-29)
219

320

‎apps/api/src/auth/acting-user.service.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,29 @@ describe('ActingUserResolver', () => {
122122
);
123123
});
124124

125+
it('filters out deactivated / inactive members so uploads cannot be attributed to offboarded owners', async () => {
126+
// Regression guard — without these filters, the oldest "owner"-role
127+
// Member would win even if the user has been deactivated/offboarded,
128+
// attributing API-driven mutations to someone who no longer has access.
129+
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' });
130+
const req = makeReq({
131+
userId: undefined,
132+
isApiKey: true,
133+
apiKeyName: 'X',
134+
});
135+
136+
await resolver.resolve(req, 'org_1');
137+
138+
expect(mockDb.member.findFirst).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
where: expect.objectContaining({
141+
deactivated: false,
142+
isActive: true,
143+
}),
144+
}),
145+
);
146+
});
147+
125148
it('picks the OLDEST owner deterministically (orderBy createdAt asc)', async () => {
126149
// Determinism matters — re-running the same automation should always
127150
// attribute to the same user, even if newer owners are added/removed.

‎apps/api/src/auth/acting-user.service.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,26 @@ export class ActingUserResolver {
9191
}
9292

9393
/**
94-
* Find the oldest owner of an organization. Oldest is deterministic and
95-
* stable: removing a recently-added owner doesn't change the attribution
94+
* Find the oldest ACTIVE owner of an organization. Oldest is deterministic
95+
* and stable: removing a recently-added owner doesn't change the attribution
9696
* target, removing the oldest one just promotes the next one. Matches the
97-
* pattern used elsewhere (e.g. tasks/task-notifier.service.ts).
97+
* pattern used elsewhere (e.g. tasks/tasks.service.ts:getApiKeyActorUserId).
9898
*
9999
* Member.role is a comma-separated string (e.g. "owner,admin"), so we use
100100
* Prisma's `contains` filter — same query shape as the 19+ other owner
101101
* lookups in this codebase.
102+
*
103+
* `deactivated: false` + `isActive: true` excludes offboarded owners so we
104+
* don't attribute new mutations to a user who no longer has org access.
102105
*/
103106
private async findOrgOwnerUserId(
104107
organizationId: string,
105108
): Promise<string | null> {
106109
const owner = await db.member.findFirst({
107110
where: {
108111
organizationId,
112+
deactivated: false,
113+
isActive: true,
109114
role: { contains: 'owner' },
110115
},
111116
orderBy: { createdAt: 'asc' },

‎apps/api/src/evidence-forms/evidence-forms.controller.spec.ts‎

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2+
import { BadRequestException } from '@nestjs/common';
23
import { EvidenceFormsController } from './evidence-forms.controller';
34
import { EvidenceFormsService } from './evidence-forms.service';
5+
import { ActingUserResolver } from '../auth/acting-user.service';
46
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
57
import { PermissionGuard } from '../auth/permission.guard';
6-
import type { AuthContext as AuthContextType } from '../auth/types';
8+
import type {
9+
AuthContext as AuthContextType,
10+
AuthenticatedRequest,
11+
} from '../auth/types';
12+
13+
jest.mock('@db', () => ({ db: {} }));
714

815
jest.mock('../auth/auth.server', () => ({
916
auth: { api: { getSession: jest.fn() } },
@@ -61,10 +68,17 @@ describe('EvidenceFormsController', () => {
6168
userRoles: ['admin'],
6269
};
6370

71+
const mockActingUser = {
72+
resolve: jest.fn(),
73+
};
74+
6475
beforeEach(async () => {
6576
const module: TestingModule = await Test.createTestingModule({
6677
controllers: [EvidenceFormsController],
67-
providers: [{ provide: EvidenceFormsService, useValue: mockService }],
78+
providers: [
79+
{ provide: EvidenceFormsService, useValue: mockService },
80+
{ provide: ActingUserResolver, useValue: mockActingUser },
81+
],
6882
})
6983
.overrideGuard(HybridAuthGuard)
7084
.useValue(mockGuard)
@@ -298,26 +312,71 @@ describe('EvidenceFormsController', () => {
298312
});
299313

300314
describe('uploadSubmission', () => {
301-
it('should call service.uploadSubmission with correct params', async () => {
315+
it('should call service.uploadSubmission with the session userId', async () => {
302316
const body = { fileUrl: 'https://example.com/file.pdf' };
303317
const mockResult = { id: 'sub_upload' };
304318
mockService.uploadSubmission.mockResolvedValue(mockResult);
319+
mockActingUser.resolve.mockResolvedValue({
320+
userId: 'user_1',
321+
source: 'session',
322+
});
305323

324+
const req = {} as AuthenticatedRequest;
306325
const result = await controller.uploadSubmission(
307326
'org_1',
308-
mockAuthContext,
309327
'security-awareness',
310328
body,
329+
req,
311330
);
312331

313332
expect(result).toEqual(mockResult);
333+
expect(mockActingUser.resolve).toHaveBeenCalledWith(req, 'org_1');
314334
expect(service.uploadSubmission).toHaveBeenCalledWith({
315335
organizationId: 'org_1',
316336
formType: 'security-awareness',
317-
authContext: mockAuthContext,
337+
userId: 'user_1',
318338
payload: body,
319339
});
320340
});
341+
342+
it('should attribute to the org owner when called with API key auth', async () => {
343+
const body = { fileUrl: 'https://example.com/file.pdf' };
344+
const mockResult = { id: 'sub_upload' };
345+
mockService.uploadSubmission.mockResolvedValue(mockResult);
346+
mockActingUser.resolve.mockResolvedValue({
347+
userId: 'owner_user',
348+
source: 'org-owner-fallback',
349+
callerLabel: 'via API key "CI"',
350+
});
351+
352+
const req = {} as AuthenticatedRequest;
353+
await controller.uploadSubmission('org_1', 'meeting', body, req);
354+
355+
expect(service.uploadSubmission).toHaveBeenCalledWith({
356+
organizationId: 'org_1',
357+
formType: 'meeting',
358+
userId: 'owner_user',
359+
payload: body,
360+
});
361+
});
362+
363+
it('should throw BadRequestException when no owner can be resolved', async () => {
364+
mockActingUser.resolve.mockResolvedValue({
365+
userId: null,
366+
source: 'org-owner-fallback',
367+
callerLabel: 'via API key',
368+
});
369+
370+
await expect(
371+
controller.uploadSubmission(
372+
'org_1',
373+
'meeting',
374+
{ fileUrl: 'x' },
375+
{} as AuthenticatedRequest,
376+
),
377+
).rejects.toBeInstanceOf(BadRequestException);
378+
expect(service.uploadSubmission).not.toHaveBeenCalled();
379+
});
321380
});
322381

323382
describe('reviewSubmission', () => {

‎apps/api/src/evidence-forms/evidence-forms.controller.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import { ActingUserResolver } from '@/auth/acting-user.service';
12
import { AuthContext, OrganizationId } from '@/auth/auth-context.decorator';
23
import { HybridAuthGuard } from '@/auth/hybrid-auth.guard';
34
import { PermissionGuard } from '@/auth/permission.guard';
45
import { RequirePermission } from '@/auth/require-permission.decorator';
5-
import type { AuthContext as AuthContextType } from '@/auth/types';
6+
import type {
7+
AuthContext as AuthContextType,
8+
AuthenticatedRequest,
9+
} from '@/auth/types';
610
import { AuditRead } from '@/audit/skip-audit-log.decorator';
711
import {
12+
BadRequestException,
813
Body,
914
Controller,
1015
Delete,
@@ -14,6 +19,7 @@ import {
1419
Patch,
1520
Post,
1621
Query,
22+
Req,
1723
Res,
1824
UseGuards,
1925
} from '@nestjs/common';
@@ -32,7 +38,10 @@ import { EvidenceFormsService } from './evidence-forms.service';
3238
required: false,
3339
})
3440
export class EvidenceFormsController {
35-
constructor(private readonly evidenceFormsService: EvidenceFormsService) {}
41+
constructor(
42+
private readonly evidenceFormsService: EvidenceFormsService,
43+
private readonly actingUser: ActingUserResolver,
44+
) {}
3645

3746
@Get()
3847
@RequirePermission('evidence', 'read')
@@ -213,18 +222,25 @@ export class EvidenceFormsController {
213222
@ApiOperation({
214223
summary: 'Upload a file as an evidence submission',
215224
description:
216-
'Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation',
225+
'Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. ' +
226+
"Accepts session, API key, or service token auth. For API key / service token callers without an explicit user attribution, the submission is attributed to the org's oldest owner.",
217227
})
218228
async uploadSubmission(
219229
@OrganizationId() organizationId: string,
220-
@AuthContext() authContext: AuthContextType,
221230
@Param('formType') formType: string,
222231
@Body() body: unknown,
232+
@Req() req: AuthenticatedRequest,
223233
) {
234+
const acting = await this.actingUser.resolve(req, organizationId);
235+
if (!acting.userId) {
236+
throw new BadRequestException(
237+
'Cannot attribute this submission — your organization must have at least one user with the "owner" role.',
238+
);
239+
}
224240
return this.evidenceFormsService.uploadSubmission({
225241
organizationId,
226242
formType,
227-
authContext,
243+
userId: acting.userId,
228244
payload: body,
229245
});
230246
}

‎apps/api/src/evidence-forms/evidence-forms.service.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -648,15 +648,15 @@ export class EvidenceFormsService {
648648
async uploadSubmission(params: {
649649
organizationId: string;
650650
formType: string;
651-
authContext: AuthContext;
651+
userId: string;
652652
payload: unknown;
653653
}) {
654654
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
655655
if (!parsedType.success) {
656656
throw new BadRequestException('Unsupported form type');
657657
}
658658

659-
const userId = this.requireJwtUser(params.authContext);
659+
const { userId } = params;
660660

661661
const parsed = uploadSubmissionBodySchema.safeParse(params.payload);
662662
if (!parsed.success) {

‎apps/app/src/components/integrations/ConnectionVariableMultiSelect.tsx‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Input, Spinner } from '@trycompai/design-system';
44
import { Close } from '@trycompai/design-system/icons';
55
import MultipleSelector from '@trycompai/ui/multiple-selector';
6-
import { useEffect, useRef } from 'react';
6+
import { useEffect, useMemo, useRef, useState } from 'react';
77
import type { ConnectionVariable, VariableValue } from './ConnectionVariablesForm';
88

99
interface ConnectionVariableMultiSelectProps {
@@ -31,6 +31,30 @@ export function ConnectionVariableMultiSelect({
3131
const isGitHubRepos = variable.id === 'target_repos';
3232
const parsedConfigs = isGitHubRepos ? normalizedSelectedValues.map(parseRepoBranch) : [];
3333

34+
const reposForSelector = isGitHubRepos
35+
? parsedConfigs.map((config) => config.repo)
36+
: normalizedSelectedValues;
37+
38+
// Remembers every value that has appeared in the multi-select during the
39+
// lifetime of this component so that values the user removes (clicking the
40+
// X on a tag) remain available as re-selectable options in the dropdown.
41+
// Without this, free-form multi-selects like Google Workspace's
42+
// `sync_excluded_emails` have an empty dropdown (no fetchOptions, no static
43+
// options), so a removed tag can only be brought back by retyping it.
44+
const [seenValues, setSeenValues] = useState<Set<string>>(() => new Set(reposForSelector));
45+
useEffect(() => {
46+
setSeenValues((prev) => {
47+
let next: Set<string> | null = null;
48+
for (const v of reposForSelector) {
49+
if (v && !prev.has(v)) {
50+
if (!next) next = new Set(prev);
51+
next.add(v);
52+
}
53+
}
54+
return next ?? prev;
55+
});
56+
}, [reposForSelector]);
57+
3458
useEffect(() => {
3559
if (
3660
variable.hasDynamicOptions &&
@@ -77,21 +101,31 @@ export function ConnectionVariableMultiSelect({
77101
onChange(newValues);
78102
};
79103

80-
const reposForSelector = isGitHubRepos
81-
? parsedConfigs.map((config) => config.repo)
82-
: normalizedSelectedValues;
83104
const isCreatable = isGitHubRepos || options.length === 0;
84105

106+
const combinedOptions = useMemo(() => {
107+
const map = new Map<string, { value: string; label: string }>();
108+
for (const option of options) {
109+
map.set(option.value, { value: option.value, label: option.label });
110+
}
111+
for (const v of seenValues) {
112+
if (!map.has(v)) {
113+
map.set(v, { value: v, label: v });
114+
}
115+
}
116+
return Array.from(map.values());
117+
}, [options, seenValues]);
118+
85119
return (
86120
<div className="space-y-3">
87121
<MultipleSelector
88122
value={reposForSelector.map((value) => ({
89123
value,
90-
label: options.find((option) => option.value === value)?.label || value,
124+
label: combinedOptions.find((option) => option.value === value)?.label || value,
91125
}))}
92126
onChange={(selected) => handleRepoSelectionChange(selected.map((item) => item.value))}
93-
defaultOptions={options.map((option) => ({ value: option.value, label: option.label }))}
94-
options={options.map((option) => ({ value: option.value, label: option.label }))}
127+
defaultOptions={combinedOptions}
128+
options={combinedOptions}
95129
placeholder={variable.placeholder || `Select ${variable.label.toLowerCase()}...`}
96130
creatable={isCreatable}
97131
emptyIndicator={

0 commit comments

Comments
 (0)