import { BaseStore } from './base-store.js'; import { SSESession } from '../utils/index.js'; import { Bytes, Encrypter, PrivateKey, PublicKey, decodeExtendedJson, } from '@xocash/primitives'; import { pack, unpack } from 'msgpackr'; import { z } from 'zod'; export class StoreRemoteHTTP extends BaseStore { static async from( baseURL: string, privateKey: PrivateKey, encrypter: Encrypter, ) { const store = new this(baseURL, privateKey, encrypter); await store.subscribeEvents(); return store; } // Optimizations/Caches. protected publicKey: PublicKey; constructor( protected baseURL: string, protected privateKey: PrivateKey, protected encrypter: Encrypter, ) { super(); // Derive the public key and cache it to optimize performance. this.publicKey = privateKey.derivePublicKey(); } async keys() { const response = await this.makeRequest('keys'); return response; } async get(key: string) { const response = await this.makeRequest('get', { key }); if (!response.value) { return undefined; } // Decrypt the value using the encrypter const decryptedValue = await this.encrypter.decrypt( new Uint8Array(response.value), ); // Parse the decrypted MessagePack value return unpack(decryptedValue) as T; } async set(key: string, value: T): Promise { // Pack the encoded value with MessagePack and encrypt it const packedValue = pack(value); const encryptedValue = await this.encrypter.encrypt(packedValue); await this.makeRequest('set', { key, value: encryptedValue, // Convert Uint8Array to regular array for MessagePack }); } async delete(key: string): Promise { await this.makeRequest('delete', { key }); } async subscribeEvents() { // Compute our signature (to authenticate with the SSE endpoint). const timestamp = Math.floor(Date.now() / 1000); const signature = await StoreRemoteHTTP.signRequest( this.privateKey.toHex(), timestamp, 'events', {}, ); // Define our headers. const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Public-Key': this.publicKey.toHex(), 'X-Signature': Bytes.fromBytes(signature).toHex(), 'X-Timestamp': timestamp.toString(), }; // Create our SSE Client. // NOTE: SSE only supports text-encoding, so we use Extended JSON instead of msgpackr (which is binary). // TODO: It would be really nice to somehow be able to support msgpackr for consistency though. // But, from a bandwidth point of view, this is not crucial: These payloads are "live" payloads (thus, there's not much data). await SSESession.from(`${this.baseURL}events`, { method: 'POST', headers, onMessage: (e) => { // Validate the payload using Zod. // TODO: Use msgpackr const payload = StoreRemoteHTTPEventSchema.parse( decodeExtendedJson(e.data), ); // Emit the event (update) to any listeners. this.emit(payload.action, { key: payload.key, value: payload.value, }); }, body: JSON.stringify({}), }); } private async makeRequest(endpoint: string, payload: object = {}) { const timestamp = Math.floor(Date.now() / 1000); const signature = await StoreRemoteHTTP.signRequest( this.privateKey.toHex(), timestamp, endpoint, payload, ); const headers = { 'Content-Type': 'application/msgpackr', Accept: 'application/msgpackr', 'X-Public-Key': this.publicKey.toHex(), 'X-Signature': Bytes.fromBytes(signature).toHex(), 'X-Timestamp': timestamp.toString(), }; const requestOptions = { method: 'POST', headers, body: payload ? pack(payload) : pack({}), }; // Use the URL class to compile the full path. // NOTE: We do this to cater for those situations where the base URL might contain a trailing slash. // E.g. https://wallet-sync.xo.cash/store/ vs https://wallet-sync.xo.cash/store // TODO: THis is broken and currently requires a trailing slash in the BaseURL! const requestURL = new URL(endpoint, this.baseURL).toString(); const response = await fetch(requestURL, requestOptions); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const responseBuffer = await response.arrayBuffer(); return unpack(new Uint8Array(responseBuffer)); } static async signRequest( privateKeyHex: string, timestamp: number, endpoint: string, payload?: object, ) { const messageBin = pack([endpoint, timestamp, payload]); return PrivateKey.fromHex(privateKeyHex).signMessageSchnorr(messageBin); } static async verifySignature( publicKeyHex: string, signatureHex: string, timestamp: number, endpoint: string, payload?: object, ) { const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > 60) { throw new Error(`Request has expired`); } const messageBin = pack([endpoint, timestamp, payload]); const isValidSignature = PublicKey.fromHex( publicKeyHex, ).verifyMessageSchnorr(messageBin, signatureHex); if (!isValidSignature) { throw new Error(`The provided signature is invalid`); } } } export const StoreRemoteHTTPEventSchema = z.object({ publicKey: z.string(), action: z.enum(['set', 'delete', 'clear']), key: z.string(), value: z.string(), timestamp: z.number(), }); export type StoreRemoteHTTPEvent = z.infer;