Files
sync-server-example/source/shared/base-client.ts
T
2026-10-22 15:19:06 +00:00

267 lines
8.9 KiB
TypeScript

import { binToHex, flattenBinArray, secp256k1, sha256 } from '@bitauth/libauth';
import { toExtendedJson, EventEmitter, fromExtendedJson } from '@xo-cash/utils';
import type { WriteRequest } from '../types.ts';
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<T = unknown> = {
'connection-updated': 'connected' | 'disconnected';
'resource-instance-updated': Resource<T> & {
resourceId: string;
};
'subscriptions-updated': Set<string>;
error: Error;
};
export type Resource<PayloadType = unknown> = z.infer<typeof BaseSyncClient.resourceInstanceSchema> & {
value: PayloadType;
};
export type ResourcesRecord<PayloadType = unknown> = Record<string, { instances: Resource<PayloadType>[] }>;
export abstract class BaseSyncClient<T = unknown> extends EventEmitter<XOSyncClientEventMap<T>> {
/**
* Connects to the sync server
*/
abstract connect(): Promise<void>;
/**
* Disconnects from the sync server
*/
abstract disconnect(): Promise<void>;
/**
* 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<Resource<T>[]> {
// 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<string, T>): Promise<ResourcesRecord<T>>;
/**
* 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<Resource<T>[]> {
// 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<ResourcesRecord<T>>;
/**
* Subscribes to a resource
* @param resourceId - The resource ID to subscribe to
*/
abstract subscribe(resourceId: string): Promise<void>;
/**
* Unsubscribes from a resource
* @param resourceId - The resource ID to unsubscribe from
*/
abstract unsubscribe(resourceId: string): Promise<void>;
/**
* 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<T = unknown>(response: z.infer<typeof BaseSyncClient.resourcesResponseSchema>): ResourcesRecord<T> {
return Object.entries(response.resources).reduce<ResourcesRecord<T>>((acc, [ resourceId, resource ]) => {
// Get the instances
const instances = resource.instances;
// Decode the instances
const decodedInstances = instances.map((instance) => {
return BaseSyncClient.decodeBlobValue<T>(instance);
});
// Add the decoded instances to the accumulator
acc[resourceId] = {
...resource,
instances: decodedInstances,
};
// Return the accumulator
return acc;
}, {});
}
static decodeBlobValue<T = unknown>(instance: z.infer<typeof BaseSyncClient.resourceInstanceSchema>): Resource<T> {
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,
),
});
}