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}` }
}
}