OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / multiwriter / shared-registry.js

377 lines · javascript ref:
/**
 * SharedRegistry — wraps Autobase with convenience methods for
 * user / notebook / permission CRUD in the shared registry.
 */

import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import { randomBytes } from 'hypercore-crypto'
import { registryApply, encodeOp } from './registry-apply.js'

export class SharedRegistry {
  constructor (corestore, opts = {}) {
    const ns = opts.bootstrapKey
      ? 'shared-registry-' + (Buffer.isBuffer(opts.bootstrapKey)
          ? opts.bootstrapKey.toString('hex').substring(0, 16)
          : opts.bootstrapKey.substring(0, 16))
      : 'shared-registry-local'
    this.store = corestore.namespace(ns)
    const handlers = {
      open: (store) => {
        const core = store.get('view', { valueEncoding: 'json' })
        return new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
      },
      apply: registryApply,
      valueEncoding: 'binary'
    }
    // Stable per-instance writer keypair (optional). Fixing the Autobase local
    // writer keypair makes localWriterKey DETERMINISTIC and bootstrap-INDEPENDENT,
    // so a joining instance knows its final writer key BEFORE re-initialising
    // against a shared bootstrap. That lets writer admission ride the
    // authenticated blind-pairing handshake (key sent as signed userData, bound
    // to the paired identity) instead of an unauthenticated HTTP callback token.
    if (opts.keyPair) handlers.keyPair = opts.keyPair
    // Autobase v7: bootstrap key is the second positional arg, NOT a handler property
    if (opts.bootstrapKey) {
      this.base = new Autobase(this.store, opts.bootstrapKey, handlers)
    } else {
      this.base = new Autobase(this.store, handlers)
    }
  }

  async ready () {
    await this.base.ready()
  }

  get bootstrapKey () {
    return this.base.key
  }

  get discoveryKey () {
    return this.base.discoveryKey
  }

  get localWriterKey () {
    return this.base.local.key
  }

  get view () {
    return this.base.view
  }

  async update () {
    await this.base.update()
  }

  replicate (stream) {
    return this.store.replicate(stream)
  }

  // ── Writers ──

