OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / browser-state-ops.cjs

106 lines · javascript ref:
// Browser-state (multi-device sync) operation schema + validation (PURE).
//
// Rollout Phase 4 (docs/AUTOBEE-RESEARCH.md): sync the user's OWN browser
// state across their devices over an ENCRYPTED Autobee. This first slice
// covers bookmarks (history is deferred — privacy/retention). CommonJS so
// Bare can require() it and Node can default-import it for tests.
//
// Constraints honored ("Design Constraints"):
//   - schema-versioned (every op carries `v`)
//   - no wall-clock used for conflict resolution (Autobase linearization is
//     the order; `addedAt` is informational metadata only)
//   - malicious inputs rejected BEFORE append (size + prototype pollution)
//   - private data → the transport is encrypted (see browser-state-sync.cjs)

const SCHEMA_VERSION = 1
const OP_ADD = 'bookmark.add'
const OP_REMOVE = 'bookmark.remove'
const OP_ADD_WRITER = 'writer.add'

const MAX_OP_BYTES = 8 * 1024
const MAX_URL = 2048
const MAX_TITLE = 1024

const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor'])

function clampStr (value, max) {
  if (typeof value !== 'string') return ''
  return value.length > max ? value.slice(0, max) : value
}

function hasUnsafeKey (value, depth = 0) {
  if (!value || typeof value !== 'object' || depth > 6) return false
  for (const key of Object.keys(value)) {
    if (UNSAFE_KEYS.has(key)) return true
    if (hasUnsafeKey(value[key], depth + 1)) return true
  }
  return false
}

function opByteLength (op) {
  try { return Buffer.byteLength(JSON.stringify(op)) } catch { return Infinity }
}

// Whitelist + clamp a bookmark record. `addedAt` is kept only if a finite
// number (informational; never used to resolve conflicts).
function sanitizeBookmark (bm) {
  const src = (bm && typeof bm === 'object') ? bm : {}
  const out = { url: clampStr(src.url, MAX_URL).trim(), title: clampStr(src.title, MAX_TITLE) }
  if (Number.isFinite(src.addedAt)) out.addedAt = src.addedAt
  return out
}

// --- Op constructors ------------------------------------------------------

function addBookmarkOp (bm) {
  const clean = sanitizeBookmark(bm)
  return { v: SCHEMA_VERSION, type: OP_ADD, url: clean.url, bookmark: clean }
}

function removeBookmarkOp (url) {
  return { v: SCHEMA_VERSION, type: OP_REMOVE, url: clampStr(String(url || ''), MAX_URL).trim() }
}

function addWriterOp (keyHex) {
  return { v: SCHEMA_VERSION, type: OP_ADD_WRITER, key: clampStr(String(keyHex || ''), 64) }
}

// --- Validation -----------------------------------------------------------
//
//   { ok: true }                      → apply it
//   { ok: false, retain: true, ... }  → keep in log, ignore in view (fwd-compat)
//   { ok: false, retain: false, ... } → reject before append (abuse/malformed)

function validateOp (op) {
  if (!op || typeof op !== 'object' || Array.isArray(op)) {
    return { ok: false, retain: false, reason: 'not-an-object' }
  }
  if (hasUnsafeKey(op)) return { ok: false, retain: false, reason: 'prototype-pollution' }
  if (opByteLength(op) > MAX_OP_BYTES) return { ok: false, retain: false, reason: 'oversized' }
  if (op.v !== SCHEMA_VERSION) return { ok: false, retain: true, reason: 'unknown-version' }

  switch (op.type) {
    case OP_ADD: {
      if (!op.bookmark || typeof op.bookmark !== 'object') return { ok: false, retain: false, reason: 'add-needs-bookmark' }
      const url = String(op.url || '').trim()
      if (!url) return { ok: false, retain: false, reason: 'add-needs-url' }
      if (String(op.bookmark.url || '').trim() !== url) return { ok: false, retain: false, reason: 'url-mismatch' }
      return { ok: true }
    }
    case OP_REMOVE:
      if (!String(op.url || '').trim()) return { ok: false, retain: false, reason: 'remove-needs-url' }
      return { ok: true }
    case OP_ADD_WRITER:
      if (!/^[0-9a-f]{64}$/i.test(String(op.key || ''))) return { ok: false, retain: false, reason: 'bad-writer-key' }
      return { ok: true }
    default:
      return { ok: false, retain: true, reason: 'unknown-type' }
  }
}

module.exports = {
  SCHEMA_VERSION, OP_ADD, OP_REMOVE, OP_ADD_WRITER, MAX_OP_BYTES,
  clampStr, hasUnsafeKey, opByteLength, sanitizeBookmark,
  addBookmarkOp, removeBookmarkOp, addWriterOp, validateOp
}