Initial Commit
This commit is contained in:
435
src/services/invitation-flow.ts
Normal file
435
src/services/invitation-flow.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Invitation Flow Manager - Manages the collaborative invitation lifecycle.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Coordinates between local Engine and remote sync-server
|
||||
* - Subscribes to SSE for real-time updates
|
||||
* - Tracks invitation state machine
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { SSESession } from '../utils/sse-client.js';
|
||||
import type { SyncClient } from './sync-client.js';
|
||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
|
||||
/**
|
||||
* States an invitation can be in.
|
||||
*/
|
||||
export type InvitationState =
|
||||
| 'created' // Just created locally
|
||||
| 'published' // Published to sync server
|
||||
| 'pending' // Waiting for other party
|
||||
| 'ready' // All requirements met, ready to sign
|
||||
| 'signed' // Signed and ready to broadcast
|
||||
| 'broadcast' // Transaction broadcast
|
||||
| 'completed' // Transaction confirmed
|
||||
| 'expired' // Invitation expired
|
||||
| 'error'; // Error state
|
||||
|
||||
/**
|
||||
* Tracked invitation with state information.
|
||||
*/
|
||||
export interface TrackedInvitation {
|
||||
/** The invitation data */
|
||||
invitation: XOInvitation;
|
||||
/** Current state */
|
||||
state: InvitationState;
|
||||
/** SSE session for updates (if subscribed) */
|
||||
sseSession?: SSESession;
|
||||
/** Timestamp when tracking started */
|
||||
trackedAt: number;
|
||||
/** Last update timestamp */
|
||||
lastUpdatedAt: number;
|
||||
/** Error message if in error state */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the invitation flow manager.
|
||||
*/
|
||||
export interface InvitationFlowEvents {
|
||||
'invitation-created': (invitation: XOInvitation) => void;
|
||||
'invitation-updated': (invitationId: string, invitation: XOInvitation) => void;
|
||||
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
|
||||
'error': (invitationId: string, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the invitation workflow.
|
||||
*/
|
||||
export class InvitationFlowManager extends EventEmitter {
|
||||
/** Map of tracked invitations by ID */
|
||||
private trackedInvitations: Map<string, TrackedInvitation> = new Map();
|
||||
|
||||
/** Wallet controller reference */
|
||||
private walletController: WalletController;
|
||||
|
||||
/** Sync client reference */
|
||||
private syncClient: SyncClient;
|
||||
|
||||
/**
|
||||
* Creates a new invitation flow manager.
|
||||
* @param walletController - Wallet controller instance
|
||||
* @param syncClient - Sync client instance
|
||||
*/
|
||||
constructor(walletController: WalletController, syncClient: SyncClient) {
|
||||
super();
|
||||
this.walletController = walletController;
|
||||
this.syncClient = syncClient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invitation Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Starts tracking an invitation.
|
||||
* @param invitation - Invitation to track
|
||||
* @param initialState - Initial state (default: 'created')
|
||||
*/
|
||||
track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation {
|
||||
const tracked: TrackedInvitation = {
|
||||
invitation,
|
||||
state: initialState,
|
||||
trackedAt: Date.now(),
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.trackedInvitations.set(invitation.invitationIdentifier, tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a tracked invitation by ID.
|
||||
* @param invitationId - Invitation ID
|
||||
* @returns Tracked invitation or undefined
|
||||
*/
|
||||
get(invitationId: string): TrackedInvitation | undefined {
|
||||
return this.trackedInvitations.get(invitationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tracked invitations.
|
||||
* @returns Array of tracked invitations
|
||||
*/
|
||||
getAll(): TrackedInvitation[] {
|
||||
return Array.from(this.trackedInvitations.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of a tracked invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @param state - New state
|
||||
*/
|
||||
private updateState(invitationId: string, state: InvitationState): void {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (tracked) {
|
||||
tracked.state = state;
|
||||
tracked.lastUpdatedAt = Date.now();
|
||||
this.emit('invitation-state-changed', invitationId, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a tracked invitation with new data.
|
||||
* @param invitation - Updated invitation
|
||||
*/
|
||||
private updateInvitation(invitation: XOInvitation): void {
|
||||
const tracked = this.trackedInvitations.get(invitation.invitationIdentifier);
|
||||
if (tracked) {
|
||||
tracked.invitation = invitation;
|
||||
tracked.lastUpdatedAt = Date.now();
|
||||
this.emit('invitation-updated', invitation.invitationIdentifier, invitation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops tracking an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
*/
|
||||
untrack(invitationId: string): void {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (tracked?.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
}
|
||||
this.trackedInvitations.delete(invitationId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Flow Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a new invitation and starts tracking it.
|
||||
* @param templateIdentifier - Template ID
|
||||
* @param actionIdentifier - Action ID
|
||||
* @returns Created and tracked invitation
|
||||
*/
|
||||
async createInvitation(
|
||||
templateIdentifier: string,
|
||||
actionIdentifier: string,
|
||||
): Promise<TrackedInvitation> {
|
||||
// Create invitation via wallet controller
|
||||
const invitation = await this.walletController.createInvitation(
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
);
|
||||
|
||||
// Track the invitation
|
||||
const tracked = this.track(invitation, 'created');
|
||||
this.emit('invitation-created', invitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an invitation to the sync server.
|
||||
* @param invitationId - Invitation ID to publish
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async publishInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Post to sync server
|
||||
await this.syncClient.postInvitation(tracked.invitation);
|
||||
|
||||
// Update state
|
||||
this.updateState(invitationId, 'published');
|
||||
|
||||
return tracked;
|
||||
} catch (error) {
|
||||
tracked.state = 'error';
|
||||
tracked.error = error instanceof Error ? error.message : String(error);
|
||||
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to SSE updates for an invitation.
|
||||
* @param invitationId - Invitation ID to subscribe to
|
||||
*/
|
||||
async subscribeToUpdates(invitationId: string): Promise<void> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Close existing SSE session if any
|
||||
if (tracked.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
}
|
||||
|
||||
// Create new SSE session
|
||||
const sseUrl = this.syncClient.getSSEUrl(invitationId);
|
||||
|
||||
tracked.sseSession = await SSESession.from(sseUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
onMessage: (event) => {
|
||||
this.handleSSEMessage(invitationId, event);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(`SSE error for ${invitationId}:`, error);
|
||||
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
onConnected: () => {
|
||||
console.log(`SSE connected for invitation: ${invitationId}`);
|
||||
},
|
||||
onDisconnected: () => {
|
||||
console.log(`SSE disconnected for invitation: ${invitationId}`);
|
||||
},
|
||||
attemptReconnect: true,
|
||||
persistent: true,
|
||||
retryDelay: 3000,
|
||||
});
|
||||
|
||||
// Update state to pending (waiting for updates)
|
||||
this.updateState(invitationId, 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an SSE message for an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @param event - SSE event
|
||||
*/
|
||||
private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void {
|
||||
try {
|
||||
// Parse the event data
|
||||
const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||
|
||||
if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') {
|
||||
// Decode the invitation data (handles ExtJSON)
|
||||
const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed);
|
||||
const invitation = invitationData as XOInvitation;
|
||||
|
||||
// Update tracked invitation
|
||||
this.updateInvitation(invitation);
|
||||
|
||||
// Check if all requirements are met
|
||||
this.checkInvitationState(invitationId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing SSE message for ${invitationId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and updates the state of an invitation based on its data.
|
||||
* @param invitationId - Invitation ID to check
|
||||
*/
|
||||
private async checkInvitationState(invitationId: string): Promise<void> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) return;
|
||||
|
||||
try {
|
||||
// Check missing requirements
|
||||
const missing = await this.walletController.getMissingRequirements(tracked.invitation);
|
||||
|
||||
// If no missing inputs/outputs, it's ready to sign
|
||||
const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0;
|
||||
const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0;
|
||||
|
||||
if (hasNoMissingInputs && hasNoMissingOutputs) {
|
||||
this.updateState(invitationId, 'ready');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors during state check
|
||||
console.error(`Error checking invitation state: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports an invitation from the sync server.
|
||||
* @param invitationId - Invitation ID to import
|
||||
* @returns Tracked invitation
|
||||
*/
|
||||
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
// Fetch from sync server
|
||||
const invitation = await this.syncClient.getInvitation(invitationId);
|
||||
if (!invitation) {
|
||||
throw new Error(`Invitation not found on server: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Track the invitation
|
||||
const tracked = this.track(invitation, 'pending');
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an invitation (joins as a participant).
|
||||
* @param invitationId - Invitation ID to accept
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Accept via wallet controller
|
||||
const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation);
|
||||
this.updateInvitation(updatedInvitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends data to an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @param params - Data to append
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async appendToInvitation(
|
||||
invitationId: string,
|
||||
params: Parameters<WalletController['appendInvitation']>[1],
|
||||
): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Append via wallet controller
|
||||
const updatedInvitation = await this.walletController.appendInvitation(
|
||||
tracked.invitation,
|
||||
params,
|
||||
);
|
||||
this.updateInvitation(updatedInvitation);
|
||||
|
||||
// Publish update to sync server
|
||||
await this.syncClient.updateInvitation(updatedInvitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Sign via wallet controller
|
||||
const signedInvitation = await this.walletController.signInvitation(tracked.invitation);
|
||||
this.updateInvitation(signedInvitation);
|
||||
this.updateState(invitationId, 'signed');
|
||||
|
||||
// Publish signed invitation to sync server
|
||||
await this.syncClient.updateInvitation(signedInvitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts the transaction for an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @returns Transaction hash
|
||||
*/
|
||||
async broadcastTransaction(invitationId: string): Promise<string> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Execute action (broadcast)
|
||||
const txHash = await this.walletController.executeAction(tracked.invitation, {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
|
||||
this.updateState(invitationId, 'broadcast');
|
||||
|
||||
// Close SSE session since we're done
|
||||
if (tracked.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
delete tracked.sseSession;
|
||||
}
|
||||
|
||||
return txHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all resources.
|
||||
*/
|
||||
destroy(): void {
|
||||
// Close all SSE sessions
|
||||
for (const tracked of this.trackedInvitations.values()) {
|
||||
if (tracked.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
}
|
||||
}
|
||||
this.trackedInvitations.clear();
|
||||
}
|
||||
}
|
||||
162
src/services/sync-client.ts
Normal file
162
src/services/sync-client.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Sync Server Client - HTTP client for sync-server communication.
|
||||
*
|
||||
* Handles:
|
||||
* - Creating/updating invitations on the server
|
||||
* - Fetching invitations by ID
|
||||
* - ExtJSON encoding/decoding for data transfer
|
||||
*/
|
||||
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js';
|
||||
|
||||
/**
|
||||
* Response from the sync server.
|
||||
*/
|
||||
export interface SyncServerResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client for sync-server communication.
|
||||
*/
|
||||
export class SyncClient {
|
||||
/** Base URL of the sync server */
|
||||
private baseUrl: string;
|
||||
|
||||
/**
|
||||
* Creates a new sync client.
|
||||
* @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000)
|
||||
*/
|
||||
constructor(baseUrl: string) {
|
||||
// Remove trailing slash if present
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an HTTP request to the sync server.
|
||||
* @param method - HTTP method
|
||||
* @param path - Request path
|
||||
* @param body - Optional request body
|
||||
* @returns Response data
|
||||
*/
|
||||
private async request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
// Encode body using ExtJSON for proper BigInt and Uint8Array serialization
|
||||
options.body = encodeExtendedJson(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
// Return empty object if no response body
|
||||
if (!responseText) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
// Decode response using ExtJSON
|
||||
return decodeExtendedJson(responseText) as T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invitation Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Posts an invitation to the sync server (create or update).
|
||||
* @param invitation - Invitation to post
|
||||
* @returns The stored invitation
|
||||
*/
|
||||
async postInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
return this.request<XOInvitation>('POST', '/invitations', invitation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an invitation from the sync server.
|
||||
* @param invitationIdentifier - Invitation ID to fetch
|
||||
* @returns The invitation or undefined if not found
|
||||
*/
|
||||
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||
try {
|
||||
// Use query parameter for GET request (can't have body)
|
||||
const response = await this.request<XOInvitation>(
|
||||
'GET',
|
||||
`/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return undefined if not found (404)
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an invitation on the sync server.
|
||||
* @param invitation - Updated invitation
|
||||
* @returns The updated invitation
|
||||
*/
|
||||
async updateInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
// Uses the same POST endpoint which handles both create and update
|
||||
return this.postInvitation(invitation);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Health Check
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Checks if the sync server is healthy.
|
||||
* @returns True if server is healthy
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.request<{ status: string }>('GET', '/health');
|
||||
return response.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base URL of the sync server.
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the SSE endpoint URL for an invitation.
|
||||
* @param invitationId - Invitation ID to subscribe to
|
||||
* @returns SSE endpoint URL
|
||||
*/
|
||||
getSSEUrl(invitationIdentifier: string): string {
|
||||
return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user