diff --git a/.mycelium/.gitignore b/.mycelium/.gitignore index f7d55a5c..c1baa23f 100644 --- a/.mycelium/.gitignore +++ b/.mycelium/.gitignore @@ -4,3 +4,4 @@ *.db-shm # Temporary files *.tmp +.linear/ diff --git a/.mycelium/mycelium.db b/.mycelium/mycelium.db index 30586269..0d61d467 100644 Binary files a/.mycelium/mycelium.db and b/.mycelium/mycelium.db differ diff --git a/.serena/project.yml b/.serena/project.yml index 0eb31e34..968c5af4 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -132,3 +132,17 @@ line_ending: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md index ea431746..ed270ff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,6 @@ history/ For more details, see README.md and QUICKSTART.md. - ## Project Management with Mycelium This project uses [Mycelium](https://github.com/tcsenpai/mycelium) (`myc`) for task and epic management. @@ -211,3 +210,55 @@ When working on this project: 3. Create tasks for new work: `myc task create --title "..." --description "..." --epic N` 4. Mark tasks complete when done: `myc task close N` 5. Use `--format json` for machine-readable output: `myc task list --format json` + +## Mental Frameworks for Mycelium Usage + +### 1. INVEST — Task Quality Gate + +Before creating or updating any task, validate it against these criteria. +A task that fails more than one is not ready to be written. + +| Criterion | Rule | +|---|---| +| **Independent** | Can be completed without unblocking other tasks first | +| **Negotiable** | The *what* is fixed; the *how* remains open | +| **Valuable** | Produces a verifiable, concrete outcome | +| **Estimable** | If you cannot size it, it is too vague or too large | +| **Small** | If it spans more than one work cycle, split it | +| **Testable** | Has an explicit, binary done condition | + +> If a task fails **Estimable** or **Testable**, convert it to an Epic and decompose. + +--- + +### 2. DAG — Dependency Graph Thinking + +Before scheduling or prioritizing, model the implicit dependency graph. + +**Rules:** +- No task moves to `in_progress` if it has an unresolved upstream blocker +- Priority is a function of both urgency **and fan-out** (how many tasks does completing this one unlock?) +- Always work the **critical path** first — not the task that feels most urgent + +**Prioritization heuristic:** +``` +score = urgency + (blocked_tasks_count × 1.5) +``` + +When creating a task, explicitly ask: *"What does this block, and what blocks this?"* +Set dependency links in Mycelium before touching status. + +--- + +### 3. Principle of Minimal Surprise (PMS) + +Mycelium's state must remain predictable and auditable at all times. + +**Rules:** +- **Prefer idempotent operations** — update before you create; never duplicate +- **Check before write** — search for an equivalent item before creating a new one +- **Always annotate mutations** — every status change, priority shift, or reassignment must carry an explicit `reason` field +- **No orphan tasks** — every task must be linked to an Epic; every Epic to a strategic goal +- Deletions are a last resort; prefer `cancelled` status with a reason + +> The state of Mycelium after any operation must be explainable to another agent with zero context. diff --git a/src/instant_messaging/L2PSMessagingPeer.ts b/src/instant_messaging/L2PSMessagingPeer.ts new file mode 100644 index 00000000..093df573 --- /dev/null +++ b/src/instant_messaging/L2PSMessagingPeer.ts @@ -0,0 +1,594 @@ +/** + * L2PSMessagingPeer - WebSocket client for L2PS-backed instant messaging + * + * Connects to the L2PS messaging server (default port 3006) and provides: + * - Registration with ed25519 proof and L2PS network isolation + * - E2E encrypted message sending/receiving + * - Conversation history with pagination + * - Peer discovery within L2PS network + * - Offline message delivery + * - Automatic reconnection with exponential backoff + */ + +import type { + SerializedEncryptedMessage, + ClientMessageType, + ServerMessageType, + RegisteredResponse, + IncomingMessage, + MessageSentResponse, + MessageQueuedResponse, + HistoryResponse, + DiscoverResponse, + PublicKeyResponse, + PeerJoinedNotification, + PeerLeftNotification, + ErrorResponse, + ErrorCode, + StoredMessage, +} from "./l2ps_types" + +// ─── Config & Handler Types ────────────────────────────────────── + +export interface L2PSMessagingConfig { + /** WebSocket URL of the L2PS messaging server (e.g. "ws://localhost:3006") */ + serverUrl: string + /** Client's ed25519 public key (hex string, 64+ chars) */ + publicKey: string + /** L2PS network UID to join */ + l2psUid: string + /** Function to sign proof strings with ed25519 private key. Returns hex signature. */ + signFn: (message: string) => Promise | string +} + +export type L2PSMessageHandler = (message: IncomingMessage["payload"]) => void +export type L2PSErrorHandler = (error: ErrorResponse["payload"]) => void +export type L2PSPeerHandler = (publicKey: string) => void +export type L2PSConnectionStateHandler = (state: "connected" | "disconnected" | "reconnecting") => void + +// ─── Internal protocol frame ──────────────────────────────────── + +interface OutgoingFrame { + type: ClientMessageType + payload: Record + timestamp: number + requestId?: string +} + +/** Discriminated union of all server→client payloads */ +type ServerPayload = + | RegisteredResponse["payload"] + | IncomingMessage["payload"] + | MessageSentResponse["payload"] + | MessageQueuedResponse["payload"] + | HistoryResponse["payload"] + | DiscoverResponse["payload"] + | PublicKeyResponse["payload"] + | PeerJoinedNotification["payload"] + | PeerLeftNotification["payload"] + | ErrorResponse["payload"] + +interface IncomingFrame { + type: ServerMessageType + payload: ServerPayload + timestamp: number + requestId?: string +} + +// ─── Client Class ──────────────────────────────────────────────── + +export class L2PSMessagingPeer { + private ws: WebSocket | null = null + private config: L2PSMessagingConfig + + // Event handlers + private messageHandlers: Set = new Set() + private errorHandlers: Set = new Set() + private peerJoinedHandlers: Set = new Set() + private peerLeftHandlers: Set = new Set() + private connectionStateHandlers: Set = new Set() + + // Pending request-response waiters + // REVIEW: resolve accepts ServerPayload (widened from generic T) because the map is homogeneous + private pendingResponses: Map< + string, + { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; _meta?: PendingMeta } + > = new Map() + + // State + private _isConnected = false + private _isRegistered = false + private onlinePeers: Set = new Set() + private messageQueue: OutgoingFrame[] = [] + + // Reconnection + private reconnectAttempts = 0 + private maxReconnectAttempts = 10 + private baseReconnectDelay = 1000 + private reconnectTimeout: NodeJS.Timeout | null = null + private shouldReconnect = true + private isReconnecting = false + + constructor(config: L2PSMessagingConfig) { + this.config = config + } + + // ─── Public Getters ────────────────────────────────────────── + + get isConnected(): boolean { + return this._isConnected + } + + get isRegistered(): boolean { + return this._isRegistered + } + + get peers(): string[] { + return Array.from(this.onlinePeers) + } + + // ─── Connection Lifecycle ──────────────────────────────────── + + /** + * Connect to L2PS messaging server and register + * @returns Registration response with online peers + */ + async connect(): Promise { + return new Promise((resolve, reject) => { + let checkOpen: NodeJS.Timeout | null = null + + const timeout = setTimeout(() => { + if (checkOpen) { + clearInterval(checkOpen) + } + this.shouldReconnect = false + if (this.ws) { + this.ws.close() + } + reject(new Error("Connection timeout (10s)")) + }, 10000) + + this.shouldReconnect = true + this.connectWebSocket() + + // Wait for WS open, then register + checkOpen = setInterval(() => { + if (this._isConnected) { + clearInterval(checkOpen!) + this.register() + .then(response => { + clearTimeout(timeout) + resolve(response) + }) + .catch(err => { + clearTimeout(timeout) + reject(err) + }) + } + }, 50) + }) + } + + /** Disconnect from the server */ + disconnect(): void { + this.shouldReconnect = false + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + // Reject all pending responses + for (const [key, pending] of this.pendingResponses) { + clearTimeout(pending.timer) + pending.reject(new Error("Disconnected")) + } + this.pendingResponses.clear() + + if (this.ws) { + this.ws.close() + this.ws = null + } + this._isConnected = false + this._isRegistered = false + this.onlinePeers.clear() + this.notifyConnectionState("disconnected") + } + + // ─── Messaging API ─────────────────────────────────────────── + + /** + * Send an encrypted message to a peer + * @param to - Recipient's public key (hex) + * @param encrypted - E2E encrypted message + * @param messageHash - SHA256 hash for dedup + * @returns Delivery confirmation (sent or queued) + */ + async send( + to: string, + encrypted: SerializedEncryptedMessage, + messageHash: string, + ): Promise { + this.ensureRegistered() + + const requestId = this.generateRequestId() + this.sendFrame({ + type: "send", + payload: { to, encrypted, messageHash }, + timestamp: Date.now(), + requestId, + }) + + // Wait for either message_sent or message_queued + return this.waitForResponse( + requestId, + ["message_sent", "message_queued"], + 15000, + ) + } + + /** + * Get conversation history with a peer + * @param peerKey - Peer's public key + * @param options - Pagination options + * @returns Message history with pagination info + */ + async history( + peerKey: string, + options: { before?: number; limit?: number } = {}, + ): Promise { + this.ensureRegistered() + + const timestamp = Date.now() + const proofString = `history:${peerKey}:${timestamp}` + const proof = await this.config.signFn(proofString) + + const requestId = this.generateRequestId() + this.sendFrame({ + type: "history", + payload: { + peerKey, + before: options.before, + limit: options.limit, + proof, + }, + timestamp, + requestId, + }) + + return this.waitForResponse( + requestId, + ["history_response"], + 10000, + ) + } + + /** + * Discover online peers in the L2PS network + * @returns List of online peer public keys + */ + async discover(): Promise { + this.ensureRegistered() + + const requestId = this.generateRequestId() + this.sendFrame({ + type: "discover", + payload: {}, + timestamp: Date.now(), + requestId, + }) + + const response = await this.waitForResponse( + requestId, + ["discover_response"], + 10000, + ) + + this.onlinePeers = new Set(response.peers) + return response.peers + } + + /** + * Request a peer's public key + * @param targetId - Target peer identifier + * @returns Public key hex string or null if not found + */ + async requestPublicKey(targetId: string): Promise { + this.ensureRegistered() + + const requestId = this.generateRequestId() + this.sendFrame({ + type: "request_public_key", + payload: { targetId }, + timestamp: Date.now(), + requestId, + }) + + const response = await this.waitForResponse( + requestId, + ["public_key_response"], + 10000, + ) + + return response.publicKey + } + + // ─── Event Handlers ────────────────────────────────────────── + + onMessage(handler: L2PSMessageHandler): void { + this.messageHandlers.add(handler) + } + + onError(handler: L2PSErrorHandler): void { + this.errorHandlers.add(handler) + } + + onPeerJoined(handler: L2PSPeerHandler): void { + this.peerJoinedHandlers.add(handler) + } + + onPeerLeft(handler: L2PSPeerHandler): void { + this.peerLeftHandlers.add(handler) + } + + onConnectionStateChange(handler: L2PSConnectionStateHandler): void { + this.connectionStateHandlers.add(handler) + } + + removeMessageHandler(handler: L2PSMessageHandler): void { + this.messageHandlers.delete(handler) + } + + removeErrorHandler(handler: L2PSErrorHandler): void { + this.errorHandlers.delete(handler) + } + + removePeerJoinedHandler(handler: L2PSPeerHandler): void { + this.peerJoinedHandlers.delete(handler) + } + + removePeerLeftHandler(handler: L2PSPeerHandler): void { + this.peerLeftHandlers.delete(handler) + } + + removeConnectionStateHandler(handler: L2PSConnectionStateHandler): void { + this.connectionStateHandlers.delete(handler) + } + + // ─── Private: WebSocket ────────────────────────────────────── + + private connectWebSocket(): void { + if (this.ws) { + this.ws.close() + } + + this.ws = new WebSocket(this.config.serverUrl) + this.notifyConnectionState("reconnecting") + + this.ws.onopen = async () => { + this._isConnected = true + this.reconnectAttempts = 0 + + // Attempt re-registration if this is a reconnection (not initial connection) + if (this.isReconnecting) { + try { + await this.register() + this.notifyConnectionState("connected") + this.flushQueue() + this.isReconnecting = false + } catch (err) { + // Re-registration failed — notify error handlers and retry + this.errorHandlers.forEach(h => + h({ code: "INTERNAL_ERROR" as ErrorCode, message: "Re-registration failed after reconnect", details: err instanceof Error ? err.message : "unknown" }), + ) + this._isConnected = false + this._isRegistered = false + this.notifyConnectionState("disconnected") + if (this.ws) { + this.ws.close() + } + if (this.shouldReconnect) { + this.attemptReconnect() + } + } + } else { + // Initial connection, registration will be handled by connect() + this.notifyConnectionState("connected") + this.flushQueue() + } + } + + this.ws.onclose = () => { + this._isConnected = false + this._isRegistered = false + this.onlinePeers.clear() + this.notifyConnectionState("disconnected") + if (this.shouldReconnect) { + this.attemptReconnect() + } + } + + this.ws.onerror = (event) => { + this.errorHandlers.forEach(h => + h({ code: "INTERNAL_ERROR" as ErrorCode, message: "WebSocket error", details: event instanceof Error ? event.message : "unknown" }), + ) + } + + this.ws.onmessage = (event) => { + try { + const frame: IncomingFrame = JSON.parse( + typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data), + ) + this.handleFrame(frame) + } catch { + // Ignore unparseable frames + } + } + } + + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + return + } + + this.isReconnecting = true + const delay = Math.min( + this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts), + 30000, + ) + + this.reconnectTimeout = setTimeout(() => { + this.reconnectAttempts++ + this.connectWebSocket() + }, delay) + } + + // ─── Private: Registration ─────────────────────────────────── + + private async register(): Promise { + const timestamp = Date.now() + const proofString = `register:${this.config.publicKey}:${timestamp}` + const proof = await this.config.signFn(proofString) + + const requestId = this.generateRequestId() + this.sendFrame({ + type: "register", + payload: { + publicKey: this.config.publicKey, + l2psUid: this.config.l2psUid, + proof, + }, + timestamp, + requestId, + }) + + const response = await this.waitForResponse( + requestId, + ["registered"], + 10000, + ) + + this._isRegistered = true + this.onlinePeers = new Set(response.onlinePeers) + return response + } + + // ─── Private: Frame Handling ───────────────────────────────── + + private handleFrame(frame: IncomingFrame): void { + // First, check if any pending response matches by requestId + if (frame.requestId && this.pendingResponses.has(frame.requestId)) { + const pending = this.pendingResponses.get(frame.requestId)! + const meta = pending._meta + if (meta && meta.types.includes(frame.type)) { + clearTimeout(pending.timer) + this.pendingResponses.delete(frame.requestId) + pending.resolve(frame.payload) + return + } + } + + // Handle error frames with requestId + if (frame.type === "error" && frame.requestId && this.pendingResponses.has(frame.requestId)) { + const pending = this.pendingResponses.get(frame.requestId)! + clearTimeout(pending.timer) + this.pendingResponses.delete(frame.requestId) + pending.reject(new Error((frame.payload as ErrorResponse["payload"])?.message || "Server error")) + return + } + + // Then dispatch to event handlers + switch (frame.type) { + case "message": { + const p = frame.payload as IncomingMessage["payload"] + this.messageHandlers.forEach(h => h(p)) + break + } + case "peer_joined": { + const p = frame.payload as PeerJoinedNotification["payload"] + this.onlinePeers.add(p.publicKey) + this.peerJoinedHandlers.forEach(h => h(p.publicKey)) + break + } + case "peer_left": { + const p = frame.payload as PeerLeftNotification["payload"] + this.onlinePeers.delete(p.publicKey) + this.peerLeftHandlers.forEach(h => h(p.publicKey)) + break + } + case "error": { + const p = frame.payload as ErrorResponse["payload"] + this.errorHandlers.forEach(h => h(p)) + break + } + default: + // message_sent, message_queued etc. without a waiter — ignore + break + } + } + + // ─── Private: Request-Response ─────────────────────────────── + + private waitForResponse( + requestId: string, + expectedTypes: ServerMessageType[], + timeoutMs: number, + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingResponses.delete(requestId) + reject(new Error(`Timeout waiting for ${expectedTypes.join("|")} (${timeoutMs}ms)`)) + }, timeoutMs) + + this.pendingResponses.set(requestId, { + resolve: resolve as (value: unknown) => void, + reject, + timer, + _meta: { types: expectedTypes }, + }) + }) + } + + // ─── Private: Sending ──────────────────────────────────────── + + private sendFrame(frame: OutgoingFrame): void { + if (!this.ws || !this._isConnected) { + this.messageQueue.push(frame) + return + } + + try { + this.ws.send(JSON.stringify(frame)) + } catch { + this.messageQueue.push(frame) + } + } + + private flushQueue(): void { + while (this.messageQueue.length > 0) { + const frame = this.messageQueue.shift() + if (frame) { + this.sendFrame(frame) + } + } + } + + // ─── Private: Guards ───────────────────────────────────────── + + private ensureRegistered(): void { + if (!this._isRegistered) { + throw new Error("Not registered. Call connect() first.") + } + } + + private notifyConnectionState(state: "connected" | "disconnected" | "reconnecting"): void { + this.connectionStateHandlers.forEach(h => h(state)) + } + + private generateRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` + } +} + +/** Internal metadata attached to pending response entries */ +interface PendingMeta { + types: ServerMessageType[] +} \ No newline at end of file diff --git a/src/instant_messaging/index.ts b/src/instant_messaging/index.ts index 4bba6f96..0a467288 100644 --- a/src/instant_messaging/index.ts +++ b/src/instant_messaging/index.ts @@ -1,3 +1,16 @@ +// Legacy signaling server client (port 3005) +// For L2PS messaging, use L2PSMessagingPeer instead + +export { L2PSMessagingPeer } from "./L2PSMessagingPeer" +export type { + L2PSMessagingConfig, + L2PSMessageHandler, + L2PSErrorHandler, + L2PSPeerHandler, + L2PSConnectionStateHandler, +} from "./L2PSMessagingPeer" +export * from "./l2ps_types" + // FIXME Integrate with l2ps.ts /** diff --git a/src/instant_messaging/l2ps_types.ts b/src/instant_messaging/l2ps_types.ts new file mode 100644 index 00000000..67c1959c --- /dev/null +++ b/src/instant_messaging/l2ps_types.ts @@ -0,0 +1,248 @@ +/** + * L2PS Messaging Protocol Types + * + * WebSocket protocol types for real-time messaging backed by L2PS rollup. + * Messages are delivered instantly via WebSocket and persisted through + * the L2PS batch → proof → L1 pipeline. + * + * These types must stay in sync with the node's L2PS messaging server + * at src/features/l2ps-messaging/types.ts + */ + +// ─── Message Envelope ──────────────────────────────────────────── + +/** The core message envelope that gets encrypted and sent through L2PS */ +export interface MessageEnvelope { + /** Unique message ID (UUID v4) */ + id: string + /** Sender's ed25519 public key (hex) */ + from: string + /** Recipient's ed25519 public key (hex) */ + to: string + /** Message type discriminator */ + type: MessageType + /** Message content (plaintext before E2E encryption) */ + content: string + /** Unix timestamp (ms) when message was created by sender */ + timestamp: number + /** Optional: reply to another message ID */ + replyTo?: string + /** Sender's ed25519 signature of the envelope (hex) */ + signature: string +} + +export type MessageType = + | "text" // Plain text message + | "media" // Media reference (URL/hash) + | "reaction" // Reaction to a message + | "system" // System notification + | "transfer" // Token transfer (future — requires L1 finality) + +// ─── WebSocket Protocol ────────────────────────────────────────── + +/** Client → Server message types */ +export type ClientMessageType = + | "register" + | "send" + | "history" + | "discover" + | "request_public_key" + | "ack" + +/** Server → Client message types */ +export type ServerMessageType = + | "registered" + | "message" + | "message_sent" + | "message_queued" + | "history_response" + | "discover_response" + | "public_key_response" + | "peer_joined" + | "peer_left" + | "error" + +/** Base protocol frame */ +export interface ProtocolFrame { + type: T + payload: Record + timestamp: number + /** Request correlation ID for request/response flows */ + requestId?: string +} + +// ─── Client → Server Messages ──────────────────────────────────── + +export interface RegisterMessage extends ProtocolFrame<"register"> { + payload: { + /** Client's ed25519 public key (hex) */ + publicKey: string + /** L2PS network UID to join */ + l2psUid: string + /** Proof: sign("register:{publicKey}:{timestamp}") */ + proof: string + } +} + +export interface SendMessage extends ProtocolFrame<"send"> { + payload: { + /** Recipient's public key (hex) */ + to: string + /** E2E encrypted message envelope (serialized) */ + encrypted: SerializedEncryptedMessage + /** Original message hash for dedup */ + messageHash: string + } +} + +export interface HistoryMessage extends ProtocolFrame<"history"> { + payload: { + /** Peer public key to get conversation with */ + peerKey: string + /** Pagination: messages before this timestamp */ + before?: number + /** Max messages to return */ + limit?: number + /** Proof: sign("history:{peerKey}:{timestamp}") */ + proof: string + } +} + +export interface DiscoverMessage extends ProtocolFrame<"discover"> { + payload: Record +} + +export interface RequestPublicKeyMessage extends ProtocolFrame<"request_public_key"> { + payload: { + /** Target peer's public key or alias */ + targetId: string + } +} + +// ─── Server → Client Messages ──────────────────────────────────── + +export interface RegisteredResponse extends ProtocolFrame<"registered"> { + payload: { + success: boolean + publicKey: string + l2psUid: string + onlinePeers: string[] + } +} + +export interface IncomingMessage extends ProtocolFrame<"message"> { + payload: { + /** Sender's public key */ + from: string + /** E2E encrypted envelope */ + encrypted: SerializedEncryptedMessage + /** Message hash */ + messageHash: string + /** Whether this was delivered from offline storage */ + offline?: boolean + } +} + +export interface MessageSentResponse extends ProtocolFrame<"message_sent"> { + payload: { + messageHash: string + /** L2PS mempool status */ + l2psStatus: "submitted" | "failed" + } +} + +export interface MessageQueuedResponse extends ProtocolFrame<"message_queued"> { + payload: { + messageHash: string + /** Recipient was offline, message queued */ + status: "queued" + } +} + +export interface HistoryResponse extends ProtocolFrame<"history_response"> { + payload: { + messages: StoredMessage[] + hasMore: boolean + } +} + +export interface DiscoverResponse extends ProtocolFrame<"discover_response"> { + payload: { + peers: string[] + } +} + +export interface PublicKeyResponse extends ProtocolFrame<"public_key_response"> { + payload: { + targetId: string + publicKey: string | null + } +} + +export interface PeerJoinedNotification extends ProtocolFrame<"peer_joined"> { + payload: { + publicKey: string + } +} + +export interface PeerLeftNotification extends ProtocolFrame<"peer_left"> { + payload: { + publicKey: string + } +} + +export interface ErrorResponse extends ProtocolFrame<"error"> { + payload: { + code: ErrorCode + message: string + details?: string + } +} + +// ─── Encryption Types ──────────────────────────────────────────── + +/** Serialized E2E encrypted message for wire transport */ +export interface SerializedEncryptedMessage { + /** Encrypted data (base64) */ + ciphertext: string + /** AES-GCM nonce/IV (base64) */ + nonce: string + /** Ephemeral public key for DH (hex) — if using X25519 */ + ephemeralKey?: string +} + +// ─── Storage Types ─────────────────────────────────────────────── + +/** Message as stored in the database / returned by history API */ +export interface StoredMessage { + id: string + from: string + to: string + messageHash: string + encrypted: SerializedEncryptedMessage + l2psUid: string + l2psTxHash: string | null + timestamp: number + status: MessageStatus +} + +export type MessageStatus = + | "delivered" // Sent to recipient via WS + | "queued" // Recipient offline, stored for later delivery + | "sent" // Delivered from offline queue + | "failed" // L2PS submission or persistence failed + | "l2ps_pending" // In L2PS mempool, not yet batched + | "l2ps_batched" // Included in L2PS batch + | "l2ps_confirmed" // Confirmed on L1 + +// ─── Error Codes ───────────────────────────────────────────────── + +export type ErrorCode = + | "INVALID_MESSAGE" + | "REGISTRATION_REQUIRED" + | "INVALID_PROOF" + | "PEER_NOT_FOUND" + | "L2PS_NOT_FOUND" + | "L2PS_SUBMIT_FAILED" + | "RATE_LIMITED" + | "INTERNAL_ERROR" \ No newline at end of file diff --git a/src/types/blockchain/Transaction.ts b/src/types/blockchain/Transaction.ts index b8652b9a..586831e5 100644 --- a/src/types/blockchain/Transaction.ts +++ b/src/types/blockchain/Transaction.ts @@ -8,7 +8,7 @@ import { GCREdit } from "./GCREdit" import { INativePayload } from "../native" // import { SubnetPayload } from "../../l2ps" // Obsolete - using new L2PS implementation import { IdentityPayload } from "../abstraction" -import { InstantMessagingPayload } from "../instantMessaging" +import { InstantMessagingPayload, L2PSInstantMessagingPayload } from "../instantMessaging" import { BridgeOperationCompiled, NativeBridgeTxPayload } from "@/bridge/nativeBridgeTypes" import { L2PSEncryptedPayload } from "@/l2ps" import { StoragePayload } from "./TransactionSubtypes/StorageTransaction" @@ -38,6 +38,7 @@ export type TransactionContentData = | ["l2psEncryptedTx", L2PSEncryptedPayload] | ["identity", IdentityPayload] | ["instantMessaging", InstantMessagingPayload] + | ["l2psInstantMessaging", L2PSInstantMessagingPayload] | ["nativeBridge", NativeBridgeTxPayload] | ["storage", StoragePayload] | ["storageProgram", StorageProgramPayload] @@ -63,6 +64,7 @@ export interface TransactionContent { | "NODE_ONLINE" | "identity" | "instantMessaging" + | "l2psInstantMessaging" | "nativeBridge" | "l2psEncryptedTx" | "storage" @@ -105,4 +107,4 @@ export interface Transaction { } // Re-export specific transaction types -export * from './TransactionSubtypes' +export * from './TransactionSubtypes' \ No newline at end of file diff --git a/src/types/blockchain/TransactionSubtypes/InstantMessagingTransaction.ts b/src/types/blockchain/TransactionSubtypes/InstantMessagingTransaction.ts index 9488b75c..2bd3e0ad 100644 --- a/src/types/blockchain/TransactionSubtypes/InstantMessagingTransaction.ts +++ b/src/types/blockchain/TransactionSubtypes/InstantMessagingTransaction.ts @@ -1,5 +1,5 @@ import { Transaction, TransactionContent } from "../Transaction" -import { InstantMessagingPayload } from "@/types/instantMessaging" +import { InstantMessagingPayload, L2PSInstantMessagingPayload } from "@/types/instantMessaging" export type InstantMessagingTransactionContent = Omit & { type: 'instantMessaging' @@ -8,4 +8,14 @@ export type InstantMessagingTransactionContent = Omit { content: InstantMessagingTransactionContent -} \ No newline at end of file +} + +/** L2PS-backed instant messaging transaction */ +export type L2PSInstantMessagingTransactionContent = Omit & { + type: 'l2psInstantMessaging' + data: ['l2psInstantMessaging', L2PSInstantMessagingPayload] +} + +export interface L2PSInstantMessagingTransaction extends Omit { + content: L2PSInstantMessagingTransactionContent +} \ No newline at end of file diff --git a/src/types/blockchain/TransactionSubtypes/index.ts b/src/types/blockchain/TransactionSubtypes/index.ts index 307e75aa..b800f449 100644 --- a/src/types/blockchain/TransactionSubtypes/index.ts +++ b/src/types/blockchain/TransactionSubtypes/index.ts @@ -25,7 +25,7 @@ import { CrosschainTransaction } from './CrosschainTransaction' import { NativeTransaction } from './NativeTransaction' import { DemosworkTransaction } from './DemosworkTransaction' import { IdentityTransaction } from './IdentityTransaction' -import { InstantMessagingTransaction } from './InstantMessagingTransaction' +import { InstantMessagingTransaction, L2PSInstantMessagingTransaction } from './InstantMessagingTransaction' import { NativeBridgeTransaction } from './NativeBridgeTransaction' import { StorageTransaction } from './StorageTransaction' import { StorageProgramTransaction } from './StorageProgramTransaction' @@ -47,6 +47,7 @@ export type SpecificTransaction = | DemosworkTransaction | IdentityTransaction | InstantMessagingTransaction + | L2PSInstantMessagingTransaction | NativeBridgeTransaction | StorageTransaction | StorageProgramTransaction diff --git a/src/types/instantMessaging/index.ts b/src/types/instantMessaging/index.ts index db3a3888..9bd05c69 100644 --- a/src/types/instantMessaging/index.ts +++ b/src/types/instantMessaging/index.ts @@ -6,3 +6,22 @@ export interface InstantMessagingPayload { messageHash: string } } + +/** L2PS-backed instant messaging transaction payload */ +export interface L2PSInstantMessagingPayload { + type: "instantMessaging" + data: { + /** UUID v4 message identifier */ + messageId: string + /** SHA256 hash for dedup */ + messageHash: string + /** E2E encrypted message (serialized) */ + encrypted: { + ciphertext: string + nonce: string + ephemeralKey?: string + } + /** Unix timestamp (ms) */ + timestamp: number + } +}