202 lines
5.6 KiB
TypeScript
202 lines
5.6 KiB
TypeScript
import { BaseStore } from './base-store.js';
|
|
import { SSESession } from '../utils/index.js';
|
|
import {
|
|
Bytes,
|
|
Encrypter,
|
|
PrivateKey,
|
|
PublicKey,
|
|
decodeExtendedJson,
|
|
} 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() {
|
|
// Compute our signature (to authenticate with the SSE endpoint).
|
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
const signature = await StoreRemoteHTTP.signRequest(
|
|
this.privateKey.toHex(),
|
|
timestamp,
|
|
'events',
|
|
{},
|
|
);
|
|
|
|
// Define our headers.
|
|
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(),
|
|
};
|
|
|
|
// Create our SSE Client.
|
|
// NOTE: SSE only supports text-encoding, so we use Extended JSON instead of msgpackr (which is binary).
|
|
// TODO: It would be really nice to somehow be able to support msgpackr for consistency though.
|
|
// But, from a bandwidth point of view, this is not crucial: These payloads are "live" payloads (thus, there's not much data).
|
|
await SSESession.from(`${this.baseURL}events`, {
|
|
method: 'POST',
|
|
headers,
|
|
onMessage: (e) => {
|
|
// Validate the payload using Zod.
|
|
// TODO: Use msgpackr
|
|
const payload = StoreRemoteHTTPEventSchema.parse(
|
|
decodeExtendedJson(e.data),
|
|
);
|
|
|
|
// Emit the event (update) to any listeners.
|
|
this.emit(payload.action, {
|
|
key: payload.key,
|
|
value: payload.value,
|
|
});
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
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>;
|