/**
* Browser Identity for pear-git-v3 (cookie-based auth)
*
* Differs from v1's hyper-identity/browser-identity.js in two ways:
* 1. Generates keypair locally via ANProtoClient.gen() — no server endpoint
* (secret key never leaves the browser)
* 2. Authentication state lives in cookies (pgv3-session), not localStorage
* tokens — so SSR navigation requests pick it up automatically
*
* The localStorage keypair is the user's "identity" for this browser. First
* time the page loads, a fresh keypair is generated and registered with the
* daemon (TOFU). Subsequent loads reuse the keypair and re-authenticate if
* the cookie has expired.
*
* Usage (in pgv3-bootstrap.js, runs on every page):
* import { BrowserIdentity } from './browser-identity.js'
* const id = new BrowserIdentity()
* await id.ensureAuthenticated()
* // After this, the pgv3-session cookie is set and any link click /
* // form submit will be authenticated.
*/
import { ANProtoClient } from './anproto-client.js'
const KEYPAIR_KEY = 'pgv3-keypair'
const V1_KEYPAIR_KEY = 'pear-git-keypair' // v1's localStorage key — bridged at first load (M6)
export class BrowserIdentity {
constructor (opts = {}) {
this._baseUrl = opts.baseUrl || ''
this._keypair = null
}
/**
* Get or generate the persistent Ed25519 keypair for this browser.
* Stored in localStorage so it survives page reloads but stays per-origin.
*
* M6 — v1→v3 bridge: if there's no `pgv3-keypair` but the v1 key
* (`pear-git-keypair`) is present at the same origin, port it over.
* Same 132-char base64 concat format (44 pub + 88 sec), so it's just a
* key-rename. Lets users who had pear-git v1 at this origin keep their
* identity through the upgrade without re-registering.
*/
async getKeypair () {
if (this._keypair) return this._keypair
if (typeof localStorage !== 'undefined') {
let stored = localStorage.getItem(KEYPAIR_KEY)
if (stored && stored.length === 132) {
this._keypair = stored
return this._keypair
}
// M6 bridge — try v1's key
const v1 = localStorage.getItem(V1_KEYPAIR_KEY)
if (v1 && v1.length === 132) {
localStorage.setItem(KEYPAIR_KEY, v1)
// Leave v1 key in place (rollback safety; user can clear later)
this._keypair = v1
// eslint-disable-next-line no-console
console.log('[pgv3] migrated keypair from v1 (' + V1_KEYPAIR_KEY + ' → ' + KEYPAIR_KEY + ')')
return this._keypair
}
}
// Generate locally — secret key never leaves the browser
this._keypair = await ANProtoClient.gen()
if (typeof localStorage !== 'undefined') {
localStorage.setItem(KEYPAIR_KEY, this._keypair)
}
return this._keypair
}
async getPublicKeyHex () {
const kp = await this.getKeypair()
return ANProtoClient.getPublicKeyHex(kp)
}
/**
* Make sure the browser has a valid pgv3-session cookie. If not, do the
* full challenge-response flow with the daemon.
*
* The cookie is HttpOnly so we cannot read it from `document.cookie` —
* we always have to ask the server "who am I?" and let it look at the
* cookie via the Cookie header. This is what /api/auth/me does.
*
* @returns {Promise<{ publicKey: string, fresh: boolean }>}
*/
async ensureAuthenticated () {
// Always ask the server first. Cookies are HttpOnly so we can't peek.
try {
const me = await this._fetch('/api/auth/me')
if (me && me.publicKey) {
return { publicKey: me.publicKey, fresh: false }
}
} catch {
// /api/auth/me threw — fall through to full auth
}
// Full auth flow
const challengeRes = await this._fetch('/api/auth/challenge')
const challenge = challengeRes.challenge
if (!challenge) throw new Error('challenge missing')
const keypair = await this.getKeypair()
// Canonical ANProto: pubkey is embedded in the signature
const signature = await ANProtoClient.sign(challenge, keypair)
const verified = await this._fetch('/api/auth/verify', {
method: 'POST',
body: { signature, challenge }
})
// `fresh: true` means the browser had to run the full challenge/verify
// ceremony. `newUser: true` means the server side created a new User
// record as part of verify — only true on the very first sign-in for
// this keypair. The UI uses newUser (not fresh) to decide whether to
// show the welcome modal.
return {
publicKey: verified.publicKey || ANProtoClient.getPublicKey(keypair),
fresh: true,
newUser: !!verified.newUser
}
}
/**
* Log out — clears the cookie via the daemon.
*/
async logout () {
try {
await this._fetch('/api/auth/logout', { method: 'POST' })
} catch {}
}
/**
* Forget the keypair entirely. Next ensureAuthenticated() will generate
* a fresh identity.
*/
forgetIdentity () {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(KEYPAIR_KEY)
}
this._keypair = null
}
async _fetch (path, opts = {}) {
const url = this._baseUrl + path
const headers = { Accept: 'application/json' }
const init = {
headers,
// Same-origin includes cookies by default, but be explicit
credentials: 'same-origin'
}
if (opts.method) init.method = opts.method
if (opts.body) {
init.method = init.method || 'POST'
headers['Content-Type'] = 'application/json'
init.body = JSON.stringify(opts.body)
}
const res = await fetch(url, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`${res.status} ${text}`)
}
return res.json()
}
}
export default BrowserIdentity