OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / profile.js

209 lines · javascript ref:
/**
 * Profile — the user's self-declared identity attributes.
 *
 * Phase B of the identity plan. A Hyperbee inside the user's Corestore
 * storing profile fields (display name, avatar, bio, email, etc.) and
 * per-app grants that let apps read specific fields.
 *
 * Only the local browser-owned context can WRITE to
 * the profile. Catalogue apps READ the profile only through the
 * `pear.login()` consent ceremony (Phase C), and only the fields the
 * user has explicitly granted.
 *
 * Data layout:
 *   profile!displayName      "Maya"
 *   profile!avatar           "hyper://.../avatar.png"
 *   profile!bio              "P2P enthusiast"
 *   profile!email            "maya@example.com"
 *
 *   grant!<driveKeyHex>      { scopes: [...], grantedAt, expiresAt, appName }
 */

const Hyperbee = require('hyperbee')

const PROFILE_FIELDS = new Set([
  'displayName',
  'avatar',
  'bio',
  'email',
  'pronouns',
  'location',
  'website',
])

class Profile {
  constructor (store) {
    if (!store) throw new Error('Profile requires a Corestore')
    this.store = store
    this._bee = null
    this._ready = false
  }

  async ready () {
    if (this._ready) return
    const core = this.store.get({ name: 'pearbrowser-profile-v1' })
    this._bee = new Hyperbee(core, {
      keyEncoding: 'utf-8',
      valueEncoding: 'json',
    })
    await this._bee.ready()
    this._ready = true
  }

  _requireReady () {
    if (!this._ready) throw new Error('Profile not ready — call ready() first')
  }

  // ---- Profile fields ----

  async getAll () {
    this._requireReady()
    const out = {}
    for await (const entry of this._bee.createReadStream({ gte: 'profile!', lt: 'profile!~' })) {
      const field = entry.key.slice('profile!'.length)
      if (PROFILE_FIELDS.has(field)) out[field] = entry.value
    }
    return out
  }

  async get (field) {
    this._requireReady()
    if (!PROFILE_FIELDS.has(field)) return null
    const entry = await this._bee.get('profile!' + field)
    return entry ? entry.value : null
  }

  async update (updates) {
    this._requireReady()
    if (!updates || typeof updates !== 'object') return {}
    for (const [field, value] of Object.entries(updates)) {
      if (!PROFILE_FIELDS.has(field)) continue
      if (value === null || value === undefined || value === '') {
        await this._bee.del('profile!' + field)
      } else if (typeof value === 'string' && value.length <= 2048) {
        await this._bee.put('profile!' + field, value)
      }
    }
    return this.getAll()
  }

  async clear () {
    this._requireReady()
    for (const field of PROFILE_FIELDS) {
      await this._bee.del('profile!' + field).catch(() => {})
    }
  }

  // ---- Grants (what apps can see what) ----

  /**
   * Record a user's grant to a specific app. Called from the consent
   * ceremony in backend/index.js after the user approves `pear.login()`.
   */
  async setGrant (driveKeyHex, grant) {
    this._requireReady()
    if (typeof driveKeyHex !== 'string' || driveKeyHex.length === 0) {
      throw new Error('driveKeyHex required')
    }
    const record = {
      scopes: Array.isArray(grant.scopes) ? grant.scopes.map(String) : [],
      appName: typeof grant.appName === 'string' ? grant.appName.slice(0, 128) : null,
      grantedAt: Date.now(),
      expiresAt: typeof grant.expiresAt === 'number' ? grant.expiresAt : (Date.now() + 30 * 24 * 60 * 60 * 1000),
    }
    await this._bee.put('grant!' + driveKeyHex, record)
    return record
  }

  async getGrant (driveKeyHex) {
    this._requireReady()
    const entry = await this._bee.get('grant!' + driveKeyHex)
    if (!entry) return null
    const grant = entry.value
    if (grant.expiresAt && grant.expiresAt < Date.now()) {
      await this._bee.del('grant!' + driveKeyHex).catch(() => {})
      return null
    }
    return grant
  }

  async listGrants () {
    this._requireReady()
    const out = []
    const now = Date.now()
    for await (const entry of this._bee.createReadStream({ gte: 'grant!', lt: 'grant!~' })) {
      const driveKeyHex = entry.key.slice('grant!'.length)
      const grant = entry.value
      if (grant.expiresAt && grant.expiresAt < now) {
        await this._bee.del(entry.key).catch(() => {})
        continue
      }
      out.push({ driveKeyHex, ...grant })
    }
    return out
  }

  async revokeGrant (driveKeyHex) {
    this._requireReady()
    await this._bee.del('grant!' + driveKeyHex).catch(() => {})
  }

  async revokeAllGrants () {
    this._requireReady()
    const keys = []
    for await (const entry of this._bee.createReadStream({ gte: 'grant!', lt: 'grant!~' })) {
      keys.push(entry.key)
    }
    for (const key of keys) await this._bee.del(key).catch(() => {})
    return keys.length
  }

  /**
   * Return the subset of the user's profile that the app at driveKeyHex
   * is currently allowed to read. Filters by the app's grant scopes.
   *
   * Scope mapping:
   *   profile:read         → all PROFILE_FIELDS
   *   profile:name         → displayName
   *   profile:avatar       → avatar
   *   profile:email        → email
   *   profile:website      → website
   *   profile:contact      → email + website (legacy bundle)
   *
   * Apps that have no grant receive `null`.
   */
  async getVisibleProfile (driveKeyHex) {
    this._requireReady()
    const grant = await this.getGrant(driveKeyHex)
    if (!grant) return null

    const scopes = new Set(grant.scopes)
    const all = await this.getAll()
    const visible = {}

    if (scopes.has('profile:read')) {
      for (const [k, v] of Object.entries(all)) visible[k] = v
    } else {
      if (scopes.has('profile:name') && all.displayName) visible.displayName = all.displayName
      if (scopes.has('profile:avatar') && all.avatar) visible.avatar = all.avatar
      if (scopes.has('profile:email') && all.email) visible.email = all.email
      if (scopes.has('profile:website') && all.website) visible.website = all.website
      if (scopes.has('profile:contact')) {
        if (all.email) visible.email = all.email
        if (all.website) visible.website = all.website
      }
    }
    return visible
  }

  async close () {
    if (this._bee) {
      try { await this._bee.close() } catch (_) {}
      this._bee = null
    }
    this._ready = false
  }
}

module.exports = { Profile, PROFILE_FIELDS: Array.from(PROFILE_FIELDS) }