267 lines
8.9 KiB
TypeScript
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,
|
|
),
|
|
});
|
|
}
|