/* * 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 . */ 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> { return this.repository.findByUserId(userId); } async initiateConnection( userId: UserID, type: ConnectionType, identifier: string, ): Promise { 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 { 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 { 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 { 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 { 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): Promise { 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 { 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(); } }