/**
* @file Log reviewers in with an account they already have and sign reviews
* through the signer, which holds one key per person.
*/
import { ORIGINAL_API, ORIGINAL_SIGNER, request } from './request.js'
import { submitReview } from './index.js'
// ============================================================================
// Authentication
// ============================================================================
/**
* Build the URL to redirect the user to for login through the signer.
* @param {string} clientId Application client ID (UUID registered with the signer).
* @param {string} redirectUri URL to redirect back to after login.
* @param {string} provider Provider name: 'osm', 'bluesky', 'google', 'github' or 'passkey'.
* A passkey login runs on a page the signer serves and needs no account elsewhere.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @param {string} [state] A random value the app keeps in the browser; the callback
* returns it, and the app accepts the callback only when the two match.
* @param {string} [handle] For 'bluesky': the person's handle or DID, which chooses
* their server; without it the login goes to bsky.social.
* @returns {string} Full login URL to redirect the user to.
*/
function loginUrl(
clientId,
redirectUri,
provider,
signerApi = ORIGINAL_SIGNER,
state,
handle
) {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
provider
})
if (state) params.set('state', state)
if (handle) params.set('handle', handle)
return `${signerApi}/auth/login?${params}`
}
/**
* Extract the session from the OAuth callback redirect.
* The token is in the URL fragment, which the browser never sends to a server.
* @param {string} url The full callback URL string from the redirect.
* @returns {Session} Object with sessionToken, reviewerId, did and state.
*/
function parseCallback(url) {
const hashIndex = url.indexOf('#')
if (hashIndex === -1) {
return { sessionToken: null, reviewerId: null, did: null, state: null }
}
const params = new URLSearchParams(url.substring(hashIndex + 1))
return {
sessionToken: params.get('session_token'),
reviewerId: params.get('reviewer_id'),
did: params.get('did'),
state: params.get('state')
}
}
// ============================================================================
// Review Signing (via signer server)
// ============================================================================
/**
* Sign a review payload using the signer server.
* @param {string} sessionToken Session token from the signer login.
* @param {Payload} payload Review payload to sign.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<SignResponse>} Signed review with JWT, signature, reviewerId, and publicKey.
*/
async function signReview(sessionToken, payload, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/reviews/sign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: sessionToken,
review: payload
})
})
}
/**
* Sign a review via the signer server and submit it to the reviewer API.
* @param {string} sessionToken Session token from the signer login.
* @param {Payload} payload Review payload to sign.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @param {string} [api=ORIGINAL_API] Reviewer API endpoint.
* @returns {Promise<SignResponse>} Result from signing (review is also submitted).
*/
async function signAndSubmitReview(
sessionToken,
payload,
signerApi = ORIGINAL_SIGNER,
api = ORIGINAL_API
) {
const result = await signReview(sessionToken, payload, signerApi)
await submitReview(result.jwt, api)
return result
}
// ============================================================================
// Review Management
// ============================================================================
/**
* Edit an existing review with updated content.
* @param {string} sessionToken Session token from the signer login.
* @param {string} reviewSignature Signature of the review to edit.
* @param {Object} updates Object containing fields to update (rating, opinion, images, metadata).
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @param {string} [api=ORIGINAL_API] Reviewer API endpoint.
* @returns {Promise<SignResponse>} Result of the submission.
*/
function editReview(
sessionToken,
reviewSignature,
updates,
signerApi = ORIGINAL_SIGNER,
api = ORIGINAL_API
) {
const payload = {
sub: `urn:maresi:${reviewSignature}`,
action: 'edit',
...updates
}
return signAndSubmitReview(sessionToken, payload, signerApi, api)
}
/**
* Delete an existing review.
* @param {string} sessionToken Session token from the signer login.
* @param {string} reviewSignature Signature of the review to delete.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @param {string} [api=ORIGINAL_API] Reviewer API endpoint.
* @returns {Promise<SignResponse>} Result of the submission.
*/
function deleteReview(
sessionToken,
reviewSignature,
signerApi = ORIGINAL_SIGNER,
api = ORIGINAL_API
) {
const payload = {
sub: `urn:maresi:${reviewSignature}`,
action: 'delete'
}
return signAndSubmitReview(sessionToken, payload, signerApi, api)
}
/**
* Report abuse for an existing review.
* @param {string} sessionToken Session token from the signer login.
* @param {string} reviewSignature Signature of the review to report.
* @param {string} [reason] Optional reason for the abuse report.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @param {string} [api=ORIGINAL_API] Reviewer API endpoint.
* @returns {Promise<SignResponse>} Result of the submission.
*/
function reportAbuseReview(
sessionToken,
reviewSignature,
reason,
signerApi = ORIGINAL_SIGNER,
api = ORIGINAL_API
) {
const payload = {
sub: `urn:maresi:${reviewSignature}`,
action: 'report_abuse'
}
if (reason) {
payload.opinion = reason
}
return signAndSubmitReview(sessionToken, payload, signerApi, api)
}
/**
* Rate an existing review.
* @param {string} sessionToken Session token from the signer login.
* @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} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @param {string} [api=ORIGINAL_API] Reviewer API endpoint.
* @returns {Promise<SignResponse>} Result of the submission.
*/
function rateReview(
sessionToken,
reviewSignature,
rating,
opinion,
signerApi = ORIGINAL_SIGNER,
api = ORIGINAL_API
) {
const payload = {
sub: `urn:maresi:${reviewSignature}`,
rating
}
if (opinion) {
payload.opinion = opinion
}
return signAndSubmitReview(sessionToken, payload, signerApi, api)
}
// ============================================================================
// Session Management
// ============================================================================
/**
* Revoke a session token.
* @param {string} sessionToken Session token to revoke.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<null>} Resolves once the session is revoked.
*/
function revokeSession(sessionToken, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/auth/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
})
}
/**
* Get a reviewer's public key.
* @param {string} reviewerId Reviewer's unique identifier.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<Object>} Object containing the public key.
*/
function getPublicKey(reviewerId, signerApi = ORIGINAL_SIGNER) {
return request(
`${signerApi}/reviewers/${encodeURIComponent(reviewerId)}/pubkey`,
{
method: 'GET'
}
)
}
// ============================================================================
// Passkeys
// ============================================================================
/**
* Start adding a passkey to the logged-in account. The signer answers with
* the URL of its passkey page; send the browser there. The page returns to
* `redirectUri` with `#passkey=added&state=...` in the fragment, or with
* `#error=...` when the ceremony failed. The session must be younger than
* the signer's freshness window (ten minutes by default), or the call
* rejects with status 403 and the app sends the person through login again.
* @param {string} sessionToken Session token from the signer login.
* @param {string} redirectUri URL to return to; must be on the app's registered list.
* @param {string} [state] A random value the app keeps in the browser and checks on return.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<string>} The page URL to send the browser to.
*/
async function passkeyAddUrl(
sessionToken,
redirectUri,
state,
signerApi = ORIGINAL_SIGNER
) {
const body = { session_token: sessionToken, redirect_uri: redirectUri }
if (state) body.state = state
const { url } = await request(`${signerApi}/passkeys/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
return url
}
/**
* The passkeys of the logged-in account, earliest first.
* @param {string} sessionToken Session token from the signer login.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<Passkey[]>} Each with `id`, `name`, `created_at` and `last_used_at`.
*/
async function listPasskeys(sessionToken, signerApi = ORIGINAL_SIGNER) {
const { passkeys } = await request(`${signerApi}/passkeys/list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
})
return passkeys
}
/**
* Remove a passkey from the logged-in account. The only login of an account
* cannot be removed, and the session must be younger than the signer's
* freshness window, as for `passkeyAddUrl`.
* @param {string} sessionToken Session token from the signer login.
* @param {string} id The passkey's id, as listed.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<Passkey[]>} The passkeys left.
*/
async function removePasskey(sessionToken, id, signerApi = ORIGINAL_SIGNER) {
const { passkeys } = await request(`${signerApi}/passkeys`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken, id })
})
return passkeys
}
// ============================================================================
// Identity
// ============================================================================
/**
* Place a recovery key ahead of the signer's key in the identity's rotation keys.
* With it the person can move the identity away from the signer.
* @param {string} sessionToken Session token from the signer login.
* @param {string} didKey The recovery key as did:key (P-256 `zDn...` or secp256k1 `zQ3s...`).
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<Identity>} The identity with the recovery key set.
*/
function setRecoveryKey(sessionToken, didKey, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/identity/recovery-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken, did_key: didKey })
})
}
/**
* Remove the recovery key from the identity's rotation keys.
* @param {string} sessionToken Session token from the signer login.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<Identity>} The identity without a recovery key.
*/
function removeRecoveryKey(sessionToken, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/identity/recovery-key`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
})
}
/**
* List a key the person holds beside the signer's key. Reviews signed with it
* count under the identity once that key claims the DID with `claimIdentity`.
* @param {string} sessionToken Session token from the signer login.
* @param {string} didKey The key as did:key (P-256 or secp256k1).
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<IdentityKeys>} The document's keys with the key listed.
*/
function addKey(sessionToken, didKey, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/identity/keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken, did_key: didKey })
})
}
/**
* Drop a key the person added. Reviews it signs from then on are its own.
* @param {string} sessionToken Session token from the signer login.
* @param {string} didKey The key as did:key.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<IdentityKeys>} The document's keys without the key.
*/
function removeKey(sessionToken, didKey, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/identity/keys`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken, did_key: didKey })
})
}
/**
* Start using the person's own Bluesky identity instead of the Mangrove one.
* The signer asks their server for a second authorization that allows
* identity operations; the browser goes to the returned page and comes back
* to `redirectUri` with `#link=code_sent&state=...` once the server has
* emailed the person a code, which `confirmIdentity` takes. The session must
* be younger than the signer's freshness window, or the call rejects with 403.
* @param {string} sessionToken Session token from a Bluesky login.
* @param {string} redirectUri URL to return to; must be on the app's registered list.
* @param {string} [state] A random value the app keeps in the browser and checks on return.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<string>} The authorization page to send the browser to.
*/
async function linkIdentityUrl(
sessionToken,
redirectUri,
state,
signerApi = ORIGINAL_SIGNER
) {
const body = { session_token: sessionToken, redirect_uri: redirectUri }
if (state) body.state = state
const { url } = await request(`${signerApi}/identity/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
return url
}
/**
* Finish using the person's own Bluesky identity with the emailed code. Their
* server adds the signer's key to their document, and the reviewer server
* binds it; from then on the session's `did` is their own.
* @param {string} sessionToken Session token from a Bluesky login.
* @param {string} code The code the person's server emailed them.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<string>} The person's DID.
*/
async function confirmIdentity(sessionToken, code, signerApi = ORIGINAL_SIGNER) {
const { did } = await request(`${signerApi}/identity/link/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken, code })
})
return did
}
/**
* Publish the identity to the PLC directory, so AT Protocol software resolves it.
* @param {string} sessionToken Session token from the signer login.
* @param {string} [signerApi=ORIGINAL_SIGNER] Signer API endpoint.
* @returns {Promise<{did: string, url: string}>} The DID and its directory URL.
*/
function publishIdentity(sessionToken, signerApi = ORIGINAL_SIGNER) {
return request(`${signerApi}/identity/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
})
}
export {
// Authentication
loginUrl,
parseCallback,
// Review signing
signReview,
signAndSubmitReview,
// Review management
editReview,
deleteReview,
reportAbuseReview,
rateReview,
// Session management
revokeSession,
getPublicKey,
// Passkeys
passkeyAddUrl,
listPasskeys,
removePasskey,
// Identity
setRecoveryKey,
removeRecoveryKey,
addKey,
removeKey,
publishIdentity,
linkIdentityUrl,
confirmIdentity,
// Utilities
ORIGINAL_SIGNER
}