mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 19:46:32 +08:00
feat(core): improve mcp management (#15221)
#### PR Dependency Tree * **PR #15221** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added MCP credential management (create/reveal, list, rotate, revoke) with expiration and status tracking. * Introduced read-only vs read/write access modes, with read/write tooling enabled only when permitted. * Added workspace MCP credential configuration UI, including token reveal and setup generation. * Added MCP credential GraphQL APIs to back the UI. * **Changes** * Replaced legacy access-token support with MCP credentials across authentication and realtime updates. * **Bug Fixes** * MCP authentication now reliably rejects revoked, rotated, expired, or disabled-user credentials. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
DROP TABLE IF EXISTS "access_tokens";
|
||||
|
||||
CREATE TYPE "McpAccessMode" AS ENUM ('READ_ONLY', 'READ_WRITE');
|
||||
|
||||
CREATE TABLE "mcp_credentials" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"family_id" VARCHAR NOT NULL,
|
||||
"generation" INTEGER NOT NULL DEFAULT 0,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"secret_hash" VARCHAR NOT NULL,
|
||||
"fingerprint" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"access_mode" "McpAccessMode" NOT NULL DEFAULT 'READ_ONLY',
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"last_used_at" TIMESTAMPTZ(3),
|
||||
"revoked_at" TIMESTAMPTZ(3),
|
||||
"replaced_by_id" VARCHAR,
|
||||
"grace_ends_at" TIMESTAMPTZ(3),
|
||||
CONSTRAINT "mcp_credentials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "mcp_credentials_secret_hash_key" ON "mcp_credentials"("secret_hash");
|
||||
CREATE UNIQUE INDEX "mcp_credentials_family_id_generation_key" ON "mcp_credentials"("family_id", "generation");
|
||||
CREATE INDEX "mcp_credentials_user_id_workspace_id_idx" ON "mcp_credentials"("user_id", "workspace_id");
|
||||
CREATE INDEX "mcp_credentials_expires_at_idx" ON "mcp_credentials"("expires_at");
|
||||
|
||||
ALTER TABLE "mcp_credentials" ADD CONSTRAINT "mcp_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "mcp_credentials" ADD CONSTRAINT "mcp_credentials_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -36,6 +36,7 @@ model User {
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
calendarAccounts CalendarAccount[]
|
||||
mcpCredentials McpCredential[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
appConfigs AppConfig[]
|
||||
@@ -51,7 +52,6 @@ model User {
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
workspaceMemberLastAccesses WorkspaceMemberLastAccess[]
|
||||
quotaState EffectiveUserQuotaState?
|
||||
@@ -217,6 +217,7 @@ model Workspace {
|
||||
projectedInvitations WorkspaceInvitation[]
|
||||
docAccessPolicies DocAccessPolicy[]
|
||||
docGrants DocGrant[]
|
||||
mcpCredentials McpCredential[]
|
||||
|
||||
@@index([lastCheckEmbeddings])
|
||||
@@index([createdAt])
|
||||
@@ -1495,18 +1496,35 @@ model CommentAttachment {
|
||||
@@map("comment_attachments")
|
||||
}
|
||||
|
||||
model AccessToken {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
token String @unique @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
enum McpAccessMode {
|
||||
READ_ONLY
|
||||
READ_WRITE
|
||||
}
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
model McpCredential {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
familyId String @map("family_id") @db.VarChar
|
||||
generation Int @default(0)
|
||||
name String @db.VarChar
|
||||
secretHash String @unique @map("secret_hash") @db.VarChar
|
||||
fingerprint String @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
accessMode McpAccessMode @default(READ_ONLY) @map("access_mode")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(3)
|
||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
||||
replacedById String? @map("replaced_by_id") @db.VarChar
|
||||
graceEndsAt DateTime? @map("grace_ends_at") @db.Timestamptz(3)
|
||||
|
||||
@@index([userId])
|
||||
@@map("access_tokens")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([familyId, generation])
|
||||
@@index([userId, workspaceId])
|
||||
@@index([expiresAt])
|
||||
@@map("mcp_credentials")
|
||||
}
|
||||
|
||||
model CalendarAccount {
|
||||
|
||||
@@ -146,21 +146,11 @@ test('should reject a legacy bearer session id', async t => {
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${t.context.sessionId}`)
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should be able to visit private api with personal access token', async t => {
|
||||
const accessToken = await t.context.models.accessToken.create({
|
||||
userId: t.context.u1.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const res = await request(t.context.server)
|
||||
await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${accessToken.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
.set('Authorization', 'Bearer aff_mcp_v1.selector.secret')
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should be able to visit private api with auth-session access jwt', async t => {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { ProjectRoot } from '@affine-tools/utils/path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { McpAccessMode, PrismaClient } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import type { Request, Response } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Sinon from 'sinon';
|
||||
import { z } from 'zod';
|
||||
@@ -42,6 +43,9 @@ import {
|
||||
CopilotEmbeddingJob,
|
||||
MockEmbeddingClient,
|
||||
} from '../../plugins/copilot/embedding';
|
||||
import { WorkspaceMcpController } from '../../plugins/copilot/mcp/controller';
|
||||
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
|
||||
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
|
||||
import { PromptService } from '../../plugins/copilot/prompt';
|
||||
import {
|
||||
CopilotProviderFactory,
|
||||
@@ -110,6 +114,8 @@ type Context = {
|
||||
cronJobs: CopilotCronJobs;
|
||||
subscription: SubscriptionService;
|
||||
quotaState: QuotaStateService;
|
||||
mcpCredentials: McpCredentialService;
|
||||
mcpProvider: WorkspaceMcpProvider;
|
||||
};
|
||||
|
||||
const buildTurn = (
|
||||
@@ -234,6 +240,8 @@ test.before(async t => {
|
||||
t.context.cronJobs = cronJobs;
|
||||
t.context.subscription = subscription;
|
||||
t.context.quotaState = quotaState;
|
||||
t.context.mcpCredentials = module.get(McpCredentialService);
|
||||
t.context.mcpProvider = module.get(WorkspaceMcpProvider);
|
||||
|
||||
await module.initTestingDB();
|
||||
});
|
||||
@@ -254,6 +262,86 @@ test.after.always(async t => {
|
||||
await t.context.module?.close();
|
||||
});
|
||||
|
||||
test('MCP credentials stay bound to their endpoint, workspace, and profile', async t => {
|
||||
const { db, mcpCredentials, mcpProvider, models, workspace } = t.context;
|
||||
const ws = await workspace.create(userId);
|
||||
const other = await workspace.create(userId);
|
||||
const issued = await mcpCredentials.create({
|
||||
userId,
|
||||
workspaceId: ws.id,
|
||||
name: 'Claude Desktop',
|
||||
accessMode: McpAccessMode.READ_ONLY,
|
||||
expirationDays: 90,
|
||||
});
|
||||
|
||||
const authenticated = await mcpCredentials.authenticate(issued.token, ws.id);
|
||||
t.is(authenticated.userId, userId);
|
||||
await t.throwsAsync(mcpCredentials.authenticate(issued.token, other.id));
|
||||
|
||||
let responseStatus: number | undefined;
|
||||
let responseBody: unknown;
|
||||
const request = {
|
||||
body: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
get: () => `Bearer ${issued.token}`,
|
||||
on: () => request,
|
||||
} as unknown as Request;
|
||||
const response = {
|
||||
status: (status: number) => {
|
||||
responseStatus = status;
|
||||
return response;
|
||||
},
|
||||
json: (body: unknown) => {
|
||||
responseBody = body;
|
||||
return response;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await t.context.module
|
||||
.get(WorkspaceMcpController)
|
||||
.mcp(request, response, ws.id);
|
||||
t.is(responseStatus, 200);
|
||||
t.like(responseBody as object, {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
});
|
||||
|
||||
const server = await mcpProvider.for(userId, ws.id, McpAccessMode.READ_ONLY);
|
||||
t.deepEqual(
|
||||
server.tools.map(tool => tool.name),
|
||||
['read_document', 'semantic_search', 'keyword_search']
|
||||
);
|
||||
|
||||
const rotated = await mcpCredentials.rotate(
|
||||
issued.credential.id,
|
||||
userId,
|
||||
ws.id,
|
||||
30
|
||||
);
|
||||
const listed = await mcpCredentials.list(userId, ws.id);
|
||||
t.is(listed.length, 1);
|
||||
t.is(listed[0].status, 'ROTATING');
|
||||
await mcpCredentials.authenticate(issued.token, ws.id);
|
||||
await mcpCredentials.revoke(rotated.credential.id, userId, ws.id);
|
||||
await t.throwsAsync(mcpCredentials.authenticate(issued.token, ws.id));
|
||||
await t.throwsAsync(mcpCredentials.authenticate(rotated.token, ws.id));
|
||||
|
||||
const disabled = await mcpCredentials.create({
|
||||
userId,
|
||||
workspaceId: ws.id,
|
||||
name: 'Disabled user',
|
||||
accessMode: McpAccessMode.READ_ONLY,
|
||||
expirationDays: 30,
|
||||
});
|
||||
await models.user.update(userId, { disabled: true });
|
||||
await t.throwsAsync(mcpCredentials.authenticate(disabled.token, ws.id));
|
||||
|
||||
await models.user.update(userId, { disabled: false });
|
||||
await db.mcpCredential.update({
|
||||
where: { id: disabled.credential.id },
|
||||
data: { expiresAt: new Date(0) },
|
||||
});
|
||||
await t.throwsAsync(mcpCredentials.authenticate(disabled.token, ws.id));
|
||||
});
|
||||
|
||||
test('should reject context file uploads after workspace write access is revoked', async t => {
|
||||
const { auth, context, models, prompt, session, storage, workspace } =
|
||||
t.context;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AccessToken } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { Mocker } from './factory';
|
||||
|
||||
export type MockAccessTokenInput = Omit<
|
||||
Prisma.AccessTokenUncheckedCreateInput,
|
||||
'token'
|
||||
>;
|
||||
|
||||
export type MockedAccessToken = AccessToken;
|
||||
|
||||
export class MockAccessToken extends Mocker<
|
||||
MockAccessTokenInput,
|
||||
MockedAccessToken
|
||||
> {
|
||||
override async create(input: MockAccessTokenInput) {
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
...input,
|
||||
name: input.name ?? faker.lorem.word(),
|
||||
token: 'ut_' + faker.string.hexadecimal({ length: 37 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export * from './user.mock';
|
||||
export * from './workspace.mock';
|
||||
export * from './workspace-user.mock';
|
||||
|
||||
import { MockAccessToken } from './access-token.mock';
|
||||
import { installMockCopilotRuntime, MockCopilotProvider } from './copilot.mock';
|
||||
import { MockDocMeta } from './doc-meta.mock';
|
||||
import { MockDocSnapshot } from './doc-snapshot.mock';
|
||||
@@ -28,7 +27,6 @@ export const Mockers = {
|
||||
DocMeta: MockDocMeta,
|
||||
DocSnapshot: MockDocSnapshot,
|
||||
DocUser: MockDocUser,
|
||||
AccessToken: MockAccessToken,
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -27,7 +27,6 @@ import { PrismaModule } from './base/prisma';
|
||||
import { RedisModule } from './base/redis';
|
||||
import { RateLimiterModule } from './base/throttler';
|
||||
import { WebSocketModule } from './base/websocket';
|
||||
import { AccessTokenModule } from './core/access-token';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { BackendRuntimeModule } from './core/backend-runtime';
|
||||
import { CommentModule } from './core/comment';
|
||||
@@ -209,7 +208,6 @@ export function buildAppModule(env: Env) {
|
||||
CalendarModule,
|
||||
TelemetryModule,
|
||||
CommentModule,
|
||||
AccessTokenModule,
|
||||
QueueDashboardModule
|
||||
)
|
||||
// doc service and front service
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessTokenResolver, UserAccessTokenResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [AccessTokenResolver, UserAccessTokenResolver],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden, EventBus } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { UserType } from '../user';
|
||||
import { AccessToken, RevealedAccessToken } from './types';
|
||||
|
||||
@InputType()
|
||||
class GenerateAccessTokenInput {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Query(() => [RevealedAccessToken], {
|
||||
deprecationReason: 'use currentUser.revealedAccessTokens',
|
||||
})
|
||||
async revealedAccessTokens(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<RevealedAccessToken[]> {
|
||||
return await this.models.accessToken.list(user.id, true);
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedAccessToken)
|
||||
async generateUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
const token = await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
this.event.emit('user.access_token.created', { userId: user.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokeUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
this.event.emit('user.access_token.revoked', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserAccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@ResolveField(() => [AccessToken])
|
||||
async accessTokens(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Parent() user: UserType
|
||||
): Promise<AccessToken[]> {
|
||||
if (!currentUser || currentUser.id !== user.id) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
return await this.models.accessToken.list(user.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => [RevealedAccessToken])
|
||||
async revealedAccessTokens(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Parent() user: UserType
|
||||
): Promise<RevealedAccessToken[]> {
|
||||
if (!currentUser || currentUser.id !== user.id) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
return await this.models.accessToken.list(user.id, true);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class AccessToken {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RevealedAccessToken extends AccessToken {
|
||||
@Field()
|
||||
token!: string;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { AuthSessionService } from './auth-session';
|
||||
import { extractTokenFromHeader } from './input';
|
||||
import { AuthService } from './service';
|
||||
import { AuthSessionPrincipal, Session, TokenSession } from './session';
|
||||
import { AuthSessionPrincipal, Session } from './session';
|
||||
import { AuthSessionHttpError } from './session-exchange';
|
||||
|
||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||
@@ -41,8 +41,7 @@ const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
|
||||
|
||||
type AuthenticatedRequestSession =
|
||||
| { type: 'jwt'; session: Session }
|
||||
| { type: 'cookie_session'; session: Session }
|
||||
| { type: 'access_token'; token: TokenSession };
|
||||
| { type: 'cookie_session'; session: Session };
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
@@ -126,11 +125,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
req: Request,
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | TokenSession | null> {
|
||||
): Promise<Session | null> {
|
||||
const result = await this.resolveRequestSession(req, res, isPublic);
|
||||
return result?.type === 'access_token'
|
||||
? result.token
|
||||
: (result?.session ?? null);
|
||||
return result?.session ?? null;
|
||||
}
|
||||
|
||||
private async resolveRequestSession(
|
||||
@@ -153,11 +150,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
if (bearer) {
|
||||
const token = await this.signInWithAccessToken(req);
|
||||
return token ? { type: 'access_token', token } : null;
|
||||
}
|
||||
|
||||
const session = await this.signInWithCookie(req, res, isPublic);
|
||||
return session ? { type: 'cookie_session', session } : null;
|
||||
}
|
||||
@@ -270,23 +262,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
|
||||
if (req.token) {
|
||||
return req.token;
|
||||
}
|
||||
|
||||
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
|
||||
|
||||
if (tokenSession) {
|
||||
req.token = { ...tokenSession.token, user: tokenSession.user };
|
||||
req.authType = 'access_token';
|
||||
|
||||
return req.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getVersionRange(versionRange: string): semver.Range | null {
|
||||
if (this.cachedVersionRange.has(versionRange)) {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createDevUsers } from './dev';
|
||||
import type { VerifiedIdentity } from './identity';
|
||||
import {
|
||||
CSRF_COOKIE_NAME,
|
||||
extractTokenFromHeader,
|
||||
getSessionOptionsFromRequest,
|
||||
SESSION_COOKIE_NAME,
|
||||
USER_COOKIE_NAME,
|
||||
@@ -277,36 +276,6 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return session;
|
||||
}
|
||||
|
||||
async getTokenSessionFromRequest(req: Request) {
|
||||
const tokenHeader = req.headers.authorization;
|
||||
if (!tokenHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenValue = extractTokenFromHeader(tokenHeader);
|
||||
|
||||
if (!tokenValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = await this.models.accessToken.getByToken(tokenValue);
|
||||
|
||||
if (token) {
|
||||
const user = await this.models.user.get(token.userId);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: sessionUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
id: string,
|
||||
newPassword: string
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { AccessToken } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../base';
|
||||
import type { User, UserSession } from '../../models';
|
||||
@@ -42,7 +41,7 @@ import type { User, UserSession } from '../../models';
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.session?.user ?? req.token?.user;
|
||||
return req.session?.user;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,7 +69,3 @@ export type AuthSessionPrincipal = Session & {
|
||||
authSessionId: string;
|
||||
authenticatedAt: Date;
|
||||
};
|
||||
|
||||
export type TokenSession = AccessToken & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
@@ -7,9 +7,8 @@ import { Models } from '../../../models';
|
||||
const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
models: Models;
|
||||
allowlistedAdminToken: string;
|
||||
nonAllowlistedAdminToken: string;
|
||||
userToken: string;
|
||||
allowlistedAdminEmail: string;
|
||||
userEmail: string;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
@@ -34,38 +33,14 @@ test.beforeEach(async t => {
|
||||
'administrator',
|
||||
'test'
|
||||
);
|
||||
const allowlistedAdminToken = await t.context.models.accessToken.create({
|
||||
userId: allowlistedAdmin.id,
|
||||
name: 'test',
|
||||
});
|
||||
t.context.allowlistedAdminToken = allowlistedAdminToken.token;
|
||||
|
||||
const nonAllowlistedAdmin = await t.context.models.user.create({
|
||||
email: 'admin2@affine.pro',
|
||||
password: '1',
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
await t.context.models.userFeature.add(
|
||||
nonAllowlistedAdmin.id,
|
||||
'administrator',
|
||||
'test'
|
||||
);
|
||||
const nonAllowlistedAdminToken = await t.context.models.accessToken.create({
|
||||
userId: nonAllowlistedAdmin.id,
|
||||
name: 'test',
|
||||
});
|
||||
t.context.nonAllowlistedAdminToken = nonAllowlistedAdminToken.token;
|
||||
t.context.allowlistedAdminEmail = allowlistedAdmin.email;
|
||||
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'user@affine.pro',
|
||||
password: '1',
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
const userToken = await t.context.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
t.context.userToken = userToken.token;
|
||||
t.context.userEmail = user.email;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
@@ -74,16 +49,20 @@ test.after.always(async t => {
|
||||
|
||||
test('should return 404 for non-admin user', async t => {
|
||||
await t.context.app
|
||||
.GET('/api/queue')
|
||||
.set('Authorization', `Bearer ${t.context.userToken}`)
|
||||
.expect(404);
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: t.context.userEmail, password: '1' })
|
||||
.expect(200);
|
||||
await t.context.app.GET('/api/queue').expect(404);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should allow allowlisted admin', async t => {
|
||||
await t.context.app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: t.context.allowlistedAdminEmail, password: '1' })
|
||||
.expect(200);
|
||||
await t.context.app
|
||||
.GET('/api/queue')
|
||||
.set('Authorization', `Bearer ${t.context.allowlistedAdminToken}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', /text\/html/);
|
||||
t.pass();
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
realtimeWorkspaceAccessRoom,
|
||||
@@ -298,7 +297,6 @@ test('room helpers produce stable realtime room names', t => {
|
||||
t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants');
|
||||
t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile');
|
||||
t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings');
|
||||
t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens');
|
||||
t.is(
|
||||
realtimeTranscriptTaskRoom('space', 'task'),
|
||||
'copilot:transcript:space:task'
|
||||
@@ -775,16 +773,6 @@ test('user realtime provider snapshots private profile settings and access token
|
||||
userFeature: {
|
||||
list: async () => ['administrator'],
|
||||
},
|
||||
accessToken: {
|
||||
list: async () => [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
new UserRealtimeProvider(models as never, registry).onModuleInit();
|
||||
@@ -814,10 +802,6 @@ test('user realtime provider snapshots private profile settings and access token
|
||||
registry.getTopic('user.settings.changed').room(user, {}),
|
||||
realtimeUserSettingsRoom('u1')
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.access-tokens.changed').room(user, {}),
|
||||
realtimeUserAccessTokensRoom('u1')
|
||||
);
|
||||
t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), {
|
||||
settings: {
|
||||
receiveInvitationEmail: true,
|
||||
@@ -825,19 +809,6 @@ test('user realtime provider snapshots private profile settings and access token
|
||||
receiveCommentEmail: true,
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
await registry.getRequest('user.access-tokens.get').handle(user, {}),
|
||||
{
|
||||
tokens: [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('new realtime providers publish changed events from domain events', t => {
|
||||
@@ -894,13 +865,6 @@ test('new realtime providers publish changed events from domain events', t => {
|
||||
userId: 'u2',
|
||||
});
|
||||
|
||||
const userProvider = new UserRealtimeProvider(
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
userProvider.onUserAccessTokenCreated({ userId: 'u1' });
|
||||
|
||||
t.deepEqual(
|
||||
published.map(args => args[0]),
|
||||
[
|
||||
@@ -909,7 +873,6 @@ test('new realtime providers publish changed events from domain events', t => {
|
||||
'workspace.invite-link.changed',
|
||||
'doc.share-state.changed',
|
||||
'doc.grants.changed',
|
||||
'user.access-tokens.changed',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,6 @@ export {
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserQuotaStateRoom,
|
||||
realtimeUserRoom,
|
||||
|
||||
@@ -9,7 +9,6 @@ export const REALTIME_GATEWAY_REQUIRED_REQUESTS = [
|
||||
'doc.grants.get',
|
||||
'user.profile.get',
|
||||
'user.settings.get',
|
||||
'user.access-tokens.get',
|
||||
'notification.count.get',
|
||||
'comment.changes.get',
|
||||
'workspace.embedding.progress.get',
|
||||
@@ -27,7 +26,6 @@ export const REALTIME_GATEWAY_REQUIRED_TOPICS = [
|
||||
'doc.grants.changed',
|
||||
'user.profile.changed',
|
||||
'user.settings.changed',
|
||||
'user.access-tokens.changed',
|
||||
'notification.count.changed',
|
||||
'comment.changed',
|
||||
'workspace.embedding.progress.changed',
|
||||
|
||||
@@ -72,7 +72,3 @@ export function realtimeUserProfileRoom(userId: string) {
|
||||
export function realtimeUserSettingsRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'settings');
|
||||
}
|
||||
|
||||
export function realtimeUserAccessTokensRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'access-tokens');
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ export class SpaceSyncGateway
|
||||
|
||||
private attachPresenceUserId(client: Socket): string | null {
|
||||
const request = client.request as Request;
|
||||
const userId = request.session?.user.id ?? request.token?.user.id;
|
||||
const userId = request.session?.user.id;
|
||||
if (typeof userId !== 'string' || !userId) {
|
||||
this.logger.warn(
|
||||
`Unable to resolve authenticated user id for socket ${client.id}`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AccessTokenSnapshot,
|
||||
CurrentUserProfileSnapshot,
|
||||
UserSettingsSnapshot,
|
||||
} from '@affine/realtime';
|
||||
@@ -14,7 +13,6 @@ import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
} from '../realtime/rooms';
|
||||
@@ -85,27 +83,6 @@ export class UserRealtimeProvider
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.access-tokens.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
tokens: await this.getAccessTokens(assertAuthenticated(user).id),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.access-tokens.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserAccessTokensRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
@@ -125,16 +102,6 @@ export class UserRealtimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.created', { suppressError: true })
|
||||
onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) {
|
||||
this.publishAccessTokens(userId, 'access-token-created');
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.revoked', { suppressError: true })
|
||||
onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) {
|
||||
this.publishAccessTokens(userId, 'access-token-revoked');
|
||||
}
|
||||
|
||||
private async getProfile(
|
||||
userId: string
|
||||
): Promise<CurrentUserProfileSnapshot> {
|
||||
@@ -166,22 +133,4 @@ export class UserRealtimeProvider
|
||||
private async getSettings(userId: string): Promise<UserSettingsSnapshot> {
|
||||
return await this.models.userSettings.get(userId);
|
||||
}
|
||||
|
||||
private async getAccessTokens(
|
||||
userId: string
|
||||
): Promise<AccessTokenSnapshot[]> {
|
||||
const tokens = await this.models.accessToken.list(userId);
|
||||
return tokens.map(token => ({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
createdAt: token.createdAt.toISOString(),
|
||||
expiresAt: token.expiresAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
private publishAccessTokens(userId: string, reason: string) {
|
||||
this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, {
|
||||
room: realtimeUserAccessTokensRoom(userId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
session?: import('./core/auth/session').Session;
|
||||
token?: import('./core/auth/session').TokenSession;
|
||||
authType?: 'jwt' | 'session' | 'access_token';
|
||||
authType?: 'jwt' | 'session';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Due } from '../../base';
|
||||
import { Models } from '../index';
|
||||
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
t.is(token.userId, user.id);
|
||||
t.is(token.name, 'test');
|
||||
t.truthy(token.token);
|
||||
t.true(token.token.startsWith('ut_'));
|
||||
t.truthy(token.createdAt);
|
||||
t.is(token.expiresAt, null);
|
||||
|
||||
const row = await module.get(PrismaClient).accessToken.findUnique({
|
||||
where: { id: token.id },
|
||||
});
|
||||
t.truthy(row);
|
||||
t.regex(row!.token, /^[0-9a-f]{64}$/);
|
||||
t.not(row!.token, token.token);
|
||||
});
|
||||
|
||||
test('should create access token with expiration', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.after('30d'),
|
||||
});
|
||||
|
||||
t.truthy(token.expiresAt);
|
||||
t.truthy(token.expiresAt! > new Date());
|
||||
});
|
||||
|
||||
test('should list access tokens without token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
await module.create(Mockers.AccessToken, { userId: user.id }, 3);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 3);
|
||||
// @ts-expect-error not exists
|
||||
t.is(listed[0].token, undefined);
|
||||
});
|
||||
|
||||
test('should not reveal access token value after creation', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const listed = await models.accessToken.list(user.id, true);
|
||||
const found = listed.find(item => item.id === token.id);
|
||||
|
||||
t.truthy(found);
|
||||
t.is(found!.token, '[REDACTED]');
|
||||
t.not(found!.token, token.token);
|
||||
});
|
||||
|
||||
test('should be able to revoke access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
|
||||
await models.accessToken.revoke(token.id, user.id);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 0);
|
||||
});
|
||||
|
||||
test('should be able to get access token by token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found?.id, token.id);
|
||||
t.is(found?.userId, user.id);
|
||||
t.is(found?.name, token.name);
|
||||
});
|
||||
|
||||
test('should not get expired access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.before('1s'),
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found, null);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
const REDACTED_TOKEN = '[REDACTED]';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.access_token.created': {
|
||||
userId: string;
|
||||
};
|
||||
'user.access_token.revoked': {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
type UserAccessToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(userId: string, revealed?: false): Promise<UserAccessToken[]>;
|
||||
async list(
|
||||
userId: string,
|
||||
revealed: true
|
||||
): Promise<(UserAccessToken & { token: string })[]>;
|
||||
async list(userId: string, revealed: boolean = false) {
|
||||
const tokens = await this.db.accessToken.findMany({
|
||||
select: { id: true, name: true, createdAt: true, expiresAt: true },
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!revealed) return tokens;
|
||||
|
||||
return tokens.map(row => ({ ...row, token: REDACTED_TOKEN }));
|
||||
}
|
||||
|
||||
async create(input: CreateAccessTokenInput) {
|
||||
const token = `ut_${this.crypto.randomBytes(32).toString('base64url')}`;
|
||||
const tokenHash = this.crypto.sha256(token).toString('hex');
|
||||
|
||||
const created = await this.db.accessToken.create({
|
||||
data: { token: tokenHash, ...input },
|
||||
});
|
||||
|
||||
// NOTE: we only return the plaintext token once, at creation time.
|
||||
return { ...created, token };
|
||||
}
|
||||
|
||||
async revoke(id: string, userId: string) {
|
||||
await this.db.accessToken.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByToken(token: string) {
|
||||
const tokenHash = this.crypto.sha256(token).toString('hex');
|
||||
|
||||
const condition = [{ expiresAt: null }, { expiresAt: { gt: new Date() } }];
|
||||
const found = await this.db.accessToken.findUnique({
|
||||
where: { token: tokenHash, OR: condition },
|
||||
});
|
||||
|
||||
if (found) return found;
|
||||
|
||||
// Compatibility: lazy-migrate old plaintext tokens in DB.
|
||||
const legacy = await this.db.accessToken.findUnique({
|
||||
where: { token, OR: condition },
|
||||
});
|
||||
|
||||
if (!legacy) return null;
|
||||
|
||||
await this.db.accessToken.update({
|
||||
where: { id: legacy.id },
|
||||
data: { token: tokenHash },
|
||||
});
|
||||
|
||||
return { ...legacy, token: tokenHash };
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ApplyType } from '../base';
|
||||
import { AccessTokenModel } from './access-token';
|
||||
import { AuthSessionModel } from './auth-session';
|
||||
import { BlobModel } from './blob';
|
||||
import { CalendarAccountModel } from './calendar-account';
|
||||
@@ -31,6 +30,7 @@ import { FeatureModel } from './feature';
|
||||
import { HistoryModel } from './history';
|
||||
import { MagicLinkOtpModel } from './magic-link-otp';
|
||||
import { MailDeliveryModel } from './mail-delivery';
|
||||
import { McpCredentialModel } from './mcp-credential';
|
||||
import { NotificationModel } from './notification';
|
||||
import { PermissionProjectionModel } from './permission-projection';
|
||||
import {
|
||||
@@ -91,7 +91,7 @@ const MODELS = {
|
||||
comment: CommentModel,
|
||||
commentAttachment: CommentAttachmentModel,
|
||||
blob: BlobModel,
|
||||
accessToken: AccessTokenModel,
|
||||
mcpCredential: McpCredentialModel,
|
||||
calendarAccount: CalendarAccountModel,
|
||||
calendarSubscription: CalendarSubscriptionModel,
|
||||
calendarEvent: CalendarEventModel,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { McpAccessMode } from '@prisma/client';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export type CreateMcpCredential = {
|
||||
id: string;
|
||||
familyId: string;
|
||||
generation: number;
|
||||
name: string;
|
||||
secretHash: string;
|
||||
fingerprint: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
accessMode: McpAccessMode;
|
||||
expiresAt: Date;
|
||||
graceEndsAt?: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class McpCredentialModel extends BaseModel {
|
||||
list(userId: string, workspaceId: string) {
|
||||
return this.db.mcpCredential.findMany({
|
||||
where: { userId, workspaceId },
|
||||
orderBy: [{ familyId: 'asc' }, { generation: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
create(input: CreateMcpCredential) {
|
||||
return this.db.mcpCredential.create({ data: input });
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.db.mcpCredential.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async revokeFamily(familyId: string, userId: string, workspaceId: string) {
|
||||
return await this.db.mcpCredential.updateMany({
|
||||
where: { familyId, userId, workspaceId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async replace(
|
||||
id: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
replacedById: string,
|
||||
expiresAt: Date
|
||||
) {
|
||||
return await this.db.mcpCredential.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
workspaceId,
|
||||
revokedAt: null,
|
||||
replacedById: null,
|
||||
},
|
||||
data: { replacedById, expiresAt },
|
||||
});
|
||||
}
|
||||
|
||||
async authenticate(id: string, workspaceId: string) {
|
||||
const now = new Date();
|
||||
return await this.db.mcpCredential.findFirst({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
user: { disabled: false },
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
async touch(id: string, before: Date, now: Date) {
|
||||
await this.db.mcpCredential.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [{ lastUsedAt: null }, { lastUsedAt: { lt: before } }],
|
||||
},
|
||||
data: { lastUsedAt: now },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { WorkspaceModule } from '../../core/workspaces';
|
||||
import { IndexerModule } from '../indexer';
|
||||
import { CopilotController } from './controller';
|
||||
import { WorkspaceMcpController } from './mcp/controller';
|
||||
import { McpCredentialService } from './mcp/credential';
|
||||
import { McpCredentialResolver } from './mcp/resolver';
|
||||
import {
|
||||
COPILOT_API_PROVIDERS,
|
||||
COPILOT_CONTEXT_REALTIME_PROVIDERS,
|
||||
@@ -69,7 +71,13 @@ export class CopilotFeatureModule {}
|
||||
export class CopilotApiModule {}
|
||||
|
||||
@Module({
|
||||
imports: [CopilotKernelModule, CopilotFeatureModule, CopilotApiModule],
|
||||
imports: [
|
||||
PermissionModule,
|
||||
CopilotKernelModule,
|
||||
CopilotFeatureModule,
|
||||
CopilotApiModule,
|
||||
],
|
||||
providers: [McpCredentialService, McpCredentialResolver],
|
||||
controllers: [CopilotController, WorkspaceMcpController],
|
||||
})
|
||||
export class CopilotModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
@@ -12,8 +13,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { Throttle } from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { ActionForbidden, Throttle } from '../../../base';
|
||||
import { Public } from '../../../core/auth';
|
||||
import { extractTokenFromHeader } from '../../../core/auth/input';
|
||||
import { McpCredentialService } from './credential';
|
||||
import { WorkspaceMcpProvider, type WorkspaceMcpServer } from './provider';
|
||||
|
||||
type JsonRpcId = string | number | null;
|
||||
@@ -47,28 +50,42 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([
|
||||
export class WorkspaceMcpController {
|
||||
private readonly logger = new Logger(WorkspaceMcpController.name);
|
||||
|
||||
constructor(private readonly provider: WorkspaceMcpProvider) {}
|
||||
constructor(
|
||||
private readonly provider: WorkspaceMcpProvider,
|
||||
private readonly credentials: McpCredentialService
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@Delete('/')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
async STATELESS_MCP_ENDPOINT() {
|
||||
return this.errorResponse(null, -32000, 'Method not allowed.');
|
||||
}
|
||||
|
||||
@Throttle('default')
|
||||
@Public()
|
||||
@Post('/')
|
||||
async mcp(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Param('workspaceId') workspaceId: string
|
||||
) {
|
||||
const abortController = new AbortController();
|
||||
req.on('close', () => abortController.abort());
|
||||
|
||||
try {
|
||||
const server = await this.provider.for(user.id, workspaceId);
|
||||
const token =
|
||||
extractTokenFromHeader(req.get('authorization') ?? '') ?? '';
|
||||
const credential = await this.credentials.authenticate(
|
||||
token,
|
||||
workspaceId
|
||||
);
|
||||
const server = await this.provider.for(
|
||||
credential.userId,
|
||||
workspaceId,
|
||||
credential.accessMode
|
||||
);
|
||||
const body = req.body as unknown;
|
||||
const isBatch = Array.isArray(body);
|
||||
const messages = isBatch ? body : [body];
|
||||
@@ -111,6 +128,18 @@ export class WorkspaceMcpController {
|
||||
|
||||
res.status(HttpStatus.OK).json(isBatch ? responses : responses[0]);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
res
|
||||
.status(error.getStatus())
|
||||
.json(this.errorResponse(null, -32000, 'Authentication failed'));
|
||||
return;
|
||||
}
|
||||
if (error instanceof ActionForbidden) {
|
||||
res
|
||||
.status(HttpStatus.FORBIDDEN)
|
||||
.json(this.errorResponse(null, -32000, 'Access denied'));
|
||||
return;
|
||||
}
|
||||
this.logger.error('Failed to handle MCP request', error);
|
||||
res
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import type { McpAccessMode } from '@prisma/client';
|
||||
|
||||
import { CryptoHelper, EventBus } from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
|
||||
const TOKEN_PREFIX = 'aff_mcp_v1';
|
||||
const ALLOWED_EXPIRATION_DAYS = new Set([30, 90, 365]);
|
||||
const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
const LAST_USED_WRITE_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'mcp.credential.created': {
|
||||
credentialId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
'mcp.credential.rotated': {
|
||||
credentialId: string;
|
||||
replacedById: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
'mcp.credential.revoked': {
|
||||
credentialId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type IssueMcpCredential = {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
accessMode: McpAccessMode;
|
||||
expirationDays: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class McpCredentialService {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
async list(userId: string, workspaceId: string) {
|
||||
const credentials = await this.models.mcpCredential.list(
|
||||
userId,
|
||||
workspaceId
|
||||
);
|
||||
const latest = new Map<string, (typeof credentials)[number]>();
|
||||
for (const credential of credentials) {
|
||||
if (!latest.has(credential.familyId)) {
|
||||
latest.set(credential.familyId, credential);
|
||||
}
|
||||
}
|
||||
return [...latest.values()]
|
||||
.map(credential => {
|
||||
const status = this.status(credential);
|
||||
return {
|
||||
...credential,
|
||||
graceEndsAt: status === 'ROTATING' ? credential.graceEndsAt : null,
|
||||
status,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async create(input: IssueMcpCredential) {
|
||||
const issued = await this.issue(input);
|
||||
this.event.emit('mcp.credential.created', {
|
||||
credentialId: issued.credential.id,
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
return issued;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async rotate(
|
||||
id: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
expirationDays: number
|
||||
) {
|
||||
const current = await this.models.mcpCredential.get(id);
|
||||
if (
|
||||
!current ||
|
||||
current.userId !== userId ||
|
||||
current.workspaceId !== workspaceId ||
|
||||
current.revokedAt ||
|
||||
current.replacedById ||
|
||||
current.expiresAt <= new Date()
|
||||
) {
|
||||
throw new BadRequestException('MCP credential not found');
|
||||
}
|
||||
|
||||
const maximumGraceEnd = new Date(Date.now() + ROTATION_GRACE_MS);
|
||||
const graceEnd =
|
||||
current.expiresAt < maximumGraceEnd ? current.expiresAt : maximumGraceEnd;
|
||||
const issued = await this.issue({
|
||||
userId,
|
||||
workspaceId,
|
||||
name: current.name,
|
||||
accessMode: current.accessMode,
|
||||
expirationDays,
|
||||
familyId: current.familyId,
|
||||
generation: current.generation + 1,
|
||||
graceEndsAt: graceEnd,
|
||||
});
|
||||
const replaced = await this.models.mcpCredential.replace(
|
||||
current.id,
|
||||
userId,
|
||||
workspaceId,
|
||||
issued.credential.id,
|
||||
graceEnd
|
||||
);
|
||||
if (replaced.count !== 1) {
|
||||
throw new BadRequestException('MCP credential not found');
|
||||
}
|
||||
this.event.emit('mcp.credential.rotated', {
|
||||
credentialId: current.id,
|
||||
replacedById: issued.credential.id,
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
return issued;
|
||||
}
|
||||
|
||||
async revoke(id: string, userId: string, workspaceId: string) {
|
||||
const credential = await this.models.mcpCredential.get(id);
|
||||
if (
|
||||
!credential ||
|
||||
credential.userId !== userId ||
|
||||
credential.workspaceId !== workspaceId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const result = await this.models.mcpCredential.revokeFamily(
|
||||
credential.familyId,
|
||||
userId,
|
||||
workspaceId
|
||||
);
|
||||
if (result.count) {
|
||||
this.event.emit('mcp.credential.revoked', {
|
||||
credentialId: id,
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
async authenticate(token: string, workspaceId: string) {
|
||||
const parsed = this.parse(token);
|
||||
if (!parsed) throw new UnauthorizedException();
|
||||
|
||||
const credential = await this.models.mcpCredential.authenticate(
|
||||
parsed.id,
|
||||
workspaceId
|
||||
);
|
||||
if (!credential) throw new UnauthorizedException();
|
||||
|
||||
const actualHash = this.crypto.sha256(parsed.secret).toString('hex');
|
||||
if (!this.crypto.compare(actualHash, credential.secretHash)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.models.mcpCredential.touch(
|
||||
credential.id,
|
||||
new Date(now.getTime() - LAST_USED_WRITE_INTERVAL_MS),
|
||||
now
|
||||
);
|
||||
return credential;
|
||||
}
|
||||
|
||||
private async issue(
|
||||
input: IssueMcpCredential & {
|
||||
familyId?: string;
|
||||
generation?: number;
|
||||
graceEndsAt?: Date;
|
||||
}
|
||||
) {
|
||||
if (!ALLOWED_EXPIRATION_DAYS.has(input.expirationDays)) {
|
||||
throw new BadRequestException('Unsupported MCP credential expiration');
|
||||
}
|
||||
const name = input.name.trim();
|
||||
if (!name || name.length > 64) {
|
||||
throw new BadRequestException('MCP credential name is required');
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const secret = this.crypto.randomBytes(32).toString('base64url');
|
||||
const secretHash = this.crypto.sha256(secret).toString('hex');
|
||||
const credential = await this.models.mcpCredential.create({
|
||||
id,
|
||||
familyId: input.familyId ?? id,
|
||||
generation: input.generation ?? 0,
|
||||
name,
|
||||
secretHash,
|
||||
fingerprint: secretHash.slice(0, 12),
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
accessMode: input.accessMode,
|
||||
expiresAt: new Date(
|
||||
Date.now() + input.expirationDays * 24 * 60 * 60 * 1000
|
||||
),
|
||||
graceEndsAt: input.graceEndsAt,
|
||||
});
|
||||
return {
|
||||
credential: { ...credential, status: this.status(credential) },
|
||||
token: `${TOKEN_PREFIX}.${id}.${secret}`,
|
||||
};
|
||||
}
|
||||
|
||||
private parse(token: string) {
|
||||
const parts = token.split('.');
|
||||
if (
|
||||
parts.length !== 3 ||
|
||||
parts[0] !== TOKEN_PREFIX ||
|
||||
!parts[1] ||
|
||||
!parts[2]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { id: parts[1], secret: parts[2] };
|
||||
}
|
||||
|
||||
private status(credential: {
|
||||
revokedAt: Date | null;
|
||||
expiresAt: Date;
|
||||
graceEndsAt: Date | null;
|
||||
}) {
|
||||
const now = Date.now();
|
||||
if (credential.revokedAt) return 'REVOKED' as const;
|
||||
if (credential.expiresAt.getTime() <= now) return 'EXPIRED' as const;
|
||||
if (credential.graceEndsAt && credential.graceEndsAt.getTime() > now) {
|
||||
return 'ROTATING' as const;
|
||||
}
|
||||
if (credential.expiresAt.getTime() - now <= 7 * 24 * 60 * 60 * 1000) {
|
||||
return 'EXPIRING' as const;
|
||||
}
|
||||
return 'ACTIVE' as const;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { McpAccessMode } from '@prisma/client';
|
||||
import { pick } from 'lodash-es';
|
||||
import z from 'zod/v3';
|
||||
|
||||
@@ -106,7 +107,11 @@ export class WorkspaceMcpProvider {
|
||||
private readonly indexer: IndexerService
|
||||
) {}
|
||||
|
||||
async for(userId: string, workspaceId: string): Promise<WorkspaceMcpServer> {
|
||||
async for(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
accessMode: McpAccessMode = McpAccessMode.READ_ONLY
|
||||
): Promise<WorkspaceMcpServer> {
|
||||
await this.ac.user(userId).workspace(workspaceId).assert('Workspace.Read');
|
||||
|
||||
const readDocument = defineTool({
|
||||
@@ -249,7 +254,10 @@ export class WorkspaceMcpProvider {
|
||||
|
||||
const tools = [readDocument, semanticSearch, keywordSearch];
|
||||
|
||||
if (env.dev || env.namespaces.canary) {
|
||||
if (
|
||||
accessMode === McpAccessMode.READ_WRITE &&
|
||||
(env.dev || env.namespaces.canary)
|
||||
) {
|
||||
const createDocument = defineTool({
|
||||
name: 'create_document',
|
||||
title: 'Create Document',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { McpAccessMode } from '@prisma/client';
|
||||
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { McpCredentialService } from './credential';
|
||||
|
||||
registerEnumType(McpAccessMode, { name: 'McpAccessMode' });
|
||||
|
||||
enum McpCredentialStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
ROTATING = 'ROTATING',
|
||||
EXPIRING = 'EXPIRING',
|
||||
EXPIRED = 'EXPIRED',
|
||||
REVOKED = 'REVOKED',
|
||||
}
|
||||
|
||||
registerEnumType(McpCredentialStatus, { name: 'McpCredentialStatus' });
|
||||
|
||||
@ObjectType()
|
||||
export class McpCredentialType {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => McpAccessMode)
|
||||
accessMode!: McpAccessMode;
|
||||
|
||||
@Field()
|
||||
fingerprint!: string;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
expiresAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastUsedAt!: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
revokedAt!: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
graceEndsAt!: Date | null;
|
||||
|
||||
@Field(() => McpCredentialStatus)
|
||||
status!: McpCredentialStatus;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class RevealedMcpCredentialType {
|
||||
@Field(() => McpCredentialType)
|
||||
credential!: McpCredentialType;
|
||||
|
||||
@Field()
|
||||
token!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class CreateMcpCredentialInput {
|
||||
@Field()
|
||||
workspaceId!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => McpAccessMode, { defaultValue: McpAccessMode.READ_ONLY })
|
||||
accessMode!: McpAccessMode;
|
||||
|
||||
@Field(() => Int, { defaultValue: 90 })
|
||||
expirationDays!: number;
|
||||
}
|
||||
|
||||
@Resolver()
|
||||
export class McpCredentialResolver {
|
||||
constructor(
|
||||
private readonly credentials: McpCredentialService,
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
@Query(() => [McpCredentialType])
|
||||
async mcpCredentials(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read');
|
||||
return await this.credentials.list(user.id, workspaceId);
|
||||
}
|
||||
|
||||
@Query(() => Boolean)
|
||||
mcpCredentialReadWriteAvailable() {
|
||||
return env.dev || env.namespaces.canary;
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedMcpCredentialType)
|
||||
async createMcpCredential(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: CreateMcpCredentialInput
|
||||
) {
|
||||
if (
|
||||
input.accessMode === McpAccessMode.READ_WRITE &&
|
||||
!env.dev &&
|
||||
!env.namespaces.canary
|
||||
) {
|
||||
throw new BadRequestException('MCP write tools are not available');
|
||||
}
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(input.workspaceId)
|
||||
.assert('Workspace.Read');
|
||||
return await this.credentials.create({ ...input, userId: user.id });
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedMcpCredentialType)
|
||||
async rotateMcpCredential(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('expirationDays', { type: () => Int, defaultValue: 90 })
|
||||
expirationDays: number
|
||||
) {
|
||||
await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read');
|
||||
return await this.credentials.rotate(
|
||||
id,
|
||||
user.id,
|
||||
workspaceId,
|
||||
expirationDays
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokeMcpCredential(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read');
|
||||
return await this.credentials.revoke(id, user.id, workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,6 @@
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
type AccessToken {
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input AddContextBlobInput {
|
||||
blobId: String!
|
||||
contextId: String!
|
||||
@@ -810,6 +803,13 @@ input CreateCheckoutSessionInput {
|
||||
variant: SubscriptionVariant
|
||||
}
|
||||
|
||||
input CreateMcpCredentialInput {
|
||||
accessMode: McpAccessMode! = READ_ONLY
|
||||
expirationDays: Int! = 90
|
||||
name: String!
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
email: String!
|
||||
name: String
|
||||
@@ -1196,11 +1196,6 @@ input ForkChatSessionInput {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
input GenerateAccessTokenInput {
|
||||
expiresAt: DateTime
|
||||
name: String!
|
||||
}
|
||||
|
||||
input GrantDocUserRolesInput {
|
||||
docId: String!
|
||||
role: DocRole!
|
||||
@@ -1533,6 +1528,33 @@ input ManageUserInput {
|
||||
name: String
|
||||
}
|
||||
|
||||
enum McpAccessMode {
|
||||
READ_ONLY
|
||||
READ_WRITE
|
||||
}
|
||||
|
||||
enum McpCredentialStatus {
|
||||
ACTIVE
|
||||
EXPIRED
|
||||
EXPIRING
|
||||
REVOKED
|
||||
ROTATING
|
||||
}
|
||||
|
||||
type McpCredentialType {
|
||||
accessMode: McpAccessMode!
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime!
|
||||
fingerprint: String!
|
||||
graceEndsAt: DateTime
|
||||
id: ID!
|
||||
lastUsedAt: DateTime
|
||||
name: String!
|
||||
revokedAt: DateTime
|
||||
status: McpCredentialStatus!
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
type MeetingActionItemType {
|
||||
deadline: String
|
||||
description: String!
|
||||
@@ -1657,6 +1679,7 @@ type Mutation {
|
||||
"""Create a stripe customer portal to manage payment methods"""
|
||||
createCustomerPortal: String!
|
||||
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink!
|
||||
createMcpCredential(input: CreateMcpCredentialInput!): RevealedMcpCredentialType!
|
||||
createReply(input: ReplyCreateInput!): ReplyObjectType!
|
||||
createSelfhostWorkspaceCustomerPortal(workspaceId: String!): String!
|
||||
|
||||
@@ -1688,7 +1711,6 @@ type Mutation {
|
||||
"""Create a chat session"""
|
||||
forkCopilotSession(options: ForkChatSessionInput!): String!
|
||||
generateLicenseKey(sessionId: String!): String!
|
||||
generateUserAccessToken(input: GenerateAccessTokenInput!): RevealedAccessToken!
|
||||
grantCommercialEntitlement(plan: String!, quantity: Int, targetId: String!, targetType: String!): Boolean!
|
||||
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
|
||||
grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean!
|
||||
@@ -1749,10 +1771,11 @@ type Mutation {
|
||||
revokeCommercialEntitlement(targetId: String!, targetType: String!): Boolean!
|
||||
revokeDocUserRoles(input: RevokeDocUserRoleInput!): Boolean!
|
||||
revokeInviteLink(workspaceId: String!): Boolean!
|
||||
revokeMcpCredential(id: ID!, workspaceId: String!): Boolean!
|
||||
revokeMember(userId: String!, workspaceId: String!): Boolean!
|
||||
revokePublicDoc(docId: String!, workspaceId: String!): DocType!
|
||||
revokeUserAccessToken(id: String!): Boolean!
|
||||
rotateAuthSigningKey(expectedActiveKeyId: String!): [AuthSigningKeyType!]!
|
||||
rotateMcpCredential(expirationDays: Int! = 90, id: ID!, workspaceId: String!): RevealedMcpCredentialType!
|
||||
sendChangeEmail(callbackUrl: String!): Boolean!
|
||||
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
@@ -2031,6 +2054,8 @@ type Query {
|
||||
|
||||
"""get workspace invitation info"""
|
||||
getInviteInfo(inviteId: String!): InvitationType!
|
||||
mcpCredentialReadWriteAvailable: Boolean!
|
||||
mcpCredentials(workspaceId: String!): [McpCredentialType!]!
|
||||
prices: [SubscriptionPrice!]!
|
||||
|
||||
"""Get public user by id"""
|
||||
@@ -2038,7 +2063,6 @@ type Query {
|
||||
|
||||
"""query workspace embedding status"""
|
||||
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus! @deprecated(reason: "Use realtime subscription \"workspace.embedding.progress.changed\" instead.")
|
||||
revealedAccessTokens: [RevealedAccessToken!]! @deprecated(reason: "use currentUser.revealedAccessTokens")
|
||||
|
||||
"""server config"""
|
||||
serverConfig: ServerConfigType!
|
||||
@@ -2173,11 +2197,8 @@ type ResponseTooLargeErrorDataType {
|
||||
receivedBytes: Int!
|
||||
}
|
||||
|
||||
type RevealedAccessToken {
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime
|
||||
id: String!
|
||||
name: String!
|
||||
type RevealedMcpCredentialType {
|
||||
credential: McpCredentialType!
|
||||
token: String!
|
||||
}
|
||||
|
||||
@@ -2700,8 +2721,6 @@ type UserSettingsType {
|
||||
}
|
||||
|
||||
type UserType {
|
||||
accessTokens: [AccessToken!]!
|
||||
|
||||
"""User avatar url"""
|
||||
avatarUrl: String
|
||||
calendarAccounts: [CalendarAccountObjectType!]!
|
||||
@@ -2737,7 +2756,6 @@ type UserType {
|
||||
notifications(pagination: PaginationInput!): PaginatedNotificationObjectType!
|
||||
quota: UserQuotaType!
|
||||
quotaUsage: UserQuotaUsageType!
|
||||
revealedAccessTokens: [RevealedAccessToken!]!
|
||||
|
||||
"""Get user settings"""
|
||||
settings: UserSettingsType!
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
mutation generateUserAccessToken($input: GenerateAccessTokenInput!) {
|
||||
generateUserAccessToken(input: $input) {
|
||||
id
|
||||
name
|
||||
token
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mutation revokeUserAccessToken($id: String!) {
|
||||
revokeUserAccessToken(id: $id)
|
||||
}
|
||||
@@ -67,28 +67,6 @@ export const licenseBodyFragment = `fragment licenseBody on License {
|
||||
validatedAt
|
||||
variant
|
||||
}`;
|
||||
export const generateUserAccessTokenMutation = {
|
||||
id: 'generateUserAccessTokenMutation' as const,
|
||||
op: 'generateUserAccessToken',
|
||||
query: `mutation generateUserAccessToken($input: GenerateAccessTokenInput!) {
|
||||
generateUserAccessToken(input: $input) {
|
||||
id
|
||||
name
|
||||
token
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const revokeUserAccessTokenMutation = {
|
||||
id: 'revokeUserAccessTokenMutation' as const,
|
||||
op: 'revokeUserAccessToken',
|
||||
query: `mutation revokeUserAccessToken($id: String!) {
|
||||
revokeUserAccessToken(id: $id)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const adminAllSharedLinksQuery = {
|
||||
id: 'adminAllSharedLinksQuery' as const,
|
||||
op: 'adminAllSharedLinks',
|
||||
@@ -2574,6 +2552,85 @@ export const listNotificationsQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const createMcpCredentialMutation = {
|
||||
id: 'createMcpCredentialMutation' as const,
|
||||
op: 'createMcpCredential',
|
||||
query: `mutation createMcpCredential($input: CreateMcpCredentialInput!) {
|
||||
createMcpCredential(input: $input) {
|
||||
credential {
|
||||
id
|
||||
name
|
||||
workspaceId
|
||||
accessMode
|
||||
fingerprint
|
||||
createdAt
|
||||
expiresAt
|
||||
lastUsedAt
|
||||
revokedAt
|
||||
graceEndsAt
|
||||
status
|
||||
}
|
||||
token
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const mcpCredentialsQuery = {
|
||||
id: 'mcpCredentialsQuery' as const,
|
||||
op: 'mcpCredentials',
|
||||
query: `query mcpCredentials($workspaceId: String!) {
|
||||
mcpCredentialReadWriteAvailable
|
||||
mcpCredentials(workspaceId: $workspaceId) {
|
||||
id
|
||||
name
|
||||
workspaceId
|
||||
accessMode
|
||||
fingerprint
|
||||
createdAt
|
||||
expiresAt
|
||||
lastUsedAt
|
||||
revokedAt
|
||||
graceEndsAt
|
||||
status
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const revokeMcpCredentialMutation = {
|
||||
id: 'revokeMcpCredentialMutation' as const,
|
||||
op: 'revokeMcpCredential',
|
||||
query: `mutation revokeMcpCredential($id: ID!, $workspaceId: String!) {
|
||||
revokeMcpCredential(id: $id, workspaceId: $workspaceId)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const rotateMcpCredentialMutation = {
|
||||
id: 'rotateMcpCredentialMutation' as const,
|
||||
op: 'rotateMcpCredential',
|
||||
query: `mutation rotateMcpCredential($id: ID!, $workspaceId: String!, $expirationDays: Int!) {
|
||||
rotateMcpCredential(
|
||||
id: $id
|
||||
workspaceId: $workspaceId
|
||||
expirationDays: $expirationDays
|
||||
) {
|
||||
credential {
|
||||
id
|
||||
name
|
||||
workspaceId
|
||||
accessMode
|
||||
fingerprint
|
||||
createdAt
|
||||
expiresAt
|
||||
lastUsedAt
|
||||
revokedAt
|
||||
graceEndsAt
|
||||
status
|
||||
}
|
||||
token
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const mentionUserMutation = {
|
||||
id: 'mentionUserMutation' as const,
|
||||
op: 'mentionUser',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
mutation createMcpCredential($input: CreateMcpCredentialInput!) {
|
||||
createMcpCredential(input: $input) {
|
||||
credential {
|
||||
id
|
||||
name
|
||||
workspaceId
|
||||
accessMode
|
||||
fingerprint
|
||||
createdAt
|
||||
expiresAt
|
||||
lastUsedAt
|
||||
revokedAt
|
||||
graceEndsAt
|
||||
status
|
||||
}
|
||||
token
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
query mcpCredentials($workspaceId: String!) {
|
||||
mcpCredentialReadWriteAvailable
|
||||
mcpCredentials(workspaceId: $workspaceId) {
|
||||
id
|
||||
name
|
||||
workspaceId
|
||||
accessMode
|
||||
fingerprint
|
||||
createdAt
|
||||
expiresAt
|
||||
lastUsedAt
|
||||
revokedAt
|
||||
graceEndsAt
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation revokeMcpCredential($id: ID!, $workspaceId: String!) {
|
||||
revokeMcpCredential(id: $id, workspaceId: $workspaceId)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
mutation rotateMcpCredential(
|
||||
$id: ID!
|
||||
$workspaceId: String!
|
||||
$expirationDays: Int!
|
||||
) {
|
||||
rotateMcpCredential(
|
||||
id: $id
|
||||
workspaceId: $workspaceId
|
||||
expirationDays: $expirationDays
|
||||
) {
|
||||
credential {
|
||||
id
|
||||
name
|
||||
workspaceId
|
||||
accessMode
|
||||
fingerprint
|
||||
createdAt
|
||||
expiresAt
|
||||
lastUsedAt
|
||||
revokedAt
|
||||
graceEndsAt
|
||||
status
|
||||
}
|
||||
token
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,6 @@ export interface Scalars {
|
||||
Upload: { input: File; output: File };
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
__typename?: 'AccessToken';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
expiresAt: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface AddContextBlobInput {
|
||||
blobId: Scalars['String']['input'];
|
||||
contextId: Scalars['String']['input'];
|
||||
@@ -954,6 +946,13 @@ export interface CreateCheckoutSessionInput {
|
||||
variant?: InputMaybe<SubscriptionVariant>;
|
||||
}
|
||||
|
||||
export interface CreateMcpCredentialInput {
|
||||
accessMode?: McpAccessMode;
|
||||
expirationDays?: Scalars['Int']['input'];
|
||||
name: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
email: Scalars['String']['input'];
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -1424,11 +1423,6 @@ export interface ForkChatSessionInput {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface GenerateAccessTokenInput {
|
||||
expiresAt?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface GrantDocUserRolesInput {
|
||||
docId: Scalars['String']['input'];
|
||||
role: DocRole;
|
||||
@@ -1754,6 +1748,34 @@ export interface ManageUserInput {
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
export enum McpAccessMode {
|
||||
READ_ONLY = 'READ_ONLY',
|
||||
READ_WRITE = 'READ_WRITE',
|
||||
}
|
||||
|
||||
export enum McpCredentialStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
EXPIRED = 'EXPIRED',
|
||||
EXPIRING = 'EXPIRING',
|
||||
REVOKED = 'REVOKED',
|
||||
ROTATING = 'ROTATING',
|
||||
}
|
||||
|
||||
export interface McpCredentialType {
|
||||
__typename?: 'McpCredentialType';
|
||||
accessMode: McpAccessMode;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
expiresAt: Scalars['DateTime']['output'];
|
||||
fingerprint: Scalars['String']['output'];
|
||||
graceEndsAt: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastUsedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
revokedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
status: McpCredentialStatus;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface MeetingActionItemType {
|
||||
__typename?: 'MeetingActionItemType';
|
||||
deadline: Maybe<Scalars['String']['output']>;
|
||||
@@ -1870,6 +1892,7 @@ export interface Mutation {
|
||||
/** Create a stripe customer portal to manage payment methods */
|
||||
createCustomerPortal: Scalars['String']['output'];
|
||||
createInviteLink: InviteLink;
|
||||
createMcpCredential: RevealedMcpCredentialType;
|
||||
createReply: ReplyObjectType;
|
||||
createSelfhostWorkspaceCustomerPortal: Scalars['String']['output'];
|
||||
/** Create a new user */
|
||||
@@ -1894,7 +1917,6 @@ export interface Mutation {
|
||||
/** Create a chat session */
|
||||
forkCopilotSession: Scalars['String']['output'];
|
||||
generateLicenseKey: Scalars['String']['output'];
|
||||
generateUserAccessToken: RevealedAccessToken;
|
||||
grantCommercialEntitlement: Scalars['Boolean']['output'];
|
||||
grantDocUserRoles: Scalars['Boolean']['output'];
|
||||
grantMember: Scalars['Boolean']['output'];
|
||||
@@ -1941,10 +1963,11 @@ export interface Mutation {
|
||||
revokeCommercialEntitlement: Scalars['Boolean']['output'];
|
||||
revokeDocUserRoles: Scalars['Boolean']['output'];
|
||||
revokeInviteLink: Scalars['Boolean']['output'];
|
||||
revokeMcpCredential: Scalars['Boolean']['output'];
|
||||
revokeMember: Scalars['Boolean']['output'];
|
||||
revokePublicDoc: DocType;
|
||||
revokeUserAccessToken: Scalars['Boolean']['output'];
|
||||
rotateAuthSigningKey: Array<AuthSigningKeyType>;
|
||||
rotateMcpCredential: RevealedMcpCredentialType;
|
||||
sendChangeEmail: Scalars['Boolean']['output'];
|
||||
sendChangePasswordEmail: Scalars['Boolean']['output'];
|
||||
sendSetPasswordEmail: Scalars['Boolean']['output'];
|
||||
@@ -2115,6 +2138,10 @@ export interface MutationCreateInviteLinkArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationCreateMcpCredentialArgs {
|
||||
input: CreateMcpCredentialInput;
|
||||
}
|
||||
|
||||
export interface MutationCreateReplyArgs {
|
||||
input: ReplyCreateInput;
|
||||
}
|
||||
@@ -2183,10 +2210,6 @@ export interface MutationGenerateLicenseKeyArgs {
|
||||
sessionId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationGenerateUserAccessTokenArgs {
|
||||
input: GenerateAccessTokenInput;
|
||||
}
|
||||
|
||||
export interface MutationGrantCommercialEntitlementArgs {
|
||||
plan: Scalars['String']['input'];
|
||||
quantity?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -2322,6 +2345,11 @@ export interface MutationRevokeInviteLinkArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRevokeMcpCredentialArgs {
|
||||
id: Scalars['ID']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRevokeMemberArgs {
|
||||
userId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
@@ -2332,14 +2360,16 @@ export interface MutationRevokePublicDocArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRevokeUserAccessTokenArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRotateAuthSigningKeyArgs {
|
||||
expectedActiveKeyId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRotateMcpCredentialArgs {
|
||||
expirationDays?: Scalars['Int']['input'];
|
||||
id: Scalars['ID']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationSendChangeEmailArgs {
|
||||
callbackUrl: Scalars['String']['input'];
|
||||
}
|
||||
@@ -2706,6 +2736,8 @@ export interface Query {
|
||||
error: ErrorDataUnion;
|
||||
/** get workspace invitation info */
|
||||
getInviteInfo: InvitationType;
|
||||
mcpCredentialReadWriteAvailable: Scalars['Boolean']['output'];
|
||||
mcpCredentials: Array<McpCredentialType>;
|
||||
prices: Array<SubscriptionPrice>;
|
||||
/** Get public user by id */
|
||||
publicUserById: Maybe<PublicUserType>;
|
||||
@@ -2714,8 +2746,6 @@ export interface Query {
|
||||
* @deprecated Use realtime subscription "workspace.embedding.progress.changed" instead.
|
||||
*/
|
||||
queryWorkspaceEmbeddingStatus: ContextWorkspaceEmbeddingStatus;
|
||||
/** @deprecated use currentUser.revealedAccessTokens */
|
||||
revealedAccessTokens: Array<RevealedAccessToken>;
|
||||
/** server config */
|
||||
serverConfig: ServerConfigType;
|
||||
/** Get user by email */
|
||||
@@ -2774,6 +2804,10 @@ export interface QueryGetInviteInfoArgs {
|
||||
inviteId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface QueryMcpCredentialsArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface QueryPublicUserByIdArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
@@ -2914,12 +2948,9 @@ export interface ResponseTooLargeErrorDataType {
|
||||
receivedBytes: Scalars['Int']['output'];
|
||||
}
|
||||
|
||||
export interface RevealedAccessToken {
|
||||
__typename?: 'RevealedAccessToken';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
expiresAt: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
export interface RevealedMcpCredentialType {
|
||||
__typename?: 'RevealedMcpCredentialType';
|
||||
credential: McpCredentialType;
|
||||
token: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
@@ -3450,7 +3481,6 @@ export interface UserSettingsType {
|
||||
|
||||
export interface UserType {
|
||||
__typename?: 'UserType';
|
||||
accessTokens: Array<AccessToken>;
|
||||
/** User avatar url */
|
||||
avatarUrl: Maybe<Scalars['String']['output']>;
|
||||
calendarAccounts: Array<CalendarAccountObjectType>;
|
||||
@@ -3480,7 +3510,6 @@ export interface UserType {
|
||||
notifications: PaginatedNotificationObjectType;
|
||||
quota: UserQuotaType;
|
||||
quotaUsage: UserQuotaUsageType;
|
||||
revealedAccessTokens: Array<RevealedAccessToken>;
|
||||
/** Get user settings */
|
||||
settings: UserSettingsType;
|
||||
subscriptions: Array<SubscriptionType>;
|
||||
@@ -3842,31 +3871,6 @@ export interface TokenType {
|
||||
token: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export type GenerateUserAccessTokenMutationVariables = Exact<{
|
||||
input: GenerateAccessTokenInput;
|
||||
}>;
|
||||
|
||||
export type GenerateUserAccessTokenMutation = {
|
||||
__typename?: 'Mutation';
|
||||
generateUserAccessToken: {
|
||||
__typename?: 'RevealedAccessToken';
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type RevokeUserAccessTokenMutationVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type RevokeUserAccessTokenMutation = {
|
||||
__typename?: 'Mutation';
|
||||
revokeUserAccessToken: boolean;
|
||||
};
|
||||
|
||||
export type AdminAllSharedLinksQueryVariables = Exact<{
|
||||
pagination: PaginationInput;
|
||||
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
|
||||
@@ -7227,6 +7231,93 @@ export type ListNotificationsQuery = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CreateMcpCredentialMutationVariables = Exact<{
|
||||
input: CreateMcpCredentialInput;
|
||||
}>;
|
||||
|
||||
export type CreateMcpCredentialMutation = {
|
||||
__typename?: 'Mutation';
|
||||
createMcpCredential: {
|
||||
__typename?: 'RevealedMcpCredentialType';
|
||||
token: string;
|
||||
credential: {
|
||||
__typename?: 'McpCredentialType';
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
accessMode: McpAccessMode;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
graceEndsAt: string | null;
|
||||
status: McpCredentialStatus;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type McpCredentialsQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type McpCredentialsQuery = {
|
||||
__typename?: 'Query';
|
||||
mcpCredentialReadWriteAvailable: boolean;
|
||||
mcpCredentials: Array<{
|
||||
__typename?: 'McpCredentialType';
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
accessMode: McpAccessMode;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
graceEndsAt: string | null;
|
||||
status: McpCredentialStatus;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type RevokeMcpCredentialMutationVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type RevokeMcpCredentialMutation = {
|
||||
__typename?: 'Mutation';
|
||||
revokeMcpCredential: boolean;
|
||||
};
|
||||
|
||||
export type RotateMcpCredentialMutationVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
expirationDays: Scalars['Int']['input'];
|
||||
}>;
|
||||
|
||||
export type RotateMcpCredentialMutation = {
|
||||
__typename?: 'Mutation';
|
||||
rotateMcpCredential: {
|
||||
__typename?: 'RevealedMcpCredentialType';
|
||||
token: string;
|
||||
credential: {
|
||||
__typename?: 'McpCredentialType';
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
accessMode: McpAccessMode;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
graceEndsAt: string | null;
|
||||
status: McpCredentialStatus;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type MentionUserMutationVariables = Exact<{
|
||||
input: MentionInput;
|
||||
}>;
|
||||
@@ -8262,6 +8353,11 @@ export type Queries =
|
||||
variables: ListNotificationsQueryVariables;
|
||||
response: ListNotificationsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'mcpCredentialsQuery';
|
||||
variables: McpCredentialsQueryVariables;
|
||||
response: McpCredentialsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'pricesQuery';
|
||||
variables: PricesQueryVariables;
|
||||
@@ -8304,16 +8400,6 @@ export type Queries =
|
||||
};
|
||||
|
||||
export type Mutations =
|
||||
| {
|
||||
name: 'generateUserAccessTokenMutation';
|
||||
variables: GenerateUserAccessTokenMutationVariables;
|
||||
response: GenerateUserAccessTokenMutation;
|
||||
}
|
||||
| {
|
||||
name: 'revokeUserAccessTokenMutation';
|
||||
variables: RevokeUserAccessTokenMutationVariables;
|
||||
response: RevokeUserAccessTokenMutation;
|
||||
}
|
||||
| {
|
||||
name: 'adminUpdateWorkspaceMutation';
|
||||
variables: AdminUpdateWorkspaceMutationVariables;
|
||||
@@ -8669,6 +8755,21 @@ export type Mutations =
|
||||
variables: PreviewLicenseMutationVariables;
|
||||
response: PreviewLicenseMutation;
|
||||
}
|
||||
| {
|
||||
name: 'createMcpCredentialMutation';
|
||||
variables: CreateMcpCredentialMutationVariables;
|
||||
response: CreateMcpCredentialMutation;
|
||||
}
|
||||
| {
|
||||
name: 'revokeMcpCredentialMutation';
|
||||
variables: RevokeMcpCredentialMutationVariables;
|
||||
response: RevokeMcpCredentialMutation;
|
||||
}
|
||||
| {
|
||||
name: 'rotateMcpCredentialMutation';
|
||||
variables: RotateMcpCredentialMutationVariables;
|
||||
response: RotateMcpCredentialMutation;
|
||||
}
|
||||
| {
|
||||
name: 'mentionUserMutation';
|
||||
variables: MentionUserMutationVariables;
|
||||
|
||||
@@ -41,10 +41,6 @@ export interface RealtimeRequestMap {
|
||||
input: Record<string, never>;
|
||||
output: { settings: UserSettingsSnapshot };
|
||||
};
|
||||
'user.access-tokens.get': {
|
||||
input: Record<string, never>;
|
||||
output: { tokens: AccessTokenSnapshot[] };
|
||||
};
|
||||
'notification.count.get': {
|
||||
input: Record<string, never>;
|
||||
output: { count: number };
|
||||
@@ -180,13 +176,6 @@ export interface UserSettingsSnapshot {
|
||||
receiveCommentEmail: boolean;
|
||||
}
|
||||
|
||||
export interface AccessTokenSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export type CommentChangeActionSnapshot = 'update' | 'delete';
|
||||
|
||||
export interface CommentChangeSnapshot {
|
||||
@@ -285,10 +274,6 @@ export interface RealtimeTopicMap {
|
||||
input: Record<string, never>;
|
||||
event: { changed: true; reason: string };
|
||||
};
|
||||
'user.access-tokens.changed': {
|
||||
input: Record<string, never>;
|
||||
event: { changed: true; reason: string };
|
||||
};
|
||||
'notification.count.changed': {
|
||||
input: Record<string, never>;
|
||||
event: {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@affine/auth": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/native": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
@@ -75,7 +76,6 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/auth": "workspace:*",
|
||||
"async-call-rpc": "^6.4.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"link-preview-js": "^4.0.0",
|
||||
|
||||
@@ -3,9 +3,27 @@ import path from 'node:path';
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import { config, mode, rootDir } from './common';
|
||||
import { config, electronDir, mode, rootDir } from './common';
|
||||
|
||||
async function assertNoRuntimeWorkspaceDependencies() {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.resolve(electronDir, 'package.json'), 'utf8')
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const workspaceDependencies = Object.entries(
|
||||
packageJson.dependencies ?? {}
|
||||
).filter(([, version]) => version.startsWith('workspace:'));
|
||||
|
||||
if (workspaceDependencies.length) {
|
||||
throw new Error(
|
||||
`Electron workspace dependencies must be bundled and declared as devDependencies: ${workspaceDependencies
|
||||
.map(([name]) => name)
|
||||
.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildLayers() {
|
||||
await assertNoRuntimeWorkspaceDependencies();
|
||||
const common = config();
|
||||
|
||||
const define: Record<string, string> = {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../../common/auth" },
|
||||
{ "path": "../../../../tools/utils" },
|
||||
{ "path": "../../../common/auth" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../native" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import { Button, Input, Modal, notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import type { McpCredential } from '@affine/core/modules/cloud/services/mcp-credential';
|
||||
import { McpAccessMode } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import * as styles from './setting-panel.css';
|
||||
|
||||
type RevealedCredential = {
|
||||
credential: McpCredential;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const McpCredentialModal = ({
|
||||
mode,
|
||||
revealed,
|
||||
config,
|
||||
workspaceName,
|
||||
readWriteAvailable,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'create' | 'reveal' | null;
|
||||
revealed: RevealedCredential | null;
|
||||
config: string;
|
||||
workspaceName?: string;
|
||||
readWriteAvailable: boolean;
|
||||
onCreate: (
|
||||
name: string,
|
||||
accessMode: McpAccessMode,
|
||||
expirationDays: number
|
||||
) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [expirationDays, setExpirationDays] = useState(90);
|
||||
const [accessMode, setAccessMode] = useState(McpAccessMode.READ_ONLY);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mode) {
|
||||
setName('');
|
||||
setExpirationDays(90);
|
||||
setAccessMode(McpAccessMode.READ_ONLY);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const copy = useAsyncCallback(
|
||||
async (value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
notify.success({ title: t['Copied to clipboard']() });
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const submit = useAsyncCallback(async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCreate(name.trim(), accessMode, expirationDays);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [accessMode, expirationDays, name, onCreate]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={mode !== null}
|
||||
onOpenChange={open => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
contentOptions={{ className: styles.modal }}
|
||||
>
|
||||
{mode === 'create' ? (
|
||||
<>
|
||||
<div className={styles.modalTitle}>
|
||||
{t['com.affine.integration.mcp-server.create.title']()}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{t['com.affine.integration.mcp-server.create.description']()}
|
||||
</div>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
{t['com.affine.integration.mcp-server.field.label']()}
|
||||
</span>
|
||||
<Input
|
||||
value={name}
|
||||
maxLength={64}
|
||||
placeholder="Claude Desktop"
|
||||
onChange={setName}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
{t['com.affine.integration.mcp-server.field.access']()}
|
||||
</span>
|
||||
{readWriteAvailable ? (
|
||||
<select
|
||||
className={styles.select}
|
||||
value={accessMode}
|
||||
onChange={event =>
|
||||
setAccessMode(event.currentTarget.value as McpAccessMode)
|
||||
}
|
||||
>
|
||||
<option value={McpAccessMode.READ_ONLY}>
|
||||
{t['com.affine.integration.mcp-server.access.read-only']()}
|
||||
</option>
|
||||
<option value={McpAccessMode.READ_WRITE}>
|
||||
{t['com.affine.integration.mcp-server.access.read-write']()}
|
||||
</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className={styles.fixedValue}>
|
||||
{t['com.affine.integration.mcp-server.access.read-only']()}
|
||||
<span className={styles.description}>
|
||||
{t[
|
||||
'com.affine.integration.mcp-server.access.read-only-desc'
|
||||
]()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
{t['com.affine.integration.mcp-server.field.expiry']()}
|
||||
</span>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={expirationDays}
|
||||
onChange={event =>
|
||||
setExpirationDays(Number(event.currentTarget.value))
|
||||
}
|
||||
>
|
||||
{[30, 90, 365].map(days => (
|
||||
<option value={days} key={days}>
|
||||
{t['com.affine.integration.mcp-server.expiry.days']({
|
||||
days: days.toString(),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.modalActions}>
|
||||
<Button onClick={onClose}>{t['Cancel']()}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim() || submitting}
|
||||
loading={submitting}
|
||||
onClick={submit}
|
||||
>
|
||||
{t['com.affine.integration.mcp-server.action.create']()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : revealed ? (
|
||||
<>
|
||||
<div className={styles.modalTitle}>
|
||||
{t['com.affine.integration.mcp-server.reveal.title']()}
|
||||
</div>
|
||||
<div className={styles.warning}>
|
||||
{t['com.affine.integration.mcp-server.reveal.warning']()}
|
||||
</div>
|
||||
<div className={styles.summary}>
|
||||
{revealed.credential.name} · {workspaceName} ·{' '}
|
||||
{revealed.credential.accessMode === McpAccessMode.READ_WRITE
|
||||
? t['com.affine.integration.mcp-server.access.read-write']()
|
||||
: t['com.affine.integration.mcp-server.access.read-only']()}{' '}
|
||||
· {new Date(revealed.credential.expiresAt).toLocaleString()}
|
||||
</div>
|
||||
{revealed.credential.graceEndsAt ? (
|
||||
<div className={styles.warning}>
|
||||
{t['com.affine.integration.mcp-server.reveal.old-valid-until']({
|
||||
date: new Date(
|
||||
revealed.credential.graceEndsAt
|
||||
).toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.codeHeader}>
|
||||
<span>{t['com.affine.integration.mcp-server.reveal.token']()}</span>
|
||||
<Button onClick={() => copy(revealed.token)}>
|
||||
{t['com.affine.integration.mcp-server.action.copy-token']()}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className={styles.preArea}>{revealed.token}</pre>
|
||||
<div className={styles.codeHeader}>
|
||||
<span>
|
||||
{t['com.affine.integration.mcp-server.reveal.config']()}
|
||||
</span>
|
||||
<Button variant="primary" onClick={() => copy(config)}>
|
||||
{t['com.affine.integration.mcp-server.action.copy-json']()}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className={styles.preArea}>{config}</pre>
|
||||
<div className={styles.modalActions}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t['com.affine.integration.mcp-server.action.done']()}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+160
-35
@@ -2,47 +2,172 @@ import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const connectButton = style({
|
||||
width: '100%',
|
||||
marginTop: '24px',
|
||||
});
|
||||
|
||||
export const section = style({
|
||||
export const stack = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
gap: '0px',
|
||||
marginBottom: '16px',
|
||||
gap: 24,
|
||||
});
|
||||
|
||||
export const sectionHeader = style({
|
||||
export const panel = style({
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
export const panelHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
});
|
||||
|
||||
export const preArea = style({
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
padding: '16px 16px',
|
||||
borderRadius: '8px',
|
||||
margin: '8px 0',
|
||||
fontFamily: cssVar('fontMonoFamily'),
|
||||
overflowX: 'auto',
|
||||
});
|
||||
|
||||
export const sectionDescription = style({
|
||||
fontSize: 13,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const sectionTitle = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.25,
|
||||
export const title = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const description = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
export const empty = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '28px 20px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
export const skeletons = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
});
|
||||
export const rows = style({ display: 'flex', flexDirection: 'column' });
|
||||
export const row = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
selectors: { '&:last-child': { borderBottom: 0 } },
|
||||
});
|
||||
export const rowDisabled = style({
|
||||
opacity: 0.55,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
export const rowMain = style({
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
export const rowTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const tag = style({
|
||||
borderRadius: 999,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
lineHeight: '16px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
export const rowActions = style({ display: 'flex', gap: 8 });
|
||||
export const capabilities = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
});
|
||||
export const capability = style({
|
||||
padding: '14px 16px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
borderRight: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
selectors: { '&:last-child': { borderRight: 0 } },
|
||||
});
|
||||
export const modal = style({
|
||||
width: 500,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: 20,
|
||||
});
|
||||
export const modalTitle = style({
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const form = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
});
|
||||
export const field = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
export const fixedValue = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const select = style({
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
padding: '0 10px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const warning = style({
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
export const summary = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
export const codeHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
export const preArea = style({
|
||||
maxHeight: 180,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
fontFamily: cssVar('fontMonoFamily'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
});
|
||||
export const modalActions = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
+292
-208
@@ -1,244 +1,328 @@
|
||||
import { Button, ErrorMessage, notify, Skeleton } from '@affine/component';
|
||||
import {
|
||||
Button,
|
||||
ErrorMessage,
|
||||
notify,
|
||||
Skeleton,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { AccessTokenService, ServerService } from '@affine/core/modules/cloud';
|
||||
import type { AccessToken } from '@affine/core/modules/cloud/stores/access-token';
|
||||
import {
|
||||
McpCredentialService,
|
||||
ServerService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import type { McpCredential } from '@affine/core/modules/cloud/services/mcp-credential';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { McpAccessMode } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { IntegrationSettingHeader } from '../setting';
|
||||
import { McpCredentialModal } from './credential-modal';
|
||||
import MCPIcon from './MCP.inline.svg';
|
||||
import * as styles from './setting-panel.css';
|
||||
|
||||
type RevealedCredential = {
|
||||
credential: McpCredential;
|
||||
token: string;
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString();
|
||||
|
||||
export const McpServerSettingPanel = () => {
|
||||
return <McpServerSetting />;
|
||||
};
|
||||
|
||||
const McpServerSettingHeader = ({ action }: { action?: ReactNode }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<IntegrationSettingHeader
|
||||
icon={<img src={MCPIcon} />}
|
||||
name={t['com.affine.integration.mcp-server.name']()}
|
||||
desc={t['com.affine.integration.mcp-server.desc']()}
|
||||
action={action}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const McpServerSetting = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const serverService = useService(ServerService);
|
||||
const credentialsService = useService(McpCredentialService);
|
||||
const credentials = useLiveData(credentialsService.credentials$);
|
||||
const loading = useLiveData(credentialsService.loading$);
|
||||
const error = useLiveData(credentialsService.error$);
|
||||
const readWriteAvailable = useLiveData(
|
||||
credentialsService.readWriteAvailable$
|
||||
);
|
||||
const workspaceId = workspaceService.workspace.id;
|
||||
const workspaceName = useLiveData(workspaceService.workspace.name$);
|
||||
const accessTokenService = useService(AccessTokenService);
|
||||
const accessTokens = useLiveData(accessTokenService.accessTokens$);
|
||||
const isRevalidating = useLiveData(accessTokenService.isRevalidating$);
|
||||
const error = useLiveData(accessTokenService.error$);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [revealedAccessToken, setRevealedAccessToken] =
|
||||
useState<AccessToken | null>(null);
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [modal, setModal] = useState<'create' | 'reveal' | null>(null);
|
||||
const [revealed, setRevealed] = useState<RevealedCredential | null>(null);
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null);
|
||||
|
||||
const mcpAccessToken = useMemo(() => {
|
||||
return accessTokens?.find(token => token.name === 'mcp');
|
||||
}, [accessTokens]);
|
||||
const statusLabel = useCallback(
|
||||
(status: McpCredential['status']) => {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return t['com.affine.integration.mcp-server.status.active']();
|
||||
case 'ROTATING':
|
||||
return t['com.affine.integration.mcp-server.status.rotating']();
|
||||
case 'EXPIRING':
|
||||
return t['com.affine.integration.mcp-server.status.expiring']();
|
||||
case 'EXPIRED':
|
||||
return t['com.affine.integration.mcp-server.status.expired']();
|
||||
case 'REVOKED':
|
||||
return t['com.affine.integration.mcp-server.status.revoked']();
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const hasMcpToken = Boolean(revealedAccessToken || mcpAccessToken);
|
||||
const hasCopyableToken = Boolean(revealedAccessToken);
|
||||
const isRedactedDisplay = hasMcpToken && !hasCopyableToken;
|
||||
const revalidate = useCallback(() => {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
credentialsService.revalidate(workspaceId);
|
||||
}, [credentialsService, workspaceId]);
|
||||
|
||||
const code = useMemo(() => {
|
||||
return revealedAccessToken
|
||||
? JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[`affine_workspace_${workspaceService.workspace.id}`]: {
|
||||
type: 'streamable-http',
|
||||
url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`,
|
||||
note: `Read docs from AFFiNE workspace "${workspaceName}"`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${revealedAccessToken.token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
useEffect(() => revalidate(), [revalidate]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
if (!revealed) return '';
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[`affine_workspace_${workspaceId}`]: {
|
||||
type: 'streamable-http',
|
||||
url: `${serverService.server.baseUrl}/api/workspaces/${workspaceId}/mcp`,
|
||||
headers: { Authorization: `Bearer ${revealed.token}` },
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
: null;
|
||||
}, [revealedAccessToken, workspaceName, workspaceService, serverService]);
|
||||
|
||||
const copyJsonDisabled = !code || mutating || isRedactedDisplay;
|
||||
const copyJsonTooltip = isRedactedDisplay
|
||||
? t['com.affine.integration.mcp-server.copy-json.disabled-hint']()
|
||||
: undefined;
|
||||
|
||||
const showLoading = accessTokens === null && isRevalidating;
|
||||
const showError = accessTokens === null && error !== null;
|
||||
|
||||
useEffect(() => {
|
||||
accessTokenService.revalidate();
|
||||
}, [accessTokenService]);
|
||||
|
||||
const handleGenerateAccessToken = useAsyncCallback(async () => {
|
||||
setMutating(true);
|
||||
try {
|
||||
if (mcpAccessToken) {
|
||||
await accessTokenService.revokeUserAccessToken(mcpAccessToken.id);
|
||||
}
|
||||
const createdToken =
|
||||
await accessTokenService.generateUserAccessToken('mcp');
|
||||
setRevealedAccessToken(createdToken);
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
error: UserFriendlyError.fromAny(err),
|
||||
});
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
}, [accessTokenService, mcpAccessToken]);
|
||||
|
||||
const handleRevokeAccessToken = useAsyncCallback(async () => {
|
||||
setMutating(true);
|
||||
try {
|
||||
if (mcpAccessToken) {
|
||||
await accessTokenService.revokeUserAccessToken(mcpAccessToken.id);
|
||||
}
|
||||
setRevealedAccessToken(null);
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
error: UserFriendlyError.fromAny(err),
|
||||
});
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
}, [accessTokenService, mcpAccessToken]);
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<div>
|
||||
<McpServerSettingHeader />
|
||||
<Skeleton />
|
||||
</div>
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
}, [revealed, serverService.server.baseUrl, workspaceId]);
|
||||
|
||||
if (showError) {
|
||||
return (
|
||||
<div>
|
||||
<McpServerSettingHeader />
|
||||
<ErrorMessage>{error}</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const create = useAsyncCallback(
|
||||
async (name: string, accessMode: McpAccessMode, expirationDays: number) => {
|
||||
try {
|
||||
const result = await credentialsService.create({
|
||||
workspaceId,
|
||||
name,
|
||||
accessMode,
|
||||
expirationDays,
|
||||
});
|
||||
setRevealed(result);
|
||||
setModal('reveal');
|
||||
} catch (error) {
|
||||
notify.error({ error: UserFriendlyError.fromAny(error) });
|
||||
}
|
||||
},
|
||||
[credentialsService, workspaceId]
|
||||
);
|
||||
|
||||
const rotate = useAsyncCallback(
|
||||
async (credential: McpCredential) => {
|
||||
setMutatingId(credential.id);
|
||||
try {
|
||||
const result = await credentialsService.rotate(
|
||||
credential.id,
|
||||
workspaceId,
|
||||
90
|
||||
);
|
||||
setRevealed(result);
|
||||
setModal('reveal');
|
||||
} catch (error) {
|
||||
notify.error({ error: UserFriendlyError.fromAny(error) });
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
},
|
||||
[credentialsService, workspaceId]
|
||||
);
|
||||
|
||||
const confirmRotate = useCallback(
|
||||
(credential: McpCredential) => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.integration.mcp-server.rotate.title'](),
|
||||
description:
|
||||
t['com.affine.integration.mcp-server.rotate.description'](),
|
||||
confirmText: t['com.affine.integration.mcp-server.action.rotate'](),
|
||||
cancelText: t['Cancel'](),
|
||||
onConfirm: () => rotate(credential),
|
||||
});
|
||||
},
|
||||
[openConfirmModal, rotate, t]
|
||||
);
|
||||
|
||||
const confirmRevoke = useCallback(
|
||||
(credential: McpCredential) => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.integration.mcp-server.revoke.title']({
|
||||
name: credential.name,
|
||||
}),
|
||||
description:
|
||||
t['com.affine.integration.mcp-server.revoke.description'](),
|
||||
confirmText: t['com.affine.integration.mcp-server.action.revoke'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmButtonOptions: { variant: 'error' },
|
||||
onConfirm: async () => {
|
||||
setMutatingId(credential.id);
|
||||
try {
|
||||
await credentialsService.revoke(credential.id, workspaceId);
|
||||
} catch (error) {
|
||||
notify.error({ error: UserFriendlyError.fromAny(error) });
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[credentialsService, openConfirmModal, t, workspaceId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<McpServerSettingHeader />
|
||||
<div className={styles.stack}>
|
||||
<IntegrationSettingHeader
|
||||
icon={<img src={MCPIcon} />}
|
||||
name={t['com.affine.integration.mcp-server.name']()}
|
||||
desc={t['com.affine.integration.mcp-server.desc']()}
|
||||
/>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Personal access token</div>
|
||||
{!hasMcpToken ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerateAccessToken}
|
||||
disabled={mutating}
|
||||
>
|
||||
Create New
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="error"
|
||||
onClick={handleRevokeAccessToken}
|
||||
disabled={mutating}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.sectionDescription}>
|
||||
This access token is used for the MCP service, please keep this
|
||||
information secure. Deleting it will invalidate the access token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Server Config</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (!code) return;
|
||||
// oxlint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
navigator.clipboard.writeText(code);
|
||||
notify.success({
|
||||
title: t['Copied to clipboard'](),
|
||||
});
|
||||
}}
|
||||
disabled={copyJsonDisabled}
|
||||
tooltip={copyJsonTooltip}
|
||||
>
|
||||
Copy json
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
{t['com.affine.integration.mcp-server.credentials.title']()}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{t['com.affine.integration.mcp-server.credentials.description']()}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setModal('create')}>
|
||||
{t['com.affine.integration.mcp-server.action.create']()}
|
||||
</Button>
|
||||
</div>
|
||||
{code ? (
|
||||
<pre className={styles.preArea}>{code}</pre>
|
||||
|
||||
{loading && credentials === null ? (
|
||||
<div className={styles.skeletons}>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : error && credentials === null ? (
|
||||
<div className={styles.empty}>
|
||||
<ErrorMessage>
|
||||
{t['com.affine.integration.mcp-server.load-error']()}
|
||||
</ErrorMessage>
|
||||
<Button onClick={revalidate}>{t['Retry']()}</Button>
|
||||
</div>
|
||||
) : credentials?.length ? (
|
||||
<div className={styles.rows}>
|
||||
{credentials.map(credential => (
|
||||
<div
|
||||
className={`${styles.row} ${
|
||||
credential.status === 'EXPIRED' ||
|
||||
credential.status === 'REVOKED'
|
||||
? styles.rowDisabled
|
||||
: ''
|
||||
}`}
|
||||
key={credential.id}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.rowTitle}>
|
||||
{credential.name}
|
||||
<span className={styles.tag}>
|
||||
{statusLabel(credential.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{credential.accessMode === McpAccessMode.READ_WRITE
|
||||
? t[
|
||||
'com.affine.integration.mcp-server.access.read-write'
|
||||
]()
|
||||
: t[
|
||||
'com.affine.integration.mcp-server.access.read-only'
|
||||
]()}{' '}
|
||||
· •••• {credential.fingerprint} ·{' '}
|
||||
{t['com.affine.integration.mcp-server.meta.expires']({
|
||||
date: formatDate(credential.expiresAt),
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{t['com.affine.integration.mcp-server.meta.created']({
|
||||
date: formatDate(credential.createdAt),
|
||||
})}{' '}
|
||||
·{' '}
|
||||
{credential.lastUsedAt
|
||||
? t['com.affine.integration.mcp-server.meta.last-used']({
|
||||
date: formatDate(credential.lastUsedAt),
|
||||
})
|
||||
: t[
|
||||
'com.affine.integration.mcp-server.meta.never-used'
|
||||
]()}
|
||||
{credential.graceEndsAt
|
||||
? ` · ${t[
|
||||
'com.affine.integration.mcp-server.meta.grace-until'
|
||||
]({ date: formatDate(credential.graceEndsAt) })}`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
{credential.status !== 'REVOKED' &&
|
||||
credential.status !== 'EXPIRED' ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => confirmRotate(credential)}
|
||||
disabled={mutatingId === credential.id}
|
||||
>
|
||||
{t['com.affine.integration.mcp-server.action.rotate']()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="error"
|
||||
onClick={() => confirmRevoke(credential)}
|
||||
disabled={mutatingId === credential.id}
|
||||
>
|
||||
{t['com.affine.integration.mcp-server.action.revoke']()}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className={styles.sectionDescription}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
No access token found, please generate one first.
|
||||
</p>
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.title}>
|
||||
{t['com.affine.integration.mcp-server.empty.title']()}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{t['com.affine.integration.mcp-server.empty.description']()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Support tools</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>doc-read</div>
|
||||
</div>
|
||||
<div className={styles.sectionDescription}>
|
||||
Return the complete text and basic metadata of a single document
|
||||
identified by docId; use this when the user needs the full content
|
||||
of a specific file rather than a search result.
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.title}>
|
||||
{t['com.affine.integration.mcp-server.capabilities.title']()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>doc-semantic-search</div>
|
||||
</div>
|
||||
<div className={styles.sectionDescription}>
|
||||
Retrieve conceptually related passages by performing vector-based
|
||||
semantic similarity search across embedded documents; use this tool
|
||||
only when exact keyword search fails or the user explicitly needs
|
||||
meaning-level matches (e.g., paraphrases, synonyms, broader
|
||||
concepts, recent documents).
|
||||
</div>
|
||||
<div className={styles.capabilities}>
|
||||
{(['read', 'keyword-search', 'semantic-search'] as const).map(key => (
|
||||
<div className={styles.capability} key={key}>
|
||||
{t[`com.affine.integration.mcp-server.capabilities.${key}`]()}
|
||||
</div>
|
||||
))}
|
||||
{readWriteAvailable ? (
|
||||
<div className={styles.capability}>
|
||||
{t['com.affine.integration.mcp-server.capabilities.write']()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>doc-keyword-search</div>
|
||||
</div>
|
||||
<div className={styles.sectionDescription}>
|
||||
Fuzzy search all workspace documents for the exact keyword or phrase
|
||||
supplied and return passages ranked by textual match. Use this tool
|
||||
by default whenever a straightforward term-based or keyword-base
|
||||
lookup is sufficient.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<McpCredentialModal
|
||||
mode={modal}
|
||||
revealed={revealed}
|
||||
config={config}
|
||||
workspaceName={workspaceName}
|
||||
readWriteAvailable={readWriteAvailable}
|
||||
onCreate={create}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
setRevealed(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ export {
|
||||
DEFAULT_SELF_HOSTED_SERVER_NAME,
|
||||
getSelfHostedServerName,
|
||||
} from './server-name';
|
||||
export { AccessTokenService } from './services/access-token';
|
||||
export { AuthService, type DeviceAuthSession } from './services/auth';
|
||||
export { CaptchaService } from './services/captcha';
|
||||
export { DefaultServerService } from './services/default-server';
|
||||
@@ -26,6 +25,7 @@ export { FetchService } from './services/fetch';
|
||||
export { GraphQLService } from './services/graphql';
|
||||
export { InvitationService } from './services/invitation';
|
||||
export { InvoicesService } from './services/invoices';
|
||||
export { McpCredentialService } from './services/mcp-credential';
|
||||
export type { PublicUserInfo } from './services/public-user';
|
||||
export { PublicUserService } from './services/public-user';
|
||||
export { RealtimeService } from './services/realtime';
|
||||
@@ -115,8 +115,8 @@ import { NbstoreService } from '../storage';
|
||||
import { DocScope, DocService, DocsService } from '../doc';
|
||||
import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync';
|
||||
import { GlobalDialogService } from '../dialogs';
|
||||
import { AccessTokenService } from './services/access-token';
|
||||
import { AccessTokenStore } from './stores/access-token';
|
||||
import { McpCredentialService } from './services/mcp-credential';
|
||||
import { McpCredentialStore } from './stores/mcp-credential';
|
||||
|
||||
export function configureCloudModule(framework: Framework) {
|
||||
configureDefaultAuthProvider(framework);
|
||||
@@ -194,8 +194,8 @@ export function configureCloudModule(framework: Framework) {
|
||||
.store(PublicUserStore, [GraphQLService])
|
||||
.service(UserSettingsService, [UserSettingsStore])
|
||||
.store(UserSettingsStore, [GraphQLService, NbstoreService])
|
||||
.service(AccessTokenService, [AccessTokenStore])
|
||||
.store(AccessTokenStore, [GraphQLService, NbstoreService]);
|
||||
.service(McpCredentialService, [McpCredentialStore])
|
||||
.store(McpCredentialStore, [GraphQLService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Framework } from '@toeverything/infra';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AccessTokenStore } from '../stores/access-token';
|
||||
import { AccessTokenService } from './access-token';
|
||||
|
||||
function createStore() {
|
||||
return {
|
||||
subscribeUserAccessTokens: vi.fn(() => new Subject()),
|
||||
listUserAccessTokens: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'token-1',
|
||||
name: 'MCP',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
]),
|
||||
generateUserAccessToken: vi.fn().mockResolvedValue({
|
||||
id: 'token-1',
|
||||
name: 'MCP',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
token: 'secret-token',
|
||||
}),
|
||||
} as unknown as AccessTokenStore;
|
||||
}
|
||||
|
||||
describe('AccessTokenService', () => {
|
||||
test('does not store generated plaintext token in the long-lived list', async () => {
|
||||
const framework = new Framework();
|
||||
framework
|
||||
.store(AccessTokenStore, createStore())
|
||||
.service(AccessTokenService, [AccessTokenStore]);
|
||||
const service = framework.provider().get(AccessTokenService);
|
||||
|
||||
const accessToken = await service.generateUserAccessToken('MCP');
|
||||
|
||||
expect(accessToken.token).toBe('secret-token');
|
||||
expect(service.accessTokens$.value).toEqual([
|
||||
{
|
||||
id: 'token-1',
|
||||
name: 'MCP',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(service.accessTokens$.value)).not.toContain(
|
||||
'secret-token'
|
||||
);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { LiveData, OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { AccountChanged } from '../events/account-changed';
|
||||
import { RealtimeLiveQuery } from '../realtime/live-query';
|
||||
import type {
|
||||
AccessToken,
|
||||
AccessTokenStore,
|
||||
ListedAccessToken,
|
||||
} from '../stores/access-token';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class AccessTokenService extends Service {
|
||||
constructor(private readonly accessTokenStore: AccessTokenStore) {
|
||||
super();
|
||||
this.liveQuery.start();
|
||||
}
|
||||
|
||||
accessTokens$ = new LiveData<ListedAccessToken[] | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
private readonly liveQuery = new RealtimeLiveQuery({
|
||||
request: signal => this.requestAccessTokens(signal),
|
||||
subscribe: () => this.accessTokenStore.subscribeUserAccessTokens(),
|
||||
applySnapshot: accessTokens => {
|
||||
this.error$.value = null;
|
||||
this.accessTokens$.value = accessTokens;
|
||||
},
|
||||
applyEvent: () => 'revalidate' as const,
|
||||
onError: error => {
|
||||
this.error$.value = error;
|
||||
},
|
||||
});
|
||||
|
||||
async generateUserAccessToken(name: string): Promise<AccessToken> {
|
||||
const accessToken =
|
||||
await this.accessTokenStore.generateUserAccessToken(name);
|
||||
const { token: _token, ...listedAccessToken } = accessToken;
|
||||
this.accessTokens$.value = [
|
||||
...(this.accessTokens$.value || []),
|
||||
listedAccessToken,
|
||||
];
|
||||
|
||||
await this.waitForRevalidation();
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async revokeUserAccessToken(id: string) {
|
||||
await this.accessTokenStore.revokeUserAccessToken(id);
|
||||
this.accessTokens$.value =
|
||||
this.accessTokens$.value?.filter(token => token.id !== id) ?? null;
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
revalidate = () => {
|
||||
this.liveQuery.revalidate();
|
||||
};
|
||||
|
||||
private onAccountChanged() {
|
||||
this.accessTokens$.value = null;
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(
|
||||
isRevalidating => !isRevalidating,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.liveQuery.dispose();
|
||||
}
|
||||
|
||||
private async requestAccessTokens(signal: AbortSignal) {
|
||||
this.isRevalidating$.value = true;
|
||||
try {
|
||||
return await this.accessTokenStore.listUserAccessTokens(signal);
|
||||
} finally {
|
||||
this.isRevalidating$.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
CreateMcpCredentialMutationVariables,
|
||||
McpCredentialsQuery,
|
||||
} from '@affine/graphql';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
|
||||
import type { McpCredentialStore } from '../stores/mcp-credential';
|
||||
|
||||
export type McpCredential = McpCredentialsQuery['mcpCredentials'][number];
|
||||
|
||||
export class McpCredentialService extends Service {
|
||||
private revalidationId = 0;
|
||||
|
||||
constructor(private readonly store: McpCredentialStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
credentials$ = new LiveData<McpCredential[] | null>(null);
|
||||
readWriteAvailable$ = new LiveData(false);
|
||||
loading$ = new LiveData(false);
|
||||
error$ = new LiveData<unknown>(null);
|
||||
|
||||
async revalidate(workspaceId: string) {
|
||||
const revalidationId = ++this.revalidationId;
|
||||
this.loading$.value = true;
|
||||
try {
|
||||
const result = await this.store.list(workspaceId);
|
||||
if (revalidationId !== this.revalidationId) return;
|
||||
this.credentials$.value = result.mcpCredentials;
|
||||
this.readWriteAvailable$.value = result.mcpCredentialReadWriteAvailable;
|
||||
this.error$.value = null;
|
||||
} catch (error) {
|
||||
if (revalidationId !== this.revalidationId) return;
|
||||
this.error$.value = error;
|
||||
} finally {
|
||||
if (revalidationId === this.revalidationId) {
|
||||
this.loading$.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateMcpCredentialMutationVariables['input']) {
|
||||
const revealed = await this.store.create(input);
|
||||
await this.revalidate(input.workspaceId);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
async rotate(id: string, workspaceId: string, expirationDays: number) {
|
||||
const revealed = await this.store.rotate(id, workspaceId, expirationDays);
|
||||
await this.revalidate(workspaceId);
|
||||
return revealed;
|
||||
}
|
||||
|
||||
async revoke(id: string, workspaceId: string) {
|
||||
await this.store.revoke(id, workspaceId);
|
||||
await this.revalidate(workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
generateUserAccessTokenMutation,
|
||||
revokeUserAccessTokenMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { AccessTokenSnapshot } from '@affine/realtime';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { NbstoreService } from '../../storage';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export type AccessToken = AccessTokenSnapshot & { token: string };
|
||||
export type ListedAccessToken = AccessTokenSnapshot;
|
||||
|
||||
export class AccessTokenStore extends Store {
|
||||
constructor(
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async listUserAccessTokens(
|
||||
signal?: AbortSignal
|
||||
): Promise<ListedAccessToken[]> {
|
||||
const { tokens } = await this.nbstoreService.realtime.request(
|
||||
'user.access-tokens.get',
|
||||
{},
|
||||
{ signal, timeoutMs: 10000 }
|
||||
);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
subscribeUserAccessTokens() {
|
||||
return this.nbstoreService.realtime.subscribe(
|
||||
'user.access-tokens.changed',
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async generateUserAccessToken(
|
||||
name: string,
|
||||
expiresAt?: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: generateUserAccessTokenMutation,
|
||||
variables: { input: { name, expiresAt } },
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return data.generateUserAccessToken;
|
||||
}
|
||||
|
||||
async revokeUserAccessToken(id: string, signal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: revokeUserAccessTokenMutation,
|
||||
variables: { id },
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return data.revokeUserAccessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CreateMcpCredentialMutationVariables } from '@affine/graphql';
|
||||
import {
|
||||
createMcpCredentialMutation,
|
||||
mcpCredentialsQuery,
|
||||
revokeMcpCredentialMutation,
|
||||
rotateMcpCredentialMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class McpCredentialStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(workspaceId: string, signal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: mcpCredentialsQuery,
|
||||
variables: { workspaceId },
|
||||
context: { signal },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async create(input: CreateMcpCredentialMutationVariables['input']) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: createMcpCredentialMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: input.workspaceId,
|
||||
name: input.name,
|
||||
accessMode: input.accessMode,
|
||||
expirationDays: input.expirationDays,
|
||||
},
|
||||
},
|
||||
});
|
||||
return data.createMcpCredential;
|
||||
}
|
||||
|
||||
async rotate(id: string, workspaceId: string, expirationDays: number) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: rotateMcpCredentialMutation,
|
||||
variables: { id, workspaceId, expirationDays },
|
||||
});
|
||||
return data.rotateMcpCredential;
|
||||
}
|
||||
|
||||
async revoke(id: string, workspaceId: string) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: revokeMcpCredentialMutation,
|
||||
variables: { id, workspaceId },
|
||||
});
|
||||
return data.revokeMcpCredential;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"ar": 94,
|
||||
"ca": 91,
|
||||
"ar": 92,
|
||||
"ca": 89,
|
||||
"da": 4,
|
||||
"de": 100,
|
||||
"el-GR": 90,
|
||||
"de": 98,
|
||||
"el-GR": 88,
|
||||
"en": 100,
|
||||
"es-AR": 90,
|
||||
"es-CL": 91,
|
||||
"es": 90,
|
||||
"fa": 90,
|
||||
"fr": 94,
|
||||
"es-AR": 88,
|
||||
"es-CL": 89,
|
||||
"es": 88,
|
||||
"fa": 88,
|
||||
"fr": 92,
|
||||
"hi": 1,
|
||||
"it": 91,
|
||||
"ja": 90,
|
||||
"kk": 98,
|
||||
"ko": 91,
|
||||
"nb-NO": 45,
|
||||
"pl": 91,
|
||||
"pt-BR": 90,
|
||||
"ru": 92,
|
||||
"sv-SE": 90,
|
||||
"tr": 98,
|
||||
"uk": 90,
|
||||
"ur": 98,
|
||||
"it": 89,
|
||||
"ja": 88,
|
||||
"kk": 96,
|
||||
"ko": 89,
|
||||
"nb-NO": 44,
|
||||
"pl": 89,
|
||||
"pt-BR": 88,
|
||||
"ru": 90,
|
||||
"sv-SE": 88,
|
||||
"tr": 96,
|
||||
"uk": 88,
|
||||
"ur": 96,
|
||||
"zh-Hans": 100,
|
||||
"zh-Hant": 92
|
||||
"zh-Hant": 90
|
||||
}
|
||||
|
||||
@@ -8882,6 +8882,196 @@ export function useAFFiNEI18N(): {
|
||||
* `The MCP token is shown only once. Delete and recreate it to copy the JSON configuration.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.copy-json.disabled-hint"](): string;
|
||||
/**
|
||||
* `Credentials`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.credentials.title"](): string;
|
||||
/**
|
||||
* `Use a separate credential for each MCP client so it can be revoked independently.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.credentials.description"](): string;
|
||||
/**
|
||||
* `Create credential`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.action.create"](): string;
|
||||
/**
|
||||
* `Rotate`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.action.rotate"](): string;
|
||||
/**
|
||||
* `Revoke credential`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.action.revoke"](): string;
|
||||
/**
|
||||
* `Copy token`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.action.copy-token"](): string;
|
||||
/**
|
||||
* `Copy JSON`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.action.copy-json"](): string;
|
||||
/**
|
||||
* `Done`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.action.done"](): string;
|
||||
/**
|
||||
* `Failed to load MCP credentials.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.load-error"](): string;
|
||||
/**
|
||||
* `No MCP credentials`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.empty.title"](): string;
|
||||
/**
|
||||
* `Create a workspace-bound credential to connect an MCP client.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.empty.description"](): string;
|
||||
/**
|
||||
* `Read only`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.access.read-only"](): string;
|
||||
/**
|
||||
* `Can read and search documents in this workspace using your current permissions.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.access.read-only-desc"](): string;
|
||||
/**
|
||||
* `Read and write`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.access.read-write"](): string;
|
||||
/**
|
||||
* `Expires {{date}}`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.meta.expires"](options: {
|
||||
readonly date: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Created {{date}}`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.meta.created"](options: {
|
||||
readonly date: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Last used {{date}}`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.meta.last-used"](options: {
|
||||
readonly date: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Never used`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.meta.never-used"](): string;
|
||||
/**
|
||||
* `Old token valid until {{date}}`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.meta.grace-until"](options: {
|
||||
readonly date: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Active`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.status.active"](): string;
|
||||
/**
|
||||
* `Rotating`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.status.rotating"](): string;
|
||||
/**
|
||||
* `Expiring`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.status.expiring"](): string;
|
||||
/**
|
||||
* `Expired`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.status.expired"](): string;
|
||||
/**
|
||||
* `Revoked`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.status.revoked"](): string;
|
||||
/**
|
||||
* `Create MCP credential`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.create.title"](): string;
|
||||
/**
|
||||
* `This credential will only work with this workspace's MCP endpoint.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.create.description"](): string;
|
||||
/**
|
||||
* `Label`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.field.label"](): string;
|
||||
/**
|
||||
* `Access`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.field.access"](): string;
|
||||
/**
|
||||
* `Expires in`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.field.expiry"](): string;
|
||||
/**
|
||||
* `{{days}} days`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.expiry.days"](options: {
|
||||
readonly days: string;
|
||||
}): string;
|
||||
/**
|
||||
* `MCP credential created`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.reveal.title"](): string;
|
||||
/**
|
||||
* `Copy this credential now. You won’t be able to see it again.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.reveal.warning"](): string;
|
||||
/**
|
||||
* `The old token remains valid until {{date}}.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.reveal.old-valid-until"](options: {
|
||||
readonly date: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Credential`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.reveal.token"](): string;
|
||||
/**
|
||||
* `MCP configuration`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.reveal.config"](): string;
|
||||
/**
|
||||
* `Rotate this credential?`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.rotate.title"](): string;
|
||||
/**
|
||||
* `A new token will be created immediately. The old token remains valid for up to 24 hours so you can update the MCP client.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.rotate.description"](): string;
|
||||
/**
|
||||
* `Revoke “{{name}}”?`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.revoke.title"](options: {
|
||||
readonly name: string;
|
||||
}): string;
|
||||
/**
|
||||
* `All generations of this credential will stop working immediately. This cannot be undone.`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.revoke.description"](): string;
|
||||
/**
|
||||
* `Supported capabilities`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.capabilities.title"](): string;
|
||||
/**
|
||||
* `Read documents`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.capabilities.read"](): string;
|
||||
/**
|
||||
* `Keyword search`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.capabilities.keyword-search"](): string;
|
||||
/**
|
||||
* `Semantic search`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.capabilities.semantic-search"](): string;
|
||||
/**
|
||||
* `Create and update documents`
|
||||
*/
|
||||
["com.affine.integration.mcp-server.capabilities.write"](): string;
|
||||
/**
|
||||
* `Notes`
|
||||
*/
|
||||
|
||||
@@ -2224,6 +2224,50 @@
|
||||
"com.affine.integration.mcp-server.name": "MCP Server",
|
||||
"com.affine.integration.mcp-server.desc": "Enable other MCP Client to search and read the doc of AFFiNE.",
|
||||
"com.affine.integration.mcp-server.copy-json.disabled-hint": "The MCP token is shown only once. Delete and recreate it to copy the JSON configuration.",
|
||||
"com.affine.integration.mcp-server.credentials.title": "Credentials",
|
||||
"com.affine.integration.mcp-server.credentials.description": "Use a separate credential for each MCP client so it can be revoked independently.",
|
||||
"com.affine.integration.mcp-server.action.create": "Create credential",
|
||||
"com.affine.integration.mcp-server.action.rotate": "Rotate",
|
||||
"com.affine.integration.mcp-server.action.revoke": "Revoke credential",
|
||||
"com.affine.integration.mcp-server.action.copy-token": "Copy token",
|
||||
"com.affine.integration.mcp-server.action.copy-json": "Copy JSON",
|
||||
"com.affine.integration.mcp-server.action.done": "Done",
|
||||
"com.affine.integration.mcp-server.load-error": "Failed to load MCP credentials.",
|
||||
"com.affine.integration.mcp-server.empty.title": "No MCP credentials",
|
||||
"com.affine.integration.mcp-server.empty.description": "Create a workspace-bound credential to connect an MCP client.",
|
||||
"com.affine.integration.mcp-server.access.read-only": "Read only",
|
||||
"com.affine.integration.mcp-server.access.read-only-desc": "Can read and search documents in this workspace using your current permissions.",
|
||||
"com.affine.integration.mcp-server.access.read-write": "Read and write",
|
||||
"com.affine.integration.mcp-server.meta.expires": "Expires {{date}}",
|
||||
"com.affine.integration.mcp-server.meta.created": "Created {{date}}",
|
||||
"com.affine.integration.mcp-server.meta.last-used": "Last used {{date}}",
|
||||
"com.affine.integration.mcp-server.meta.never-used": "Never used",
|
||||
"com.affine.integration.mcp-server.meta.grace-until": "Old token valid until {{date}}",
|
||||
"com.affine.integration.mcp-server.status.active": "Active",
|
||||
"com.affine.integration.mcp-server.status.rotating": "Rotating",
|
||||
"com.affine.integration.mcp-server.status.expiring": "Expiring",
|
||||
"com.affine.integration.mcp-server.status.expired": "Expired",
|
||||
"com.affine.integration.mcp-server.status.revoked": "Revoked",
|
||||
"com.affine.integration.mcp-server.create.title": "Create MCP credential",
|
||||
"com.affine.integration.mcp-server.create.description": "This credential will only work with this workspace's MCP endpoint.",
|
||||
"com.affine.integration.mcp-server.field.label": "Label",
|
||||
"com.affine.integration.mcp-server.field.access": "Access",
|
||||
"com.affine.integration.mcp-server.field.expiry": "Expires in",
|
||||
"com.affine.integration.mcp-server.expiry.days": "{{days}} days",
|
||||
"com.affine.integration.mcp-server.reveal.title": "MCP credential created",
|
||||
"com.affine.integration.mcp-server.reveal.warning": "Copy this credential now. You won’t be able to see it again.",
|
||||
"com.affine.integration.mcp-server.reveal.old-valid-until": "The old token remains valid until {{date}}.",
|
||||
"com.affine.integration.mcp-server.reveal.token": "Credential",
|
||||
"com.affine.integration.mcp-server.reveal.config": "MCP configuration",
|
||||
"com.affine.integration.mcp-server.rotate.title": "Rotate this credential?",
|
||||
"com.affine.integration.mcp-server.rotate.description": "A new token will be created immediately. The old token remains valid for up to 24 hours so you can update the MCP client.",
|
||||
"com.affine.integration.mcp-server.revoke.title": "Revoke “{{name}}”?",
|
||||
"com.affine.integration.mcp-server.revoke.description": "All generations of this credential will stop working immediately. This cannot be undone.",
|
||||
"com.affine.integration.mcp-server.capabilities.title": "Supported capabilities",
|
||||
"com.affine.integration.mcp-server.capabilities.read": "Read documents",
|
||||
"com.affine.integration.mcp-server.capabilities.keyword-search": "Keyword search",
|
||||
"com.affine.integration.mcp-server.capabilities.semantic-search": "Semantic search",
|
||||
"com.affine.integration.mcp-server.capabilities.write": "Create and update documents",
|
||||
"com.affine.audio.notes": "Notes",
|
||||
"com.affine.audio.transcribing": "Transcribing",
|
||||
"com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",
|
||||
|
||||
@@ -2224,6 +2224,50 @@
|
||||
"com.affine.integration.mcp-server.name": "MCP服务器",
|
||||
"com.affine.integration.mcp-server.desc": "允许其他 MCP 客户端搜索和 AFFiNE 的文档。",
|
||||
"com.affine.integration.mcp-server.copy-json.disabled-hint": "MCP token 仅显示一次。请删除并重新创建,以复制 JSON 配置。",
|
||||
"com.affine.integration.mcp-server.credentials.title": "凭证",
|
||||
"com.affine.integration.mcp-server.credentials.description": "为每个 MCP 客户端使用独立凭证,以便单独吊销。",
|
||||
"com.affine.integration.mcp-server.action.create": "创建凭证",
|
||||
"com.affine.integration.mcp-server.action.rotate": "轮换",
|
||||
"com.affine.integration.mcp-server.action.revoke": "吊销凭证",
|
||||
"com.affine.integration.mcp-server.action.copy-token": "复制 token",
|
||||
"com.affine.integration.mcp-server.action.copy-json": "复制 JSON",
|
||||
"com.affine.integration.mcp-server.action.done": "完成",
|
||||
"com.affine.integration.mcp-server.load-error": "加载 MCP 凭证失败。",
|
||||
"com.affine.integration.mcp-server.empty.title": "暂无 MCP 凭证",
|
||||
"com.affine.integration.mcp-server.empty.description": "创建绑定当前工作区的凭证以连接 MCP 客户端。",
|
||||
"com.affine.integration.mcp-server.access.read-only": "只读",
|
||||
"com.affine.integration.mcp-server.access.read-only-desc": "可按你的当前权限读取和搜索此工作区的文档。",
|
||||
"com.affine.integration.mcp-server.access.read-write": "读写",
|
||||
"com.affine.integration.mcp-server.meta.expires": "{{date}} 到期",
|
||||
"com.affine.integration.mcp-server.meta.created": "创建于 {{date}}",
|
||||
"com.affine.integration.mcp-server.meta.last-used": "最近使用于 {{date}}",
|
||||
"com.affine.integration.mcp-server.meta.never-used": "从未使用",
|
||||
"com.affine.integration.mcp-server.meta.grace-until": "旧 token 有效至 {{date}}",
|
||||
"com.affine.integration.mcp-server.status.active": "有效",
|
||||
"com.affine.integration.mcp-server.status.rotating": "轮换中",
|
||||
"com.affine.integration.mcp-server.status.expiring": "即将到期",
|
||||
"com.affine.integration.mcp-server.status.expired": "已到期",
|
||||
"com.affine.integration.mcp-server.status.revoked": "已吊销",
|
||||
"com.affine.integration.mcp-server.create.title": "创建 MCP 凭证",
|
||||
"com.affine.integration.mcp-server.create.description": "此凭证只能用于当前工作区的 MCP endpoint。",
|
||||
"com.affine.integration.mcp-server.field.label": "名称",
|
||||
"com.affine.integration.mcp-server.field.access": "权限",
|
||||
"com.affine.integration.mcp-server.field.expiry": "有效期",
|
||||
"com.affine.integration.mcp-server.expiry.days": "{{days}} 天",
|
||||
"com.affine.integration.mcp-server.reveal.title": "MCP 凭证已创建",
|
||||
"com.affine.integration.mcp-server.reveal.warning": "请立即复制此凭证,关闭后将无法再次查看。",
|
||||
"com.affine.integration.mcp-server.reveal.old-valid-until": "旧 token 在 {{date}} 前仍然有效。",
|
||||
"com.affine.integration.mcp-server.reveal.token": "凭证",
|
||||
"com.affine.integration.mcp-server.reveal.config": "MCP 配置",
|
||||
"com.affine.integration.mcp-server.rotate.title": "轮换此凭证?",
|
||||
"com.affine.integration.mcp-server.rotate.description": "系统将立即创建新 token;旧 token 最多保留 24 小时,以便更新 MCP 客户端。",
|
||||
"com.affine.integration.mcp-server.revoke.title": "吊销“{{name}}”?",
|
||||
"com.affine.integration.mcp-server.revoke.description": "此凭证的所有世代会立即失效,且无法恢复。",
|
||||
"com.affine.integration.mcp-server.capabilities.title": "支持的能力",
|
||||
"com.affine.integration.mcp-server.capabilities.read": "读取文档",
|
||||
"com.affine.integration.mcp-server.capabilities.keyword-search": "关键词搜索",
|
||||
"com.affine.integration.mcp-server.capabilities.semantic-search": "语义搜索",
|
||||
"com.affine.integration.mcp-server.capabilities.write": "创建和更新文档",
|
||||
"com.affine.audio.notes": "笔记",
|
||||
"com.affine.audio.transcribing": "转录",
|
||||
"com.affine.audio.transcribe.non-owner.confirm.title": "无法检索他人的 AI 结果",
|
||||
|
||||
@@ -1209,8 +1209,8 @@ export const PackageList = [
|
||||
location: 'packages/frontend/apps/electron',
|
||||
name: '@affine/electron',
|
||||
workspaceDependencies: [
|
||||
'packages/common/auth',
|
||||
'tools/utils',
|
||||
'packages/common/auth',
|
||||
'packages/frontend/i18n',
|
||||
'packages/frontend/native',
|
||||
'packages/common/nbstore',
|
||||
|
||||
Reference in New Issue
Block a user