Doc commit tag conventions

This commit is contained in:
Jim Hamill
2025-03-10 05:36:16 +00:00
parent 307650e9fc
commit 0346f3f79e
23 changed files with 1290 additions and 286 deletions
@@ -0,0 +1,189 @@
import { BaseStore } from './base-store.js';
import { Encrypter, SSEClient } from '../utils';
import { Bytes, PrivateKey, PublicKey } 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<T>(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<T>(key: string, value: T): Promise<void> {
// 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<void> {
await this.makeRequest('delete', { key });
}
async subscribeEvents() {
const timestamp = Math.floor(Date.now() / 1000);
const signature = await StoreRemoteHTTP.signRequest(
this.privateKey.toHex(),
timestamp,
'events',
{},
);
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(),
};
const sseClient = new SSEClient(`${this.baseURL}events`, {
method: 'POST',
headers,
onMessage: (e) => {
// Validate the payload using Zod.
// TODO: Use msgpackr
const payload = StoreRemoteHTTPEventSchema.parse(JSON.parse(e.data));
// Emit the event (update) to any listeners.
this.emit(payload.action, {
key: payload.key,
value: payload.value,
});
},
body: JSON.stringify({}),
});
await sseClient.connect();
}
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<typeof StoreRemoteHTTPEventSchema>;