import { binToHex, flattenBinArray, secp256k1, sha256 } from "@bitauth/libauth"; import { toExtendedJson, EventEmitter, fromExtendedJson } from "@xo-cash/utils"; import type { WriteRequest } from "../types.js"; import z from "zod"; export type AuthenticatedRequestHeaders = { 'X-Public-Key': string; 'X-Timestamp': string; 'X-Signature': string; }; export type SignedPayload = { publicKey: string; signature: string }; export type XOSyncClientEventMap = { 'connection-updated': 'connected' | 'disconnected'; 'resource-instance-updated': Resource & { resourceId: string; }; 'subscriptions-updated': Set; 'error': Error; }; export type Resource = z.infer & { value: PayloadType; }; export type ResourcesRecord = Record[] }>; export abstract class BaseSyncClient extends EventEmitter> { /** * Connects to the sync server */ abstract connect(): Promise; /** * Disconnects from the sync server */ abstract disconnect(): Promise; /** * Sign and write one resource * @param resourceId - The resource ID to write to * @param value - The value to write * @returns The Resource Object with the decoded value */ async write(resourceId: string, value: T): Promise[]> { // Write the resource to the server const response = await this.writeMany({ [resourceId]: value }); // Get the resource from the response const resource = response[resourceId]; // If the resource is not found, throw an error if (!resource) { throw new Error(`Resource ${resourceId} not found`); } // Return the resource instances return resource.instances; } /** * Writes multiple values to resources * @param resources - The resources to write to * @returns The resources with the written values */ abstract writeMany(resources: Record): Promise>; /** * Reads a value from a resource * @param resourceId - The resource ID to read from * @returns The resource with the read value */ async read(resourceId: string): Promise[]> { // Read the resource from the server const response = await this.readMany([ resourceId ]); // Get the resource from the response const resource = response[resourceId]; // If the resource is not found, throw an error if (!resource) { throw new Error(`Resource ${resourceId} not found`); } // Return the resource instances return resource.instances; } /** * Reads multiple values from resources * @param resourceIds - The resource IDs to read from * @returns The resources with the read values */ abstract readMany(resourceIds: string[]): Promise>; /** * Subscribes to a resource * @param resourceId - The resource ID to subscribe to */ abstract subscribe(resourceId: string): Promise; /** * Unsubscribes from a resource * @param resourceId - The resource ID to unsubscribe from */ abstract unsubscribe(resourceId: string): Promise; /** * Derives the resource private key from the private key and resource ID * @param privateKey - The private key to derive the resource private key from * @param resourceId - The resource ID to derive the resource private key from * @returns The resource private key */ static deriveResourcePrivateKey(privateKey: Uint8Array, resourceId: string): Uint8Array { // Convert the resource ID to bytes const resourceIdBytes = new TextEncoder().encode(resourceId); // Hash the private key and resource ID return sha256.hash(flattenBinArray([ privateKey, resourceIdBytes ])); } /** * Signs a payload with the private key and returns the public key and signature * @param privateKey - The private key to sign the payload with * @param payload - The payload to sign * @returns The public key and signature */ static signPayload(privateKey: Uint8Array, payload: string): SignedPayload { // Convert the payload to Binary and hash it const payloadHash = sha256.hash(new TextEncoder().encode(payload)); // Derive the public key const publicKey = secp256k1.derivePublicKeyCompressed(privateKey); // If the public key is a string, throw an error if (typeof publicKey === 'string') { throw new Error('Failed to derive public key'); } // Sign the payload const signature = secp256k1.signMessageHashDER(privateKey, payloadHash); // If the signature is a string, throw an error if (typeof signature === 'string') { throw new Error('Failed to sign message'); } return { publicKey: binToHex(publicKey), signature: binToHex(signature), }; } /** * Signs a write request with the private key and returns the write request * @param privateKey - The private key to sign the write request with * @param resourceId - The resource ID to write to * @param value - The value to write * @returns The write request */ static signWriteRequest(privateKey: Uint8Array, resourceId: string, value: Uint8Array): WriteRequest { // Derive the resource private key const derivedKey = this.deriveResourcePrivateKey(privateKey, resourceId); // Create the payload const timestamp = Date.now(); const payload = `${timestamp}:${resourceId}:${toExtendedJson(value)}`; // Sign the payload const { publicKey, signature } = this.signPayload(derivedKey, payload); return { id: resourceId, value, publicKey, timestamp, signature, }; } /** * Signs a request with the private key and returns the public key, timestamp, and signature * @param privateKey - The private key to sign the request with * @param path - The path of the request * @param body - The body of the request * @returns The public key, timestamp, and signature */ static signRequest(privateKey: Uint8Array, path: string, body: unknown): AuthenticatedRequestHeaders { // Create the payload const timestamp = Date.now(); // Convert the body to a string if it isn't already a string const bodyStr = typeof body === 'string' ? body : toExtendedJson(body); // Create the payload as `Timestamp:Path:Body` const payload = `${timestamp}:${path}:${bodyStr}`; // Sign the payload const { publicKey, signature } = this.signPayload(privateKey, payload); return { 'X-Public-Key': publicKey, 'X-Timestamp': timestamp.toString(), 'X-Signature': signature, }; } /** * Decodes the 'blob' field of the Resource Object from the server from a read/write * @param response - The ResourceResponse Object from the server from a read/write * @returns The Resource Object with the decoded value */ static decodeResourcesResponse(response: z.infer): ResourcesRecord { return Object.entries(response.resources).reduce>((acc, [resourceId, resource]) => { // Get the instances const instances = resource.instances; // Decode the instances const decodedInstances = instances.map((instance) => { return BaseSyncClient.decodeBlobValue(instance); }); // Add the decoded instances to the accumulator acc[resourceId] = { ...resource, instances: decodedInstances, }; // Return the accumulator return acc; }, {}); } static decodeBlobValue(instance: z.infer): Resource { const decodedBlob = new TextDecoder().decode(instance.blob); const value = fromExtendedJson(decodedBlob) as T; return { ...instance, value, }; } static resourceInstanceSchema = z.object({ resourceId: z.string(), blob: z.instanceof(Uint8Array), timestamp: z.number(), publicKey: z.string(), signature: z.string(), }); static rawResourcesSchema = z.object({ instances: z.array(BaseSyncClient.resourceInstanceSchema), }); static resourcesResponseSchema = z.object({ resources: z.record( // Resource ID z.string(), // Resource instances BaseSyncClient.rawResourcesSchema, ), }); }