import {
base64UrlDecodeText,
base64UrlEncodeText,
base64UrlDecodeBinary,
base64UrlEncodeBinary,
decodeJwtHeader,
decodeJwtPayload,
cleanPayload,
escapeGeoSub
} from './utils'
import {
verifyJwtSignature,
generateKeypair,
jwkToKeypair,
keypairToJwk,
publicToPem,
privateToPem,
pemToJwk,
multikeyFromPem,
didKeyFromPem
} from './crypto.js'
import {
ApiError,
ORIGINAL_API,
ORIGINAL_FEDIVERSE,
request
} from './request.js'
/**
* @file Main Mangrove Reviews implementation
* @requires module:mangrove-types
*/
/**
* 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)
}
/**
* Claim that one subject is equivalent to another.
* @param {Keypair} keypair WebCrypto keypair can be any valid keypair.
* @param {string} sourceSubject URI of the source subject.
* @param {string} targetSubject URI of the equivalent target subject.
* @param {string} [api=ORIGINAL_API] API endpoint used for the submission.
* @returns {Promise<boolean>} Result of the submission.
*/
async function claimEquivalence(
keypair,
sourceSubject,
targetSubject,
api = ORIGINAL_API
) {
const payload = {
sub: sourceSubject,
action: 'equivalence',
opinion: targetSubject
}
return signAndSubmitReview(keypair, payload, api)
}
/**
* Bind the signing key to a decentralized identifier.
* The server accepts the claim only when the DID document lists the key.
* @param {Keypair} keypair WebCrypto keypair whose public key the DID document lists.
* @param {string} did Identifier of the form did:plc:... or did:web:...
* @param {string} [api=ORIGINAL_API] API endpoint used for the submission.
* @returns {Promise<boolean>} Result of the submission.
*/
async function claimIdentity(keypair, did, api = ORIGINAL_API) {
const payload = {
sub: did,
action: 'identity'
}
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 = base64UrlEncodeText(JSON.stringify(header))
const base64Payload = base64UrlEncodeText(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 = base64UrlEncodeBinary(signature)
return `${dataToSign}.${base64Signature}`
}
/**
* 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)}`)
}
/**
* Get an identity by its DID or by a verified alias: `did`, the DID
* `document`, the verified `aliases` (an `at://` handle that resolves to the
* DID, an `https://` page that contains it) and the `source` of the document.
* @param {string} id - A did:plc:... or did:web:... identifier, or an alias
* without its scheme, such as `alice.bsky.social`.
* @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
* @param {boolean} [fresh=false] - Ask the sources again instead of the
* server's cache, when its last resolution is more than a minute old.
* @returns {Promise<Identity>} The identity.
*/
function getIdentity(id, api = ORIGINAL_API, fresh = false) {
const query = fresh ? '?fresh=true' : ''
return request(`${api}/identity/${encodeURIComponent(id)}${query}`)
}
/**
* 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' }
})
}
/**
* Get the Fediverse account of a subject or a reviewer, so a person can follow it from Mastodon.
* The bridge creates the account on the first request. A subject needs no reviews yet;
* a reviewer's key must have at least one review in the dataset.
* @param {Object} target One of the two fields.
* @param {string} [target.sub] Subject URI, the same value used when reading or writing reviews.
* @param {string} [target.kid] Reviewer public key in PEM format.
* @param {string} [bridge=ORIGINAL_FEDIVERSE] Fediverse bridge endpoint.
* @returns {Promise<FediverseHandle>} The handle, the `acct` to display, the actor URL and the display name.
*/
function getFediverseHandle({ sub, kid }, bridge = ORIGINAL_FEDIVERSE) {
if (sub) {
return request(
`${bridge}/subjects/handle?${new URLSearchParams({ sub: escapeGeoSub(sub) })}`
)
}
if (kid) {
return request(`${bridge}/reviewers/handle?${new URLSearchParams({ kid })}`)
}
throw new Error('getFediverseHandle needs a sub or a kid')
}
/**
* Build the link that opens the follow dialog on the person's own Mastodon server.
* @param {string} server Host of the server the person's account is on, such as `mastodon.social`.
* @param {string} actor Actor URL from {@link getFediverseHandle}.
* @returns {string} URL to open in the browser.
*/
function followUrl(server, actor) {
return `https://${server}/authorize_interaction?${new URLSearchParams({ uri: actor })}`
}
// Public API
export {
// Key management
generateKeypair,
jwkToKeypair,
keypairToJwk,
publicToPem,
privateToPem,
pemToJwk,
multikeyFromPem,
didKeyFromPem,
// Review operations
signReview,
submitReview,
signAndSubmitReview,
editReview,
deleteReview,
reportAbuseReview,
claimEquivalence,
claimIdentity,
getIdentity,
rateReview,
// Data retrieval
getReviews,
getSubject,
getIssuer,
batchAggregate,
// Fediverse
getFediverseHandle,
followUrl,
// Utilities
ApiError,
ORIGINAL_API,
ORIGINAL_FEDIVERSE,
// New specialized base64 functions
base64UrlDecodeText,
base64UrlEncodeText,
base64UrlDecodeBinary,
base64UrlEncodeBinary,
// JWT-specific functions
decodeJwtHeader,
decodeJwtPayload,
verifyJwtSignature
}