refactor progress
This commit is contained in:
200
packages/api/src/connection/ConnectionController.tsx
Normal file
200
packages/api/src/connection/ConnectionController.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {requireOAuth2ScopeForBearer} from '@fluxer/api/src/middleware/OAuth2ScopeMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {ConnectionRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/ConnectionRateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
ConnectionListResponse,
|
||||
ConnectionResponse,
|
||||
ConnectionTypeParam,
|
||||
ConnectionVerificationResponse,
|
||||
CreateConnectionRequest,
|
||||
ReorderConnectionsRequest,
|
||||
UpdateConnectionRequest,
|
||||
VerifyAndCreateConnectionRequest,
|
||||
} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
|
||||
|
||||
export function ConnectionController(app: HonoApp) {
|
||||
app.get(
|
||||
'/users/@me/connections',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_LIST),
|
||||
requireOAuth2ScopeForBearer('connections'),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_connections',
|
||||
summary: 'List user connections',
|
||||
responseSchema: ConnectionListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description: 'Retrieves all external service connections for the authenticated user.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const connections = await ctx.get('connectionRequestService').listConnections(ctx.get('user').id);
|
||||
return ctx.json(connections);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/connections',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', CreateConnectionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'initiate_connection',
|
||||
summary: 'Initiate connection',
|
||||
responseSchema: ConnectionVerificationResponse,
|
||||
statusCode: 201,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description:
|
||||
'Initiates a new external service connection and returns verification instructions. No database record is created until verification succeeds.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const verification = await ctx
|
||||
.get('connectionRequestService')
|
||||
.initiateConnection(ctx.get('user').id, ctx.req.valid('json'));
|
||||
return ctx.json(verification, 201);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/connections/verify',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_VERIFY_AND_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', VerifyAndCreateConnectionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'verify_and_create_connection',
|
||||
summary: 'Verify and create connection',
|
||||
responseSchema: ConnectionResponse,
|
||||
statusCode: 201,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description:
|
||||
'Verifies the external service connection using the initiation token and creates the connection record on success.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const connection = await ctx
|
||||
.get('connectionRequestService')
|
||||
.verifyAndCreateConnection(ctx.get('user').id, ctx.req.valid('json'));
|
||||
return ctx.json(connection, 201);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/connections/:type/:connection_id',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ConnectionTypeParam),
|
||||
Validator('json', UpdateConnectionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_connection',
|
||||
summary: 'Update connection',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description: 'Updates visibility and sort order settings for an external service connection.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {type, connection_id} = ctx.req.valid('param');
|
||||
await ctx
|
||||
.get('connectionRequestService')
|
||||
.updateConnection(ctx.get('user').id, type, connection_id, ctx.req.valid('json'));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/connections/:type/:connection_id',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ConnectionTypeParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_connection',
|
||||
summary: 'Delete connection',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description: "Removes an external service connection from the authenticated user's profile.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {type, connection_id} = ctx.req.valid('param');
|
||||
await ctx.get('connectionRequestService').deleteConnection(ctx.get('user').id, type, connection_id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/connections/:type/:connection_id/verify',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_VERIFY),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ConnectionTypeParam),
|
||||
OpenAPI({
|
||||
operationId: 'verify_connection',
|
||||
summary: 'Verify connection',
|
||||
responseSchema: ConnectionResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description: 'Triggers verification for an external service connection.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {type, connection_id} = ctx.req.valid('param');
|
||||
const connection = await ctx
|
||||
.get('connectionRequestService')
|
||||
.verifyConnection(ctx.get('user').id, type, connection_id);
|
||||
return ctx.json(connection);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/connections/reorder',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', ReorderConnectionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reorder_connections',
|
||||
summary: 'Reorder connections',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description: 'Updates the display order of multiple connections in a single operation.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
await ctx.get('connectionRequestService').reorderConnections(ctx.get('user').id, body.connection_ids);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
81
packages/api/src/connection/ConnectionInitiationToken.tsx
Normal file
81
packages/api/src/connection/ConnectionInitiationToken.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createHmac, timingSafeEqual} from 'node:crypto';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
|
||||
export interface ConnectionInitiationTokenPayload {
|
||||
userId: string;
|
||||
type: ConnectionType;
|
||||
identifier: string;
|
||||
verificationCode: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function computeSignature(payloadBase64: string, secret: string): Buffer {
|
||||
return createHmac('sha256', secret).update(payloadBase64).digest();
|
||||
}
|
||||
|
||||
export function signInitiationToken(payload: ConnectionInitiationTokenPayload, secret: string): string {
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadBase64 = Buffer.from(payloadJson).toString('base64url');
|
||||
const signature = computeSignature(payloadBase64, secret).toString('base64url');
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyInitiationToken(token: string, secret: string): ConnectionInitiationTokenPayload | null {
|
||||
const dotIndex = token.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadBase64 = token.slice(0, dotIndex);
|
||||
const signatureBase64 = token.slice(dotIndex + 1);
|
||||
|
||||
const expectedSignature = computeSignature(payloadBase64, secret);
|
||||
|
||||
let providedSignature: Buffer;
|
||||
try {
|
||||
providedSignature = Buffer.from(signatureBase64, 'base64url');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (expectedSignature.length !== providedSignature.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!timingSafeEqual(expectedSignature, providedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload: ConnectionInitiationTokenPayload;
|
||||
try {
|
||||
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
|
||||
payload = JSON.parse(payloadJson) as ConnectionInitiationTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > payload.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
32
packages/api/src/connection/ConnectionMappers.tsx
Normal file
32
packages/api/src/connection/ConnectionMappers.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {ConnectionResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
|
||||
|
||||
export function mapConnectionToResponse(row: UserConnectionRow): ConnectionResponse {
|
||||
return {
|
||||
id: row.connection_id,
|
||||
type: row.connection_type,
|
||||
name: row.name,
|
||||
verified: row.verified,
|
||||
visibility_flags: row.visibility_flags,
|
||||
sort_order: row.sort_order,
|
||||
};
|
||||
}
|
||||
144
packages/api/src/connection/ConnectionRepository.tsx
Normal file
144
packages/api/src/connection/ConnectionRepository.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
type CreateConnectionParams,
|
||||
IConnectionRepository,
|
||||
type UpdateConnectionParams,
|
||||
} from '@fluxer/api/src/connection/IConnectionRepository';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import {UserConnections} from '@fluxer/api/src/Tables';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
|
||||
const FETCH_CONNECTIONS_BY_USER_CQL = UserConnections.selectCql({
|
||||
where: UserConnections.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_CONNECTION_BY_ID_CQL = UserConnections.selectCql({
|
||||
where: [
|
||||
UserConnections.where.eq('user_id'),
|
||||
UserConnections.where.eq('connection_type'),
|
||||
UserConnections.where.eq('connection_id'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const COUNT_CONNECTIONS_CQL = UserConnections.selectCountCql({
|
||||
where: UserConnections.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class ConnectionRepository extends IConnectionRepository {
|
||||
async findByUserId(userId: UserID): Promise<Array<UserConnectionRow>> {
|
||||
return fetchMany<UserConnectionRow>(FETCH_CONNECTIONS_BY_USER_CQL, {user_id: userId});
|
||||
}
|
||||
|
||||
async findById(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
): Promise<UserConnectionRow | null> {
|
||||
return fetchOne<UserConnectionRow>(FETCH_CONNECTION_BY_ID_CQL, {
|
||||
user_id: userId,
|
||||
connection_type: connectionType,
|
||||
connection_id: connectionId,
|
||||
});
|
||||
}
|
||||
|
||||
async findByTypeAndIdentifier(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
identifier: string,
|
||||
): Promise<UserConnectionRow | null> {
|
||||
const connections = await this.findByUserId(userId);
|
||||
return (
|
||||
connections.find(
|
||||
(c) => c.connection_type === connectionType && c.identifier.toLowerCase() === identifier.toLowerCase(),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async create(params: CreateConnectionParams): Promise<UserConnectionRow> {
|
||||
const now = new Date();
|
||||
const row: UserConnectionRow = {
|
||||
user_id: params.user_id,
|
||||
connection_id: params.connection_id,
|
||||
connection_type: params.connection_type,
|
||||
identifier: params.identifier,
|
||||
name: params.name,
|
||||
verified: params.verified ?? false,
|
||||
visibility_flags: params.visibility_flags,
|
||||
sort_order: params.sort_order,
|
||||
verification_token: params.verification_token,
|
||||
verified_at: params.verified_at ?? null,
|
||||
last_verified_at: params.last_verified_at ?? null,
|
||||
created_at: now,
|
||||
version: 1,
|
||||
};
|
||||
await upsertOne(UserConnections.upsertAll(row));
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
params: UpdateConnectionParams,
|
||||
): Promise<void> {
|
||||
const patch: Record<string, ReturnType<typeof Db.set>> = {};
|
||||
if (params.name !== undefined) {
|
||||
patch['name'] = Db.set(params.name);
|
||||
}
|
||||
if (params.visibility_flags !== undefined) {
|
||||
patch['visibility_flags'] = Db.set(params.visibility_flags);
|
||||
}
|
||||
if (params.sort_order !== undefined) {
|
||||
patch['sort_order'] = Db.set(params.sort_order);
|
||||
}
|
||||
if (params.verified !== undefined) {
|
||||
patch['verified'] = Db.set(params.verified);
|
||||
}
|
||||
if (params.verified_at !== undefined) {
|
||||
patch['verified_at'] = Db.set(params.verified_at);
|
||||
}
|
||||
if (params.last_verified_at !== undefined) {
|
||||
patch['last_verified_at'] = Db.set(params.last_verified_at);
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await upsertOne(
|
||||
UserConnections.patchByPk(
|
||||
{user_id: userId, connection_type: connectionType, connection_id: connectionId},
|
||||
patch,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId: UserID, connectionType: ConnectionType, connectionId: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserConnections.deleteByPk({user_id: userId, connection_type: connectionType, connection_id: connectionId}),
|
||||
);
|
||||
}
|
||||
|
||||
async count(userId: UserID): Promise<number> {
|
||||
const result = await fetchOne<{count: bigint}>(COUNT_CONNECTIONS_CQL, {user_id: userId});
|
||||
return result ? Number(result.count) : 0;
|
||||
}
|
||||
}
|
||||
122
packages/api/src/connection/ConnectionRequestService.tsx
Normal file
122
packages/api/src/connection/ConnectionRequestService.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {signInitiationToken, verifyInitiationToken} from '@fluxer/api/src/connection/ConnectionInitiationToken';
|
||||
import {mapConnectionToResponse} from '@fluxer/api/src/connection/ConnectionMappers';
|
||||
import {ConnectionInitiationTokenInvalidError} from '@fluxer/api/src/connection/errors/ConnectionInitiationTokenInvalidError';
|
||||
import type {IConnectionService} from '@fluxer/api/src/connection/IConnectionService';
|
||||
import {
|
||||
CONNECTION_INITIATION_TOKEN_EXPIRY_MS,
|
||||
type ConnectionType,
|
||||
ConnectionTypes,
|
||||
} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import type {
|
||||
ConnectionResponse,
|
||||
ConnectionVerificationResponse,
|
||||
CreateConnectionRequest,
|
||||
UpdateConnectionRequest,
|
||||
VerifyAndCreateConnectionRequest,
|
||||
} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
|
||||
|
||||
export class ConnectionRequestService {
|
||||
constructor(
|
||||
private readonly connectionService: IConnectionService,
|
||||
private readonly connectionInitiationSecret: string,
|
||||
) {}
|
||||
|
||||
async listConnections(userId: UserID): Promise<Array<ConnectionResponse>> {
|
||||
const rows = await this.connectionService.getConnectionsForUser(userId);
|
||||
return rows.sort((a, b) => a.sort_order - b.sort_order).map((row) => mapConnectionToResponse(row));
|
||||
}
|
||||
|
||||
async initiateConnection(userId: UserID, body: CreateConnectionRequest): Promise<ConnectionVerificationResponse> {
|
||||
const result = await this.connectionService.initiateConnection(userId, body.type, body.identifier);
|
||||
const instructions = this.generateVerificationInstructions(body.type, body.identifier);
|
||||
const initiationToken = signInitiationToken(
|
||||
{
|
||||
userId: String(userId),
|
||||
type: body.type,
|
||||
identifier: body.identifier,
|
||||
verificationCode: result.verificationCode,
|
||||
expiresAt: Date.now() + CONNECTION_INITIATION_TOKEN_EXPIRY_MS,
|
||||
},
|
||||
this.connectionInitiationSecret,
|
||||
);
|
||||
|
||||
return {
|
||||
token: result.verificationCode,
|
||||
type: body.type,
|
||||
id: body.identifier,
|
||||
instructions,
|
||||
initiation_token: initiationToken,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyAndCreateConnection(userId: UserID, body: VerifyAndCreateConnectionRequest): Promise<ConnectionResponse> {
|
||||
const payload = verifyInitiationToken(body.initiation_token, this.connectionInitiationSecret);
|
||||
if (!payload || payload.userId !== String(userId)) {
|
||||
throw new ConnectionInitiationTokenInvalidError();
|
||||
}
|
||||
|
||||
const row = await this.connectionService.verifyAndCreateConnection(
|
||||
userId,
|
||||
payload.type,
|
||||
payload.identifier,
|
||||
payload.verificationCode,
|
||||
body.visibility_flags ?? 1,
|
||||
);
|
||||
return mapConnectionToResponse(row);
|
||||
}
|
||||
|
||||
async updateConnection(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
body: UpdateConnectionRequest,
|
||||
): Promise<void> {
|
||||
await this.connectionService.updateConnection(userId, connectionType, connectionId, body);
|
||||
}
|
||||
|
||||
async deleteConnection(userId: UserID, connectionType: ConnectionType, connectionId: string): Promise<void> {
|
||||
await this.connectionService.deleteConnection(userId, connectionType, connectionId);
|
||||
}
|
||||
|
||||
async verifyConnection(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
): Promise<ConnectionResponse> {
|
||||
const row = await this.connectionService.verifyConnection(userId, connectionType, connectionId);
|
||||
return mapConnectionToResponse(row);
|
||||
}
|
||||
|
||||
async reorderConnections(userId: UserID, connectionIds: Array<string>): Promise<void> {
|
||||
await this.connectionService.reorderConnections(userId, connectionIds);
|
||||
}
|
||||
|
||||
private generateVerificationInstructions(connectionType: ConnectionType, identifier: string): string {
|
||||
switch (connectionType) {
|
||||
case ConnectionTypes.DOMAIN:
|
||||
return `Add a DNS TXT record at _fluxer.${identifier} with the value fluxer-verification=<token>, or serve the token at https://${identifier}/.well-known/fluxer-verification`;
|
||||
default:
|
||||
return 'Follow the platform-specific verification instructions';
|
||||
}
|
||||
}
|
||||
}
|
||||
361
packages/api/src/connection/ConnectionService.tsx
Normal file
361
packages/api/src/connection/ConnectionService.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomBytes, randomUUID} from 'node:crypto';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IBlueskyOAuthService} from '@fluxer/api/src/bluesky/IBlueskyOAuthService';
|
||||
import {mapConnectionToResponse} from '@fluxer/api/src/connection/ConnectionMappers';
|
||||
import {BlueskyOAuthNotEnabledError} from '@fluxer/api/src/connection/errors/BlueskyOAuthNotEnabledError';
|
||||
import {ConnectionAlreadyExistsError} from '@fluxer/api/src/connection/errors/ConnectionAlreadyExistsError';
|
||||
import {ConnectionInvalidTypeError} from '@fluxer/api/src/connection/errors/ConnectionInvalidTypeError';
|
||||
import {ConnectionLimitReachedError} from '@fluxer/api/src/connection/errors/ConnectionLimitReachedError';
|
||||
import {ConnectionNotFoundError} from '@fluxer/api/src/connection/errors/ConnectionNotFoundError';
|
||||
import {ConnectionVerificationFailedError} from '@fluxer/api/src/connection/errors/ConnectionVerificationFailedError';
|
||||
import type {IConnectionRepository, UpdateConnectionParams} from '@fluxer/api/src/connection/IConnectionRepository';
|
||||
import {IConnectionService, type InitiateConnectionResult} from '@fluxer/api/src/connection/IConnectionService';
|
||||
import {BlueskyOAuthVerifier} from '@fluxer/api/src/connection/verification/BlueskyOAuthVerifier';
|
||||
import {DomainConnectionVerifier} from '@fluxer/api/src/connection/verification/DomainConnectionVerifier';
|
||||
import type {IConnectionVerifier} from '@fluxer/api/src/connection/verification/IConnectionVerifier';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {
|
||||
CONNECTION_VERIFICATION_TOKEN_LENGTH,
|
||||
type ConnectionType,
|
||||
ConnectionTypes,
|
||||
ConnectionVisibilityFlags,
|
||||
MAX_CONNECTIONS_PER_USER,
|
||||
} from '@fluxer/constants/src/ConnectionConstants';
|
||||
|
||||
export class ConnectionService extends IConnectionService {
|
||||
constructor(
|
||||
private readonly repository: IConnectionRepository,
|
||||
private readonly gateway: IGatewayService,
|
||||
private readonly blueskyOAuthService: IBlueskyOAuthService | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getConnectionsForUser(userId: UserID): Promise<Array<UserConnectionRow>> {
|
||||
return this.repository.findByUserId(userId);
|
||||
}
|
||||
|
||||
async initiateConnection(
|
||||
userId: UserID,
|
||||
type: ConnectionType,
|
||||
identifier: string,
|
||||
): Promise<InitiateConnectionResult> {
|
||||
if (type === ConnectionTypes.BLUESKY) {
|
||||
throw new BlueskyOAuthNotEnabledError();
|
||||
}
|
||||
if (type !== ConnectionTypes.DOMAIN) {
|
||||
throw new ConnectionInvalidTypeError();
|
||||
}
|
||||
|
||||
const count = await this.repository.count(userId);
|
||||
if (count >= MAX_CONNECTIONS_PER_USER) {
|
||||
throw new ConnectionLimitReachedError();
|
||||
}
|
||||
|
||||
const existing = await this.repository.findByTypeAndIdentifier(userId, type, identifier);
|
||||
if (existing) {
|
||||
throw new ConnectionAlreadyExistsError();
|
||||
}
|
||||
|
||||
const verificationCode = randomBytes(CONNECTION_VERIFICATION_TOKEN_LENGTH).toString('hex');
|
||||
return {verificationCode};
|
||||
}
|
||||
|
||||
private normalizeVisibilityFlags(flags: number): number {
|
||||
let normalized = flags;
|
||||
|
||||
const hasEveryone = (normalized & ConnectionVisibilityFlags.EVERYONE) === ConnectionVisibilityFlags.EVERYONE;
|
||||
const hasFriends = (normalized & ConnectionVisibilityFlags.FRIENDS) === ConnectionVisibilityFlags.FRIENDS;
|
||||
const hasMutualGuilds =
|
||||
(normalized & ConnectionVisibilityFlags.MUTUAL_GUILDS) === ConnectionVisibilityFlags.MUTUAL_GUILDS;
|
||||
|
||||
if (hasEveryone && hasFriends && hasMutualGuilds) {
|
||||
normalized =
|
||||
ConnectionVisibilityFlags.EVERYONE |
|
||||
ConnectionVisibilityFlags.FRIENDS |
|
||||
ConnectionVisibilityFlags.MUTUAL_GUILDS;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async verifyAndCreateConnection(
|
||||
userId: UserID,
|
||||
type: ConnectionType,
|
||||
identifier: string,
|
||||
verificationCode: string,
|
||||
visibilityFlags: number,
|
||||
): Promise<UserConnectionRow> {
|
||||
if (type === ConnectionTypes.BLUESKY) {
|
||||
throw new BlueskyOAuthNotEnabledError();
|
||||
}
|
||||
if (type !== ConnectionTypes.DOMAIN) {
|
||||
throw new ConnectionInvalidTypeError();
|
||||
}
|
||||
|
||||
const count = await this.repository.count(userId);
|
||||
if (count >= MAX_CONNECTIONS_PER_USER) {
|
||||
throw new ConnectionLimitReachedError();
|
||||
}
|
||||
|
||||
const existing = await this.repository.findByTypeAndIdentifier(userId, type, identifier);
|
||||
if (existing) {
|
||||
throw new ConnectionAlreadyExistsError();
|
||||
}
|
||||
|
||||
const verifier = this.getVerifier(type);
|
||||
const isValid = await verifier.verify({identifier, verification_token: verificationCode});
|
||||
if (!isValid) {
|
||||
throw new ConnectionVerificationFailedError();
|
||||
}
|
||||
|
||||
const connectionId = randomUUID();
|
||||
const sortOrder = count;
|
||||
const now = new Date();
|
||||
|
||||
const created = await this.repository.create({
|
||||
user_id: userId,
|
||||
connection_id: connectionId,
|
||||
connection_type: type,
|
||||
identifier,
|
||||
name: identifier,
|
||||
visibility_flags: this.normalizeVisibilityFlags(visibilityFlags),
|
||||
sort_order: sortOrder,
|
||||
verification_token: verificationCode,
|
||||
verified: true,
|
||||
verified_at: now,
|
||||
last_verified_at: now,
|
||||
});
|
||||
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: connections.map(mapConnectionToResponse)},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateConnection(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
patch: UpdateConnectionParams,
|
||||
): Promise<void> {
|
||||
const connection = await this.repository.findById(userId, connectionType, connectionId);
|
||||
if (!connection) {
|
||||
throw new ConnectionNotFoundError();
|
||||
}
|
||||
|
||||
const normalizedPatch =
|
||||
patch.visibility_flags !== undefined
|
||||
? {...patch, visibility_flags: this.normalizeVisibilityFlags(patch.visibility_flags)}
|
||||
: patch;
|
||||
|
||||
await this.repository.update(userId, connectionType, connectionId, normalizedPatch);
|
||||
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: connections.map(mapConnectionToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConnection(userId: UserID, connectionType: ConnectionType, connectionId: string): Promise<void> {
|
||||
const connection = await this.repository.findById(userId, connectionType, connectionId);
|
||||
if (!connection) {
|
||||
throw new ConnectionNotFoundError();
|
||||
}
|
||||
|
||||
await this.repository.delete(userId, connectionType, connectionId);
|
||||
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: connections.map(mapConnectionToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
async verifyConnection(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
): Promise<UserConnectionRow> {
|
||||
const connection = await this.repository.findById(userId, connectionType, connectionId);
|
||||
if (!connection) {
|
||||
throw new ConnectionNotFoundError();
|
||||
}
|
||||
|
||||
const {isValid, updateParams} = await this.revalidateConnection(connection);
|
||||
|
||||
if (updateParams) {
|
||||
await this.repository.update(userId, connectionType, connectionId, updateParams);
|
||||
}
|
||||
|
||||
const updated = await this.repository.findById(userId, connectionType, connectionId);
|
||||
if (!updated) {
|
||||
throw new ConnectionNotFoundError();
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new ConnectionVerificationFailedError();
|
||||
}
|
||||
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: connections.map(mapConnectionToResponse)},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reorderConnections(userId: UserID, connectionIds: Array<string>): Promise<void> {
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
|
||||
for (let i = 0; i < connectionIds.length; i++) {
|
||||
const connectionId = connectionIds[i];
|
||||
const connection = connections.find((c) => c.connection_id === connectionId);
|
||||
if (connection) {
|
||||
await this.repository.update(userId, connection.connection_type, connectionId, {
|
||||
sort_order: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedConnections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: updatedConnections.map(mapConnectionToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
async createOrUpdateBlueskyConnection(userId: UserID, did: string, handle: string): Promise<UserConnectionRow> {
|
||||
const existing = await this.repository.findByTypeAndIdentifier(userId, ConnectionTypes.BLUESKY, did);
|
||||
|
||||
if (existing) {
|
||||
const now = new Date();
|
||||
await this.repository.update(userId, ConnectionTypes.BLUESKY, existing.connection_id, {
|
||||
name: handle,
|
||||
verified: true,
|
||||
verified_at: existing.verified_at ?? now,
|
||||
last_verified_at: now,
|
||||
});
|
||||
|
||||
const updated = await this.repository.findById(userId, ConnectionTypes.BLUESKY, existing.connection_id);
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: connections.map(mapConnectionToResponse)},
|
||||
});
|
||||
return updated!;
|
||||
}
|
||||
|
||||
const count = await this.repository.count(userId);
|
||||
if (count >= MAX_CONNECTIONS_PER_USER) {
|
||||
throw new ConnectionLimitReachedError();
|
||||
}
|
||||
|
||||
const connectionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
const created = await this.repository.create({
|
||||
user_id: userId,
|
||||
connection_id: connectionId,
|
||||
connection_type: ConnectionTypes.BLUESKY,
|
||||
identifier: did,
|
||||
name: handle,
|
||||
visibility_flags: ConnectionVisibilityFlags.EVERYONE,
|
||||
sort_order: count,
|
||||
verification_token: '',
|
||||
verified: true,
|
||||
verified_at: now,
|
||||
last_verified_at: now,
|
||||
});
|
||||
|
||||
const connections = await this.repository.findByUserId(userId);
|
||||
await this.gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_CONNECTIONS_UPDATE',
|
||||
data: {connections: connections.map(mapConnectionToResponse)},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async revalidateConnection(connection: UserConnectionRow): Promise<{
|
||||
isValid: boolean;
|
||||
updateParams: UpdateConnectionParams | null;
|
||||
}> {
|
||||
const verifier = this.getVerifier(connection.connection_type);
|
||||
const isValid = await verifier.verify({
|
||||
identifier: connection.identifier,
|
||||
verification_token: connection.verification_token,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (!isValid && connection.verified) {
|
||||
return {
|
||||
isValid: false,
|
||||
updateParams: {
|
||||
verified: false,
|
||||
verified_at: null,
|
||||
last_verified_at: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
isValid: true,
|
||||
updateParams: {
|
||||
verified: true,
|
||||
verified_at: connection.verified_at ? connection.verified_at : now,
|
||||
last_verified_at: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {isValid: false, updateParams: null};
|
||||
}
|
||||
|
||||
private getVerifier(type: ConnectionType): IConnectionVerifier {
|
||||
if (type === ConnectionTypes.BLUESKY) {
|
||||
if (!this.blueskyOAuthService) {
|
||||
throw new BlueskyOAuthNotEnabledError();
|
||||
}
|
||||
return new BlueskyOAuthVerifier(this.blueskyOAuthService);
|
||||
}
|
||||
if (type === ConnectionTypes.DOMAIN) {
|
||||
return new DomainConnectionVerifier();
|
||||
}
|
||||
throw new ConnectionInvalidTypeError();
|
||||
}
|
||||
}
|
||||
68
packages/api/src/connection/IConnectionRepository.tsx
Normal file
68
packages/api/src/connection/IConnectionRepository.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
|
||||
export interface CreateConnectionParams {
|
||||
user_id: UserID;
|
||||
connection_id: string;
|
||||
connection_type: ConnectionType;
|
||||
identifier: string;
|
||||
name: string;
|
||||
visibility_flags: number;
|
||||
sort_order: number;
|
||||
verification_token: string;
|
||||
verified?: boolean;
|
||||
verified_at?: Date | null;
|
||||
last_verified_at?: Date | null;
|
||||
}
|
||||
|
||||
export interface UpdateConnectionParams {
|
||||
name?: string;
|
||||
visibility_flags?: number;
|
||||
sort_order?: number;
|
||||
verified?: boolean;
|
||||
verified_at?: Date | null;
|
||||
last_verified_at?: Date | null;
|
||||
}
|
||||
|
||||
export abstract class IConnectionRepository {
|
||||
abstract findByUserId(userId: UserID): Promise<Array<UserConnectionRow>>;
|
||||
abstract findById(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
): Promise<UserConnectionRow | null>;
|
||||
abstract findByTypeAndIdentifier(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
identifier: string,
|
||||
): Promise<UserConnectionRow | null>;
|
||||
abstract create(params: CreateConnectionParams): Promise<UserConnectionRow>;
|
||||
abstract update(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
params: UpdateConnectionParams,
|
||||
): Promise<void>;
|
||||
abstract delete(userId: UserID, connectionType: ConnectionType, connectionId: string): Promise<void>;
|
||||
abstract count(userId: UserID): Promise<number>;
|
||||
}
|
||||
61
packages/api/src/connection/IConnectionService.tsx
Normal file
61
packages/api/src/connection/IConnectionService.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UpdateConnectionParams} from '@fluxer/api/src/connection/IConnectionRepository';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
|
||||
export interface InitiateConnectionResult {
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export abstract class IConnectionService {
|
||||
abstract getConnectionsForUser(userId: UserID): Promise<Array<UserConnectionRow>>;
|
||||
abstract initiateConnection(
|
||||
userId: UserID,
|
||||
type: ConnectionType,
|
||||
identifier: string,
|
||||
): Promise<InitiateConnectionResult>;
|
||||
abstract verifyAndCreateConnection(
|
||||
userId: UserID,
|
||||
type: ConnectionType,
|
||||
identifier: string,
|
||||
verificationCode: string,
|
||||
visibilityFlags: number,
|
||||
): Promise<UserConnectionRow>;
|
||||
abstract updateConnection(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
patch: UpdateConnectionParams,
|
||||
): Promise<void>;
|
||||
abstract deleteConnection(userId: UserID, connectionType: ConnectionType, connectionId: string): Promise<void>;
|
||||
abstract verifyConnection(
|
||||
userId: UserID,
|
||||
connectionType: ConnectionType,
|
||||
connectionId: string,
|
||||
): Promise<UserConnectionRow>;
|
||||
abstract reorderConnections(userId: UserID, connectionIds: Array<string>): Promise<void>;
|
||||
abstract revalidateConnection(connection: UserConnectionRow): Promise<{
|
||||
isValid: boolean;
|
||||
updateParams: UpdateConnectionParams | null;
|
||||
}>;
|
||||
abstract createOrUpdateBlueskyConnection(userId: UserID, did: string, handle: string): Promise<UserConnectionRow>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class BlueskyOAuthCallbackFailedError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.BLUESKY_OAUTH_CALLBACK_FAILED});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class BlueskyOAuthNotEnabledError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.BLUESKY_OAUTH_NOT_ENABLED});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
|
||||
|
||||
export class BlueskyOAuthSessionExpiredError extends ForbiddenError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.BLUESKY_OAUTH_SESSION_EXPIRED});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class BlueskyOAuthStateInvalidError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.BLUESKY_OAUTH_STATE_INVALID});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
|
||||
|
||||
export class ConnectionAlreadyExistsError extends ConflictError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CONNECTION_ALREADY_EXISTS});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class ConnectionInitiationTokenInvalidError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CONNECTION_INITIATION_TOKEN_INVALID});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class ConnectionInvalidTypeError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CONNECTION_INVALID_TYPE});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
|
||||
|
||||
export class ConnectionLimitReachedError extends ThrottledError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CONNECTION_LIMIT_REACHED});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
|
||||
export class ConnectionNotFoundError extends NotFoundError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CONNECTION_NOT_FOUND});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
|
||||
|
||||
export class ConnectionVerificationFailedError extends ForbiddenError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CONNECTION_VERIFICATION_FAILED});
|
||||
}
|
||||
}
|
||||
408
packages/api/src/connection/tests/ConnectionCrud.test.tsx
Normal file
408
packages/api/src/connection/tests/ConnectionCrud.test.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
createBlueskyConnectionViaOAuth,
|
||||
createBlueskyDid,
|
||||
createBlueskyHandle,
|
||||
deleteConnection,
|
||||
initiateConnection,
|
||||
listConnections,
|
||||
reorderConnections,
|
||||
updateConnection,
|
||||
} from '@fluxer/api/src/connection/tests/ConnectionTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ConnectionTypes, ConnectionVisibilityFlags} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Connection CRUD', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('List connections', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const connections = await listConnections(harness, account.token);
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns created connections', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId);
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
expect(connections).toHaveLength(1);
|
||||
expect(connections[0].type).toBe(ConnectionTypes.BLUESKY);
|
||||
expect(connections[0].name).toBe(handle);
|
||||
expect(connections[0].verified).toBe(true);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/users/@me/connections').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initiate connection', () => {
|
||||
it('rejects Bluesky type from initiate endpoint', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections')
|
||||
.body({
|
||||
type: ConnectionTypes.BLUESKY,
|
||||
identifier: 'test.bsky.social',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.BLUESKY_OAUTH_NOT_ENABLED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('initiates valid domain connection', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
const verification = await initiateConnection(harness, account.token, {
|
||||
type: ConnectionTypes.DOMAIN,
|
||||
identifier: domain,
|
||||
});
|
||||
|
||||
expect(verification.type).toBe(ConnectionTypes.DOMAIN);
|
||||
expect(verification.id).toBe(domain);
|
||||
expect(verification.token).toBeTruthy();
|
||||
expect(verification.token.length).toBeGreaterThan(0);
|
||||
expect(verification.instructions).toBeTruthy();
|
||||
expect(verification.initiation_token).toBeTruthy();
|
||||
expect(verification.initiation_token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not create a DB record for domain initiation', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
await initiateConnection(harness, account.token, {
|
||||
type: ConnectionTypes.DOMAIN,
|
||||
identifier: domain,
|
||||
});
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
expect(connections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns INVALID_FORM_BODY for invalid type', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections')
|
||||
.body({
|
||||
type: 'invalid_type',
|
||||
identifier: 'test',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns CONNECTION_LIMIT_REACHED when limit exceeded', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await createBlueskyConnectionViaOAuth(
|
||||
harness,
|
||||
account.token,
|
||||
createBlueskyHandle(`user${i}`),
|
||||
createBlueskyDid(`user${i}`),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {
|
||||
userId,
|
||||
did: createBlueskyDid('user21'),
|
||||
handle: createBlueskyHandle('user21'),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=mock&state=mock&iss=mock',
|
||||
method: 'GET',
|
||||
headers: {Authorization: account.token},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get('location') ?? '';
|
||||
expect(location).toContain('status=error');
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/connections')
|
||||
.body({
|
||||
type: ConnectionTypes.DOMAIN,
|
||||
identifier: 'example.com',
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verify and create connection', () => {
|
||||
it('returns CONNECTION_INITIATION_TOKEN_INVALID for expired token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections/verify')
|
||||
.body({
|
||||
initiation_token: 'expired.token.value',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CONNECTION_INITIATION_TOKEN_INVALID)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns CONNECTION_INITIATION_TOKEN_INVALID for tampered token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
const verification = await initiateConnection(harness, account.token, {
|
||||
type: ConnectionTypes.DOMAIN,
|
||||
identifier: domain,
|
||||
});
|
||||
|
||||
const tamperedToken = `${verification.initiation_token}tampered`;
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections/verify')
|
||||
.body({
|
||||
initiation_token: tamperedToken,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CONNECTION_INITIATION_TOKEN_INVALID)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/connections/verify')
|
||||
.body({
|
||||
initiation_token: 'some-token',
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bluesky OAuth connection', () => {
|
||||
it('creates connection via OAuth flow', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId);
|
||||
|
||||
expect(connection.type).toBe(ConnectionTypes.BLUESKY);
|
||||
expect(connection.name).toBe(handle);
|
||||
expect(connection.verified).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts visibility_flags via OAuth flow', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId, {
|
||||
visibility_flags: ConnectionVisibilityFlags.FRIENDS,
|
||||
});
|
||||
|
||||
expect(connection.visibility_flags).toBe(ConnectionVisibilityFlags.FRIENDS);
|
||||
});
|
||||
|
||||
it('updates existing connection on re-authorisation', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const newHandle = 'newalias.bsky.social';
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId);
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {userId, did, handle: newHandle},
|
||||
});
|
||||
|
||||
await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=mock&state=mock&iss=mock',
|
||||
method: 'GET',
|
||||
headers: {Authorization: account.token},
|
||||
});
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
expect(connections).toHaveLength(1);
|
||||
expect(connections[0].name).toBe(newHandle);
|
||||
expect(connections[0].verified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update connection', () => {
|
||||
it('updates visibility_flags', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId);
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
const connectionId = connections[0].id;
|
||||
|
||||
await updateConnection(harness, account.token, ConnectionTypes.BLUESKY, connectionId, {
|
||||
visibility_flags: ConnectionVisibilityFlags.MUTUAL_GUILDS,
|
||||
});
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections[0].visibility_flags).toBe(ConnectionVisibilityFlags.MUTUAL_GUILDS);
|
||||
});
|
||||
|
||||
it('updates sort_order', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId);
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
const connectionId = connections[0].id;
|
||||
|
||||
await updateConnection(harness, account.token, ConnectionTypes.BLUESKY, connectionId, {
|
||||
sort_order: 5,
|
||||
});
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections[0].sort_order).toBe(5);
|
||||
});
|
||||
|
||||
it('returns CONNECTION_NOT_FOUND for non-existent connection', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/connections/${ConnectionTypes.BLUESKY}/nonexistent`)
|
||||
.body({visibility_flags: ConnectionVisibilityFlags.EVERYONE})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.CONNECTION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch(`/users/@me/connections/${ConnectionTypes.BLUESKY}/test`)
|
||||
.body({visibility_flags: ConnectionVisibilityFlags.EVERYONE})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete connection', () => {
|
||||
it('deletes existing connection', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle, did, userId);
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
const connectionId = connections[0].id;
|
||||
|
||||
await deleteConnection(harness, account.token, ConnectionTypes.BLUESKY, connectionId);
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns CONNECTION_NOT_FOUND for non-existent connection', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/connections/${ConnectionTypes.BLUESKY}/nonexistent`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.CONNECTION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.delete(`/users/@me/connections/${ConnectionTypes.BLUESKY}/test`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reorder connections', () => {
|
||||
it('reorders multiple connections', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle1 = createBlueskyHandle('user1');
|
||||
const handle2 = createBlueskyHandle('user2');
|
||||
const did1 = createBlueskyDid('user1');
|
||||
const did2 = createBlueskyDid('user2');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle1, did1, userId);
|
||||
await createBlueskyConnectionViaOAuth(harness, account.token, handle2, did2, userId);
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
const id1 = connections[0].id;
|
||||
const id2 = connections[1].id;
|
||||
|
||||
await reorderConnections(harness, account.token, [id2, id1]);
|
||||
|
||||
const reordered = await listConnections(harness, account.token);
|
||||
const conn1 = reordered.find((c) => c.id === id1);
|
||||
const conn2 = reordered.find((c) => c.id === id2);
|
||||
|
||||
expect(conn2?.sort_order).toBe(0);
|
||||
expect(conn1?.sort_order).toBe(1);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch('/users/@me/connections/reorder')
|
||||
.body({connection_ids: ['id1', 'id2']})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
packages/api/src/connection/tests/ConnectionTestUtils.tsx
Normal file
177
packages/api/src/connection/tests/ConnectionTestUtils.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {BlueskyAuthorizeResponse} from '@fluxer/schema/src/domains/connection/BlueskyOAuthSchemas';
|
||||
import type {
|
||||
ConnectionListResponse,
|
||||
ConnectionResponse,
|
||||
ConnectionVerificationResponse,
|
||||
CreateConnectionRequest,
|
||||
VerifyAndCreateConnectionRequest,
|
||||
} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
|
||||
|
||||
export async function initiateConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
request: CreateConnectionRequest,
|
||||
): Promise<ConnectionVerificationResponse> {
|
||||
return await createBuilder<ConnectionVerificationResponse>(harness, token)
|
||||
.post('/users/@me/connections')
|
||||
.body(request)
|
||||
.expect(201)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function verifyAndCreateConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
request: VerifyAndCreateConnectionRequest,
|
||||
): Promise<ConnectionResponse> {
|
||||
return await createBuilder<ConnectionResponse>(harness, token)
|
||||
.post('/users/@me/connections/verify')
|
||||
.body(request)
|
||||
.expect(201)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function listConnections(harness: ApiTestHarness, token: string): Promise<ConnectionListResponse> {
|
||||
return await createBuilder<ConnectionListResponse>(harness, token)
|
||||
.get('/users/@me/connections')
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function verifyConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
type: string,
|
||||
connectionId: string,
|
||||
): Promise<ConnectionResponse> {
|
||||
return await createBuilder<ConnectionResponse>(harness, token)
|
||||
.post(`/users/@me/connections/${type}/${connectionId}/verify`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
type: string,
|
||||
connectionId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/users/@me/connections/${type}/${connectionId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function updateConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
type: string,
|
||||
connectionId: string,
|
||||
body: {visibility_flags?: number; sort_order?: number},
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.patch(`/users/@me/connections/${type}/${connectionId}`)
|
||||
.body(body)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function reorderConnections(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
connectionIds: Array<string>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.patch('/users/@me/connections/reorder')
|
||||
.body({connection_ids: connectionIds})
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function createVerifiedConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
request: CreateConnectionRequest,
|
||||
initiationToken: string,
|
||||
): Promise<ConnectionResponse> {
|
||||
return verifyAndCreateConnection(harness, token, {
|
||||
initiation_token: initiationToken,
|
||||
visibility_flags: request.visibility_flags,
|
||||
});
|
||||
}
|
||||
|
||||
export async function authorizeBlueskyConnection(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
handle: string,
|
||||
): Promise<BlueskyAuthorizeResponse> {
|
||||
return await createBuilder<BlueskyAuthorizeResponse>(harness, token)
|
||||
.post('/users/@me/connections/bluesky/authorize')
|
||||
.body({handle})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function createBlueskyConnectionViaOAuth(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
handle: string,
|
||||
did: string,
|
||||
userId: UserID,
|
||||
options?: {visibility_flags?: number},
|
||||
): Promise<ConnectionResponse> {
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {userId, did, handle},
|
||||
});
|
||||
|
||||
await authorizeBlueskyConnection(harness, token, handle);
|
||||
|
||||
await harness.requestJson({
|
||||
path: `/connections/bluesky/callback?code=mock_code&state=mock_state&iss=mock_iss`,
|
||||
method: 'GET',
|
||||
headers: {Authorization: token},
|
||||
});
|
||||
|
||||
const connections = await listConnections(harness, token);
|
||||
const connection = connections.find((c) => c.name === handle);
|
||||
if (!connection) {
|
||||
throw new Error(`Bluesky connection for handle '${handle}' was not created`);
|
||||
}
|
||||
|
||||
if (options?.visibility_flags !== undefined) {
|
||||
await updateConnection(harness, token, connection.type, connection.id, {
|
||||
visibility_flags: options.visibility_flags,
|
||||
});
|
||||
const updatedConnections = await listConnections(harness, token);
|
||||
return updatedConnections.find((c) => c.id === connection.id)!;
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function createBlueskyHandle(username: string): string {
|
||||
return `${username}.bsky.social`;
|
||||
}
|
||||
|
||||
export function createBlueskyDid(username: string): string {
|
||||
return `did:plc:${username}123`;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
createBlueskyConnectionViaOAuth,
|
||||
initiateConnection,
|
||||
listConnections,
|
||||
verifyAndCreateConnection,
|
||||
verifyConnection,
|
||||
} from '@fluxer/api/src/connection/tests/ConnectionTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {server} from '@fluxer/api/src/test/msw/server';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ConnectionTypes} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function createDomainConnectionViaFlow(
|
||||
harness: ApiTestHarness,
|
||||
accountToken: string,
|
||||
domain: string,
|
||||
): Promise<{connectionId: string; verificationToken: string}> {
|
||||
const verification = await initiateConnection(harness, accountToken, {
|
||||
type: ConnectionTypes.DOMAIN,
|
||||
identifier: domain,
|
||||
});
|
||||
|
||||
const verificationToken = verification.token;
|
||||
|
||||
server.use(
|
||||
http.get(`https://${domain}/.well-known/fluxer-verification`, () => {
|
||||
return HttpResponse.text(verificationToken);
|
||||
}),
|
||||
);
|
||||
|
||||
const connection = await verifyAndCreateConnection(harness, accountToken, {
|
||||
initiation_token: verification.initiation_token,
|
||||
});
|
||||
|
||||
return {connectionId: connection.id, verificationToken};
|
||||
}
|
||||
|
||||
const testHandle = 'testuser.bsky.social';
|
||||
const testDid = 'did:plc:testuser123';
|
||||
|
||||
describe('Connection verification', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Bluesky verification', () => {
|
||||
it('verifies successfully when OAuth session is valid', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, testHandle, testDid, userId);
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
restoreAndVerifyResult: {handle: testHandle},
|
||||
});
|
||||
|
||||
const result = await verifyConnection(harness, account.token, ConnectionTypes.BLUESKY, connection.id);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.type).toBe(ConnectionTypes.BLUESKY);
|
||||
expect(result.name).toBe(testHandle);
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections[0].verified).toBe(true);
|
||||
});
|
||||
|
||||
it('fails verification when OAuth session is expired', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, testHandle, testDid, userId);
|
||||
|
||||
const connectionsBefore = await listConnections(harness, account.token);
|
||||
expect(connectionsBefore[0].verified).toBe(true);
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
restoreAndVerifyResult: null,
|
||||
});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.BLUESKY}/${connection.id}/verify`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONNECTION_VERIFICATION_FAILED)
|
||||
.execute();
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections[0].verified).toBe(false);
|
||||
});
|
||||
|
||||
it('fails verification when OAuth session cannot be restored', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, testHandle, testDid, userId);
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
restoreAndVerifyResult: null,
|
||||
});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.BLUESKY}/${connection.id}/verify`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONNECTION_VERIFICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('fails verification when OAuth restore throws', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, testHandle, testDid, userId);
|
||||
|
||||
harness.mockBlueskyOAuthService.restoreAndVerifySpy.mockRejectedValue(new Error('OAuth restore failure'));
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.BLUESKY}/${connection.id}/verify`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONNECTION_VERIFICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Domain verification', () => {
|
||||
it('verifies successfully via well-known endpoint', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
const {connectionId, verificationToken} = await createDomainConnectionViaFlow(harness, account.token, domain);
|
||||
|
||||
server.use(
|
||||
http.get(`https://${domain}/.well-known/fluxer-verification`, () => {
|
||||
return HttpResponse.text(verificationToken);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifyConnection(harness, account.token, ConnectionTypes.DOMAIN, connectionId);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.type).toBe(ConnectionTypes.DOMAIN);
|
||||
expect(result.name).toBe(domain);
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections[0].verified).toBe(true);
|
||||
});
|
||||
|
||||
it('fails verification when well-known endpoint returns wrong token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
const {connectionId} = await createDomainConnectionViaFlow(harness, account.token, domain);
|
||||
|
||||
const connectionsBefore = await listConnections(harness, account.token);
|
||||
expect(connectionsBefore[0].verified).toBe(true);
|
||||
|
||||
server.use(
|
||||
http.get(`https://${domain}/.well-known/fluxer-verification`, () => {
|
||||
return HttpResponse.text('wrong-token');
|
||||
}),
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.DOMAIN}/${connectionId}/verify`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONNECTION_VERIFICATION_FAILED)
|
||||
.execute();
|
||||
|
||||
const updatedConnections = await listConnections(harness, account.token);
|
||||
expect(updatedConnections[0].verified).toBe(false);
|
||||
});
|
||||
|
||||
it('fails verification when well-known endpoint is not found', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
const {connectionId} = await createDomainConnectionViaFlow(harness, account.token, domain);
|
||||
|
||||
server.use(
|
||||
http.get(`https://${domain}/.well-known/fluxer-verification`, () => {
|
||||
return HttpResponse.text('Not found', {status: 404});
|
||||
}),
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.DOMAIN}/${connectionId}/verify`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONNECTION_VERIFICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('fails verification when well-known endpoint returns 500', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const domain = 'example.com';
|
||||
|
||||
const {connectionId} = await createDomainConnectionViaFlow(harness, account.token, domain);
|
||||
|
||||
server.use(
|
||||
http.get(`https://${domain}/.well-known/fluxer-verification`, () => {
|
||||
return HttpResponse.text('Internal server error', {status: 500});
|
||||
}),
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.DOMAIN}/${connectionId}/verify`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONNECTION_VERIFICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verification errors', () => {
|
||||
it('returns CONNECTION_NOT_FOUND for non-existent connection', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/users/@me/connections/${ConnectionTypes.BLUESKY}/nonexistent/verify`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.CONNECTION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows re-verification of already verified connection', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
const connection = await createBlueskyConnectionViaOAuth(harness, account.token, testHandle, testDid, userId);
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
restoreAndVerifyResult: {handle: testHandle},
|
||||
});
|
||||
|
||||
await verifyConnection(harness, account.token, ConnectionTypes.BLUESKY, connection.id);
|
||||
|
||||
const result = await verifyConnection(harness, account.token, ConnectionTypes.BLUESKY, connection.id);
|
||||
expect(result.verified).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IBlueskyOAuthService} from '@fluxer/api/src/bluesky/IBlueskyOAuthService';
|
||||
import type {
|
||||
ConnectionVerificationParams,
|
||||
IConnectionVerifier,
|
||||
} from '@fluxer/api/src/connection/verification/IConnectionVerifier';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
|
||||
export class BlueskyOAuthVerifier implements IConnectionVerifier {
|
||||
constructor(private readonly oauthService: IBlueskyOAuthService) {}
|
||||
|
||||
async verify(params: ConnectionVerificationParams): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.oauthService.restoreAndVerify(params.identifier);
|
||||
return result !== null;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
identifier: params.identifier,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'Failed to verify Bluesky connection',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {resolve} from 'node:dns/promises';
|
||||
import type {
|
||||
ConnectionVerificationParams,
|
||||
IConnectionVerifier,
|
||||
} from '@fluxer/api/src/connection/verification/IConnectionVerifier';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
|
||||
|
||||
const VERIFICATION_TIMEOUT_MS = 5000;
|
||||
|
||||
export class DomainConnectionVerifier implements IConnectionVerifier {
|
||||
async verify(params: ConnectionVerificationParams): Promise<boolean> {
|
||||
const domain = params.identifier;
|
||||
const token = params.verification_token;
|
||||
|
||||
const dnsResult = await this.checkDnsTxt(domain, token);
|
||||
if (dnsResult) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.checkWellKnown(domain, token);
|
||||
}
|
||||
|
||||
private async checkDnsTxt(domain: string, token: string): Promise<boolean> {
|
||||
try {
|
||||
const records = await resolve(`_fluxer.${domain}`, 'TXT');
|
||||
for (const record of records) {
|
||||
const value = record.join('');
|
||||
if (value === `fluxer-verification=${token}`) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.debug({domain, error}, 'DNS TXT verification lookup failed');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkWellKnown(domain: string, token: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await FetchUtils.sendRequest({
|
||||
url: `https://${domain}/.well-known/fluxer-verification`,
|
||||
method: 'GET',
|
||||
timeout: VERIFICATION_TIMEOUT_MS,
|
||||
serviceName: 'connection_verification',
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await FetchUtils.streamToString(response.stream);
|
||||
return body.trim() === token;
|
||||
} catch (error) {
|
||||
Logger.debug({domain, error}, 'Well-known verification lookup failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface ConnectionVerificationParams {
|
||||
identifier: string;
|
||||
verification_token: string;
|
||||
}
|
||||
|
||||
export interface IConnectionVerifier {
|
||||
verify(params: ConnectionVerificationParams): Promise<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user