Source: signer.js

/**
 * @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
}