Doc commit tag conventions
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user