OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity-core / lib / operations.js

197 lines · javascript ref:
import b4a from 'b4a'

/**
 * Identity operation types for Autobase
 */
export const OpTypes = {
  CREATE_USER: 'create-user',
  UPDATE_USER: 'update-user',
  DELETE_USER: 'delete-user',
  ADD_IDENTITY: 'add-identity',
  REMOVE_IDENTITY: 'remove-identity',
  LINK_IDENTITY: 'link-identity',
  UPGRADE_PERMISSIONS: 'upgrade-permissions',
  MERGE_ACCOUNTS: 'merge-accounts',
  REVOKE_IDENTITY: 'revoke-identity',
  RESTORE_IDENTITY: 'restore-identity',
  SET_USERNAME: 'set-username',
  DEACTIVATE_USER: 'deactivate-user',
  REACTIVATE_USER: 'reactivate-user',
  // F.7.2 — link-identity ceremony. CREATE stores a hashed token tied to a
  // user; CONSUME validates expiry + single-use, attaches a new identity
  // to the target user, and optionally deactivates an emptied throwaway.
  CREATE_LINK_TOKEN: 'create-link-token',
  CONSUME_LINK_TOKEN: 'consume-link-token',
  // Multi-writer — expand the Autobase writer set when a new device is
  // linked to the identity. Recorded in the `writer:<key>` view for
  // audit/introspection, and propagated to the Autobase host via
  // host.addWriter in the apply function so the new device's local
  // core contributes to the linearized view (true p2p, not hub-spoke).
  ADD_WRITER: 'add-writer',
  REMOVE_WRITER: 'remove-writer',
  // Multi-autobase coordination. A linked device running a mirror of a
  // multi-autobase app (e.g. pear-git-v3 with orgs/issue-tracker
  // autobases alongside this one) appends this op with the auto-gen
  // local.key of each OTHER autobase it opens. The primary daemon
  // (which IS a writer on those other autobases) watches the view and
  // issues a canonical in-band `addWriter` op per namespace. This op
  // is a control-plane message for the enclosing app — the apply
  // here only records it, no side effects. See pear-git-v3 docs.
  REGISTER_MIRROR_WRITERS: 'register-mirror-writers',
  // Full identity record register. Writes the identity record (method,
  // publicKey, credentials, fingerprint reverse-lookups) into the
  // Autobase view so mirror peers replicate it. Replaces the previous
  // direct-write to a per-daemon Hyperbee, which was local-only and
  // broke cross-daemon visibility (mirrors saw user.identities=[<id>]
  // but had no full record → "Linked identities (0)" / "No SSH keys
  // linked" symptom).
  // See pear-git-v3/test/identity-record-replication.test.js for the
  // mechanical proof + control case.
  REGISTER_IDENTITY: 'register-identity'
}

/**
 * Encode operation for Autobase
 * @param {Object} op - Operation object
 * @returns {Buffer}
 */
export function encodeOp (op) {
  // ⛔ THE CLOCK IS STAMPED HERE, ONCE, ON THE AUTHORING PEER — never inside apply.
  //
  // apply is replayed on every boot and every reorg, and each peer replays independently. A
  // `Date.now()` inside it means two peers replaying the SAME ops write DIFFERENT view bytes, and
  // in the one place apply branched on the clock (link-token expiry) it meant they reached
  // different STATE — one peer consuming a token the other rejected.
  //
  // Putting it in the op makes it part of the bytes every peer agrees on, so replay is a pure
  // function of the log. Same fix hyper-rooms uses (lib/room.js:220,260,273 write it, :418 reads
  // `op.timestamp || Date.now()`).
  //
  // The field is `timestamp` to match hyper-rooms (lib/room.js:220,260,273), the reference
  // implementation on this same substrate — one convention across the stack, not a second one.
  //
  // `...op` comes AFTER so an explicit timestamp wins: the timestamp-preserving paths (a reconciler
  // re-emitting a record it already holds) must not have their clock bumped by a re-encode.
  return b4a.from(JSON.stringify({ timestamp: Date.now(), ...op }))
}

/**
 * Decode operation from Autobase
 * @param {Buffer} buf - Encoded operation
 * @returns {Object}
 */
