/**
* 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()
}
}