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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- One-time links that finish a connector's setup in the dashboard, handed to
-- the user by an AI client that installed the connector through MCP. Only the
-- token's SHA-256 is stored; see the model comment.
CREATE TABLE "connector_setup_links" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"connector_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"organization_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),

CONSTRAINT "connector_setup_links_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "connector_setup_links_token_hash_key" ON "connector_setup_links"("token_hash");
CREATE INDEX "connector_setup_links_connector_id_user_id_idx" ON "connector_setup_links"("connector_id", "user_id");
CREATE INDEX "connector_setup_links_expires_at_idx" ON "connector_setup_links"("expires_at");
19 changes: 19 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,25 @@ model ConnectorOAuthAttempt {
@@map("connector_oauth_attempts")
}

/// One-time link that finishes a connector's setup in the dashboard: the
/// secrets and the provider sign-in a chat must not handle. Created when a
/// connector is installed through MCP (or its status asked for); opened by the
/// same user, signed in, once. Only the SHA-256 of the token is stored.
model ConnectorSetupLink {
id String @id @default(cuid())
tokenHash String @unique @map("token_hash")
connectorId String @map("connector_id")
userId String @map("user_id")
organizationId String @map("organization_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")

@@index([connectorId, userId])
@@index([expiresAt])
@@map("connector_setup_links")
}

model SsoLoginAttempt {
id String @id @default(cuid())

Expand Down
57 changes: 56 additions & 1 deletion packages/backend/src/adapters/adapters.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
UseGuards,
ForbiddenException,
BadRequestException,
HttpCode,
HttpException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
Expand Down Expand Up @@ -156,9 +158,39 @@ export class AdaptersController {
'Returns the full adapter definition with connector config and all tool mappings.',
})
getBySlug(@Param('slug') slug: string) {
return this.adaptersService.getBySlug(slug);
return this.adaptersService.describe(slug);
}

@Post(':slug/verify')
@HttpCode(200)
@ApiOperation({
summary: 'Try an adapter with credentials before saving it',
description:
'Runs the adapter\'s safe read call against an in-memory connector. Nothing is stored. ' +
'ok=true: it worked; ok=false: a value is missing or the API refused; ok=null: nothing to try yet (needs a sign-in at the provider, or no safe call).',
})
async verify(
@Req() req: any,
@Param('slug') slug: string,
@Body() body: { credentials?: Record<string, string>; connectorId?: string },
) {
if (req.user.role === 'VIEWER') {
throw new ForbiddenException('Viewers cannot modify connectors');
}
// Each try is a real call to the provider; a form does not need more.
if (!this.verifyLimiter.take(req.user.sub)) {
throw new HttpException('Too many attempts. Wait a minute and try again.', 429);
}
return this.adaptersService.verifyCredentials(
slug,
req.user.organizationId,
body?.credentials,
body?.connectorId,
);
}

private readonly verifyLimiter = new PerKeyWindowLimiter(20, 60_000);

@Post(':slug/import')
@ApiOperation({
summary: 'Import a built-in adapter as a new connector',
Expand Down Expand Up @@ -210,3 +242,26 @@ export interface StarterPackInstallResult {
probeOk?: boolean | null;
error?: string;
}

/** At most `max` events per key within a sliding window of `windowMs`. In memory, per instance. */
export class PerKeyWindowLimiter {
private readonly hits = new Map<string, number[]>();
constructor(
private readonly max: number,
private readonly windowMs: number,
) {}

take(key: string, now = Date.now()): boolean {
const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
if (recent.length >= this.max) {
this.hits.set(key, recent);
return false;
}
recent.push(now);
this.hits.set(key, recent);
if (this.hits.size > 10_000) {
for (const [k, v] of this.hits) if (v.every((t) => now - t >= this.windowMs)) this.hits.delete(k);
}
return true;
}
}
6 changes: 4 additions & 2 deletions packages/backend/src/adapters/adapters.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import {
AdaptersPublicController,
} from './adapters.controller';
import { AdaptersService } from './adapters.service';
import { ConnectorSetupService } from './connector-setup.service';
import { SetupLinksController } from './setup-links.controller';
import { McpServerModule } from '../mcp-server/mcp-server.module';
import { LicenseModule } from '../license/license.module';
import { McpServersModule } from '../mcp-servers/mcp-servers.module';
import { ConnectorsModule } from '../connectors/connectors.module';

@Module({
imports: [McpServerModule, LicenseModule, McpServersModule, ConnectorsModule],
controllers: [AdaptersPublicController, AdaptersController],
providers: [AdaptersService],
controllers: [AdaptersPublicController, AdaptersController, SetupLinksController],
providers: [AdaptersService, ConnectorSetupService],
})
export class AdaptersModule {}
74 changes: 74 additions & 0 deletions packages/backend/src/adapters/adapters.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,80 @@ describe('AdaptersService starter pack', () => {
});
});

