Source: index.js

import {
  arrayBufferToString,
  base64ToU8a,
  base64UrlEncode,
  cleanPayload,
  escapeGeoSub,
  u8aToBase64
} from './utils'

/**
 * @file Main Mangrove Reviews implementation
 * @requires module:mangrove-types
 */

/** The API of the server used for https://mangrove.reviews */
const ORIGINAL_API = 'https://api.mangrove.reviews'

async function request(url, options = {}) {
  try {
    const response = await fetch(url, options)

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`)
    }

    return await response.json()
  } catch (error) {
    console.error('Error fetching data:', error)
    throw error // Re-throw to allow proper error handling by consumers
  }
}

/**
 * Edit an existing review with updated content.
 * @param {Keypair} keypair WebCrypto keypair must be the same keypair used for the original review.
 * @param {string} reviewSignature Signature of the review to edit.
 * @param {Object} updates Object containing fields to update (rating, opinion, images, metadata).
 * @param {string} [api=ORIGINAL_API] API endpoint used for the submission.
 * @returns {Promise<boolean>} Result of the submission.
 */
async function editReview(
  keypair,
  reviewSignature,
  updates,
  api = ORIGINAL_API
) {
  const payload = {
    sub: `urn:maresi:${reviewSignature}`,
    action: 'edit',
    ...updates
  }

  return signAndSubmitReview(keypair, payload, api)
}

/**
 * Delete an existing review.
 * @param {Keypair} keypair WebCrypto keypair must be the same keypair used for the original review.
 * @param {string} reviewSignature Signature of the review to delete.
 * @param {string} [api=ORIGINAL_API] API endpoint used for the submission.
 * @returns {Promise<boolean>} Result of the submission.
 */
async function deleteReview(keypair, reviewSignature, api = ORIGINAL_API) {
  const payload = {
    sub: `urn:maresi:${reviewSignature}`,
    action: 'delete'
  }

  return signAndSubmitReview(keypair, payload, api)
}

/**
 * Report abuse for an existing review.
 * @param {Keypair} keypair WebCrypto keypair can be any valid keypair.
 * @param {string} reviewSignature Signature of the review to report.
 * @param {string} [reason] Optional reason for the abuse report.
 * @param {string} [api=ORIGINAL_API] API endpoint used for the submission.
 * @returns {Promise<boolean>} Result of the submission.
 */
async function reportAbuseReview(
  keypair,
  reviewSignature,
  reason,
  api = ORIGINAL_API
) {
  const payload = {
    sub: `urn:maresi:${reviewSignature}`,
    action: 'report_abuse'
  }

  if (reason) {
    payload.opinion = reason
  }

  return signAndSubmitReview(keypair, payload, api)
}

/**
 * Rate an existing review.
 * @param {Keypair} keypair WebCrypto keypair can be any valid keypair.
 * @param {string} reviewSignature Signature of the review to rate.
 * @param {number} rating Rating value between 0 and 100.
 * @param {string} [opinion] Optional opinion text about the review.
 * @param {string} [api=ORIGINAL_API] API endpoint used for the submission.
 * @returns {Promise<boolean>} Result of the submission.
 */
async function rateReview(
  keypair,
  reviewSignature,
  rating,
  opinion,
  api = ORIGINAL_API
) {
  const payload = {
    sub: `urn:maresi:${reviewSignature}`,
    rating
  }

  if (opinion) {
    payload.opinion = opinion
  }

  return signAndSubmitReview(keypair, payload, api)
}

/** Assembles JWT from base payload, mutates the payload as needed.
 * @param {Keypair} keypair - WebCrypto keypair, can be generated with `generateKeypair`.
 * @param {Payload} payload - Base {@link Payload} to be cleaned, it will be mutated.
 * @returns {Promise<string>} Mangrove Review encoded as JWT.
 */
async function signReview(keypair, payload) {
  const cleanedPayload = cleanPayload(payload)

  const pemPublicKey = await publicToPem(keypair.publicKey)
  const jwkPublicKey = await crypto.subtle.exportKey('jwk', keypair.publicKey)

  const header = {
    alg: 'ES256',
    typ: 'JWT',
    kid: pemPublicKey,
    jwk: JSON.stringify(jwkPublicKey)
  }

  const base64Header = base64UrlEncode(JSON.stringify(header))
  const base64Payload = base64UrlEncode(JSON.stringify(cleanedPayload))
  const dataToSign = `${base64Header}.${base64Payload}`

  // Sign the data with the private key
  const encoder = new TextEncoder()
  const data = encoder.encode(dataToSign)
  const signature = await crypto.subtle.sign(
    {
      name: 'ECDSA',
      hash: { name: 'SHA-256' }
    },
    keypair.privateKey,
    data
  )
  const base64Signature = base64UrlEncode(arrayBufferToString(signature))
  const jwt = `${dataToSign}.${base64Signature}`
  return jwt
}

/**
 * Submit a signed review to be stored in the database.
 * @param {string} jwt Signed review in JWT format.
 * @param {string} [api=ORIGINAL_API] API endpoint used to fetch the data.
 * @returns {Promise<boolean>} Resolves to "true" in case of successful insertion or rejects with errors.
 */
function submitReview(jwt, api = ORIGINAL_API) {
  return request(`${api}/submit/${jwt}`, { method: 'PUT' })
}

/**
 * Composition of `signReview` and `submitReview`.
 * @param {Keypair} keypair WebCrypto keypair can be generated with `generateKeypair`.
 * @param {Payload} payload Base {@link Payload} to be cleaned, it will be mutated.
 * @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
 * @returns {Promise<boolean>} Result of the submission
 */
async function signAndSubmitReview(keypair, payload, api = ORIGINAL_API) {
  const jwt = await signReview(keypair, payload)
  return submitReview(jwt, api)
}

/**
 * Retrieve reviews which fulfill the query.
 * @param {Object} query Query to be passed to API, see the API documentation for examples.
 * @param {string} [query.q] Search for reviews that have this string in `sub` or `opinion` field.
 * @param {string} [query.signature] Review with this `signature` value.
 * @param {string} [query.kid] Reviews by issuer with the following PEM public key.
 * @param {number} [query.iat] Reviews issued at this UNIX time.
 * @param {number} [query.gt_iat] Reviews with UNIX timestamp greater than this.
 * @param {string} [query.sub] Reviews of the given subject URI.
 * @param {number} [query.rating] Reviews with the given rating.
 * @param {string} [query.opinion] Reviews with the given opinion.
 * @param {number} [query.limit] Maximum number of reviews to be returned.
 * @param {number} [query.limit] Maximum number of reviews to be returned.
 * @param {number} [query.latest_edits_only] For edited reviews, return only the most recent version (true by default).
 * @param {boolean} [query.opinionated] Get only reviews with opinion text.
 * @param {boolean} [query.examples] Include reviews of example subjects.
 * @param {boolean} [query.issuers] Include aggregate information about review issuers.
 * @param {boolean} [query.maresi_subjects] Include aggregate information about reviews of returned reviews.
 * @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
 * @returns {Promise<Reviews>} Reviews data
 */
function getReviews(query, api = ORIGINAL_API) {
  const q = {
    ...query,
    ...(query.sub && { sub: escapeGeoSub(query.sub) })
  }

  const params = new URLSearchParams()
  Object.entries(q).forEach(([key, value]) => {
    if (value !== undefined && value !== null) {
      params.append(key, String(value))
    }
  })

  return request(`${api}/reviews?${params}`, {
    method: 'GET',
    headers: { Accept: 'application/json' }
  })
}

/**
 * Get aggregate information about the review subject.
 * @param {string} uri URI of the review subject.
 * @param {string} [api=ORIGINAL_API] API endpoint used to fetch the data.
 * @returns {Promise<Subject>} Subject data
 */
function getSubject(uri, api = ORIGINAL_API) {
  return request(`${api}/subject/${encodeURIComponent(uri)}`, { method: 'GET' })
}

/**
 * Get aggregate information about the reviewer.
 * @param {string} pem - Reviewer public key in PEM format.
 * @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
 * @returns {Promise<Issuer>} Issuer data
 */
function getIssuer(pem, api = ORIGINAL_API) {
  return request(`${api}/issuer/${encodeURIComponent(pem)}`)
}

/**
 * Retrieve aggregates for multiple subjects or issuers.
 * @param {Object} query Batch query listing identifiers to use for fetching.
 * @param {string[]} [query.subs] A list of subject URIs to get aggregates for.
 * @param {string[]} [query.pems] A list of issuer PEM public keys to get aggregates for.
 * @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
 * @returns {Promise<BatchReturn|null>} Batch aggregation data or null if no query params
 */
function batchAggregate(query, api = ORIGINAL_API) {
  if (!query.pems && !query.subs) {
    return null
  }

  return request(`${api}/batch`, {
    method: 'POST',
    body: JSON.stringify(query),
    headers: { 'Content-Type': 'application/json' }
  })
}

/**
 * Generate a new user identity, which can be used for signing reviews and stored for later.
 * [WebCrypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
 * key pair with `privateKey` and `publicKey`
 * @returns {Promise<Keypair>} An ECDSA WebCrypto keypair
 */
async function generateKeypair() {
  const { publicKey, privateKey } = await crypto.subtle.generateKey(
    {
      name: 'ECDSA',
      namedCurve: 'P-256'
    },
    true,
    ['sign', 'verify']
  )

  return { publicKey, privateKey }
}

const PRIVATE_KEY_METADATA = 'Mangrove private key'

/**
 * Come back from JWK representation to representation which allows for signing.
 * Import keys which were exported with `keypairToJwk`.
 * @param {Object} jwk - Private JSON Web Key (JWK) to be converted in to a WebCrypto keypair.
 * @returns {Promise<{publicKey: CryptoKey, privateKey: CryptoKey}>} WebCrypto keypair
 */
async function jwkToKeypair(jwk) {
  // Do not mutate the argument.
  let key = { ...jwk }
  if (!key || key.metadata !== PRIVATE_KEY_METADATA) {
    throw new Error(
      `does not contain the required metadata field "${PRIVATE_KEY_METADATA}"`
    )
  }
  const sk = await crypto.subtle.importKey(
    'jwk',
    key,
    {
      name: 'ECDSA',
      namedCurve: 'P-256'
    },
    true,
    ['sign']
  )
  delete key.d
  delete key.dp
  delete key.dq
  delete key.q
  delete key.qi
  key.key_ops = ['verify']
  const pk = await crypto.subtle.importKey(
    'jwk',
    key,
    {
      name: 'ECDSA',
      namedCurve: 'P-256'
    },
    true,
    ['verify']
  )

  return { privateKey: sk, publicKey: pk }
}

/**
 * Exports a keypair to JSON Web Key (JWK) of the private key.
 * JWK is a format that can be then used to stringify and store.
 * You can later import it back with `jwkToKeypair`.
 * @param {Keypair} keypair - WebCrypto key pair can be generated with `generateKeypair`.
 * @returns {Promise<JsonWebKey>} JWK representation of the keypair
 */
async function keypairToJwk(keypair) {
  const s = await crypto.subtle.exportKey('jwk', keypair.privateKey)
  // @ts-expect-error: TS2339
  s.metadata = PRIVATE_KEY_METADATA
  return s
}

/**
 * Get PEM represenation of the user "password".
 * @param {CryptoKey} key - Private WebCrypto key to be exported.
 * @returns {Promise<string>} PEM format of the private key
 */
async function privateToPem(key) {
  let exported = await crypto.subtle.exportKey('pkcs8', key)
  const exportedAsBase64 = u8aToBase64(exported)
  return `-----BEGIN PRIVATE KEY-----\n${exportedAsBase64}\n-----END PRIVATE KEY-----`
}

/**
 * Get PEM representation of public reviewer identity.
 * This format can be found in the `kid` field of a Mangrove Review Header.
 * @param {CryptoKey} key - Public WebCrypto key to be exported.
 * @returns {Promise<string>} PEM format of the public key
 */
async function publicToPem(key) {
  let exported = await crypto.subtle.exportKey('spki', key)
  const exportedAsBase64 = u8aToBase64(exported)
  // Do not add new lines so that It's copyable from plain string representation.
  return `-----BEGIN PUBLIC KEY-----${exportedAsBase64}-----END PUBLIC KEY-----`
}

/**
 * Convert a PEM private key to JWK format for use with Mangrove
 * @param {string} pemPrivateKey - The private key in PEM format
 * @returns {Promise<JsonWebKey>} The private key in JWK format
 */
async function pemToJwk(pemPrivateKey) {
  const pemHeader = '-----BEGIN PRIVATE KEY-----'
  const pemFooter = '-----END PRIVATE KEY-----'
  const pemContents = pemPrivateKey
    .substring(
      pemPrivateKey.indexOf(pemHeader) + pemHeader.length,
      pemPrivateKey.indexOf(pemFooter)
    )
    .replace(/\s/g, '')
  const byteArray = base64ToU8a(pemContents)
  const cryptoKey = await window.crypto.subtle.importKey(
    'pkcs8',
    byteArray,
    {
      name: 'ECDSA',
      namedCurve: 'P-256'
    },
    true,
    ['sign']
  )
  const jwk = await window.crypto.subtle.exportKey('jwk', cryptoKey)
  // @ts-expect-error: TS2339
  jwk.metadata = PRIVATE_KEY_METADATA
  return jwk
}

// Public API
export {
  // Key management
  generateKeypair,
  jwkToKeypair,
  keypairToJwk,
  publicToPem,
  privateToPem,
  pemToJwk,

  // Review operations
  signReview,
  submitReview,
  signAndSubmitReview,
  editReview,
  deleteReview,
  reportAbuseReview,
  rateReview,

  // Data retrieval
  getReviews,
  getSubject,
  getIssuer,
  batchAggregate,

  // Utilities
  ORIGINAL_API
}