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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"^@db$": "<rootDir>/../prisma/index",
"^@/(.*)$": "<rootDir>/$1",
"^\\./sku-definitions\\.js$": "<rootDir>/../../../packages/billing/src/sku-definitions.ts",
"^@trycompai/auth/participation$": "<rootDir>/../../../packages/auth/src/participation.ts",
"^@trycompai/auth$": "<rootDir>/../../../packages/auth/src/index.ts",
"^@trycompai/billing$": "<rootDir>/../../../packages/billing/src/index.ts",
"^@trycompai/company$": "<rootDir>/../../../packages/company/src/index.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { db } from '@db';
import { isOrgParticipant } from '@trycompai/auth/participation';
import { isOrgParticipant } from '../utils/org-participation-rule';
import { Injectable, Logger } from '@nestjs/common';
import { isUserUnsubscribed } from '@trycompai/email';
import { triggerEmail } from '../email/trigger-email';
Expand Down
56 changes: 56 additions & 0 deletions apps/api/src/utils/org-participation-rule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
PLATFORM_ADMIN_ROLE as AUTH_PLATFORM_ADMIN_ROLE,
isOrgParticipant as authIsOrgParticipant,
} from '@trycompai/auth/participation';
import {
isOrgParticipant,
PLATFORM_ADMIN_ROLE,
} from './org-participation-rule';

describe('org-participation-rule (API mirror)', () => {
it('excludes platform admins in a customer org', () => {
expect(isOrgParticipant(PLATFORM_ADMIN_ROLE, { orgIsInternal: false })).toBe(
false,
);
});

it('includes platform admins in an internal org', () => {
expect(isOrgParticipant(PLATFORM_ADMIN_ROLE, { orgIsInternal: true })).toBe(
true,
);
});

it('includes non-admin and null roles in a customer org', () => {
expect(isOrgParticipant('user', { orgIsInternal: false })).toBe(true);
expect(isOrgParticipant('owner', { orgIsInternal: false })).toBe(true);
expect(isOrgParticipant(null, { orgIsInternal: false })).toBe(true);
expect(isOrgParticipant(undefined, { orgIsInternal: false })).toBe(true);
});
});

// Drift guard: this API-local rule is a deliberate dependency-free mirror of
// `@trycompai/auth/participation` (files in the Trigger.dev bundle can't import
// the auth package — its dist isn't built in that deploy). Fail CI if the two
// ever diverge.
describe('API rule stays in sync with @trycompai/auth', () => {
const roles: Array<string | null | undefined> = [
'admin',
'owner',
'user',
'auditor',
'',
null,
undefined,
];

it('matches the canonical predicate for every role × internal flag', () => {
expect(PLATFORM_ADMIN_ROLE).toBe(AUTH_PLATFORM_ADMIN_ROLE);
for (const role of roles) {
for (const orgIsInternal of [true, false]) {
expect(isOrgParticipant(role, { orgIsInternal })).toBe(
authIsOrgParticipant(role, { orgIsInternal }),
);
}
}
});
});
25 changes: 25 additions & 0 deletions apps/api/src/utils/org-participation-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Pure, dependency-free org-participation rule.
*
* This is a deliberate mirror of `packages/auth/src/participation.ts`. Files
* that end up in the API's Trigger.dev bundle (e.g. task notifiers) cannot
* import `@trycompai/auth/participation`: the auth package's dist is not built
* in the Trigger.dev deploy workflow, so esbuild fails to resolve the subpath
* ("Could not resolve @trycompai/auth/participation"). Keeping this rule free of
* imports lets both NestJS services and Trigger.dev tasks share one
* implementation. KEEP IN SYNC with the auth package version — the drift-guard
* test in `org-participation-rule.spec.ts` fails CI if they diverge.
*
* Platform admins (`User.role === 'admin'`) are Comp AI staff embedded in
* customer orgs for support; they are excluded from an org's business logic
* UNLESS the org is internal (platform-operated, e.g. Comp AI's own org).
*/
export const PLATFORM_ADMIN_ROLE = 'admin';

export function isOrgParticipant(
userRole: string | null | undefined,
{ orgIsInternal }: { orgIsInternal: boolean },
): boolean {
if (orgIsInternal) return true;
return userRole !== PLATFORM_ADMIN_ROLE;
}
11 changes: 5 additions & 6 deletions apps/api/src/utils/org-participation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { db, Prisma } from '@db';
// Import from the dedicated subpath (not the package index) so this stays free
// of better-auth — keeps the util light and Jest-loadable without ESM interop.
import {
PLATFORM_ADMIN_ROLE,
isOrgParticipant,
} from '@trycompai/auth/participation';
// Use the dependency-free local mirror (not @trycompai/auth) so this file is
// safe in the API's Trigger.dev bundle — the auth package's dist isn't built in
// that deploy, so esbuild can't resolve `@trycompai/auth/participation`. The
// mirror is kept in sync with the auth package by org-participation-rule.spec.ts.
import { PLATFORM_ADMIN_ROLE, isOrgParticipant } from './org-participation-rule';

/**
* Resolve whether an organization is platform-operated ("internal", e.g. Comp
Expand Down
Loading