describe('AdaptersService.verifyCredentials', () => {
function build(execute: jest.Mock) {
const prisma = { connector: { create: jest.fn() }, mcpTool: { create: jest.fn() } };
const service = new AdaptersService(
prisma as any,
{ reloadConnectorTools: jest.fn() } as any,
{ get: (k: string) => (k === 'ENCRYPTION_KEY' ? 'a'.repeat(48) : undefined) } as any,
{ executeConnectorCall: execute } as any,
);
return { service, prisma };
}

it('runs the probe with the given key in memory and writes nothing', async () => {
const execute = jest.fn().mockResolvedValue({ companyName: 'Acme GmbH' });
const { service, prisma } = build(execute);
const out = await service.verifyCredentials('lexware-office', 'org1', { LEXWARE_API_KEY: ' key-1 ' });
expect(out).toMatchObject({ ok: true, sample: expect.stringContaining('Acme GmbH') });
const [connector] = execute.mock.calls[0];
// No id: OAuth and login-token caches stay in memory.
expect(connector.id).toBe('');
expect(connector.envVars).toEqual({ LEXWARE_API_KEY: 'key-1' });
expect(prisma.connector.create).not.toHaveBeenCalled();
});

it('reports a refused key as auth_failed, with the provider message', async () => {
const err: any = new Error('401 Unauthorized: invalid token');
err.status = 401;
const { service } = build(jest.fn().mockRejectedValue(err));
const out = await service.verifyCredentials('lexware-office', 'org1', { LEXWARE_API_KEY: 'bad' });
expect(out).toMatchObject({ ok: false, kind: 'auth_failed', status: 401 });
});

it('names what is still empty without calling the API', async () => {
const execute = jest.fn();
const { service } = build(execute);
const out = await service.verifyCredentials('weclapp', 'org1', { WECLAPP_API_TOKEN: 't' });
expect(out).toMatchObject({ ok: false, kind: 'invalid_input', missing: ['WECLAPP_TENANT'] });
expect(execute).not.toHaveBeenCalled();
});

it('refuses an address variable that is not one', async () => {
const execute = jest.fn();
const { service } = build(execute);
const out = await service.verifyCredentials('substack', 'org1', { SUBSTACK_PUBLICATION_URL: 'not a url at all' });
expect(out).toMatchObject({ ok: false, kind: 'invalid_input' });
expect(execute).not.toHaveBeenCalled();
});

it('has nothing to try for Etsy before the sign-in at Etsy', async () => {
const execute = jest.fn();
const { service } = build(execute);
const out = await service.verifyCredentials('etsy', 'org1', { ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' });
expect(out).toEqual({ ok: null, skipped: 'authorization' });
expect(execute).not.toHaveBeenCalled();
});
});

describe('AdaptersService.verifyCredentials on an existing connector', () => {
it('fills a field left empty from what the connector stores, only within the organization', async () => {
const execute = jest.fn().mockResolvedValue({ ok: 1 });
const findFirst = jest.fn().mockResolvedValue({ envVars: { LEXWARE_API_KEY: 'stored-key' } });
const service = new AdaptersService(
{ connector: { findFirst } } as any,
{ reloadConnectorTools: jest.fn() } as any,
{ get: (k: string) => (k === 'ENCRYPTION_KEY' ? 'a'.repeat(48) : undefined) } as any,
{ executeConnectorCall: execute } as any,
);
const out = await service.verifyCredentials('lexware-office', 'org1', { LEXWARE_API_KEY: '' }, 'c1');
expect(out.ok).toBe(true);
expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'c1', organizationId: 'org1' } }));
expect(execute.mock.calls[0][0].envVars).toEqual({ LEXWARE_API_KEY: 'stored-key' });
});
});

describe('AdaptersService unlisted adapters', () => {
const { getAdapter } = jest.requireActual('./catalog');

Expand Down
Loading
Loading