  async addWriter (writerKey) {
    await this.base.append(encodeOp({
      type: 'add-writer',
      writerKey: Buffer.isBuffer(writerKey) ? writerKey.toString('hex') : writerKey,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  // ── Users ──

  async createUser ({ userId, name, role, identityKey } = {}) {
    const id = userId || randomBytes(16).toString('hex')
    await this.base.append(encodeOp({
      type: 'create-user',
      userId: id,
      name: name || '',
      role: role || 'read',
      identityKey: identityKey || null,
      timestamp: Date.now()
    }))
    await this.base.update()
    return { userId: id }
  }

  async getUser (userId) {
    await this.base.update()
    const entry = await this.base.view.get(`user:${userId}`)
    return entry ? entry.value : null
  }

  async getUserByIdentity (identityKey) {
    await this.base.update()
    const entry = await this.base.view.get(`identity:${identityKey}`)
    if (!entry) return null
    return this.getUser(entry.value.userId)
  }

  async listUsers () {
    await this.base.update()
    const users = []
    const stream = this.base.view.createReadStream({ gte: 'user:', lt: 'user;' })
    for await (const entry of stream) {
      users.push(entry.value)
    }
    return users
  }

  async updateUser (userId, fields) {
    await this.base.append(encodeOp({
      type: 'update-user',
      userId,
      fields,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async deleteUser (userId) {
    await this.base.append(encodeOp({
      type: 'delete-user',
      userId,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  // ── Notebooks ──

  async createNotebook ({ slug, title, owner } = {}) {
    const notebookId = randomBytes(16).toString('hex')
    await this.base.append(encodeOp({
      type: 'create-notebook',
      notebookId,
      slug: slug || notebookId,
      title: title || '',
      owner: owner || null,
      shared: false,
      timestamp: Date.now()
    }))
    await this.base.update()
    return { notebookId, slug: slug || notebookId }
  }

  async getNotebook (notebookId) {
    await this.base.update()
    const entry = await this.base.view.get(`notebook:${notebookId}`)
    return entry ? entry.value : null
  }

  async getNotebookBySlug (slug) {
    await this.base.update()
    const entry = await this.base.view.get(`notebook-slug:${slug}`)
    if (!entry) return null
    return this.getNotebook(entry.value.notebookId)
  }

  async listNotebooks () {
    await this.base.update()
    const notebooks = []
    const stream = this.base.view.createReadStream({ gte: 'notebook:', lt: 'notebook;' })
    for await (const entry of stream) {
      // notebook-slug: entries sort after notebook: but before notebook;
      // however notebook-slug starts with 'notebook-' which is < 'notebook:'
      // so they won't appear in this range. But be defensive:
      if (entry.key && entry.key.startsWith('notebook-slug:')) continue
      notebooks.push(entry.value)
    }
    return notebooks
  }

  async shareNotebook (notebookId, autobaseKey) {
    await this.base.append(encodeOp({
      type: 'update-notebook',
      notebookId,
      fields: {
        shared: true,
        autobaseKey: autobaseKey || null
      },
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async unshareNotebook (notebookId) {
    await this.base.append(encodeOp({
      type: 'update-notebook',
      notebookId,
      fields: {
        shared: false,
        autobaseKey: null
      },
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async deleteNotebook (notebookId) {
    await this.base.append(encodeOp({
      type: 'delete-notebook',
      notebookId,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  // ── Permissions ──

  async setPermission (userId, role, opts = {}) {
    await this.base.append(encodeOp({
      type: 'set-permission',
      userId,
      role,
      scope: opts.scope || '*',
      notebookId: opts.notebookId || null,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async getPermission (userId) {
    await this.base.update()
    const entry = await this.base.view.get(`permission:${userId}`)
    return entry ? entry.value : null
  }

  // ── Instance linking ──

  async listLinkedInstances () {
    await this.base.update()
    const instances = []
    const stream = this.base.view.createReadStream({ gte: 'instance:', lt: 'instance;' })
    for await (const entry of stream) {
      instances.push(entry.value)
    }
    return instances
  }

  async linkInstance (instanceKey, adminKey) {
    await this.base.append(encodeOp({
      type: 'link-instance',
      instanceKey,
      adminKey: adminKey || null,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async unlinkInstance (instanceKey) {
    await this.base.append(encodeOp({
      type: 'unlink-instance',
      instanceKey,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  // ── Notebook writer registration (for cross-instance write access) ──

  async registerNotebookWriter (slug, instanceKey, writerKey) {
    await this.base.append(encodeOp({
      type: 'register-notebook-writer',
      slug,
      instanceKey,
      writerKey: Buffer.isBuffer(writerKey) ? writerKey.toString('hex') : writerKey,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async listNotebookWriters (slug) {
    await this.base.update()
    const writers = []
    const stream = this.base.view.createReadStream({ gte: `nb-writer:${slug}:`, lt: `nb-writer:${slug};` })
    for await (const entry of stream) {
      writers.push(entry.value)
    }
    return writers
  }

  // ── Drive registration (for mirror sync) ──

  async registerDrive (slug, driveKey, discoveryKey, opts = {}) {
    await this.base.append(encodeOp({
      type: 'register-drive',
      slug,
      driveKey,
      discoveryKey,
      sourceInstance: opts.sourceInstance || null,
      title: opts.title || slug,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async unregisterDrive (slug) {
    await this.base.append(encodeOp({
      type: 'unregister-drive',
      slug,
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async listRegisteredDrives () {
    await this.base.update()
    const drives = []
    const stream = this.base.view.createReadStream({ gte: 'drive:', lt: 'drive;' })
    for await (const entry of stream) {
      drives.push(entry.value)
    }
    return drives
  }

  async getRegisteredDrive (slug) {
    await this.base.update()
    const entry = await this.base.view.get(`drive:${slug}`)
    return entry ? entry.value : null
  }

  // ── Identity sync (for mirror auth) ──

  async syncIdentity (userId, userData) {
    await this.base.append(encodeOp({
      type: 'sync-identity',
      userId,
      username: userData.username || null,
      name: userData.name || '',
      globalPermissions: userData.globalPermissions || ['read'],
      notebooks: userData.notebooks || [],
      publicKey: userData.publicKey || null,
      identityMethod: userData.identityMethod || 'web-identity',
      timestamp: Date.now()
    }))
    await this.base.update()
  }

  async getSharedUser (userId) {
    await this.base.update()
    const entry = await this.base.view.get(`shared-user:${userId}`)
    return entry ? entry.value : null
  }

  async getSharedUserByPublicKey (publicKey) {
    await this.base.update()
    const ref = await this.base.view.get(`shared-pubkey:${publicKey}`)
    if (!ref) return null
    return this.getSharedUser(ref.value.userId)
  }

  async listSharedUsers () {
    await this.base.update()
    const users = []
    const stream = this.base.view.createReadStream({ gte: 'shared-user:', lt: 'shared-user;' })
    for await (const entry of stream) {
      users.push(entry.value)
    }
    return users
  }

  async close () {
    await this.base.close()
    await this.store.close()
  }
}