Initial Commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/node_modules
|
||||
1308
package-lock.json
generated
Normal file
1308
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "sync-server",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "DEBUG=xo:* tsx src/app.ts",
|
||||
"dev": "DEBUG=xo:* tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12",
|
||||
"@xo-cash/types": "file:../types",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.0.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"debug": "^4.4.3",
|
||||
"fastify": "^5.7.2",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
40
src/app.ts
Normal file
40
src/app.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { HTTPService } from './services/http-router';
|
||||
import { InvitationsRoute } from './routes/invitations';
|
||||
import { InvitationStore } from './services/invitation-store';
|
||||
import { SSEBroadcaster } from './services/sse-broadcast';
|
||||
|
||||
export class App {
|
||||
static async create() {
|
||||
// Create the invitation store (this is a in-memory store for now)
|
||||
const invitationStore = new InvitationStore();
|
||||
|
||||
// Create the SSE Broadcaster
|
||||
const sseBroadcaster = new SSEBroadcaster();
|
||||
|
||||
// Create the Invitation route, passing in the invitation store and sse broadcaster
|
||||
const invitationsRoute = new InvitationsRoute(invitationStore, sseBroadcaster);
|
||||
|
||||
// Create the HTTP service, passing in the invitation route
|
||||
const http = new HTTPService([
|
||||
invitationsRoute,
|
||||
]);
|
||||
|
||||
// Create the app instance, passing in the HTTP service
|
||||
return new App(http);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance of App.
|
||||
* @param http - The HTTP service instance.
|
||||
*/
|
||||
constructor(private readonly http: HTTPService) {}
|
||||
|
||||
async start() {
|
||||
// Start the HTTP service
|
||||
await this.http.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Create the app instance and start it
|
||||
const app = await App.create();
|
||||
await app.start();
|
||||
1
src/routes/index.ts
Normal file
1
src/routes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './invitations.js';
|
||||
89
src/routes/invitations.ts
Normal file
89
src/routes/invitations.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { FastifyRequest, FastifyReply, RouteOptions } from 'fastify';
|
||||
|
||||
import type { SSEBroadcaster } from '../services/sse-broadcast';
|
||||
import type { InvitationStore } from '../services/invitation-store';
|
||||
import { parseInvitation } from '../utils/invitation-parser';
|
||||
|
||||
export class InvitationsRoute {
|
||||
constructor(
|
||||
private readonly invitationStore: InvitationStore,
|
||||
private readonly sseBroadcaster: SSEBroadcaster,
|
||||
) {}
|
||||
|
||||
async getRoutes(): Promise<Array<RouteOptions>> {
|
||||
return [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/invitations',
|
||||
handler: this.getInvitation.bind(this),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/invitations',
|
||||
handler: this.updateInvitation.bind(this),
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async getInvitation(request: FastifyRequest, reply: FastifyReply) {
|
||||
// Get the invitation identifier from the query
|
||||
const { invitationIdentifier } = request.query as { invitationIdentifier?: string };
|
||||
|
||||
// If the invitation identifier is not provided, return an error.
|
||||
if (!invitationIdentifier) {
|
||||
return reply.status(400).send({ error: 'Invitation Identifier is required' });
|
||||
}
|
||||
|
||||
// Get the invitation from the store
|
||||
const storedInvitation = await this.invitationStore.getInvitation(invitationIdentifier);
|
||||
|
||||
if (request.headers['accept'] === 'text/event-stream') {
|
||||
// Subscribe the client to the SSE stream.
|
||||
await this.sseBroadcaster.subscribe(request, reply);
|
||||
|
||||
// If the invitation doesn't exist, don't send anything.
|
||||
if (!storedInvitation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the invitation to the client as if it was a get request.
|
||||
this.sseBroadcaster.sendEvent(reply, 'invitation-updated', storedInvitation);
|
||||
|
||||
// Return early
|
||||
return;
|
||||
}
|
||||
|
||||
if(!storedInvitation) {
|
||||
return reply.status(200).send({});
|
||||
}
|
||||
|
||||
// If the client is not subscribing to the SSE stream, return the invitation.
|
||||
return reply.status(200).send(storedInvitation);
|
||||
}
|
||||
|
||||
async updateInvitation(request: FastifyRequest, reply: FastifyReply) {
|
||||
console.log('updateInvitation', request.body);
|
||||
|
||||
// Parse the invitation
|
||||
const invitation = parseInvitation.parse(request.body);
|
||||
|
||||
// If the invitation doesnt exist yet, create it
|
||||
if (!await this.invitationStore.getInvitation(invitation.invitationIdentifier)) {
|
||||
await this.invitationStore.storeInvitation({ ...invitation, commits: [] });
|
||||
}
|
||||
|
||||
// Store each commit individually (I dont know)
|
||||
for(const commit of invitation.commits) {
|
||||
await this.invitationStore.updateInvitation(invitation.invitationIdentifier, commit);
|
||||
}
|
||||
|
||||
// Broadcast the invitation update (We send down the whole invitation. Clients will have to compare commitIds)
|
||||
await this.sseBroadcaster.broadcast(invitation.invitationIdentifier, 'invitation-updated', invitation);
|
||||
|
||||
console.log('Invitation updated successfully');
|
||||
|
||||
return reply.status(200).send(invitation);
|
||||
}
|
||||
|
||||
static parseInvitation = parseInvitation
|
||||
}
|
||||
138
src/services/http-router.ts
Normal file
138
src/services/http-router.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import debug from "debug";
|
||||
import fastify, { type FastifyInstance, type RouteOptions } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
decodeExtendedJsonObject,
|
||||
encodeExtendedJsonObject,
|
||||
} from "../utils/ext-json";
|
||||
|
||||
// Interface to add to our route classes so that we can register them.
|
||||
// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration.
|
||||
export interface APIRoutes {
|
||||
getRoutes(): Promise<Array<RouteOptions>>;
|
||||
}
|
||||
|
||||
export class HTTPService {
|
||||
private debug: debug.Debugger;
|
||||
private server: FastifyInstance;
|
||||
|
||||
constructor(
|
||||
private routes: Array<APIRoutes> = [],
|
||||
private port: number = 3000,
|
||||
private host: string = "0.0.0.0",
|
||||
) {
|
||||
this.debug = debug("xo:http-router");
|
||||
|
||||
this.server = fastify({
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.debug(`Starting on http://${this.host}:${this.port}`);
|
||||
|
||||
// Setup ExtJSON handling.
|
||||
this.handleExtJSON();
|
||||
|
||||
// Setup Error Handling (to give more verbose Zod errors)
|
||||
this.handleErrors();
|
||||
|
||||
// Allow CORS requests. This allows requests from any origin/domain.
|
||||
// Capacitor apps (like XO Wallet) use localhost:3000 as the origin, making it difficult to meaningfully restrict requests by origin for security.
|
||||
await this.server.register(cors);
|
||||
|
||||
// Register your routes here before starting the server
|
||||
this.server.get("/health", async () => {
|
||||
return { status: "ok" };
|
||||
});
|
||||
|
||||
// Register each route.
|
||||
for (const routes of this.routes) {
|
||||
for (const routeOptions of await routes.getRoutes()) {
|
||||
this.server.route(routeOptions);
|
||||
}
|
||||
}
|
||||
|
||||
await this.server.ready();
|
||||
|
||||
await this.server.listen({
|
||||
port: this.port,
|
||||
host: this.host,
|
||||
});
|
||||
|
||||
this.debug(`Started on http://${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
// Helper method to access the server instance
|
||||
getInstance(): FastifyInstance {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
private handleErrors() {
|
||||
// Customize our error handler to give better errors.
|
||||
// NOTE: This will nicely format the Zod validation errors.
|
||||
this.server.setErrorHandler((error, _request, reply) => {
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedErrors = error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
}));
|
||||
|
||||
this.debug(`Error: ${error}`);
|
||||
|
||||
return reply.status(400).send({
|
||||
statusCode: 400,
|
||||
error: "Validation Error",
|
||||
details: formattedErrors,
|
||||
});
|
||||
}
|
||||
|
||||
this.debug(`Error: ${error}`);
|
||||
|
||||
// Handle other types of errors
|
||||
reply.status(500).send({ error: "Internal Server Error" });
|
||||
});
|
||||
}
|
||||
|
||||
private handleExtJSON() {
|
||||
// Add onRequest hook to decode requests from ExtJSON
|
||||
this.server.addHook("onRequest", async (request, _reply) => {
|
||||
this.debug(`Request: ${JSON.stringify(request.body)}`);
|
||||
this.debug(`Request URL: ${request.method} ${request.url}`);
|
||||
// Only transform JSON requests
|
||||
if (
|
||||
request.headers["content-type"]?.includes("application/json") &&
|
||||
request.body
|
||||
) {
|
||||
try {
|
||||
// Decode ExtJSON body
|
||||
request.body = decodeExtendedJsonObject(request.body);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{
|
||||
err: error,
|
||||
body: request.body,
|
||||
},
|
||||
"Failed to decode ExtJSON request body",
|
||||
);
|
||||
throw new Error("Invalid JSON in request body");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add onSend hook to encode responses to ExtJSON
|
||||
this.server.addHook("onSend", async (_request, reply, payload) => {
|
||||
// Only transform JSON responses
|
||||
if (
|
||||
reply.getHeader("content-type")?.toString().includes("application/json")
|
||||
) {
|
||||
// If payload is a string (already serialized), parse it first
|
||||
const data =
|
||||
typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
return JSON.stringify(encodeExtendedJsonObject(data));
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
}
|
||||
46
src/services/invitation-store.ts
Normal file
46
src/services/invitation-store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { XOInvitation, XOInvitationCommit } from '@xo-cash/types';
|
||||
|
||||
export class InvitationStore {
|
||||
|
||||
private readonly invitations: Map<string, XOInvitation> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.invitations = new Map();
|
||||
}
|
||||
|
||||
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||
return this.invitations.get(invitationIdentifier);
|
||||
}
|
||||
|
||||
async storeInvitation(invitation: XOInvitation): Promise<void> {
|
||||
const invitationIdentifier = invitation.invitationIdentifier;
|
||||
this.invitations.set(invitationIdentifier, invitation);
|
||||
}
|
||||
|
||||
async deleteInvitation(invitationIdentifier: string): Promise<void> {
|
||||
this.invitations.delete(invitationIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: This should maybe merge? I dont know. Currently, setting is not the best idea
|
||||
* @param invitation
|
||||
*/
|
||||
async updateInvitation(id: string, commit: XOInvitationCommit): Promise<XOInvitation> {
|
||||
// Get the invitation identifier
|
||||
const invitation = await this.getInvitation(id);
|
||||
if (!invitation) {
|
||||
throw new Error(`Invitation not found: ${id}`);
|
||||
}
|
||||
|
||||
// If the commit already exists, return the invitation
|
||||
if (invitation.commits.some(c => c.commitIdentifier === commit.commitIdentifier)) {
|
||||
return invitation;
|
||||
}
|
||||
|
||||
// Update the invitation with the commit
|
||||
invitation.commits.push(commit);
|
||||
this.invitations.set(id, invitation);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
}
|
||||
270
src/services/sse-broadcast.ts
Normal file
270
src/services/sse-broadcast.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import debug, { type Debugger } from "debug";
|
||||
|
||||
/**
|
||||
* Represents an event stored in the history buffer.
|
||||
* Used for replaying missed events to reconnecting clients.
|
||||
*/
|
||||
interface HistoricalEvent {
|
||||
/** The event topic/type (e.g., 'invitation-created', 'invitation-updated') */
|
||||
topic: string;
|
||||
/** The event payload data */
|
||||
data: unknown;
|
||||
/** Unix timestamp in milliseconds when the event was created */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for configuring the SSE service.
|
||||
*/
|
||||
interface SSEOptions {
|
||||
/** Maximum age of events to keep in history (in milliseconds). Default: 5 minutes */
|
||||
maxHistoryAge?: number;
|
||||
/** Maximum number of events to keep per user. Default: 1000 */
|
||||
maxHistorySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-Sent Events broadcaster with event history support.
|
||||
*
|
||||
* Maintains a per-user event history buffer that allows clients to replay
|
||||
* missed events when reconnecting. This makes reconnections robust against
|
||||
* network interruptions.
|
||||
*/
|
||||
export class SSEBroadcaster {
|
||||
/**
|
||||
* Factory method to create and start an SSE broadcaster.
|
||||
* @param options - Configuration options for the SSE service
|
||||
* @returns A started SSE instance
|
||||
*/
|
||||
static async from(options?: SSEOptions) {
|
||||
const broadcaster = new SSEBroadcaster(options);
|
||||
await broadcaster.start();
|
||||
return broadcaster;
|
||||
}
|
||||
|
||||
/** Map of Invitation IDs to their connected SSE response streams */
|
||||
private clients: Map<(string), Set<FastifyReply>> = new Map();
|
||||
|
||||
/** Map of Invitation IDs to their event history buffers */
|
||||
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
|
||||
|
||||
/** Maximum age of events to keep in history (in milliseconds) */
|
||||
private maxHistoryAge: number;
|
||||
|
||||
/** Maximum number of events to keep per user */
|
||||
private maxHistorySize: number;
|
||||
|
||||
private debug: Debugger;
|
||||
|
||||
constructor(options?: SSEOptions) {
|
||||
this.clients = new Map();
|
||||
this.eventHistory = new Map();
|
||||
this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default
|
||||
this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default
|
||||
this.debug = debug('xo:sse');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the SSE broadcaster.
|
||||
* @returns The SSE instance for chaining
|
||||
*/
|
||||
start() {
|
||||
this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)',
|
||||
this.maxHistoryAge, this.maxHistorySize);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to a client.
|
||||
*
|
||||
* @param client - The client to send the event to
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
*/
|
||||
static sendEvent(client: FastifyReply, topic: string, data: unknown) {
|
||||
const timestamp = Date.now();
|
||||
client.raw.write(`id: ${timestamp}\n`);
|
||||
client.raw.write(`event: ${topic}\n`);
|
||||
client.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to a client.
|
||||
*
|
||||
* @param client - The client to send the event to
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
*/
|
||||
sendEvent(client: FastifyReply, topic: string, data: unknown) {
|
||||
try {
|
||||
SSEBroadcaster.sendEvent(client, topic, data);
|
||||
} catch (error) {
|
||||
this.debug('Error sending event to client', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts an event to all connected clients for a user and stores it in history.
|
||||
*
|
||||
* @param clientId - The user ID to broadcast to
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
*/
|
||||
async broadcast(clientId: string, topic: string, data: unknown) {
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Store the event in history for potential replay
|
||||
this.storeEvent(clientId, topic, data, timestamp);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
this.clients.get(clientId)?.forEach((client: FastifyReply) => {
|
||||
try {
|
||||
this.sendEvent(client, topic, data);
|
||||
} catch (error) {
|
||||
this.debug('Error sending event to client', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.debug('SSE broadcasted message', topic, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a client to receive SSE events.
|
||||
*
|
||||
* If lastEventTime is provided, all events that occurred after that timestamp
|
||||
* will be replayed to the client before starting the live stream.
|
||||
*
|
||||
* @param req - The authenticated request containing the user ID
|
||||
* @param res - The Express response object to use for SSE streaming
|
||||
* @param lastEventTime - Optional timestamp to replay events from (in milliseconds)
|
||||
*/
|
||||
async subscribe(req: FastifyRequest, res: FastifyReply, lastEventTime?: number) {
|
||||
// Get the invitation ID from the request
|
||||
const { invitationIdentifier } = req.query as { invitationIdentifier?: string };
|
||||
if (!invitationIdentifier) {
|
||||
throw new Error('Invitation Identifier is required');
|
||||
}
|
||||
|
||||
// Initialize client set for this user if needed
|
||||
if (!this.clients.has(invitationIdentifier)) {
|
||||
this.clients.set(invitationIdentifier, new Set());
|
||||
}
|
||||
|
||||
// Manually include the CORS header since `writeHead` bypasses the auto-injection by the @fastify/cors plugin.
|
||||
// This statement grabs the CORS header that the CORS plugin would have added to the response, configured in the HTTP service.
|
||||
const corsHeader = res.getHeader("access-control-allow-origin");
|
||||
|
||||
// Disable timeout for the connection. Without this, the connection will drop and the client will have to reconnect.
|
||||
req.raw.socket.setTimeout(0);
|
||||
|
||||
// Set up SSE headers
|
||||
// Set headers for SSE
|
||||
res.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": corsHeader,
|
||||
});
|
||||
|
||||
// Force the headers to be dispatched to the client.
|
||||
// NOTE: This is very important: A `fetch` call will NOT resolve until it has received the headers.
|
||||
// And Fastify, unless otherwise specified, will not send the headers until it sends the body.
|
||||
res.raw.flushHeaders();
|
||||
|
||||
// Set retry interval for automatic reconnection
|
||||
res.raw.write('retry: 3000\n\n');
|
||||
|
||||
// Replay missed events if a lastEventTime was provided
|
||||
if (lastEventTime !== undefined) {
|
||||
const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime);
|
||||
this.debug('SSE replaying %d missed events for invitation %s (since %d)',
|
||||
missedEvents.length, invitationIdentifier, lastEventTime);
|
||||
|
||||
for (const event of missedEvents) {
|
||||
try {
|
||||
await this.sendEvent(res, event.topic, event.data);
|
||||
} catch (error) {
|
||||
this.debug('Error sending event to client', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add client to the set for live updates
|
||||
this.clients.get(invitationIdentifier)?.add(res);
|
||||
|
||||
this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)',
|
||||
invitationIdentifier, lastEventTime ?? 'none');
|
||||
|
||||
// Clean up when client disconnects
|
||||
res.raw.on('close', () => {
|
||||
this.clients.get(invitationIdentifier)?.delete(res);
|
||||
this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event in the user's history buffer.
|
||||
* Automatically prunes old events based on age and size limits.
|
||||
*
|
||||
* @param invitationId - The invitation ID to store the event for
|
||||
* @param topic - The event topic/type
|
||||
* @param data - The event payload data
|
||||
* @param timestamp - The event timestamp
|
||||
*/
|
||||
private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) {
|
||||
// Initialize history array for this user if needed
|
||||
if (!this.eventHistory.has(invitationId)) {
|
||||
this.eventHistory.set(invitationId, []);
|
||||
}
|
||||
|
||||
const history = this.eventHistory.get(invitationId)!;
|
||||
|
||||
// Add the new event
|
||||
history.push({ topic, data, timestamp });
|
||||
|
||||
// Prune old events
|
||||
this.pruneHistory(invitationId, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes old events from a invitation's history based on age and size limits.
|
||||
*
|
||||
* @param invitationId - The invitation ID whose history to prune
|
||||
* @param currentTime - The current timestamp for age calculations
|
||||
*/
|
||||
private pruneHistory(invitationId: string, currentTime: number) {
|
||||
const history = this.eventHistory.get(invitationId);
|
||||
if (!history) return;
|
||||
|
||||
const cutoffTime = currentTime - this.maxHistoryAge;
|
||||
|
||||
// Remove events older than maxHistoryAge
|
||||
const prunedByAge = history.filter(event => event.timestamp > cutoffTime);
|
||||
|
||||
// If still over size limit, remove oldest events
|
||||
const prunedBySize = prunedByAge.length > this.maxHistorySize
|
||||
? prunedByAge.slice(-this.maxHistorySize)
|
||||
: prunedByAge;
|
||||
|
||||
this.eventHistory.set(invitationId, prunedBySize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all events for a user that occurred after a given timestamp.
|
||||
*
|
||||
* @param invitationId - The invitation ID to get events for
|
||||
* @param afterTimestamp - The timestamp to get events after (exclusive)
|
||||
* @returns Array of events that occurred after the timestamp
|
||||
*/
|
||||
private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] {
|
||||
const history = this.eventHistory.get(invitationId);
|
||||
if (!history) return [];
|
||||
|
||||
// First prune old events to ensure we don't return stale data
|
||||
this.pruneHistory(invitationId, Date.now());
|
||||
|
||||
return history.filter(event => event.timestamp > afterTimestamp);
|
||||
}
|
||||
}
|
||||
124
src/utils/ext-json.ts
Normal file
124
src/utils/ext-json.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
|
||||
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
|
||||
* See: https://github.com/bitauth/libauth/pull/108
|
||||
*/
|
||||
|
||||
import { binToHex, hexToBin } from '@bitauth/libauth';
|
||||
|
||||
export const extendedJsonReplacer = function (value: any): any {
|
||||
if (typeof value === 'bigint') {
|
||||
return `<bigint: ${value.toString()}n>`;
|
||||
} else if (value instanceof Uint8Array) {
|
||||
return `<Uint8Array: ${binToHex(value)}>`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const extendedJsonReviver = function (value: any): any {
|
||||
// Define RegEx that matches our Extended JSON fields.
|
||||
const bigIntRegex = /^<bigint: (?<bigint>[+-]?[0-9]*)n>$/;
|
||||
const uint8ArrayRegex = /^<Uint8Array: (?<hex>[a-f0-9]*)>$/;
|
||||
|
||||
// Only perform a check if the value is a string.
|
||||
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
||||
if (typeof value === 'string') {
|
||||
// Check if this value matches an Extended JSON encoded bigint.
|
||||
const bigintMatch = value.match(bigIntRegex);
|
||||
if (bigintMatch) {
|
||||
// Access the named group directly instead of using array indices
|
||||
const { bigint } = bigintMatch.groups!;
|
||||
|
||||
// Return the value casted to bigint.
|
||||
return BigInt(bigint);
|
||||
}
|
||||
|
||||
const uint8ArrayMatch = value.match(uint8ArrayRegex);
|
||||
if (uint8ArrayMatch) {
|
||||
// Access the named group directly instead of using array indices
|
||||
const { hex } = uint8ArrayMatch.groups!;
|
||||
|
||||
// Return the value casted to bigint.
|
||||
return hexToBin(hex);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original value.
|
||||
return value;
|
||||
};
|
||||
|
||||
export const encodeExtendedJsonObject = function (value: any): any {
|
||||
// If this is an object type (and it is not null - which is technically an "object")...
|
||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!ArrayBuffer.isView(value)
|
||||
) {
|
||||
// If this is an array, recursively call this function on each value.
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(encodeExtendedJsonObject);
|
||||
}
|
||||
|
||||
// Declare object to store extended JSON entries.
|
||||
const encodedObject: any = {};
|
||||
|
||||
// Iterate through each entry and encode it to extended JSON.
|
||||
for (const [key, valueToEncode] of Object.entries(value)) {
|
||||
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
||||
}
|
||||
|
||||
// Return the extended JSON encoded object.
|
||||
return encodedObject;
|
||||
}
|
||||
|
||||
// Return the replaced value.
|
||||
return extendedJsonReplacer(value);
|
||||
};
|
||||
|
||||
export const decodeExtendedJsonObject = function (value: any): any {
|
||||
// If this is an object type (and it is not null - which is technically an "object")...
|
||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!ArrayBuffer.isView(value)
|
||||
) {
|
||||
// If this is an array, recursively call this function on each value.
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(decodeExtendedJsonObject);
|
||||
}
|
||||
|
||||
// Declare object to store decoded JSON entries.
|
||||
const decodedObject: any = {};
|
||||
|
||||
// Iterate through each entry and decode it from extended JSON.
|
||||
for (const [key, valueToEncode] of Object.entries(value)) {
|
||||
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
||||
}
|
||||
|
||||
// Return the extended JSON encoded object.
|
||||
return decodedObject;
|
||||
}
|
||||
|
||||
// Return the revived value.
|
||||
return extendedJsonReviver(value);
|
||||
};
|
||||
|
||||
export const encodeExtendedJson = function (
|
||||
value: any,
|
||||
space: number | undefined = undefined,
|
||||
): string {
|
||||
const replacedObject = encodeExtendedJsonObject(value);
|
||||
const stringifiedObject = JSON.stringify(replacedObject, null, space);
|
||||
|
||||
return stringifiedObject;
|
||||
};
|
||||
|
||||
export const decodeExtendedJson = function (json: string): any {
|
||||
const parsedObject = JSON.parse(json);
|
||||
const revivedObject = decodeExtendedJsonObject(parsedObject);
|
||||
|
||||
return revivedObject;
|
||||
};
|
||||
66
src/utils/invitation-parser.ts
Normal file
66
src/utils/invitation-parser.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas for invitation validation.
|
||||
*
|
||||
* IMPORTANT: We use .passthrough() on all object schemas to preserve fields
|
||||
* that aren't explicitly defined. This is critical because:
|
||||
* 1. Invitations are signed based on stringify(commit.data)
|
||||
* 2. If we strip fields, the signature verification will fail
|
||||
* 3. The actual XOInvitation types have many more fields than we validate here
|
||||
*/
|
||||
|
||||
const variableSchema = z.object({
|
||||
variableIdentifier: z.string(),
|
||||
roleIdentifier: z.string().optional(),
|
||||
value: z.number().or(z.string()).or(z.boolean()).or(z.bigint()),
|
||||
}).passthrough();
|
||||
|
||||
const mergesWithSchema = z.object({
|
||||
commitIdentifier: z.string(),
|
||||
index: z.number(),
|
||||
}).passthrough();
|
||||
|
||||
const inputSchema = z.object({
|
||||
inputIdentifier: z.string().optional(),
|
||||
transactionIndex: z.number().optional(),
|
||||
roleIdentifier: z.string().optional(),
|
||||
mergesWith: mergesWithSchema.optional(),
|
||||
// Additional fields preserved via passthrough:
|
||||
// outpointTransactionHash, outpointIndex, sequenceNumber, unlockingBytecode, etc.
|
||||
}).passthrough();
|
||||
|
||||
const outputSchema = z.object({
|
||||
outputIdentifier: z.string().optional(),
|
||||
roleIdentifier: z.string().optional(),
|
||||
secretIdentifier: z.string().optional(),
|
||||
transactionIndex: z.number().optional(),
|
||||
mergesWith: mergesWithSchema.optional(),
|
||||
// Additional fields preserved via passthrough:
|
||||
// valueSatoshis, lockingBytecode, token, etc.
|
||||
}).passthrough();
|
||||
|
||||
const dataSchema = z.object({
|
||||
transactionVersion: z.number().optional(),
|
||||
transactionLocktime: z.number().optional(),
|
||||
variables: z.array(variableSchema).optional(),
|
||||
inputs: z.array(inputSchema).optional(),
|
||||
outputs: z.array(outputSchema).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const commitSchema = z.object({
|
||||
commitIdentifier: z.string(),
|
||||
previousCommitIdentifier: z.string().or(z.undefined()),
|
||||
entityIdentifier: z.string(),
|
||||
data: dataSchema,
|
||||
signature: z.string(),
|
||||
expiresAtTimestamp: z.number(),
|
||||
}).passthrough();
|
||||
|
||||
export const parseInvitation = z.object({
|
||||
invitationIdentifier: z.string(),
|
||||
commits: z.array(commitSchema),
|
||||
createdAtTimestamp: z.number(),
|
||||
templateIdentifier: z.string(),
|
||||
actionIdentifier: z.string(),
|
||||
}).passthrough();
|
||||
Reference in New Issue
Block a user