export function decodeOp (buf) {
  return JSON.parse(b4a.toString(buf))
}

/**
 * Validate operation structure
 * @param {Object} op - Operation to validate
 * @returns {{ valid: boolean, error?: string }}
 */
export function validateOp (op) {
  if (!op || !op.type) return { valid: false, error: 'Missing op type' }

  switch (op.type) {
    case OpTypes.CREATE_USER:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      if (!op.name) return { valid: false, error: 'Missing name' }
      return { valid: true }

    case OpTypes.UPDATE_USER:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      return { valid: true }

    case OpTypes.DELETE_USER:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      return { valid: true }

    case OpTypes.ADD_IDENTITY:
    case OpTypes.REMOVE_IDENTITY:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      if (!op.identityId) return { valid: false, error: 'Missing identityId' }
      return { valid: true }

    case OpTypes.LINK_IDENTITY:
      if (!op.identityId) return { valid: false, error: 'Missing identityId' }
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      return { valid: true }

    case OpTypes.UPGRADE_PERMISSIONS:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      if (!op.resourceName) return { valid: false, error: 'Missing resourceName' }
      if (!Array.isArray(op.permissions)) return { valid: false, error: 'permissions must be array' }
      return { valid: true }

    case OpTypes.MERGE_ACCOUNTS:
      if (!op.primaryUserId) return { valid: false, error: 'Missing primaryUserId' }
      if (!op.secondaryUserId) return { valid: false, error: 'Missing secondaryUserId' }
      return { valid: true }

    case OpTypes.REVOKE_IDENTITY:
    case OpTypes.RESTORE_IDENTITY:
      if (!op.identityId) return { valid: false, error: 'Missing identityId' }
      return { valid: true }

    case OpTypes.SET_USERNAME:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      if (!op.username) return { valid: false, error: 'Missing username' }
      return { valid: true }

    case OpTypes.DEACTIVATE_USER:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      return { valid: true }

    case OpTypes.REACTIVATE_USER:
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      return { valid: true }

    case OpTypes.CREATE_LINK_TOKEN:
      if (!op.tokenHash) return { valid: false, error: 'Missing tokenHash' }
      if (!op.userId) return { valid: false, error: 'Missing userId' }
      if (typeof op.expiresAt !== 'number') return { valid: false, error: 'Missing expiresAt' }
      return { valid: true }

    case OpTypes.CONSUME_LINK_TOKEN:
      if (!op.tokenHash) return { valid: false, error: 'Missing tokenHash' }
      if (!op.newIdentityId) return { valid: false, error: 'Missing newIdentityId' }
      return { valid: true }

    case OpTypes.ADD_WRITER:
    case OpTypes.REMOVE_WRITER:
      if (!op.writerKey || typeof op.writerKey !== 'string' || op.writerKey.length !== 64) {
        return { valid: false, error: 'writerKey must be 64-char hex' }
      }
      return { valid: true }

    case OpTypes.REGISTER_MIRROR_WRITERS:
      if (!op.devicePubkey || typeof op.devicePubkey !== 'string' || op.devicePubkey.length !== 64) {
        return { valid: false, error: 'devicePubkey must be 64-char hex' }
      }
      if (!op.keys || typeof op.keys !== 'object') {
        return { valid: false, error: 'keys must be a namespace→hex map' }
      }
      for (const [ns, key] of Object.entries(op.keys)) {
        if (typeof key !== 'string' || key.length !== 64) {
          return { valid: false, error: `keys[${ns}] must be 64-char hex` }
        }
      }
      return { valid: true }

    case OpTypes.REGISTER_IDENTITY:
      if (!op.identity || typeof op.identity !== 'object') {
        return { valid: false, error: 'identity object required' }
      }
      if (!op.identity.identityId || typeof op.identity.identityId !== 'string') {
        return { valid: false, error: 'identity.identityId required' }
      }
      if (!op.identity.method || typeof op.identity.method !== 'string') {
        return { valid: false, error: 'identity.method required' }
      }
      return { valid: true }

    default:
      return { valid: false, error: `Unknown op type: ${op.type}` }
  }
}