@imigueldiaz/mongodb-labeler
Version:
A MongoDB-based labeling system for content moderation with cryptographic signing
389 lines (388 loc) • 16.1 kB
JavaScript
import { Secp256k1Keypair } from "@atproto/crypto";
import { MongoDBClient } from "./mongodb.js";
import { validateCid, validateDid, validateVal, validateCts, validateExp, validateUri } from "./util/validators.js";
import { LabelerServerError, AtProtocolValidationError } from "./errors";
import { ObjectId } from "mongodb";
/**
* LabelerServer is responsible for managing label creation, querying, and deletion
* operations. It interfaces with a MongoDB database to persist label data and uses
* cryptographic signing to ensure label authenticity.
*
* This class requires initialization with LabelerOptions, which include database
* connection details and a signing key. The server supports operations such as
* creating new labels, querying existing ones by ID, and marking labels as deleted
* by signing them with a negation flag.
*
* The labels are signed using a Secp256k1Keypair, and the signatures are stored
* in the database alongside the label data.
*/
export class LabelerServer {
get db() {
return this._db;
}
set _setSigner(value) {
this._signer = value;
}
get did() {
return this._did;
}
/**
* Returns the promise that resolves when the signer is initialized.
* @returns A promise that resolves when initialization is complete.
* @throws {Error} If initialization failed
*/
getInitializationPromise() {
if (this._initializationError) {
throw this._initializationError;
}
return this._initializeSigner;
}
/**
* Creates a new instance of the LabelerServer.
* @param options The options for the server
* @throws {LabelerServerError} If initialization fails
*/
constructor(options) {
try {
// Validate required parameters
if (!options.signingKey) {
throw new LabelerServerError("Invalid server configuration: Missing required parameter: signingKey");
}
if (!options.mongoUri) {
throw new LabelerServerError("Invalid server configuration: Missing required parameter: mongoUri");
}
// Validate the server's DID from the start
validateDid(options.did);
// Initialize MongoDB client
this._db = new MongoDBClient(options.mongoUri);
this._did = options.did;
// Initialize the signer
this._initializeSigner = Secp256k1Keypair.import(options.signingKey).then(keypair => {
this._signer = keypair;
}).catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
this._initializationError = new Error(`Failed to initialize signer: ${errorMessage}`);
throw this._initializationError;
});
}
catch (error) {
if (error instanceof Error) {
throw new LabelerServerError(`Invalid server configuration: ${error.message}`, error);
}
throw new LabelerServerError(`Invalid server configuration: ${String(error)}`);
}
}
/**
* Establishes a connection to the MongoDB instance.
*
* This method should be called before performing any database operations
* to ensure that the database connection is properly established.
*
* @throws {LabelerServerError} If the connection attempt fails
*/
async connect() {
try {
await this.db.connect();
}
catch (error) {
throw new LabelerServerError(`Failed to connect to database: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Close the connection to the MongoDB instance.
*
* This method should be called when the LabelerServer is no longer needed.
* @throws {LabelerServerError} If closing the connection fails
*/
async close() {
try {
await this.db.close();
}
catch (error) {
if (error instanceof Error) {
throw new LabelerServerError("Failed to close database connection", error);
}
else {
throw new LabelerServerError("Failed to close database connection", new Error(String(error)));
}
}
}
/**
* Signs a label using the server's signing key.
*/
async _signLabel(unsignedLabel) {
try {
await this.getInitializationPromise();
const sig = await this._signer.sign(Buffer.from(JSON.stringify(unsignedLabel)));
return { ...unsignedLabel, sig: new Uint8Array(sig) };
}
catch (error) {
throw new LabelerServerError("Failed to sign label", error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Creates a new signed label using the provided data.
*
* This function constructs an unsigned label with the current timestamp and
* the source set to the provided source or the server's DID. It then signs
* the label using the server's signing key and saves it to the database.
*
* @param data - The data required to create a label.
* @param allowExpired - Whether to allow expired timestamps
* @returns A promise that resolves to the signed label.
* @throws {LabelerServerError} If validation fails or label creation fails
*/
async createLabel(data, allowExpired = false) {
try {
await this.getInitializationPromise();
// Validate version
if (data.ver !== 1) {
throw new AtProtocolValidationError("Label validation failed: Label version must be 1");
}
// Validate label value
validateVal(data.val);
// Validate URI
validateUri(data.uri);
// Validate URI and CID relationship
if (data.uri.startsWith('did:')) {
if (data.cid) {
throw new AtProtocolValidationError("CID cannot be provided for DID URIs");
}
}
else if (data.uri.startsWith('at://')) {
if (data.cid) {
validateCid(data.cid);
}
else {
console.warn('Warning: Creating a label for an at:// URI without a CID. ' +
'Best practice is to include a CID for content-specific labels to reference a specific version. ' +
'The client should obtain the appropriate CID for the content being labeled.');
}
}
else {
throw new AtProtocolValidationError("URI must start with either \"did:\" or \"at://\"");
}
// Validate source DID if provided
if (data.src) {
validateDid(data.src);
}
// Generate current timestamp if not provided
const cts = data.cts ?? new Date().toISOString();
// Validate timestamps
validateCts(cts);
if (data.exp) {
validateExp(data.exp, allowExpired);
}
const unsignedLabel = {
ver: 1,
val: data.val,
uri: data.uri,
...(data.cid ? { cid: data.cid } : {}),
...(data.neg ? { neg: true } : {}),
exp: data.exp,
cts,
src: data.src ?? this.did,
};
// Sign the label
const signedLabel = await this._signLabel(unsignedLabel);
const savedLabel = {
...unsignedLabel,
sig: signedLabel.sig,
// eslint-disable-next-line @typescript-eslint/naming-convention
_id: new ObjectId(),
};
await this.db.saveLabel(savedLabel);
return signedLabel;
}
catch (error) {
if (error instanceof AtProtocolValidationError) {
throw new LabelerServerError(`Label validation failed: ${error.message}`, error);
}
throw new LabelerServerError("Failed to create label", error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Query all labels from the database.
*
* @param query - Optional query parameters
* @returns A promise that resolves to an array of signed labels
* @throws {LabelerServerError} If the query operation fails
*/
async queryLabels(query) {
try {
await this.getInitializationPromise();
// Validate expiration timestamp if present
if (query?.exp) {
validateExp(query.exp, query.allowExpired);
}
const labels = await this.db.findLabels(query || {});
if (!Array.isArray(labels)) {
throw new Error("Invalid response from database");
}
// Filter out expired labels if allowExpired is not true
const filteredLabels = !query?.allowExpired
? labels.filter(label => !label.exp || new Date(label.exp) > new Date())
: labels;
// Convert ArrayBuffer to Uint8Array for the signature
return filteredLabels.map(label => ({
...label,
sig: new Uint8Array(label.sig)
}));
}
catch (error) {
if (error instanceof AtProtocolValidationError) {
throw new LabelerServerError(`Label validation failed: ${error.message}`, error);
}
throw new LabelerServerError("Failed to query labels", error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Query a specific label from the database by its ID.
*
* @param id - The ID of the label to query.
* @returns A promise that resolves to the signed label if found, or null if not found.
* @throws {LabelerServerError} If the query operation fails
*/
async queryLabel(id) {
try {
await this.getInitializationPromise();
// eslint-disable-next-line @typescript-eslint/naming-convention
const label = await this.db.findOne({ _id: id });
if (!label) {
return null;
}
return {
...label,
sig: new Uint8Array(label.sig),
};
}
catch (error) {
throw new LabelerServerError("Failed to query label", error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Deletes a label from the database.
*
* This function first queries the label from the database to verify its existence.
* If the label exists, it creates a new label with the same properties as the original,
* but sets the neg field to true. It then signs the new label and saves it to the
* database. If the label does not exist, the function does nothing.
*
* @param id - The ID of the label to delete.
* @returns A promise that resolves to the signed label with negation if successful, or null if not found.
* @throws {LabelerServerError} If the deletion operation fails
*/
async deleteLabel(id) {
try {
await this.getInitializationPromise();
// eslint-disable-next-line @typescript-eslint/naming-convention
const label = await this.db.findOne({ _id: id });
if (!label) {
return null;
}
const unsignedLabel = {
...label,
neg: true,
};
let signedLabel;
try {
// Sign the label
signedLabel = await this._signLabel(unsignedLabel);
}
catch (error) {
throw new LabelerServerError("Failed to delete label", error instanceof Error ? error : new Error(String(error)));
}
const savedLabel = {
...unsignedLabel,
sig: signedLabel.sig,
// eslint-disable-next-line @typescript-eslint/naming-convention
_id: new ObjectId(),
};
try {
await this.db.saveLabel(savedLabel);
}
catch (error) {
throw new LabelerServerError(`Failed to save negated label to database: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : new Error(String(error)));
}
return signedLabel;
}
catch (error) {
if (error instanceof LabelerServerError) {
throw error;
}
throw new LabelerServerError("Failed to delete label", error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Reverses the negation of a label in the database.
*
* This function first queries the label from the database to verify its existence.
* If the label exists, it creates a new label with the same properties as the original,
* but sets the neg field to the opposite of the original. It then signs the new label.
* If the save parameter is true, it saves the new label to the database. If the label
* does not exist, the function does nothing.
*
* @param id - The ID of the label to reverse the negation of.
* @param save - Whether to save the new label to the database. Defaults to false.
* @returns A promise that resolves to the signed label with the reversed negation if
* the label exists, or null if not.
*/
async reverseLabelNegation(id, save = false) {
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
const labels = await this.db.findLabels({ _id: id });
if (!Array.isArray(labels) || labels.length === 0) {
return null;
}
const label = labels[0];
const unsignedLabel = {
...label,
neg: !label.neg,
};
// Sign the label
const signedLabel = await this._signLabel(unsignedLabel);
if (save) {
const savedLabel = {
...unsignedLabel,
sig: signedLabel.sig,
// eslint-disable-next-line @typescript-eslint/naming-convention
_id: new ObjectId(),
};
await this.db.updateLabel(id, savedLabel);
}
return signedLabel;
}
catch (error) {
throw new LabelerServerError("Failed to reverse label negation", error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Retrieve a limited number of labels from the database that have an ID greater than the specified cursor.
*
* @param cursor - The ID after which labels should be retrieved.
* @param limit - The maximum number of labels to return.
* @returns A promise that resolves to an array of labels with IDs greater than the cursor.
* @throws {LabelerServerError} If the query operation fails
*/
async getLabelsAfterCursor(cursor, limit) {
try {
await this.getInitializationPromise();
const labels = await this.db.findLabels(
// eslint-disable-next-line @typescript-eslint/naming-convention
{ _id: { $gt: cursor } },
// eslint-disable-next-line @typescript-eslint/naming-convention
{ limit, sort: { _id: 1 } });
if (!Array.isArray(labels)) {
throw new Error("Invalid response from database");
}
// Convert ArrayBuffer to Uint8Array for the signature
return labels.map(label => ({
...label,
sig: new Uint8Array(label.sig)
}));
}
catch (error) {
throw new LabelerServerError("Failed to retrieve labels after cursor", error instanceof Error ? error : new Error(String(error)));
}
}
}