/**
* signed-rpc — browser RPC helper that signs every call with canonical
* anproto. Replaces the auth-challenge / auth-verify / session-cookie
* dance with a per-call signed envelope.
*
* Wire format canonicalized in `spec/flows/auth-envelope.md`.
*
* Usage:
* import { signRpc, signedFetch, getKeypair } from './signed-rpc.js'
*
* const result = await signRpc('create-issue', {
* repo: 'pear-git',
* title: 'bug'
* })
*
* Keypair lives in localStorage['pgv3-keypair']. `getKeypair()` returns
* (and if needed creates) the 132-char canonical anproto keypair.
*
* Uses the vendored `./anproto-client.js` (pure-JS noble-ed25519 +
* noble-sha512) so it works on HTTP (no crypto.subtle for signing)
* and doesn't need a bare-specifier import map in the browser.
*/
/* global localStorage, fetch, crypto */
import { ANProtoClient } from './anproto-client.js'
const STORAGE_KEY = 'pgv3-keypair'
const RPC_ENDPOINT = ((document.body && document.body.dataset.prefix) || '') + '/api/rpc' // server-side route lives at /api/rpc (see spec/flows/auth-envelope.md)
const NONCE_BYTES = 12
/**
* Get the browser's anproto keypair. Lazy-creates on first call and
* persists. 132-char canonical form: pubkey(44 b64) + secret(88 b64).
*
* @returns {Promise<string>} 132-char keypair
*/
export async function getKeypair () {
let kp = null
try { kp = localStorage.getItem(STORAGE_KEY) } catch {}
if (kp && typeof kp === 'string' && kp.length === 132) return kp
kp = await ANProtoClient.gen()
try { localStorage.setItem(STORAGE_KEY, kp) } catch {}
return kp
}
/**
* Get the browser's public key (hex, 64 chars). Useful for display +
* IdentityRegistry lookups.
*/
export async function getPublicKeyHex () {
const kp = await getKeypair()
return ANProtoClient.getPublicKeyHex(kp)
}
/**
* Clear the browser's stored keypair. UI "sign out" action. Next call
* to `getKeypair` generates a fresh identity.
*/
export function clearIdentity () {
try { localStorage.removeItem(STORAGE_KEY) } catch {}
}
/**
* Sign and send an RPC. Returns the decoded JSON response or throws.
*
* On transport failure (network error) → throws a `TypeError`.
* On server-side envelope rejection → throws an Error with `.code`
* (one of the EnvelopeError codes from envelope.js).
*
* @param {string} method — RPC method name
* @param {object} [params] — arguments (serialized into the signed payload)
* @param {object} [opts]
* opts.keypair — override the stored keypair (for tests)
* opts.endpoint — override /rpc (for proxied deployments)
* @returns {Promise<any>} — the decoded response JSON
*/
export async function signRpc (method, params = {}, opts = {}) {
const keypair = opts.keypair || await getKeypair()
const endpoint = opts.endpoint || RPC_ENDPOINT
const nonce = randomNonceHex()
const payload = JSON.stringify({ method, params, nonce })
// ANProtoClient.sign(data, keypair) produces the canonical wire
// format: pubkey(44) + base64(signature(64) || ts+hash(data)). That
// matches what server's `an.open` + envelope verifier expect for
// `sig` — the server re-hashes `payload`, compares, then trusts the
// decoded method/params.
const sig = await ANProtoClient.sign(payload, keypair)
const envelope = { payload, sig }
let res
try {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(envelope),
credentials: 'same-origin' // allow set-cookie from /api/rpc
})
} catch (err) {
throw err
}
const bodyText = await res.text()
let body
try { body = bodyText ? JSON.parse(bodyText) : null } catch { body = null }
if (!res.ok) {
const err = new Error(
(body && body.error) || 'RPC failed (status ' + res.status + ')'
)
err.status = res.status
err.code = body && body.code
err.body = body
throw err
}
return body
}
/**
* signedFetch — lower-level helper for callers who want the raw Response
* object. Wraps arbitrary HTTP calls with envelope auth, e.g. for binary
* uploads the server still requires caller identity for.
*
* NOT for GET routes; those are unauthenticated by design.
*
* @param {string} url
* @param {object} init — fetch init. `init.body` should be the envelope's
* payload (stringified JSON object containing
* the method-specific params + nonce).
*/
export async function signedFetch (url, init = {}) {
const keypair = init.keypair || await getKeypair()
const bodyStr = typeof init.body === 'string'
? init.body
: JSON.stringify(init.body || {})
const sig = await ANProtoClient.sign(bodyStr, keypair)
const headers = Object.assign({}, init.headers || {}, {
'Content-Type': 'application/json',
'X-Pgv3-Sig': sig
})
return fetch(url, {
...init,
body: bodyStr,
headers,
credentials: 'same-origin'
})
}
// --- helpers ---
function randomNonceHex () {
const bytes = new Uint8Array(NONCE_BYTES)
crypto.getRandomValues(bytes)
let hex = ''
for (const b of bytes) hex += b.toString(16).padStart(2, '0')
return hex
}
// Exposed on window for legacy code paths (and debugging)
if (typeof window !== 'undefined') {
window.__pgv3SignRpc = signRpc
window.__pgv3GetPublicKeyHex = getPublicKeyHex
}