diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index f190e0b6c..b59b4f073 100644 --- a/packages/access-api/test/helpers/context.js +++ b/packages/access-api/test/helpers/context.js @@ -44,12 +44,12 @@ export async function context() { return { mf, - conn: await connection( + conn: connection({ principal, // @ts-ignore - mf.dispatchFetch.bind(mf), - new URL('http://localhost:8787') - ), + fetch: mf.dispatchFetch.bind(mf), + url: new URL('http://localhost:8787'), + }), service: Signer.parse(bindings.PRIVATE_KEY), issuer: principal, db: new D1QB(db), diff --git a/packages/access-client/package.json b/packages/access-client/package.json index 9d9910c5f..0da196800 100644 --- a/packages/access-client/package.json +++ b/packages/access-client/package.json @@ -28,6 +28,7 @@ "exports": { ".": "./src/index.js", "./agent": "./src/agent.js", + "./drivers/*": "./src/drivers/*.js", "./stores/*": "./src/stores/*.js", "./types": "./src/types.js", "./encoding": "./src/encoding.js" @@ -40,6 +41,9 @@ "types": [ "dist/src/types" ], + "drivers/*": [ + "dist/src/drivers/*" + ], "stores/*": [ "dist/src/stores/*" ], diff --git a/packages/access-client/src/agent-data.js b/packages/access-client/src/agent-data.js new file mode 100644 index 000000000..60265a412 --- /dev/null +++ b/packages/access-client/src/agent-data.js @@ -0,0 +1,145 @@ +import { Signer } from '@ucanto/principal' +import { Signer as EdSigner } from '@ucanto/principal/ed25519' +import { importDAG } from '@ucanto/core/delegation' +import { CID } from 'multiformats' + +/** @typedef {import('./types').AgentDataModel} AgentDataModel */ + +/** @implements {AgentDataModel} */ +export class AgentData { + /** @type {(data: import('./types').AgentDataExport) => Promise | void} */ + #save + + /** + * @param {import('./types').AgentDataModel} data + * @param {import('./types').AgentDataOptions} [options] + */ + constructor(data, options = {}) { + this.meta = data.meta + this.principal = data.principal + this.spaces = data.spaces + this.delegations = data.delegations + this.currentSpace = data.currentSpace + this.#save = options.store ? options.store.save : () => {} + } + + /** + * Create a new AgentData instance from the passed initialization data. + * + * @param {Partial} [init] + * @param {import('./types').AgentDataOptions} [options] + */ + static async create(init = {}, options = {}) { + const agentData = new AgentData( + { + meta: { name: 'agent', type: 'device', ...init.meta }, + principal: init.principal ?? (await EdSigner.generate()), + spaces: init.spaces ?? new Map(), + delegations: init.delegations ?? new Map(), + currentSpace: init.currentSpace, + }, + options + ) + if (options.store) { + await options.store.save(agentData.export()) + } + return agentData + } + + /** + * Instantiate AgentData from previously exported data. + * + * @param {import('./types').AgentDataExport} raw + * @param {import('./types').AgentDataOptions} [options] + */ + static fromExport(raw, options) { + /** @type {import('./types').AgentDataModel['delegations']} */ + const dels = new Map() + + for (const [key, value] of raw.delegations) { + dels.set(key, { + delegation: importDAG( + value.delegation.map((d) => ({ + cid: CID.parse(d.cid), + bytes: d.bytes, + })) + ), + meta: value.meta, + }) + } + + return new AgentData( + { + meta: raw.meta, + // @ts-expect-error + principal: Signer.from(raw.principal), + currentSpace: raw.currentSpace, + spaces: raw.spaces, + delegations: dels, + }, + options + ) + } + + /** + * Export data in a format safe to pass to `structuredClone()`. + */ + export() { + /** @type {import('./types').AgentDataExport} */ + const raw = { + meta: this.meta, + principal: this.principal.toArchive(), + currentSpace: this.currentSpace, + spaces: this.spaces, + delegations: new Map(), + } + for (const [key, value] of this.delegations) { + raw.delegations.set(key, { + meta: value.meta, + delegation: [...value.delegation.export()].map((b) => ({ + cid: b.cid.toString(), + bytes: b.bytes, + })), + }) + } + return raw + } + + /** + * @param {import('@ucanto/interface').DID} did + * @param {import('./types').SpaceMeta} meta + * @param {import('@ucanto/interface').Delegation} [proof] + */ + async addSpace(did, meta, proof) { + this.spaces.set(did, meta) + await (proof ? this.addDelegation(proof) : this.#save(this.export())) + } + + /** + * @param {import('@ucanto/interface').DID} did + */ + async setCurrentSpace(did) { + this.currentSpace = did + await this.#save(this.export()) + } + + /** + * @param {import('@ucanto/interface').Delegation} delegation + * @param {import('./types').DelegationMeta} [meta] + */ + async addDelegation(delegation, meta) { + this.delegations.set(delegation.cid.toString(), { + delegation, + meta: meta ?? {}, + }) + await this.#save(this.export()) + } + + /** + * @param {import('@ucanto/interface').UCANLink} cid + */ + async removeDelegation(cid) { + this.delegations.delete(cid.toString()) + await this.#save(this.export()) + } +} diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 4fad41faa..22497c49c 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -22,8 +22,14 @@ import { validate, canDelegateCapability, } from './delegations.js' +import { AgentData } from './agent-data.js' + +export { AgentData } const HOST = 'https://access.web3.storage' +const PRINCIPAL = DID.parse( + 'did:key:z6MkqdncRZ1wj8zxCTDUQ8CRT8NQWd63T7mZRvZUX8B7XDFi' +) /** * Creates a Ucanto connection for the w3access API @@ -35,28 +41,26 @@ const HOST = 'https://access.web3.storage' * ``` * * @template {string} T - DID method - * @param {Ucanto.Principal} principal - w3access API Principal - * @param {typeof fetch} _fetch - fetch implementation to use - * @param {URL} url - w3access API URL - * @param {Ucanto.Transport.Channel} [channel] - Ucanto channel to use + * @param {object} [options] + * @param {Ucanto.Principal} [options.principal] - w3access API Principal + * @param {URL} [options.url] - w3access API URL + * @param {Ucanto.Transport.Channel} [options.channel] - Ucanto channel to use + * @param {typeof fetch} [options.fetch] - Fetch implementation to use * @returns {Ucanto.ConnectionView} */ -export function connection(principal, _fetch, url, channel) { - const _channel = - channel || - HTTP.open({ - url, - method: 'POST', - fetch: _fetch, - }) - const connection = Client.connect({ - id: principal, +export function connection(options = {}) { + return Client.connect({ + id: options.principal ?? PRINCIPAL, encoder: CAR, decoder: CBOR, - channel: _channel, + channel: + options.channel ?? + HTTP.open({ + url: options.url ?? new URL(HOST), + method: 'POST', + fetch: options.fetch ?? globalThis.fetch.bind(globalThis), + }), }) - - return connection } /** @@ -67,79 +71,58 @@ export function connection(principal, _fetch, url, channel) { * ```js * import { Agent } from '@web3-storage/access/agent' * ``` - * - * @template {Ucanto.Signer} T - Ucanto Signer ie. ed25519, RSA or others */ export class Agent { - /** @type {Ucanto.Principal<"key">|undefined} */ - #service - - /** @type {typeof fetch} */ - #fetch - - /** @type {import('./types').AgentData} */ + /** @type {import('./agent-data').AgentData} */ #data /** - * @param {import('./types').AgentOptions} opts + * @param {import('./agent-data').AgentData} data - Agent data + * @param {import('./types').AgentOptions} [options] */ - constructor(opts) { - this.url = opts.url || new URL(HOST) - this.connection = opts.connection - this.issuer = opts.data.principal - this.meta = opts.data.meta - this.store = opts.store - - // private - this.#data = opts.data - this.#fetch = opts.fetch - this.#service = undefined + constructor(data, options = {}) { + this.url = options.url ?? new URL(HOST) + this.connection = + options.connection ?? + connection({ + principal: options.servicePrincipal, + url: this.url, + }) + this.#data = data } /** - * @template {Ucanto.Signer} T - * @param {import('./types').AgentCreateOptions} opts + * Create a new Agent instance, optionally with the passed initialization data. + * + * @param {Partial} [init] + * @param {import('./types').AgentOptions & import('./types').AgentDataOptions} [options] */ - static async create(opts) { - let _fetch = opts.fetch - const url = opts.url || new URL(HOST) - - // validate fetch implementation - if (!_fetch) { - if (typeof globalThis.fetch !== 'undefined') { - _fetch = globalThis.fetch.bind(globalThis) - } else { - throw new TypeError( - `Agent got undefined \`fetch\`. Try passing in a \`fetch\` implementation explicitly.` - ) - } - } + static async create(init, options = {}) { + const data = await AgentData.create(init, options) + return new Agent(data, options) + } - if (!(await opts.store.exists())) { - throw new Error('Store is not initialized, run "Store.init()" first.') - } - const data = await opts.store.load() - return new Agent({ - connection: await connection(data.principal, _fetch, url, opts.channel), - fetch: _fetch, - url, - store: opts.store, - data, - }) + /** + * Instantiate an Agent from pre-exported agent data. + * + * @param {import('./types').AgentDataExport} raw + * @param {import('./types').AgentOptions & import('./types').AgentDataOptions} [options] + */ + static from(raw, options = {}) { + const data = AgentData.fromExport(raw, options) + return new Agent(data, options) } - get spaces() { - return this.#data.spaces + get issuer() { + return this.#data.principal } - async service() { - if (this.#service) { - return this.#service - } - const rsp = await this.#fetch(this.url + 'version') - const { did } = await rsp.json() - this.#service = DID.parse(did) - return this.#service + get meta() { + return this.#data.meta + } + + get spaces() { + return this.#data.spaces } did() { @@ -158,13 +141,7 @@ export class Agent { checkAudience: this.issuer, checkIsExpired: true, }) - - this.#data.delegations.set(delegation.cid.toString(), { - delegation, - meta: { audience: this.meta }, - }) - - await this.store.save(this.#data) + await this.#data.addDelegation(delegation, { audience: this.meta }) } /** @@ -174,7 +151,7 @@ export class Agent { */ async *#delegations(caps) { const _caps = new Set(caps) - for (const [key, value] of this.#data.delegations) { + for (const [, value] of this.#data.delegations) { // check expiration if (!isExpired(value.delegation)) { // check if delegation can be used @@ -193,11 +170,9 @@ export class Agent { } } else { // delete any expired delegation - this.#data.delegations.delete(key) + await this.#data.removeDelegation(value.delegation.cid) } } - - await this.store.save(this.#data) } /** @@ -257,13 +232,8 @@ export class Agent { expiration: Infinity, }) - const meta = { - name, - isRegistered: false, - } - this.#data.spaces.set(signer.did(), meta) - - await this.addProof(proof) + const meta = { name, isRegistered: false } + await this.#data.addSpace(signer.did(), meta, proof) return { did: signer.did(), @@ -311,7 +281,6 @@ export class Agent { * @param {AbortSignal} [opts.signal] */ async recover(email, opts) { - const service = await this.service() const inv = await this.invokeAndExecute(Space.recoverValidation, { with: URI.from(this.did()), nb: { identity: URI.from(`mailto:${email}`) }, @@ -328,7 +297,7 @@ export class Agent { await this.addProof(spaceRecover) const recoverInv = await this.invokeAndExecute(Space.recover, { - with: URI.from(service.did()), + with: URI.from(this.connection.id.did()), nb: { identity: URI.from(`mailto:${email}`), }, @@ -365,8 +334,7 @@ export class Agent { throw new Error(`Agent has no proofs for ${space}.`) } - this.#data.currentSpace = space - await this.store.save(this.#data) + await this.#data.setCurrentSpace(space) return space } @@ -420,7 +388,7 @@ export class Agent { */ async registerSpace(email, opts) { const space = this.currentSpace() - const service = await this.service() + const service = this.connection.id const spaceMeta = space ? this.#data.spaces.get(space) : undefined if (!space || !spaceMeta) { @@ -435,6 +403,7 @@ export class Agent { nb: { identity: URI.from(`mailto:${email}`), product: 'product:free', + // @ts-expect-error expected did:key but connection can be did:any service: service.did(), }, }) @@ -480,10 +449,8 @@ export class Agent { spaceMeta.isRegistered = true - this.#data.spaces.set(space, spaceMeta) - this.#data.delegations.delete(voucherRedeem.cid.toString()) - - this.store.save(this.#data) + this.#data.addSpace(space, spaceMeta) + this.#data.removeDelegation(voucherRedeem.cid) } /** @@ -548,13 +515,10 @@ export class Agent { ...options, }) - this.#data.delegations.set(delegation.cid.toString(), { - delegation, - meta: { - audience: options.audienceMeta, - }, + await this.#data.addDelegation(delegation, { + audience: options.audienceMeta, }) - await this.store.save(this.#data) + return delegation } @@ -665,7 +629,7 @@ export class Agent { const extraProofs = options.proofs || [] const inv = invoke({ ...options, - audience: options.audience || (await this.service()), + audience: options.audience || this.connection.id, // @ts-ignore capability: cap.create({ with: space, diff --git a/packages/access-client/src/awake/peer.js b/packages/access-client/src/awake/peer.js index 7f7153805..fa050699f 100644 --- a/packages/access-client/src/awake/peer.js +++ b/packages/access-client/src/awake/peer.js @@ -7,14 +7,11 @@ import * as u8 from 'uint8arrays' import { decodeDelegations, encodeDelegations } from '../encoding.js' import * as Messages from './messages.js' -/** - * @template {UCAN.Signer} T - */ export class Peer { /** * @param {{ - * channel: import('./types').Channel; - * agent: import('../agent').Agent; + * channel: import('./types').Channel + * agent: import('../agent').Agent * }} opts */ constructor(opts) { diff --git a/packages/access-client/src/cli/cmd-create-space.js b/packages/access-client/src/cli/cmd-create-space.js index e5ee8a8f9..f84ca1d1e 100644 --- a/packages/access-client/src/cli/cmd-create-space.js +++ b/packages/access-client/src/cli/cmd-create-space.js @@ -12,13 +12,11 @@ import { getService } from './utils.js' export async function cmdCreateSpace(opts) { const { url } = await getService(opts.env) const store = new StoreConf({ profile: opts.profile }) + const data = await store.load() - if (await store.exists()) { + if (data) { const spinner = ora('Registering with the service').start() - const agent = await Agent.create({ - store, - url, - }) + const agent = Agent.from(data, { store, url }) spinner.stopAndPersist() const { email, name } = await inquirer.prompt([ diff --git a/packages/access-client/src/cli/cmd-link.js b/packages/access-client/src/cli/cmd-link.js index f329ac1a7..791586c1c 100644 --- a/packages/access-client/src/cli/cmd-link.js +++ b/packages/access-client/src/cli/cmd-link.js @@ -17,16 +17,14 @@ import { getService } from './utils.js' export async function cmdLink(channel, opts) { const { url } = await getService(opts.env) const store = new StoreConf({ profile: opts.profile }) + const data = await store.load() - if (!store.exists()) { + if (!data) { console.error('run setup first') process.exit(1) } - const agent = await Agent.create({ - store, - url, - }) + const agent = Agent.from(data, { store, url }) console.log('DID:', agent.did()) let done = false diff --git a/packages/access-client/src/cli/cmd-setup.js b/packages/access-client/src/cli/cmd-setup.js index 3c1ab01c6..8d40d6f5e 100644 --- a/packages/access-client/src/cli/cmd-setup.js +++ b/packages/access-client/src/cli/cmd-setup.js @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import inquirer from 'inquirer' +import { AgentData } from '../agent-data.js' import { StoreConf } from '../stores/store-conf.js' /** @@ -13,7 +14,9 @@ export async function cmdSetup(opts) { await store.reset() } - if (await store.exists()) { + const data = await store.load() + + if (data) { console.log('Agent is already setup.') } else { const { name, type } = await inquirer.prompt([ @@ -31,12 +34,15 @@ export async function cmdSetup(opts) { message: 'Select this agent type:', }, ]) - await store.init({ - meta: { - name, - type, + await AgentData.create( + { + meta: { + name, + type, + }, }, - }) + { store } + ) console.log('Agent is ready to use.') } diff --git a/packages/access-client/src/cli/cmd-whoami.js b/packages/access-client/src/cli/cmd-whoami.js index cde9105c7..101b0fe97 100644 --- a/packages/access-client/src/cli/cmd-whoami.js +++ b/packages/access-client/src/cli/cmd-whoami.js @@ -9,16 +9,17 @@ import { NAME } from './config.js' */ export async function cmdWhoami(opts) { const store = new StoreConf({ profile: opts.profile }) - if (await store.exists()) { - const agent = await Agent.create({ - store, - }) + const data = await store.load() + if (data) { + const agent = Agent.from(data, { store }) console.log('Agent', agent.issuer.did(), agent.meta) console.log('Current Space', await agent.currentSpaceWithMeta()) console.log('\nSpaces:') for (const space of agent.spaces) { console.log( - `Name: ${space[1].name} DID: ${space[0]} Registered: ${space[1].isRegistered}` + `Name: ${space[1].name ?? 'none'} DID: ${space[0]} Registered: ${ + space[1].isRegistered + }` ) } console.log('\nProofs:') @@ -32,7 +33,11 @@ export async function cmdWhoami(opts) { console.log('\nDelegations:') for await (const { meta, delegation } of agent.delegationsWithMeta()) { - console.log(`Audience ${meta.audience.name} (${meta.audience.type}):`) + console.log( + `Audience ${meta.audience?.name ?? 'unknown'} (${ + meta.audience?.type ?? 'unknown' + }):` + ) for (const cap of delegation.capabilities) { const expires = expirationToDate(delegation.expiration) console.log( diff --git a/packages/access-client/src/cli/index.js b/packages/access-client/src/cli/index.js index 1008da487..0c87f5af9 100755 --- a/packages/access-client/src/cli/index.js +++ b/packages/access-client/src/cli/index.js @@ -41,12 +41,10 @@ prog .describe('Space info.') .action(async (opts) => { const store = new StoreConf({ profile: opts.profile }) + const data = await store.load() const { url } = await getService(opts.env) - if (await store.exists()) { - const agent = await Agent.create({ - store, - url, - }) + if (data) { + const agent = Agent.from(data, { store, url }) const space = await selectSpace(agent) try { const result = await agent.getSpaceInfo(space) @@ -66,12 +64,10 @@ prog .option('--file', 'File to write the delegation into.') .action(async (opts) => { const store = new StoreConf({ profile: opts.profile }) + const data = await store.load() const { url } = await getService(opts.env) - if (await store.exists()) { - const agent = await Agent.create({ - store, - url, - }) + if (data) { + const agent = Agent.from(data, { store, url }) const space = await selectSpace(agent) await agent.setCurrentSpace(space) @@ -138,12 +134,10 @@ prog .option('--delegation') .action(async (opts) => { const store = new StoreConf({ profile: opts.profile }) + const data = await store.load() const { url } = await getService(opts.env) - if (await store.exists()) { - const agent = await Agent.create({ - store, - url, - }) + if (data) { + const agent = Agent.from(data, { store, url }) const del = fs.readFileSync(path.resolve(opts.delegation), { encoding: 'utf8', @@ -160,12 +154,10 @@ prog .describe('Recover spaces with email.') .action(async (opts) => { const store = new StoreConf({ profile: opts.profile }) + const data = await store.load() const { url } = await getService(opts.env) - if (await store.exists()) { - const agent = await Agent.create({ - store, - url, - }) + if (data) { + const agent = Agent.from(data, { store, url }) const { email } = await inquirer.prompt([ { diff --git a/packages/access-client/src/cli/utils.js b/packages/access-client/src/cli/utils.js index 24acb6c71..e686850f0 100644 --- a/packages/access-client/src/cli/utils.js +++ b/packages/access-client/src/cli/utils.js @@ -36,7 +36,7 @@ export async function getService(env) { /** * @template {Ucanto.Signer} T - * @param {import('../agent').Agent} agent + * @param {import('../agent').Agent} agent */ export async function selectSpace(agent) { const choices = [] diff --git a/packages/access-client/src/drivers/conf.js b/packages/access-client/src/drivers/conf.js new file mode 100644 index 000000000..268a7f040 --- /dev/null +++ b/packages/access-client/src/drivers/conf.js @@ -0,0 +1,60 @@ +import Conf from 'conf' +import * as JSON from '../utils/json.js' + +/** + * @template T + * @typedef {import('./types').Driver} Driver + */ + +/** + * Driver implementation with "[conf](https://github.com/sindresorhus/conf)" + * + * Usage: + * + * ```js + * import { ConfDriver } from '@web3-storage/access/drivers/conf' + * ``` + * + * @template {Record} T + * @implements {Driver} + */ +export class ConfDriver { + /** + * @type {Conf} + */ + #config + + /** + * @param {{ profile: string }} opts + */ + constructor(opts) { + this.#config = new Conf({ + projectName: 'w3access', + projectSuffix: '', + configName: opts.profile, + serialize: (v) => JSON.stringify(v), + deserialize: (v) => JSON.parse(v), + }) + this.path = this.#config.path + } + + async open() {} + + async close() {} + + async reset() { + this.#config.clear() + } + + /** @param {T} data */ + async save(data) { + this.#config.set(data) + } + + /** @returns {Promise} */ + async load() { + const data = this.#config.store ?? {} + if (Object.keys(data).length === 0) return + return data + } +} diff --git a/packages/access-client/src/drivers/indexeddb.js b/packages/access-client/src/drivers/indexeddb.js new file mode 100644 index 000000000..58b41d7c8 --- /dev/null +++ b/packages/access-client/src/drivers/indexeddb.js @@ -0,0 +1,193 @@ +import defer from 'p-defer' + +/** + * @template T + * @typedef {import('./types').Driver} Driver + */ + +const STORE_NAME = 'AccessStore' +const DATA_ID = 1 + +/** + * Driver implementation for the browser. + * + * Usage: + * + * ```js + * import { IndexedDBDriver } from '@web3-storage/access/drivers/indexeddb' + * ``` + * + * @template T + * @implements {Driver} + */ +export class IndexedDBDriver { + /** @type {string} */ + #dbName + + /** @type {number|undefined} */ + #dbVersion + + /** @type {string} */ + #dbStoreName + + /** @type {IDBDatabase|undefined} */ + #db + + /** @type {boolean} */ + #autoOpen + + /** + * @param {string} dbName + * @param {object} [options] + * @param {number} [options.dbVersion] + * @param {string} [options.dbStoreName] + * @param {boolean} [options.autoOpen] + */ + constructor(dbName, options = {}) { + this.#dbName = dbName + this.#dbVersion = options.dbVersion + this.#dbStoreName = options.dbStoreName ?? STORE_NAME + this.#autoOpen = options.autoOpen ?? true + } + + /** @returns {Promise} */ + async #getOpenDB() { + if (!this.#db) { + if (!this.#autoOpen) throw new Error('Store is not open') + await this.open() + } + // @ts-expect-error open sets this.#db + return this.#db + } + + async open() { + const db = this.#db + if (db) return + + /** @type {import('p-defer').DeferredPromise} */ + const { resolve, reject, promise } = defer() + const openReq = indexedDB.open(this.#dbName, this.#dbVersion) + + openReq.addEventListener('upgradeneeded', () => { + const db = openReq.result + db.createObjectStore(this.#dbStoreName, { keyPath: 'id' }) + }) + + openReq.addEventListener('success', () => { + this.#db = openReq.result + resolve() + }) + + openReq.addEventListener('error', () => reject(openReq.error)) + + return promise + } + + async close() { + const db = this.#db + if (!db) throw new Error('Store is not open') + + db.close() + this.#db = undefined + } + + /** @param {T} data */ + async save(data) { + const db = await this.#getOpenDB() + + const putData = withObjectStore( + db, + 'readwrite', + this.#dbStoreName, + async (store) => { + /** @type {import('p-defer').DeferredPromise} */ + const { resolve, reject, promise } = defer() + const putReq = store.put({ id: DATA_ID, ...data }) + putReq.addEventListener('success', () => resolve()) + putReq.addEventListener('error', () => + reject(new Error('failed to query DB', { cause: putReq.error })) + ) + + return promise + } + ) + + return await putData() + } + + async load() { + const db = await this.#getOpenDB() + + const getData = withObjectStore( + db, + 'readonly', + this.#dbStoreName, + async (store) => { + /** @type {import('p-defer').DeferredPromise} */ + const { resolve, reject, promise } = defer() + + const getReq = store.get(DATA_ID) + getReq.addEventListener('success', () => resolve(getReq.result)) + getReq.addEventListener('error', () => + reject(new Error('failed to query DB', { cause: getReq.error })) + ) + + return promise + } + ) + + return await getData() + } + + async reset() { + const db = await this.#getOpenDB() + + withObjectStore(db, 'readwrite', this.#dbStoreName, (s) => { + /** @type {import('p-defer').DeferredPromise} */ + const { resolve, reject, promise } = defer() + const req = s.clear() + req.addEventListener('success', () => { + resolve() + }) + + req.addEventListener('error', () => + reject(new Error('failed to query DB', { cause: req.error })) + ) + + return promise + }) + } +} + +/** + * @template T + * @param {IDBDatabase} db + * @param {IDBTransactionMode} txnMode + * @param {string} storeName + * @param {(s: IDBObjectStore) => Promise} fn + * @returns + */ +function withObjectStore(db, txnMode, storeName, fn) { + return async () => { + const tx = db.transaction(storeName, txnMode) + /** @type {import('p-defer').DeferredPromise} */ + const { resolve, reject, promise } = defer() + /** @type {T} */ + let result + tx.addEventListener('complete', () => resolve(result)) + tx.addEventListener('abort', () => + reject(tx.error || new Error('transaction aborted')) + ) + tx.addEventListener('error', () => + reject(new Error('transaction error', { cause: tx.error })) + ) + try { + result = await fn(tx.objectStore(storeName)) + tx.commit() + } catch (error) { + reject(error) + tx.abort() + } + return promise + } +} diff --git a/packages/access-client/src/drivers/types.ts b/packages/access-client/src/drivers/types.ts new file mode 100644 index 000000000..193f1dffa --- /dev/null +++ b/packages/access-client/src/drivers/types.ts @@ -0,0 +1,25 @@ +/** + * Driver interface that all drivers implement. + */ +export interface Driver { + /** + * Open driver + */ + open: () => Promise + /** + * Clean up and close driver + */ + close: () => Promise + /** + * Persist data to the driver's backend + */ + save: (data: T) => Promise + /** + * Loads data from the driver's backend + */ + load: () => Promise + /** + * Clean all the data in the driver's backend + */ + reset: () => Promise +} diff --git a/packages/access-client/src/stores/store-conf.js b/packages/access-client/src/stores/store-conf.js index d8a59464d..5244b06aa 100644 --- a/packages/access-client/src/stores/store-conf.js +++ b/packages/access-client/src/stores/store-conf.js @@ -1,25 +1,4 @@ -/* eslint-disable unicorn/no-null */ -/* eslint-disable jsdoc/check-indentation */ -import Conf from 'conf' -import { Signer } from '@ucanto/principal/ed25519' -import { delegationToString, stringToDelegation } from '../encoding.js' -// eslint-disable-next-line no-unused-vars -import * as Ucanto from '@ucanto/interface' - -/** - * @typedef {import('../types').AgentData} StoreData - * @typedef {import('./types').IStore} Store - * @typedef {{ - * meta: import('../types.js').AgentMeta - * principal: string - * currentSpace?: Ucanto.DID - * spaces: Array<[Ucanto.DID, import('../types').SpaceMeta]> - * delegations: Array<[import('../types').CIDString, { - * meta: import('../types').DelegationMeta, - * delegation: import('../types.js').EncodedDelegation - * }]> - * }} Data - */ +import { ConfDriver } from '../drivers/conf.js' /** * Store implementation with "[conf](https://github.com/sindresorhus/conf)" @@ -30,115 +9,6 @@ import * as Ucanto from '@ucanto/interface' * import { StoreConf } from '@web3-storage/access/stores/store-conf' * ``` * - * @implements {Store} + * @extends {ConfDriver} */ -export class StoreConf { - /** - * @type {Conf} - */ - #config - /** - * - * @param {{ - * profile: string - * }} opts - */ - constructor(opts) { - this.#config = new Conf({ - projectName: 'w3access', - projectSuffix: '', - configName: opts.profile, - }) - this.path = this.#config.path - } - - /** - * - * @returns {Promise} - */ - async open() { - return this - } - - async close() {} - - async reset() { - this.#config.clear() - } - - async exists() { - return this.#config.has('meta') && this.#config.has('principal') - } - - /** @type {Store['init']} */ - async init(data) { - const principal = data.principal || (await Signer.generate()) - - /** @type {StoreData} */ - const storeData = { - meta: data.meta || { name: 'agent', type: 'device' }, - spaces: data.spaces || new Map(), - delegations: data.delegations || new Map(), - principal, - currentSpace: data.currentSpace, - } - - await this.save(storeData) - return storeData - } - - /** - * - * @param {StoreData} data - * @returns {Promise} - */ - async save(data) { - /** @type {Data['delegations']} */ - const dels = [] - - for (const [key, value] of data.delegations) { - dels.push([ - key, - { - meta: value.meta, - delegation: await delegationToString(value.delegation), - }, - ]) - } - /** @type {Data} */ - const encodedData = { - currentSpace: data.currentSpace || undefined, - spaces: [...data.spaces.entries()], - meta: data.meta, - principal: Signer.format(data.principal), - delegations: dels, - } - - this.#config.set(encodedData) - - return this - } - - /** @type {Store['load']} */ - async load() { - const data = this.#config.store - - /** @type {StoreData['delegations']} */ - const dels = new Map() - - for (const [key, value] of data.delegations) { - dels.set(key, { - delegation: await stringToDelegation(value.delegation), - meta: value.meta, - }) - } - /** @type {StoreData} */ - return { - principal: Signer.parse(data.principal), - currentSpace: data.currentSpace === null ? undefined : data.currentSpace, - meta: data.meta, - spaces: new Map(data.spaces), - delegations: dels, - } - } -} +export class StoreConf extends ConfDriver {} diff --git a/packages/access-client/src/stores/store-indexeddb.js b/packages/access-client/src/stores/store-indexeddb.js index e748bb882..362fa8165 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -1,15 +1,4 @@ -import { importDAG } from '@ucanto/core/delegation' -import * as Signer from '@ucanto/principal/rsa' -import defer from 'p-defer' -import { CID } from 'multiformats/cid' - -/** - * @typedef {import('../types').AgentData} StoreData - * @typedef {import('./types').IStore} Store - */ - -const STORE_NAME = 'AccessStore' -const DATA_ID = 1 +import { IndexedDBDriver } from '../drivers/indexeddb.js' /** * Store implementation for the browser. @@ -20,282 +9,6 @@ const DATA_ID = 1 * import { StoreIndexedDB } from '@web3-storage/access/stores/store-indexeddb' * ``` * - * @implements {Store} - */ -export class StoreIndexedDB { - /** @type {string} */ - #dbName - - /** @type {number|undefined} */ - #dbVersion - - /** @type {string} */ - #dbStoreName - - /** @type {IDBDatabase|undefined} */ - #db - - /** - * @param {string} dbName - * @param {object} [options] - * @param {number} [options.dbVersion] - * @param {string} [options.dbStoreName] - */ - constructor(dbName, options = {}) { - this.#dbName = dbName - this.#dbVersion = options.dbVersion - this.#dbStoreName = options.dbStoreName ?? STORE_NAME - } - - /** - * - * @returns {Promise} - */ - async open() { - /** @type {import('p-defer').DeferredPromise} */ - const { resolve, reject, promise } = defer() - const openReq = indexedDB.open(this.#dbName, this.#dbVersion) - - openReq.addEventListener('upgradeneeded', () => { - const db = openReq.result - db.createObjectStore(this.#dbStoreName, { keyPath: 'id' }) - }) - - openReq.addEventListener('success', () => { - this.#db = openReq.result - resolve(this) - }) - - openReq.addEventListener('error', () => reject(openReq.error)) - - return promise - } - - async close() { - const db = this.#db - if (!db) throw new Error('Store is not open') - - db.close() - this.#db = undefined - } - - async exists() { - const db = this.#db - if (!db) throw new Error('Store is not open') - - const getExists = withObjectStore( - db, - 'readonly', - this.#dbStoreName, - async (store) => { - /** @type {import('p-defer').DeferredPromise} */ - const { resolve, reject, promise } = defer() - - const getReq = store.get(DATA_ID) - getReq.addEventListener('success', () => - resolve(Boolean(getReq.result)) - ) - getReq.addEventListener('error', () => - reject(new Error('failed to query DB', { cause: getReq.error })) - ) - return promise - } - ) - - return await getExists() - } - - /** - * Opens (or creates) a store and initializes it if not already initialized. - * - * @param {string} dbName - * @param {object} [options] - * @param {number} [options.dbVersion] - * @param {string} [options.dbStoreName] - * @returns {Promise} - */ - static async open(dbName, options) { - const store = new StoreIndexedDB(dbName, options) - await store.open() - const exists = await store.exists() - if (!exists) { - await store.init({}) - } - return store - } - - /** @type {Store['init']} */ - async init(data) { - /** @type {StoreData} */ - const storeData = { - meta: data.meta || { name: 'agent', type: 'device' }, - principal: - data.principal || (await Signer.generate({ extractable: false })), - spaces: data.spaces || new Map(), - delegations: data.delegations || new Map(), - currentSpace: data.currentSpace, - } - - await this.save(storeData) - return storeData - } - - /** - * @param {StoreData} data - * @returns {Promise} - */ - async save(data) { - const db = this.#db - if (!db) throw new Error('Store is not open') - - const putData = withObjectStore( - db, - 'readwrite', - this.#dbStoreName, - async (store) => { - /** @type {import('p-defer').DeferredPromise} */ - const { resolve, reject, promise } = defer() - - /** @type {import('./types').StoreDataIDB} */ - const raw = { - id: DATA_ID, - meta: data.meta, - // @ts-expect-error - principal: data.principal.toArchive(), - currentSpace: data.currentSpace, - spaces: data.spaces, - delegations: new Map(), - } - for (const [key, value] of data.delegations) { - raw.delegations.set(key, { - meta: value.meta, - delegation: [...value.delegation.export()].map((b) => ({ - cid: b.cid.toString(), - bytes: b.bytes, - })), - }) - } - const putReq = store.put(raw) - putReq.addEventListener('success', () => resolve(this)) - putReq.addEventListener('error', () => - reject(new Error('failed to query DB', { cause: putReq.error })) - ) - - return promise - } - ) - - return await putData() - } - - /** @type {Store['load']} */ - async load() { - const db = this.#db - if (!db) throw new Error('Store is not open') - - const getData = withObjectStore( - db, - 'readonly', - this.#dbStoreName, - async (store) => { - /** @type {import('p-defer').DeferredPromise} */ - const { resolve, reject, promise } = defer() - - const getReq = store.get(DATA_ID) - getReq.addEventListener('success', () => { - try { - /** @type {import('./types').StoreDataIDB} */ - const raw = getReq.result - if (!raw) throw new Error('Store is not initialized') - - /** @type {StoreData['delegations']} */ - const dels = new Map() - - for (const [key, value] of raw.delegations) { - dels.set(key, { - delegation: importDAG( - value.delegation.map((d) => ({ - cid: CID.parse(d.cid), - bytes: d.bytes, - })) - ), - meta: value.meta, - }) - } - - /** @type {StoreData} */ - const data = { - meta: raw.meta, - principal: Signer.from(raw.principal), - currentSpace: raw.currentSpace, - spaces: raw.spaces, - delegations: dels, - } - resolve(data) - } catch (error) { - reject(error) - } - }) - getReq.addEventListener('error', () => - reject(new Error('failed to query DB', { cause: getReq.error })) - ) - - return promise - } - ) - - return await getData() - } - - async reset() { - if (this.#db) { - withObjectStore(this.#db, 'readwrite', this.#dbStoreName, (s) => { - /** @type {import('p-defer').DeferredPromise} */ - const { resolve, reject, promise } = defer() - const req = s.clear() - req.addEventListener('success', () => { - resolve() - }) - - req.addEventListener('error', () => - reject(new Error('failed to query DB', { cause: req.error })) - ) - - return promise - }) - } - } -} - -/** - * @template T - * @param {IDBDatabase} db - * @param {IDBTransactionMode} txnMode - * @param {string} storeName - * @param {(s: IDBObjectStore) => Promise} fn - * @returns + * @extends {IndexedDBDriver} */ -function withObjectStore(db, txnMode, storeName, fn) { - return async () => { - const tx = db.transaction(storeName, txnMode) - /** @type {import('p-defer').DeferredPromise} */ - const { resolve, reject, promise } = defer() - /** @type {T} */ - let result - tx.addEventListener('complete', () => resolve(result)) - tx.addEventListener('abort', () => - reject(tx.error || new Error('transaction aborted')) - ) - tx.addEventListener('error', () => - reject(new Error('transaction error', { cause: tx.error })) - ) - try { - result = await fn(tx.objectStore(storeName)) - tx.commit() - } catch (error) { - reject(error) - tx.abort() - } - return promise - } -} +export class StoreIndexedDB extends IndexedDBDriver {} diff --git a/packages/access-client/src/stores/store-memory.js b/packages/access-client/src/stores/store-memory.js deleted file mode 100644 index 1109a4625..000000000 --- a/packages/access-client/src/stores/store-memory.js +++ /dev/null @@ -1,83 +0,0 @@ -import { Signer } from '@ucanto/principal/ed25519' -// @ts-ignore -// eslint-disable-next-line no-unused-vars -import * as Types from '@ucanto/interface' - -/** - * @typedef {import('../types').AgentData} StoreData - * @typedef {import('./types').IStore} Store - */ - -/** - * Store implementation with "conf" - * - * @implements {Store} - */ -export class StoreMemory { - constructor() { - /** @type {StoreData} */ - // @ts-ignore - this.data = {} - } - - async open() { - return this - } - - async close() {} - - async reset() { - // @ts-ignore - this.data = {} - } - - async exists() { - return this.data.meta !== undefined && this.data.principal !== undefined - } - - /** - * - * @param {Partial} [data] - * @returns {Promise} - */ - static async create(data = {}) { - const store = new StoreMemory() - await store.init(data) - - return store - } - - /** @type {Store['init']} */ - async init(data) { - const principal = data.principal || (await Signer.generate()) - /** @type {StoreData} */ - const storeData = { - meta: data.meta || { name: 'agent', type: 'device' }, - principal, - spaces: data.spaces || new Map(), - delegations: data.delegations || new Map(), - currentSpace: data.currentSpace, - } - - await this.save(storeData) - return storeData - } - - /** - * - * @param {StoreData} data - * @returns {Promise} - */ - async save(data) { - this.data = { - ...data, - } - return this - } - - /** @type {Store['load']} */ - async load() { - /** @type {StoreData} */ - return this.data - } -} diff --git a/packages/access-client/src/stores/types.ts b/packages/access-client/src/stores/types.ts deleted file mode 100644 index dbc98747f..000000000 --- a/packages/access-client/src/stores/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - AgentData, - AgentMeta, - CIDString, - DelegationMeta, - SpaceMeta, -} from '../types.js' -import { RSASigner } from '@ucanto/principal/rsa' -import { SignerArchive, DID } from '@ucanto/interface' - -/** - * Store interface that all stores need to implement - */ -export interface IStore { - /** - * Open store - */ - open: () => Promise> - /** - * Clean up and close store - */ - close: () => Promise - /** - * Check if store exists and is initialized - */ - exists: () => Promise - /** - * Initilize store with data - * - * @param data - */ - init: (data: Partial>) => Promise> - /** - * Persist data to the store's backend - * - * @param data - */ - save: (data: AgentData) => Promise> - /** - * Loads data from the store's backend - */ - load: () => Promise> - /** - * Clean all the data in the store's backend - */ - reset: () => Promise -} - -// Store IDB -export interface StoreDataIDB { - id: number - meta: AgentMeta - principal: SignerArchive - currentSpace?: DID - spaces: Map - delegations: Map< - CIDString, - { - meta: DelegationMeta - delegation: Array<{ cid: CIDString; bytes: Uint8Array }> - } - > -} diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index c295a62d9..4036b7c4a 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -17,9 +17,10 @@ import type { Match, Ability, UnknownMatch, - Transport, Delegation, DID, + Signer, + SignerArchive, } from '@ucanto/interface' import type { @@ -31,12 +32,11 @@ import type { VoucherRedeem, Top, } from '@web3-storage/capabilities/types' -import { IStore } from './stores/types.js' import type { SetRequired } from 'type-fest' +import { Driver } from './drivers/types.js' // export other types export * from '@web3-storage/capabilities/types' -export * from './stores/types.js' /** * Access api service definition type @@ -75,16 +75,33 @@ export interface Service { export type CIDString = string /** - * Data schema used by the agent and persisted by stores + * Data schema used internally by the agent. */ -export interface AgentData { +export interface AgentDataModel { meta: AgentMeta - principal: T + principal: Signer currentSpace?: DID spaces: Map delegations: Map } +/** + * Agent data that is safe to pass to structuredClone() and persisted by stores. + */ +export type AgentDataExport = Pick< + AgentDataModel, + 'meta' | 'currentSpace' | 'spaces' +> & { + principal: SignerArchive + delegations: Map< + CIDString, + { + meta: DelegationMeta + delegation: Array<{ cid: CIDString; bytes: Uint8Array }> + } + > +} + /** * Agent metadata used to describe an agent ("audience") * with a more human and UI friendly data @@ -105,7 +122,7 @@ export interface DelegationMeta { * Audience metadata to be easier to build UIs with human readable data * Normally used with delegations issued to third parties or other devices. */ - audience: AgentMeta + audience?: AgentMeta } /** @@ -138,19 +155,14 @@ export interface SpaceD1 { * Agent class types */ -export interface AgentOptions { - store: IStore - connection: ConnectionView +export interface AgentOptions { url?: URL - fetch: typeof fetch - data: AgentData + connection?: ConnectionView + servicePrincipal?: Principal } -export interface AgentCreateOptions { - channel?: Transport.Channel - store: IStore - url?: URL - fetch?: typeof fetch +export interface AgentDataOptions { + store?: Driver } export type InvokeOptions< diff --git a/packages/access-client/src/utils/json.js b/packages/access-client/src/utils/json.js new file mode 100644 index 000000000..2b8f0a763 --- /dev/null +++ b/packages/access-client/src/utils/json.js @@ -0,0 +1,38 @@ +// JSON.stringify and JSON.parse with URL, Map and Uint8Array type support. + +/** + * @param {string} k + * @param {any} v + */ +export const replacer = (k, v) => { + if (v instanceof URL) { + return { $url: v.toString() } + } else if (v instanceof Map) { + return { $map: [...v.entries()] } + } else if (v instanceof Uint8Array) { + return { $bytes: [...v.values()] } + } + return v +} + +/** + * @param {string} k + * @param {any} v + */ +export const reviver = (k, v) => { + if (!v) return v + if (v.$url) return new URL(v.$url) + if (v.$map) return new Map(v.$map) + if (v.$bytes) return new Uint8Array(v.$bytes) + return v +} + +/** + * @param {any} value + * @param {number|string} [space] + */ +export const stringify = (value, space) => + JSON.stringify(value, replacer, space) + +/** @param {string} value */ +export const parse = (value) => JSON.parse(value, reviver) diff --git a/packages/access-client/test/agent.test.js b/packages/access-client/test/agent.test.js index 304eeca8e..b1b2c6397 100644 --- a/packages/access-client/test/agent.test.js +++ b/packages/access-client/test/agent.test.js @@ -1,41 +1,19 @@ import assert from 'assert' import { URI } from '@ucanto/validator' -import { Agent } from '../src/agent.js' -import { StoreMemory } from '../src/stores/store-memory.js' +import { Agent, connection } from '../src/agent.js' import * as Space from '@web3-storage/capabilities/space' import { createServer } from './helpers/utils.js' import * as fixtures from './helpers/fixtures.js' describe('Agent', function () { - it('should fail if store is not initialized', async function () { - const store = new StoreMemory() - - return assert.rejects( - Agent.create({ - store, - }), - { - name: 'Error', - message: 'Store is not initialized, run "Store.init()" first.', - } - ) - }) - it('should return did', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) + const agent = await Agent.create() assert.ok(agent.did()) }) it('should create space', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) - + const agent = await Agent.create() const space = await agent.createSpace('test-create') assert(typeof space.did === 'string') @@ -43,24 +21,15 @@ describe('Agent', function () { }) it('should add proof when creating acccount', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) - + const agent = await Agent.create() const space = await agent.createSpace('test-add') - const delegations = await agent.proofs() assert.equal(space.proof.cid, delegations[0].cid) }) it('should set current space', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) - + const agent = await Agent.create() const space = await agent.createSpace('test') await agent.setCurrentSpace(space.did) @@ -75,10 +44,7 @@ describe('Agent', function () { }) it('fails set current space with no proofs', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) + const agent = await Agent.create() await assert.rejects( () => { @@ -91,10 +57,8 @@ describe('Agent', function () { }) it('should invoke and execute', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - channel: createServer(), + const agent = await Agent.create(undefined, { + connection: connection({ channel: createServer() }), }) const space = await agent.createSpace('execute') @@ -115,10 +79,8 @@ describe('Agent', function () { }) it('should execute', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - channel: createServer(), + const agent = await Agent.create(undefined, { + connection: connection({ channel: createServer() }), }) const space = await agent.createSpace('execute') @@ -152,10 +114,8 @@ describe('Agent', function () { }) it('should fail execute with no proofs', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - channel: createServer(), + const agent = await Agent.create(undefined, { + connection: connection({ channel: createServer() }), }) await assert.rejects( @@ -175,17 +135,11 @@ describe('Agent', function () { }) it('should get space info', async function () { - const store = await StoreMemory.create() const server = createServer() - const agent = await Agent.create({ - store, - channel: server, + const agent = await Agent.create(undefined, { + connection: connection({ principal: server.id, channel: server }), }) - // mock service - // @ts-ignore - agent.service = async () => server.id - const space = await agent.createSpace('execute') await agent.setCurrentSpace(space.did) @@ -201,11 +155,9 @@ describe('Agent', function () { }) it('should delegate', async function () { - const store = await StoreMemory.create() const server = createServer() - const agent = await Agent.create({ - store, - channel: server, + const agent = await Agent.create(undefined, { + connection: connection({ channel: server }), }) const space = await agent.createSpace('execute') diff --git a/packages/access-client/test/awake.node.test.js b/packages/access-client/test/awake.node.test.js index 5f55a2684..f31348366 100644 --- a/packages/access-client/test/awake.node.test.js +++ b/packages/access-client/test/awake.node.test.js @@ -7,7 +7,6 @@ import PQueue from 'p-queue' import delay from 'delay' import pWaitFor from 'p-wait-for' import { Agent } from '../src/agent.js' -import { StoreMemory } from '../src/stores/store-memory.js' describe('awake', function () { const host = new URL('ws://127.0.0.1:8788/connect') @@ -38,14 +37,12 @@ describe('awake', function () { }) it('should send msgs', async function () { - const agent1 = await Agent.create({ - store: await StoreMemory.create(), + const agent1 = await Agent.create(undefined, { url: new URL('http://127.0.0.1:8787'), }) const space = await agent1.createSpace('responder') await agent1.setCurrentSpace(space.did) - const agent2 = await Agent.create({ - store: await StoreMemory.create(), + const agent2 = await Agent.create(undefined, { url: new URL('http://127.0.0.1:8787'), }) const responder = agent1.peer(ws1) diff --git a/packages/access-client/test/stores/store-indexeddb.browser.test.js b/packages/access-client/test/stores/store-indexeddb.browser.test.js index aef7f67ad..abe57c0b2 100644 --- a/packages/access-client/test/stores/store-indexeddb.browser.test.js +++ b/packages/access-client/test/stores/store-indexeddb.browser.test.js @@ -1,68 +1,77 @@ import assert from 'assert' import { top } from '@web3-storage/capabilities/top' +import { Signer as EdSigner } from '@ucanto/principal/ed25519' +import * as RSASigner from '@ucanto/principal/rsa' +import { AgentData } from '../../src/agent-data.js' import { StoreIndexedDB } from '../../src/stores/store-indexeddb.js' -import { Signer } from '@ucanto/principal/ed25519' describe('IndexedDB store', () => { it('should create and load data', async () => { - const store = await StoreIndexedDB.open('test-access-db-' + Date.now()) - const data = await store.load() - assert(data) + const data = await AgentData.create({ + principal: await RSASigner.generate({ extractable: false }), + }) + + const store = new StoreIndexedDB('test-access-db-' + Date.now()) + await store.open() + await store.save(data.export()) + + const exportData = await store.load() + assert(exportData) // principal private key is not extractable - const archive = data.principal.toArchive() + const archive = exportData.principal assert(!(archive instanceof Uint8Array)) assert(archive.key instanceof CryptoKey) assert.equal(archive.key.extractable, false) // no accounts or delegations yet - assert.equal(data.spaces.size, 0) - assert.equal(data.delegations.size, 0) + assert.equal(exportData.spaces.size, 0) + assert.equal(exportData.delegations.size, 0) // default meta - assert.equal(data.meta.name, 'agent') - assert.equal(data.meta.type, 'device') + assert.equal(exportData.meta.name, 'agent') + assert.equal(exportData.meta.type, 'device') }) it('should allow custom store name', async () => { - const store = await StoreIndexedDB.open('test-access-db-' + Date.now(), { + const store = new StoreIndexedDB('test-access-db-' + Date.now(), { dbStoreName: `store-${Date.now()}`, }) - const data = await store.load() - assert(data) - }) - - it('should check existence', async () => { - const store = new StoreIndexedDB('test-access-db-' + Date.now()) await store.open() - let exists = await store.exists() - assert.equal(exists, false) + const data0 = await AgentData.create() + await store.save(data0.export()) + + await store.close() + await store.open() - await store.init({}) + const exportedData = await store.load() + assert(exportedData) - exists = await store.exists() - assert(exists) + const data1 = AgentData.fromExport(exportedData) + assert.equal(data1.principal.did(), data0.principal.did()) }) it('should close and disallow usage', async () => { - const store = await StoreIndexedDB.open('test-access-db-' + Date.now()) - const data = await store.load() - + const store = new StoreIndexedDB('test-access-db-' + Date.now(), { + autoOpen: false, + }) + await store.open() + await store.load() await store.close() - // should all fail - await assert.rejects(store.init({}), { message: 'Store is not open' }) - await assert.rejects(store.save(data), { message: 'Store is not open' }) - await assert.rejects(store.exists(), { message: 'Store is not open' }) + // should fail + // @ts-expect-error object is not agent data export + await assert.rejects(store.save({}), { message: 'Store is not open' }) await assert.rejects(store.close(), { message: 'Store is not open' }) }) it('should round trip delegations', async () => { - const store = await StoreIndexedDB.open('test-access-db-' + Date.now()) - const data0 = await store.load() + const store = new StoreIndexedDB('test-access-db-' + Date.now()) + await store.open() - const signer = await Signer.generate() + const data0 = await AgentData.create() + const signer = await EdSigner.generate() const del0 = await top.delegate({ issuer: signer, audience: data0.principal, @@ -70,13 +79,13 @@ describe('IndexedDB store', () => { expiration: Infinity, }) - data0.delegations.set(del0.cid.toString(), { - delegation: del0, - meta: { audience: { name: 'test', type: 'device' } }, - }) - await store.save(data0) + data0.addDelegation(del0, { audience: { name: 'test', type: 'device' } }) + await store.save(data0.export()) + + const exportData1 = await store.load() + assert(exportData1) - const data1 = await store.load() + const data1 = AgentData.fromExport(exportData1) const { delegation: del1 } = data1.delegations.get(del0.cid.toString()) ?? {} diff --git a/packages/access-client/test/stores/store-memory.test.js b/packages/access-client/test/stores/store-memory.test.js deleted file mode 100644 index d88829664..000000000 --- a/packages/access-client/test/stores/store-memory.test.js +++ /dev/null @@ -1,10 +0,0 @@ -import assert from 'assert' -import { StoreMemory } from '../../src/stores/store-memory.js' - -describe('Store Memory', function () { - it('should not be initialized', async function () { - const store = new StoreMemory() - - assert.ok(!(await store.exists())) - }) -})