Initial Commit

This commit is contained in:
2026-01-29 07:13:33 +00:00
commit 399e93f714
34 changed files with 7663 additions and 0 deletions

View 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
View 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)}`;
}
}