fix: small refactor

This commit is contained in:
Philipinho
2026-08-26 12:09:43 +01:00
parent 90e3437be7
commit 1a4ed15c38
15 changed files with 107 additions and 314 deletions
+1
View File
@@ -25,6 +25,7 @@ export const Feature = {
BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -1,32 +1,15 @@
import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { TrustedOAuthClient } from '../workspace.util';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class TrustedOAuthClientDto {
@IsString()
@IsNotEmpty()
origin: string;
@IsString()
@IsNotEmpty()
@MaxLength(64)
name: string;
}
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsArray()
@@ -86,13 +69,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsIn(['read', 'edit'])
defaultPageEditMode: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => TrustedOAuthClientDto)
trustedOauthClients?: TrustedOAuthClient[];
@IsOptional()
@IsBoolean()
aiChatReadOnly: boolean;
@@ -100,4 +76,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
aiChatWorkspaceKnowledgeOnly: boolean;
@IsOptional()
@IsBoolean()
mcpOauthOnly: boolean;
}
@@ -30,10 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import {
isAdminActingOnOwner,
normalizeTrustedOAuthClients,
} from '../workspace.util';
import { isAdminActingOnOwner } from '../workspace.util';
import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -317,12 +314,6 @@ export class WorkspaceService {
.filter(Boolean);
}
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
updateWorkspaceDto.trustedOauthClients = normalizeTrustedOAuthClients(
updateWorkspaceDto.trustedOauthClients,
);
}
if (updateWorkspaceDto.hostname) {
const hostname = updateWorkspaceDto.hostname;
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
@@ -344,9 +335,9 @@ export class WorkspaceService {
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
typeof updateWorkspaceDto.trustedOauthClients !== 'undefined' ||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined'
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -374,18 +365,6 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.OAUTH,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
@@ -413,6 +392,18 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.MCP_CONTROLS,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -583,6 +574,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
const prev = settingsBefore?.ai?.mcpOauthOnly ?? false;
if (prev !== updateWorkspaceDto.mcpOauthOnly) {
before.mcpOauthOnly = prev;
after.mcpOauthOnly = updateWorkspaceDto.mcpOauthOnly;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'mcpOauthOnly',
updateWorkspaceDto.mcpOauthOnly,
trx,
);
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
@@ -622,6 +627,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.defaultPageEditMode;
delete updateWorkspaceDto.aiChatReadOnly;
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
delete updateWorkspaceDto.mcpOauthOnly;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -661,7 +667,6 @@ export class WorkspaceService {
'enforceMfa',
'emailDomains',
'isScimEnabled',
'trustedOauthClients',
],
updateWorkspaceDto,
workspaceBefore,
@@ -1,49 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeTrustedOAuthClients } from './workspace.util';
describe('normalizeTrustedOAuthClients', () => {
it('lowercases origins and trims names', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: ' Acme MCP ' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Acme MCP' }]);
});
it('dedupes origins case-insensitively with the last entry winning', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: 'First' },
{ origin: 'https://mcp.acme.com', name: 'Second' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Second' }]);
});
it.each([
['http origin', 'http://mcp.acme.com'],
['trailing slash', 'https://mcp.acme.com/'],
['path suffix', 'https://mcp.acme.com/oauth'],
['uppercase host', 'https://MCP.acme.com'],
['not a url', 'mcp.acme.com'],
])('rejects %s naming the origin', (_label, origin) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(BadRequestException);
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(origin);
});
it.each([
['blank', ' '],
['too long', 'x'.repeat(65)],
])('rejects a %s name', (_label, name) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin: 'https://mcp.acme.com', name }]),
).toThrow(BadRequestException);
});
it('returns an empty array for no entries', () => {
expect(normalizeTrustedOAuthClients([])).toEqual([]);
});
});
@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import { UserRole } from '../../common/helpers/types/permission';
export function isAdminActingOnOwner(
@@ -8,39 +7,6 @@ export function isAdminActingOnOwner(
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
}
export type TrustedOAuthClient = { origin: string; name: string };
// Origins must be exact https origins; duplicates collapse case-insensitively, last entry wins.
export function normalizeTrustedOAuthClients(
entries: { origin: string; name: string }[],
): TrustedOAuthClient[] {
const byOrigin = new Map<string, TrustedOAuthClient>();
for (const entry of entries) {
const name = entry.name.trim();
if (name.length < 1 || name.length > 64) {
throw new BadRequestException(
`Invalid trusted application name for origin: ${entry.origin}`,
);
}
let parsed: URL;
try {
parsed = new URL(entry.origin);
} catch {
throw new BadRequestException(
`Invalid trusted application origin: ${entry.origin}`,
);
}
if (parsed.protocol !== 'https:' || parsed.origin !== entry.origin) {
throw new BadRequestException(
`Trusted application origin must be an exact https origin: ${entry.origin}`,
);
}
const origin = entry.origin.toLowerCase();
byOrigin.set(origin, { origin, name });
}
return Array.from(byOrigin.values());
}
export type PageEditMode = 'read' | 'edit';
export function getWorkspaceDefaultPageEditMode(
@@ -78,17 +78,9 @@ export async function up(db: Kysely<any>): Promise<void> {
await db.schema.createIndex('oauth_tokens_access_expires_at_idx').on('oauth_tokens').column('access_expires_at').execute();
await db.schema.createIndex('oauth_tokens_refresh_expires_at_idx').on('oauth_tokens').column('refresh_expires_at').execute();
await db.schema.createIndex('oauth_tokens_revoked_at_idx').on('oauth_tokens').column('revoked_at').execute();
await db.schema
.alterTable('workspaces')
.addColumn('trusted_oauth_clients', 'jsonb', (col) => col.defaultTo(sql`'[]'::jsonb`))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE workspaces DROP COLUMN IF EXISTS trusted_oauth_clients`.execute(
db,
);
await db.schema.dropTable('oauth_tokens').execute();
await db.schema.dropTable('oauth_grants').execute();
await db.schema.dropTable('oauth_authorization_codes').execute();
@@ -20,7 +20,6 @@ export class WorkspaceRepo {
'hostname',
'customDomain',
'settings',
'trustedOauthClients',
'defaultRole',
'emailDomains',
'defaultSpaceId',
-1
View File
@@ -460,7 +460,6 @@ export interface Workspaces {
status: string | null;
stripeCustomerId: string | null;
trialEndAt: Timestamp | null;
trustedOauthClients: Generated<Json | null>;
updatedAt: Generated<Timestamp>;
}