From be9cc5e791f3384d7c5ccdb87da0c37cf84b52ed Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 1 Dec 2022 14:44:30 +0000 Subject: [PATCH 01/13] refactor: wip --- packages/access-client/src/agent-data.js | 79 +++++++++ packages/access-client/src/agent.js | 165 ++++++++++-------- .../src/stores/store-indexeddb.js | 145 ++------------- .../access-client/src/stores/store-memory.js | 83 --------- packages/access-client/src/stores/types.ts | 40 +---- packages/access-client/src/types.ts | 43 +++-- 6 files changed, 216 insertions(+), 339 deletions(-) create mode 100644 packages/access-client/src/agent-data.js delete mode 100644 packages/access-client/src/stores/store-memory.js diff --git a/packages/access-client/src/agent-data.js b/packages/access-client/src/agent-data.js new file mode 100644 index 000000000..48cb16078 --- /dev/null +++ b/packages/access-client/src/agent-data.js @@ -0,0 +1,79 @@ +import { Signer } from '@ucanto/principal' +import { Signer as EdSigner } from '@ucanto/principal/ed25519' +import { importDAG } from '@ucanto/core/delegation' +import { CID } from 'multiformats' + +export const AgentData = { + /** + * @param {Partial} [init] + * @returns {Promise} + */ + async create (init = {}) { + return { + meta: { + name: 'agent', + // @ts-ignore + type: 'device', + ...init.meta + }, + principal: init.principal ?? (await EdSigner.generate()), + spaces: init.spaces ?? new Map(), + delegations: init.delegations ?? new Map(), + currentSpace: init.currentSpace, + } + }, + + /** + * @param {import('./types').AgentDataExport} raw + * @returns {import('./types').AgentData} + */ + from (raw) { + /** @type {import('./types').AgentData['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 { + meta: raw.meta, + // @ts-expect-error + principal: Signer.from(raw.principal), + currentSpace: raw.currentSpace, + spaces: raw.spaces, + delegations: dels, + } + }, + + /** + * @param {import('./types').AgentData} data + * @returns {import('./types').AgentDataExport} + */ + export (data) { + const raw = { + meta: data.meta, + 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, + })), + }) + } + return raw + } +} diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 4fad41faa..d32a34b20 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -13,9 +13,10 @@ import * as Space from '@web3-storage/capabilities/space' import * as Voucher from '@web3-storage/capabilities/voucher' import { stringToDelegation } from './encoding.js' import { Websocket, AbortError } from './utils/ws.js' -import { Signer } from '@ucanto/principal/ed25519' +import { Signer as EdSigner } from '@ucanto/principal/ed25519' import { Verifier } from '@ucanto/principal' import { invoke, delegate } from '@ucanto/core' +import { AgentData } from './agent-data.js' import { isExpired, isTooEarly, @@ -23,7 +24,9 @@ import { canDelegateCapability, } from './delegations.js' -const HOST = 'https://access.web3.storage' +const HOST = 'https://w3access-staging.protocol-labs.workers.dev' +const PRINCIPAL = DID.parse('did:key:z6MkwTYX2JHHd8bmaEuDdS1LJjrpFspirjDcQ4DvAiDP49Gm') +const notInitialized = () => new Error('not initialized') /** * Creates a Ucanto connection for the w3access API @@ -35,28 +38,22 @@ 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 * @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' + }), }) - - return connection } /** @@ -67,65 +64,79 @@ 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').AgentOptions} */ + #options /** @type {import('./types').AgentData} */ #data /** - * @param {import('./types').AgentOptions} opts + * @param {import('./types').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 + constructor(data, options = {}) { + this.url = options.url ?? new URL(HOST) + this.connection = options.connection ?? connection({ + principal: options.servicePrincipal, + url: this.url + }) + this.#data = data this.#service = undefined + this.#options = options + } + + get issuer () { + if (this.data == null) throw notInitialized() + return this.data.principal } /** - * @template {Ucanto.Signer} T - * @param {import('./types').AgentCreateOptions} opts + * Initialize the agent based on the passed data. Calls `save` to persist the + * fully initialized data. + * + * @param {Partial} [initialData] */ - 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.` - ) - } - } + async init (initialData = {}) { + this.data = await AgentData.create(initialData) + await this.#save() + return this + } - 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, - }) + async #save () { + if (!this.#options.save) return + if (this.data == null) throw notInitialized() + return await this.#options.save(AgentData.export(this.data)) + } + + /** + * Create and initialize a new agent based on the provided data. + * + * @param {Partial} [initialData] Agent data + * @param {import('./types').AgentOptions} [options] + */ + static async create (initialData, options = {}) { + const agent = new Agent(undefined, options) + return await agent.init(initialData) + } + + /** + * Instantiate an Agent, backed by data persisted in the passed store. + * + * @param {import('./types').IStore} store + * @param {import('./types').AgentOptions & { initialData?: Partial }} options + */ + static async fromStore (store, options = {}) { + options = { ...options, save: data => { store.save(data) } } + await store.open() + const storedData = await store.load() // { ... } or null/undefined + return storedData + ? new Agent(AgentData.from(storedData), options) + : await Agent.create(options.initialData, options) } get spaces() { @@ -136,14 +147,15 @@ export class Agent { if (this.#service) { return this.#service } - const rsp = await this.#fetch(this.url + 'version') + const rsp = await fetch(this.url + 'version') const { did } = await rsp.json() this.#service = DID.parse(did) return this.#service } did() { - return this.#data.principal.did() + if (this.data == null) throw notInitialized() + return this.data.principal.did() } /** @@ -154,6 +166,8 @@ export class Agent { * @param {Ucanto.Delegation} delegation */ async addProof(delegation) { + if (this.data == null) throw notInitialized() + validate(delegation, { checkAudience: this.issuer, checkIsExpired: true, @@ -164,7 +178,7 @@ export class Agent { meta: { audience: this.meta }, }) - await this.store.save(this.#data) + await this.#save() } /** @@ -173,6 +187,7 @@ export class Agent { * @param {import('@ucanto/interface').Capability[]} [caps] */ async *#delegations(caps) { + if (this.data == null) throw notInitialized() const _caps = new Set(caps) for (const [key, value] of this.#data.delegations) { // check expiration @@ -197,7 +212,7 @@ export class Agent { } } - await this.store.save(this.#data) + await this.#save() } /** @@ -249,7 +264,8 @@ export class Agent { * @param {string} [name] */ async createSpace(name) { - const signer = await Signer.generate() + if (this.data == null) throw notInitialized() + const signer = await EdSigner.generate() const proof = await Space.top.delegate({ issuer: signer, audience: this.issuer, @@ -354,6 +370,7 @@ export class Agent { * @param {Ucanto.DID} space */ async setCurrentSpace(space) { + if (this.data == null) throw notInitialized() const proofs = await this.proofs([ { can: 'space/info', @@ -365,8 +382,8 @@ export class Agent { throw new Error(`Agent has no proofs for ${space}.`) } - this.#data.currentSpace = space - await this.store.save(this.#data) + this.data.currentSpace = space + await this.#save() return space } @@ -375,14 +392,16 @@ export class Agent { * Get current space DID */ currentSpace() { - return this.#data.currentSpace + if (this.data == null) throw notInitialized() + return this.data.currentSpace } /** * Get current space DID, proofs and abilities */ async currentSpaceWithMeta() { - if (!this.#data.currentSpace) { + if (this.data == null) throw notInitialized() + if (!this.data.currentSpace) { return } @@ -419,6 +438,7 @@ export class Agent { * @param {AbortSignal} [opts.signal] */ async registerSpace(email, opts) { + if (this.data == null) throw notInitialized() const space = this.currentSpace() const service = await this.service() const spaceMeta = space ? this.#data.spaces.get(space) : undefined @@ -483,7 +503,7 @@ export class Agent { this.#data.spaces.set(space, spaceMeta) this.#data.delegations.delete(voucherRedeem.cid.toString()) - this.store.save(this.#data) + this.#save() } /** @@ -526,6 +546,7 @@ export class Agent { * @param {import('./types').DelegationOptions} options */ async delegate(options) { + if (this.data == null) throw notInitialized() const space = await this.currentSpaceWithMeta() if (!space) { throw new Error('there no space selected.') @@ -554,7 +575,7 @@ export class Agent { audience: options.audienceMeta, }, }) - await this.store.save(this.#data) + await this.#save() return delegation } diff --git a/packages/access-client/src/stores/store-indexeddb.js b/packages/access-client/src/stores/store-indexeddb.js index e748bb882..b2abde7c2 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -1,11 +1,8 @@ -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 + * @template T + * @typedef {import('./types').IStore} Store */ const STORE_NAME = 'AccessStore' @@ -20,7 +17,8 @@ const DATA_ID = 1 * import { StoreIndexedDB } from '@web3-storage/access/stores/store-indexeddb' * ``` * - * @implements {Store} + * @template T + * @implements {Store} */ export class StoreIndexedDB { /** @type {string} */ @@ -48,11 +46,13 @@ export class StoreIndexedDB { } /** - * - * @returns {Promise} + * @returns {Promise>} */ async open() { - /** @type {import('p-defer').DeferredPromise} */ + const db = this.#db + if (db) return this + + /** @type {import('p-defer').DeferredPromise>} */ const { resolve, reject, promise } = defer() const openReq = indexedDB.open(this.#dbName, this.#dbVersion) @@ -79,70 +79,9 @@ export class StoreIndexedDB { 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} + * @param {T} data + * @returns {Promise>} */ async save(data) { const db = this.#db @@ -153,29 +92,9 @@ export class StoreIndexedDB { 'readwrite', this.#dbStoreName, async (store) => { - /** @type {import('p-defer').DeferredPromise} */ + /** @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) + const putReq = store.put({ id: DATA_ID, ...data }) putReq.addEventListener('success', () => resolve(this)) putReq.addEventListener('error', () => reject(new Error('failed to query DB', { cause: putReq.error })) @@ -188,7 +107,6 @@ export class StoreIndexedDB { return await putData() } - /** @type {Store['load']} */ async load() { const db = this.#db if (!db) throw new Error('Store is not open') @@ -198,44 +116,11 @@ export class StoreIndexedDB { 'readonly', this.#dbStoreName, async (store) => { - /** @type {import('p-defer').DeferredPromise} */ + /** @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('success', () => resolve(getReq.result)) getReq.addEventListener('error', () => reject(new Error('failed to query DB', { cause: getReq.error })) ) 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 index dbc98747f..7ef22b7c1 100644 --- a/packages/access-client/src/stores/types.ts +++ b/packages/access-client/src/stores/types.ts @@ -1,13 +1,3 @@ -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 */ @@ -20,44 +10,18 @@ export interface IStore { * 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> + save: (data: T) => Promise> /** * Loads data from the store's backend */ - load: () => Promise> + 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..a86e2f8c5 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 { @@ -75,16 +76,30 @@ 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 AgentData { 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 & { + 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 @@ -138,19 +153,15 @@ export interface SpaceD1 { * Agent class types */ -export interface AgentOptions { - store: IStore - connection: ConnectionView +export interface AgentOptions { url?: URL - fetch: typeof fetch - data: AgentData -} - -export interface AgentCreateOptions { - channel?: Transport.Channel - store: IStore - url?: URL - fetch?: typeof fetch + connection?: ConnectionView + servicePrincipal?: Principal + /** + * Called after agent data has been mutated and must be persisted. Data is + * provided in a format that is safe to be passed to structuredClone(). + */ + save?: (data: AgentDataExport) => Promise | void } export type InvokeOptions< From 3a64e3f126f2f6f9501c05917164e1e94dc1cd4a Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 1 Dec 2022 14:48:05 +0000 Subject: [PATCH 02/13] fix: fix --- packages/access-client/src/agent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index d32a34b20..7e16aa32f 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -13,7 +13,7 @@ import * as Space from '@web3-storage/capabilities/space' import * as Voucher from '@web3-storage/capabilities/voucher' import { stringToDelegation } from './encoding.js' import { Websocket, AbortError } from './utils/ws.js' -import { Signer as EdSigner } from '@ucanto/principal/ed25519' +import { Signer } from '@ucanto/principal/ed25519' import { Verifier } from '@ucanto/principal' import { invoke, delegate } from '@ucanto/core' import { AgentData } from './agent-data.js' @@ -265,7 +265,7 @@ export class Agent { */ async createSpace(name) { if (this.data == null) throw notInitialized() - const signer = await EdSigner.generate() + const signer = await Signer.generate() const proof = await Space.top.delegate({ issuer: signer, audience: this.issuer, From 9c4f9267c45dc702554cb9e53b78dccf56ba6f7a Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 1 Dec 2022 17:18:58 +0000 Subject: [PATCH 03/13] fix: conf store --- .../access-client/src/stores/store-conf.js | 111 +++--------------- packages/access-client/src/types.ts | 6 +- packages/access-client/src/utils/json.js | 38 ++++++ 3 files changed, 60 insertions(+), 95 deletions(-) create mode 100644 packages/access-client/src/utils/json.js diff --git a/packages/access-client/src/stores/store-conf.js b/packages/access-client/src/stores/store-conf.js index d8a59464d..256066408 100644 --- a/packages/access-client/src/stores/store-conf.js +++ b/packages/access-client/src/stores/store-conf.js @@ -1,24 +1,9 @@ -/* 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' +import * as JSON from '../utils/json.js' /** - * @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 + * @template T + * @typedef {import('./types').IStore} Store */ /** @@ -30,31 +15,31 @@ import * as Ucanto from '@ucanto/interface' * import { StoreConf } from '@web3-storage/access/stores/store-conf' * ``` * - * @implements {Store} + * @template {Record} T + * @implements {Store} */ export class StoreConf { /** - * @type {Conf} + * @type {Conf} */ #config + /** - * - * @param {{ - * profile: string - * }} opts + * @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 } /** - * - * @returns {Promise} + * @returns {Promise>} */ async open() { return this @@ -66,79 +51,19 @@ export class StoreConf { 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} + * @param {T} 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) - + this.#config.set(data) return this } - /** @type {Store['load']} */ + /** @returns {Promise} */ 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, - } + const data = this.#config.store ?? {} + if (Object.keys(data).length === 0) return + return data } } diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index a86e2f8c5..a08f21645 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -32,7 +32,6 @@ import type { VoucherRedeem, Top, } from '@web3-storage/capabilities/types' -import { IStore } from './stores/types.js' import type { SetRequired } from 'type-fest' // export other types @@ -89,7 +88,10 @@ export interface AgentData { /** * Agent data that is safe to pass to structuredClone() and persisted by stores. */ -export type AgentDataExport = Pick & { +export type AgentDataExport = Pick< + AgentData, + 'meta' | 'currentSpace' | 'spaces' +> & { principal: SignerArchive delegations: Map< CIDString, 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) From ab321fc17065a23339d9850c9bc06a2fdb228413 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 2 Dec 2022 14:57:24 +0000 Subject: [PATCH 04/13] feat: make AgentData a class --- packages/access-client/src/agent-data.js | 155 +++++++++++++----- packages/access-client/src/agent.js | 126 +++----------- .../src/stores/store-indexeddb.js | 2 +- packages/access-client/src/types.ts | 16 +- packages/access-client/test/agent.test.js | 83 +++------- .../access-client/test/awake.node.test.js | 10 +- .../stores/store-indexeddb.browser.test.js | 83 +++++----- .../test/stores/store-memory.test.js | 10 -- 8 files changed, 231 insertions(+), 254 deletions(-) delete mode 100644 packages/access-client/test/stores/store-memory.test.js diff --git a/packages/access-client/src/agent-data.js b/packages/access-client/src/agent-data.js index 48cb16078..e3c58657b 100644 --- a/packages/access-client/src/agent-data.js +++ b/packages/access-client/src/agent-data.js @@ -3,32 +3,75 @@ import { Signer as EdSigner } from '@ucanto/principal/ed25519' import { importDAG } from '@ucanto/core/delegation' import { CID } from 'multiformats' -export const AgentData = { +/** @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 : () => {} + } + /** - * @param {Partial} [init] - * @returns {Promise} + * @param {Partial} [init] + * @param {import('./types').AgentDataOptions} [options] */ - async create (init = {}) { - return { - meta: { - name: 'agent', - // @ts-ignore - type: 'device', - ...init.meta + 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, }, - 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, backed by data persisted in the passed store. + * + * @param {import('./types').IStore} store + * @param {import('./types').AgentDataOptions & { initialData?: Partial }} options + */ + static async fromStore(store, options = {}) { + await store.open() + const storedData = await store.load() // { ... } or null/undefined + return storedData + ? AgentData.fromExport(storedData) + : await AgentData.create(options.initialData, { + store: { + save: async (d) => { + await store.save(d) + }, + }, + }) + } /** * @param {import('./types').AgentDataExport} raw - * @returns {import('./types').AgentData} + * @param {import('./types').AgentDataOptions} [options] */ - from (raw) { - /** @type {import('./types').AgentData['delegations']} */ + static fromExport(raw, options) { + /** @type {import('./types').AgentDataModel['delegations']} */ const dels = new Map() for (const [key, value] of raw.delegations) { @@ -43,29 +86,29 @@ export const AgentData = { }) } - return { - meta: raw.meta, - // @ts-expect-error - principal: Signer.from(raw.principal), - currentSpace: raw.currentSpace, - spaces: raw.spaces, - delegations: dels, - } - }, + return new AgentData( + { + meta: raw.meta, + // @ts-expect-error + principal: Signer.from(raw.principal), + currentSpace: raw.currentSpace, + spaces: raw.spaces, + delegations: dels, + }, + options + ) + } - /** - * @param {import('./types').AgentData} data - * @returns {import('./types').AgentDataExport} - */ - export (data) { + export() { + /** @type {import('./types').AgentDataExport} */ const raw = { - meta: data.meta, - principal: data.principal.toArchive(), - currentSpace: data.currentSpace, - spaces: data.spaces, + meta: this.meta, + principal: this.principal.toArchive(), + currentSpace: this.currentSpace, + spaces: this.spaces, delegations: new Map(), } - for (const [key, value] of data.delegations) { + for (const [key, value] of this.delegations) { raw.delegations.set(key, { meta: value.meta, delegation: [...value.delegation.export()].map((b) => ({ @@ -76,4 +119,42 @@ export const AgentData = { } 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 7e16aa32f..8f354dbc8 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -16,7 +16,6 @@ import { Websocket, AbortError } from './utils/ws.js' import { Signer } from '@ucanto/principal/ed25519' import { Verifier } from '@ucanto/principal' import { invoke, delegate } from '@ucanto/core' -import { AgentData } from './agent-data.js' import { isExpired, isTooEarly, @@ -25,8 +24,9 @@ import { } from './delegations.js' const HOST = 'https://w3access-staging.protocol-labs.workers.dev' -const PRINCIPAL = DID.parse('did:key:z6MkwTYX2JHHd8bmaEuDdS1LJjrpFspirjDcQ4DvAiDP49Gm') -const notInitialized = () => new Error('not initialized') +const PRINCIPAL = DID.parse( + 'did:key:z6MkwTYX2JHHd8bmaEuDdS1LJjrpFspirjDcQ4DvAiDP49Gm' +) /** * Creates a Ucanto connection for the w3access API @@ -49,10 +49,12 @@ export function connection(options = {}) { id: options.principal ?? PRINCIPAL, encoder: CAR, decoder: CBOR, - channel: options.channel ?? HTTP.open({ - url: options.url ?? new URL(HOST), - method: 'POST' - }), + channel: + options.channel ?? + HTTP.open({ + url: options.url ?? new URL(HOST), + method: 'POST', + }), }) } @@ -69,14 +71,11 @@ export class Agent { /** @type {Ucanto.Principal<"key">|undefined} */ #service - /** @type {import('./types').AgentOptions} */ - #options - - /** @type {import('./types').AgentData} */ + /** @type {import('./agent-data').AgentData} */ #data /** - * @param {import('./types').AgentData} [data] Agent data + * @param {import('./agent-data').AgentData} data - Agent data * @param {import('./types').AgentOptions} [options] */ constructor(data, options = {}) { @@ -87,62 +86,12 @@ export class Agent { }) this.#data = data this.#service = undefined - this.#options = options } - get issuer () { - if (this.data == null) throw notInitialized() + get issuer() { return this.data.principal } - /** - * Initialize the agent based on the passed data. Calls `save` to persist the - * fully initialized data. - * - * @param {Partial} [initialData] - */ - async init (initialData = {}) { - this.data = await AgentData.create(initialData) - await this.#save() - return this - } - - async #save () { - if (!this.#options.save) return - if (this.data == null) throw notInitialized() - return await this.#options.save(AgentData.export(this.data)) - } - - /** - * Create and initialize a new agent based on the provided data. - * - * @param {Partial} [initialData] Agent data - * @param {import('./types').AgentOptions} [options] - */ - static async create (initialData, options = {}) { - const agent = new Agent(undefined, options) - return await agent.init(initialData) - } - - /** - * Instantiate an Agent, backed by data persisted in the passed store. - * - * @param {import('./types').IStore} store - * @param {import('./types').AgentOptions & { initialData?: Partial }} options - */ - static async fromStore (store, options = {}) { - options = { ...options, save: data => { store.save(data) } } - await store.open() - const storedData = await store.load() // { ... } or null/undefined - return storedData - ? new Agent(AgentData.from(storedData), options) - : await Agent.create(options.initialData, options) - } - - get spaces() { - return this.#data.spaces - } - async service() { if (this.#service) { return this.#service @@ -154,7 +103,6 @@ export class Agent { } did() { - if (this.data == null) throw notInitialized() return this.data.principal.did() } @@ -166,19 +114,11 @@ export class Agent { * @param {Ucanto.Delegation} delegation */ async addProof(delegation) { - if (this.data == null) throw notInitialized() - validate(delegation, { checkAudience: this.issuer, checkIsExpired: true, }) - - this.#data.delegations.set(delegation.cid.toString(), { - delegation, - meta: { audience: this.meta }, - }) - - await this.#save() + await this.data.addDelegation(delegation, { audience: this.meta }) } /** @@ -187,9 +127,8 @@ export class Agent { * @param {import('@ucanto/interface').Capability[]} [caps] */ async *#delegations(caps) { - if (this.data == null) throw notInitialized() 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 @@ -208,11 +147,9 @@ export class Agent { } } else { // delete any expired delegation - this.#data.delegations.delete(key) + await this.data.removeDelegation(value.delegation.cid) } } - - await this.#save() } /** @@ -264,7 +201,6 @@ export class Agent { * @param {string} [name] */ async createSpace(name) { - if (this.data == null) throw notInitialized() const signer = await Signer.generate() const proof = await Space.top.delegate({ issuer: signer, @@ -273,13 +209,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(), @@ -370,7 +301,6 @@ export class Agent { * @param {Ucanto.DID} space */ async setCurrentSpace(space) { - if (this.data == null) throw notInitialized() const proofs = await this.proofs([ { can: 'space/info', @@ -382,8 +312,7 @@ export class Agent { throw new Error(`Agent has no proofs for ${space}.`) } - this.data.currentSpace = space - await this.#save() + await this.data.setCurrentSpace(space) return space } @@ -392,7 +321,6 @@ export class Agent { * Get current space DID */ currentSpace() { - if (this.data == null) throw notInitialized() return this.data.currentSpace } @@ -400,7 +328,6 @@ export class Agent { * Get current space DID, proofs and abilities */ async currentSpaceWithMeta() { - if (this.data == null) throw notInitialized() if (!this.data.currentSpace) { return } @@ -438,7 +365,6 @@ export class Agent { * @param {AbortSignal} [opts.signal] */ async registerSpace(email, opts) { - if (this.data == null) throw notInitialized() const space = this.currentSpace() const service = await this.service() const spaceMeta = space ? this.#data.spaces.get(space) : undefined @@ -500,10 +426,8 @@ export class Agent { spaceMeta.isRegistered = true - this.#data.spaces.set(space, spaceMeta) - this.#data.delegations.delete(voucherRedeem.cid.toString()) - - this.#save() + this.data.addSpace(space, spaceMeta) + this.data.removeDelegation(voucherRedeem.cid) } /** @@ -546,7 +470,6 @@ export class Agent { * @param {import('./types').DelegationOptions} options */ async delegate(options) { - if (this.data == null) throw notInitialized() const space = await this.currentSpaceWithMeta() if (!space) { throw new Error('there no space selected.') @@ -569,13 +492,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.#save() + return delegation } diff --git a/packages/access-client/src/stores/store-indexeddb.js b/packages/access-client/src/stores/store-indexeddb.js index b2abde7c2..d5d6e1faa 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -116,7 +116,7 @@ export class StoreIndexedDB { 'readonly', this.#dbStoreName, async (store) => { - /** @type {import('p-defer').DeferredPromise} */ + /** @type {import('p-defer').DeferredPromise} */ const { resolve, reject, promise } = defer() const getReq = store.get(DATA_ID) diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index a08f21645..f1a233079 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -77,7 +77,7 @@ export type CIDString = string /** * Data schema used internally by the agent. */ -export interface AgentData { +export interface AgentDataModel { meta: AgentMeta principal: Signer currentSpace?: DID @@ -89,7 +89,7 @@ export interface AgentData { * Agent data that is safe to pass to structuredClone() and persisted by stores. */ export type AgentDataExport = Pick< - AgentData, + AgentDataModel, 'meta' | 'currentSpace' | 'spaces' > & { principal: SignerArchive @@ -159,11 +159,17 @@ export interface AgentOptions { url?: URL connection?: ConnectionView servicePrincipal?: Principal +} + +export interface AgentDataOptions { + store?: StorageDriver +} + +export interface StorageDriver { /** - * Called after agent data has been mutated and must be persisted. Data is - * provided in a format that is safe to be passed to structuredClone(). + * Data is in a format that is safe to be passed to structuredClone(). */ - save?: (data: AgentDataExport) => Promise | void + save: (data: T) => Promise | void } export type InvokeOptions< diff --git a/packages/access-client/test/agent.test.js b/packages/access-client/test/agent.test.js index 304eeca8e..278bde9b2 100644 --- a/packages/access-client/test/agent.test.js +++ b/packages/access-client/test/agent.test.js @@ -1,40 +1,22 @@ 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 { AgentData } from '../src/agent-data.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 data = await AgentData.create() + const agent = new Agent(data) assert.ok(agent.did()) }) it('should create space', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) + const data = await AgentData.create() + const agent = new Agent(data) const space = await agent.createSpace('test-create') @@ -43,10 +25,8 @@ describe('Agent', function () { }) it('should add proof when creating acccount', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) + const data = await AgentData.create() + const agent = new Agent(data) const space = await agent.createSpace('test-add') @@ -56,10 +36,8 @@ describe('Agent', function () { }) it('should set current space', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - }) + const data = await AgentData.create() + const agent = new Agent(data) const space = await agent.createSpace('test') @@ -75,10 +53,8 @@ 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 data = await AgentData.create() + const agent = new Agent(data) await assert.rejects( () => { @@ -91,10 +67,9 @@ describe('Agent', function () { }) it('should invoke and execute', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - channel: createServer(), + const data = await AgentData.create() + const agent = new Agent(data, { + connection: connection({ channel: createServer() }), }) const space = await agent.createSpace('execute') @@ -115,10 +90,9 @@ describe('Agent', function () { }) it('should execute', async function () { - const store = await StoreMemory.create() - const agent = await Agent.create({ - store, - channel: createServer(), + const data = await AgentData.create() + const agent = new Agent(data, { + connection: connection({ channel: createServer() }), }) const space = await agent.createSpace('execute') @@ -152,10 +126,9 @@ 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 data = await AgentData.create() + const agent = new Agent(data, { + connection: connection({ channel: createServer() }), }) await assert.rejects( @@ -175,11 +148,10 @@ 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 data = await AgentData.create() + const agent = new Agent(data, { + connection: connection({ channel: server }), }) // mock service @@ -201,11 +173,10 @@ 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 data = await AgentData.create() + const agent = new Agent(data, { + 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..15eb776b7 100644 --- a/packages/access-client/test/awake.node.test.js +++ b/packages/access-client/test/awake.node.test.js @@ -7,7 +7,7 @@ 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' +import { AgentData } from '../src/agent-data.js' describe('awake', function () { const host = new URL('ws://127.0.0.1:8788/connect') @@ -38,14 +38,14 @@ describe('awake', function () { }) it('should send msgs', async function () { - const agent1 = await Agent.create({ - store: await StoreMemory.create(), + const data1 = await AgentData.create() + const agent1 = new Agent(data1, { 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 data2 = await AgentData.create() + const agent2 = new Agent(data2, { 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..df0bf9b68 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 }), + }) + + /** @type {StoreIndexedDB} */ + 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(), { + /** @type {StoreIndexedDB} */ + 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()) + 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 + 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() + /** @type {StoreIndexedDB} */ + 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())) - }) -}) From 144048b8da3fb0c8e96830fb50986dab24b9cbfb Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 2 Dec 2022 15:01:31 +0000 Subject: [PATCH 05/13] fix: awake peer type in options --- packages/access-client/src/awake/peer.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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) { From b6e0ff082b5ba6ea9b682aeb51cda19dbe3c9426 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 11:20:26 +0000 Subject: [PATCH 06/13] feat: add static agent constructor methods --- packages/access-client/src/agent-data.js | 15 +++-- packages/access-client/src/agent.js | 66 ++++++++++++++----- .../access-client/src/stores/store-conf.js | 13 +--- .../src/stores/store-indexeddb.js | 18 ++--- packages/access-client/src/stores/types.ts | 8 +-- packages/access-client/src/types.ts | 4 +- packages/access-client/test/agent.test.js | 35 +++------- .../access-client/test/awake.node.test.js | 7 +- 8 files changed, 85 insertions(+), 81 deletions(-) diff --git a/packages/access-client/src/agent-data.js b/packages/access-client/src/agent-data.js index e3c58657b..11639753f 100644 --- a/packages/access-client/src/agent-data.js +++ b/packages/access-client/src/agent-data.js @@ -5,9 +5,7 @@ import { CID } from 'multiformats' /** @typedef {import('./types').AgentDataModel} AgentDataModel */ -/** - * @implements {AgentDataModel} - */ +/** @implements {AgentDataModel} */ export class AgentData { /** @type {(data: import('./types').AgentDataExport) => Promise | void} */ #save @@ -26,6 +24,8 @@ export class AgentData { } /** + * Create a new AgentData instance from the passed initialization data. + * * @param {Partial} [init] * @param {import('./types').AgentDataOptions} [options] */ @@ -67,6 +67,8 @@ export class AgentData { } /** + * Instantiate AgentData from previously exported data. + * * @param {import('./types').AgentDataExport} raw * @param {import('./types').AgentDataOptions} [options] */ @@ -99,6 +101,9 @@ export class AgentData { ) } + /** + * Export data in a format safe to pass to `structuredClone()`. + */ export() { /** @type {import('./types').AgentDataExport} */ const raw = { @@ -127,7 +132,7 @@ export class AgentData { */ async addSpace(did, meta, proof) { this.spaces.set(did, meta) - await (proof ? this.addDelegation(proof) : this.#save(this.export())); + await (proof ? this.addDelegation(proof) : this.#save(this.export())) } /** @@ -145,7 +150,7 @@ export class AgentData { async addDelegation(delegation, meta) { this.delegations.set(delegation.cid.toString(), { delegation, - ...(meta ? { meta } : {}), + meta: meta ?? {}, }) await this.#save(this.export()) } diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 8f354dbc8..de18cbc87 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -22,6 +22,9 @@ import { validate, canDelegateCapability, } from './delegations.js' +import { AgentData } from './agent-data.js' + +export { AgentData } const HOST = 'https://w3access-staging.protocol-labs.workers.dev' const PRINCIPAL = DID.parse( @@ -80,16 +83,47 @@ export class Agent { */ constructor(data, options = {}) { this.url = options.url ?? new URL(HOST) - this.connection = options.connection ?? connection({ - principal: options.servicePrincipal, - url: this.url - }) + this.connection = + options.connection ?? + connection({ + principal: options.servicePrincipal, + url: this.url, + }) this.#data = data this.#service = undefined } + /** + * Create a new Agent instance, optionally with the passed initialization data. + * + * @param {Partial} [init] + * @param {import('./types').AgentOptions & { store?: import('./types').IStore }} [options] + */ + static async create(init, options = {}) { + const { store } = options + if (store) await store.open() + const data = await AgentData.create(init, { store }) + return new Agent(data, options) + } + + /** + * Create a new Agent instance from pre-exported agent data. + * + * @param {import('./types').AgentDataExport} raw + * @param {import('./types').AgentOptions & { store?: import('./types').IStore }} [options] + */ + static async from(raw, options = {}) { + const { store } = options + const data = AgentData.fromExport(raw, { store }) + return new Agent(data, options) + } + get issuer() { - return this.data.principal + return this.#data.principal + } + + get meta() { + return this.#data.meta } async service() { @@ -103,7 +137,7 @@ export class Agent { } did() { - return this.data.principal.did() + return this.#data.principal.did() } /** @@ -118,7 +152,7 @@ export class Agent { checkAudience: this.issuer, checkIsExpired: true, }) - await this.data.addDelegation(delegation, { audience: this.meta }) + await this.#data.addDelegation(delegation, { audience: this.meta }) } /** @@ -128,7 +162,7 @@ export class Agent { */ async *#delegations(caps) { const _caps = new Set(caps) - for (const [, value] of this.data.delegations) { + for (const [, value] of this.#data.delegations) { // check expiration if (!isExpired(value.delegation)) { // check if delegation can be used @@ -147,7 +181,7 @@ export class Agent { } } else { // delete any expired delegation - await this.data.removeDelegation(value.delegation.cid) + await this.#data.removeDelegation(value.delegation.cid) } } } @@ -210,7 +244,7 @@ export class Agent { }) const meta = { name, isRegistered: false } - await this.data.addSpace(signer.did(), meta, proof) + await this.#data.addSpace(signer.did(), meta, proof) return { did: signer.did(), @@ -312,7 +346,7 @@ export class Agent { throw new Error(`Agent has no proofs for ${space}.`) } - await this.data.setCurrentSpace(space) + await this.#data.setCurrentSpace(space) return space } @@ -321,14 +355,14 @@ export class Agent { * Get current space DID */ currentSpace() { - return this.data.currentSpace + return this.#data.currentSpace } /** * Get current space DID, proofs and abilities */ async currentSpaceWithMeta() { - if (!this.data.currentSpace) { + if (!this.#data.currentSpace) { return } @@ -426,8 +460,8 @@ export class Agent { spaceMeta.isRegistered = true - this.data.addSpace(space, spaceMeta) - this.data.removeDelegation(voucherRedeem.cid) + this.#data.addSpace(space, spaceMeta) + this.#data.removeDelegation(voucherRedeem.cid) } /** @@ -492,7 +526,7 @@ export class Agent { ...options, }) - await this.data.addDelegation(delegation, { + await this.#data.addDelegation(delegation, { audience: options.audienceMeta, }) diff --git a/packages/access-client/src/stores/store-conf.js b/packages/access-client/src/stores/store-conf.js index 256066408..50f2d452d 100644 --- a/packages/access-client/src/stores/store-conf.js +++ b/packages/access-client/src/stores/store-conf.js @@ -38,12 +38,7 @@ export class StoreConf { this.path = this.#config.path } - /** - * @returns {Promise>} - */ - async open() { - return this - } + async open() {} async close() {} @@ -51,13 +46,9 @@ export class StoreConf { this.#config.clear() } - /** - * @param {T} data - * @returns {Promise>} - */ + /** @param {T} data */ async save(data) { this.#config.set(data) - return this } /** @returns {Promise} */ diff --git a/packages/access-client/src/stores/store-indexeddb.js b/packages/access-client/src/stores/store-indexeddb.js index d5d6e1faa..965f4b36e 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -45,14 +45,11 @@ export class StoreIndexedDB { this.#dbStoreName = options.dbStoreName ?? STORE_NAME } - /** - * @returns {Promise>} - */ async open() { const db = this.#db - if (db) return this + if (db) return - /** @type {import('p-defer').DeferredPromise>} */ + /** @type {import('p-defer').DeferredPromise} */ const { resolve, reject, promise } = defer() const openReq = indexedDB.open(this.#dbName, this.#dbVersion) @@ -63,7 +60,7 @@ export class StoreIndexedDB { openReq.addEventListener('success', () => { this.#db = openReq.result - resolve(this) + resolve() }) openReq.addEventListener('error', () => reject(openReq.error)) @@ -79,10 +76,7 @@ export class StoreIndexedDB { this.#db = undefined } - /** - * @param {T} data - * @returns {Promise>} - */ + /** @param {T} data */ async save(data) { const db = this.#db if (!db) throw new Error('Store is not open') @@ -92,10 +86,10 @@ export class StoreIndexedDB { 'readwrite', this.#dbStoreName, async (store) => { - /** @type {import('p-defer').DeferredPromise>} */ + /** @type {import('p-defer').DeferredPromise} */ const { resolve, reject, promise } = defer() const putReq = store.put({ id: DATA_ID, ...data }) - putReq.addEventListener('success', () => resolve(this)) + putReq.addEventListener('success', () => resolve()) putReq.addEventListener('error', () => reject(new Error('failed to query DB', { cause: putReq.error })) ) diff --git a/packages/access-client/src/stores/types.ts b/packages/access-client/src/stores/types.ts index 7ef22b7c1..188003b16 100644 --- a/packages/access-client/src/stores/types.ts +++ b/packages/access-client/src/stores/types.ts @@ -5,21 +5,19 @@ export interface IStore { /** * Open store */ - open: () => Promise> + open: () => Promise /** * Clean up and close store */ close: () => Promise /** * Persist data to the store's backend - * - * @param data */ - save: (data: T) => Promise> + save: (data: T) => Promise /** * Loads data from the store's backend */ - load: () => Promise + load: () => Promise /** * Clean all the data in the store's backend */ diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index f1a233079..cba58145a 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -96,7 +96,7 @@ export type AgentDataExport = Pick< delegations: Map< CIDString, { - meta?: DelegationMeta + meta: DelegationMeta delegation: Array<{ cid: CIDString; bytes: Uint8Array }> } > @@ -122,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 } /** diff --git a/packages/access-client/test/agent.test.js b/packages/access-client/test/agent.test.js index 278bde9b2..302ce99a6 100644 --- a/packages/access-client/test/agent.test.js +++ b/packages/access-client/test/agent.test.js @@ -1,23 +1,19 @@ import assert from 'assert' import { URI } from '@ucanto/validator' import { Agent, connection } from '../src/agent.js' -import { AgentData } from '../src/agent-data.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 return did', async function () { - const data = await AgentData.create() - const agent = new Agent(data) + const agent = await Agent.create() assert.ok(agent.did()) }) it('should create space', async function () { - const data = await AgentData.create() - const agent = new Agent(data) - + const agent = await Agent.create() const space = await agent.createSpace('test-create') assert(typeof space.did === 'string') @@ -25,20 +21,15 @@ describe('Agent', function () { }) it('should add proof when creating acccount', async function () { - const data = await AgentData.create() - const agent = new Agent(data) - + 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 data = await AgentData.create() - const agent = new Agent(data) - + const agent = await Agent.create() const space = await agent.createSpace('test') await agent.setCurrentSpace(space.did) @@ -53,8 +44,7 @@ describe('Agent', function () { }) it('fails set current space with no proofs', async function () { - const data = await AgentData.create() - const agent = new Agent(data) + const agent = await Agent.create() await assert.rejects( () => { @@ -67,8 +57,7 @@ describe('Agent', function () { }) it('should invoke and execute', async function () { - const data = await AgentData.create() - const agent = new Agent(data, { + const agent = await Agent.create(undefined, { connection: connection({ channel: createServer() }), }) @@ -90,8 +79,7 @@ describe('Agent', function () { }) it('should execute', async function () { - const data = await AgentData.create() - const agent = new Agent(data, { + const agent = await Agent.create(undefined, { connection: connection({ channel: createServer() }), }) @@ -126,8 +114,7 @@ describe('Agent', function () { }) it('should fail execute with no proofs', async function () { - const data = await AgentData.create() - const agent = new Agent(data, { + const agent = await Agent.create(undefined, { connection: connection({ channel: createServer() }), }) @@ -149,8 +136,7 @@ describe('Agent', function () { it('should get space info', async function () { const server = createServer() - const data = await AgentData.create() - const agent = new Agent(data, { + const agent = await Agent.create(undefined, { connection: connection({ channel: server }), }) @@ -174,8 +160,7 @@ describe('Agent', function () { it('should delegate', async function () { const server = createServer() - const data = await AgentData.create() - const agent = new Agent(data, { + const agent = await Agent.create(undefined, { connection: connection({ channel: server }), }) diff --git a/packages/access-client/test/awake.node.test.js b/packages/access-client/test/awake.node.test.js index 15eb776b7..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 { AgentData } from '../src/agent-data.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 data1 = await AgentData.create() - const agent1 = new Agent(data1, { + 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 data2 = await AgentData.create() - const agent2 = new Agent(data2, { + const agent2 = await Agent.create(undefined, { url: new URL('http://127.0.0.1:8787'), }) const responder = agent1.peer(ws1) From 00315580903d0569b638c2b6eebdfd77c6fa1b92 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 12:08:00 +0000 Subject: [PATCH 07/13] refactor: simplify more, auto open --- packages/access-client/src/agent-data.js | 20 --------- packages/access-client/src/agent.js | 13 +++--- .../src/stores/store-indexeddb.js | 41 +++++++++++-------- 3 files changed, 28 insertions(+), 46 deletions(-) diff --git a/packages/access-client/src/agent-data.js b/packages/access-client/src/agent-data.js index 11639753f..60265a412 100644 --- a/packages/access-client/src/agent-data.js +++ b/packages/access-client/src/agent-data.js @@ -46,26 +46,6 @@ export class AgentData { return agentData } - /** - * Instantiate AgentData, backed by data persisted in the passed store. - * - * @param {import('./types').IStore} store - * @param {import('./types').AgentDataOptions & { initialData?: Partial }} options - */ - static async fromStore(store, options = {}) { - await store.open() - const storedData = await store.load() // { ... } or null/undefined - return storedData - ? AgentData.fromExport(storedData) - : await AgentData.create(options.initialData, { - store: { - save: async (d) => { - await store.save(d) - }, - }, - }) - } - /** * Instantiate AgentData from previously exported data. * diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index de18cbc87..0982912a3 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -97,24 +97,21 @@ export class Agent { * Create a new Agent instance, optionally with the passed initialization data. * * @param {Partial} [init] - * @param {import('./types').AgentOptions & { store?: import('./types').IStore }} [options] + * @param {import('./types').AgentOptions & import('./types').AgentDataOptions} [options] */ static async create(init, options = {}) { - const { store } = options - if (store) await store.open() - const data = await AgentData.create(init, { store }) + const data = await AgentData.create(init, options) return new Agent(data, options) } /** - * Create a new Agent instance from pre-exported agent data. + * Instantiate an Agent from pre-exported agent data. * * @param {import('./types').AgentDataExport} raw - * @param {import('./types').AgentOptions & { store?: import('./types').IStore }} [options] + * @param {import('./types').AgentOptions & import('./types').AgentDataOptions} [options] */ static async from(raw, options = {}) { - const { store } = options - const data = AgentData.fromExport(raw, { store }) + const data = AgentData.fromExport(raw, options) return new Agent(data, options) } diff --git a/packages/access-client/src/stores/store-indexeddb.js b/packages/access-client/src/stores/store-indexeddb.js index 965f4b36e..34900d7d9 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -45,6 +45,13 @@ export class StoreIndexedDB { this.#dbStoreName = options.dbStoreName ?? STORE_NAME } + /** @returns {Promise} */ + async #getOpenDB() { + if (!this.#db) await this.open() + // @ts-expect-error open sets this.#db + return this.#db + } + async open() { const db = this.#db if (db) return @@ -78,8 +85,7 @@ export class StoreIndexedDB { /** @param {T} data */ async save(data) { - const db = this.#db - if (!db) throw new Error('Store is not open') + const db = await this.#getOpenDB() const putData = withObjectStore( db, @@ -102,8 +108,7 @@ export class StoreIndexedDB { } async load() { - const db = this.#db - if (!db) throw new Error('Store is not open') + const db = await this.#getOpenDB() const getData = withObjectStore( db, @@ -127,22 +132,22 @@ export class StoreIndexedDB { } 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() - }) + 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 })) - ) + req.addEventListener('error', () => + reject(new Error('failed to query DB', { cause: req.error })) + ) - return promise - }) - } + return promise + }) } } From 2b1623f38528f9d9db3a76dde6bdbe8ac35309de Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 12:13:01 +0000 Subject: [PATCH 08/13] fix: autoopen param --- packages/access-client/src/stores/store-indexeddb.js | 10 +++++++++- .../test/stores/store-indexeddb.browser.test.js | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/access-client/src/stores/store-indexeddb.js b/packages/access-client/src/stores/store-indexeddb.js index 34900d7d9..534e37620 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -33,21 +33,29 @@ export class StoreIndexedDB { /** @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) await this.open() + 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 } 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 df0bf9b68..be39dfc4f 100644 --- a/packages/access-client/test/stores/store-indexeddb.browser.test.js +++ b/packages/access-client/test/stores/store-indexeddb.browser.test.js @@ -55,7 +55,9 @@ describe('IndexedDB store', () => { }) it('should close and disallow usage', async () => { - const store = new StoreIndexedDB('test-access-db-' + Date.now()) + const store = new StoreIndexedDB('test-access-db-' + Date.now(), { + autoOpen: false, + }) await store.open() await store.load() await store.close() From a34f06c780d91c03a1856b870ce4bf69a3d5c622 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 12:15:17 +0000 Subject: [PATCH 09/13] fix: from is not async --- packages/access-client/src/agent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 0982912a3..b7855b2be 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -110,7 +110,7 @@ export class Agent { * @param {import('./types').AgentDataExport} raw * @param {import('./types').AgentOptions & import('./types').AgentDataOptions} [options] */ - static async from(raw, options = {}) { + static from(raw, options = {}) { const data = AgentData.fromExport(raw, options) return new Agent(data, options) } From c26651753df6ae6cc0047f7d7f37de39691c8ef4 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 12:35:08 +0000 Subject: [PATCH 10/13] fix: tests --- packages/access-api/test/helpers/context.js | 8 ++--- packages/access-client/src/agent.js | 6 ++++ .../access-client/src/cli/cmd-create-space.js | 9 +++-- packages/access-client/src/cli/cmd-link.js | 9 +++-- packages/access-client/src/cli/cmd-setup.js | 18 ++++++---- packages/access-client/src/cli/cmd-whoami.js | 18 ++++++---- packages/access-client/src/cli/index.js | 36 +++++++++---------- packages/access-client/src/cli/utils.js | 2 +- 8 files changed, 59 insertions(+), 47 deletions(-) diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index f190e0b6c..7df1cb4fa 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: await 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/src/agent.js b/packages/access-client/src/agent.js index b7855b2be..370b77e32 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -45,6 +45,7 @@ const PRINCIPAL = DID.parse( * @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(options = {}) { @@ -57,6 +58,7 @@ export function connection(options = {}) { HTTP.open({ url: options.url ?? new URL(HOST), method: 'POST', + fetch: options.fetch ?? globalThis.fetch.bind(globalThis), }), }) } @@ -123,6 +125,10 @@ export class Agent { return this.#data.meta } + get spaces() { + return this.#data.spaces + } + async service() { if (this.#service) { return this.#service diff --git a/packages/access-client/src/cli/cmd-create-space.js b/packages/access-client/src/cli/cmd-create-space.js index e5ee8a8f9..5fef0734a 100644 --- a/packages/access-client/src/cli/cmd-create-space.js +++ b/packages/access-client/src/cli/cmd-create-space.js @@ -11,14 +11,13 @@ import { getService } from './utils.js' */ export async function cmdCreateSpace(opts) { const { url } = await getService(opts.env) + /** @type {StoreConf} */ 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..ba2f05e55 100644 --- a/packages/access-client/src/cli/cmd-link.js +++ b/packages/access-client/src/cli/cmd-link.js @@ -16,17 +16,16 @@ import { getService } from './utils.js' */ export async function cmdLink(channel, opts) { const { url } = await getService(opts.env) + /** @type {StoreConf} */ 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..fce37e34f 100644 --- a/packages/access-client/src/cli/cmd-whoami.js +++ b/packages/access-client/src/cli/cmd-whoami.js @@ -8,17 +8,19 @@ import { NAME } from './config.js' * @param {{ profile: any; env : string }} opts */ export async function cmdWhoami(opts) { + /** @type {StoreConf} */ 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 +34,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..d762031fb 100755 --- a/packages/access-client/src/cli/index.js +++ b/packages/access-client/src/cli/index.js @@ -40,13 +40,12 @@ prog .command('space') .describe('Space info.') .action(async (opts) => { + /** @type {StoreConf} */ 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) @@ -65,13 +64,12 @@ prog .describe('Delegation capabilities.') .option('--file', 'File to write the delegation into.') .action(async (opts) => { + /** @type {StoreConf} */ 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) @@ -137,13 +135,12 @@ prog .describe('Import delegation.') .option('--delegation') .action(async (opts) => { + /** @type {StoreConf} */ 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', @@ -159,13 +156,12 @@ prog .command('recover') .describe('Recover spaces with email.') .action(async (opts) => { + /** @type {StoreConf} */ 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 = [] From e63e7ddeccf29f104515f8921e7c3774051b3aa5 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 12:38:58 +0000 Subject: [PATCH 11/13] fix: no need to await --- packages/access-api/test/helpers/context.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index 7df1cb4fa..b59b4f073 100644 --- a/packages/access-api/test/helpers/context.js +++ b/packages/access-api/test/helpers/context.js @@ -44,7 +44,7 @@ export async function context() { return { mf, - conn: await connection({ + conn: connection({ principal, // @ts-ignore fetch: mf.dispatchFetch.bind(mf), From f1339549557bcecabc38ba58c99cbd7d1a139b17 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 16:16:40 +0000 Subject: [PATCH 12/13] fix: remove service() --- packages/access-client/src/agent.js | 26 ++++++----------------- packages/access-client/test/agent.test.js | 6 +----- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 370b77e32..22497c49c 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -26,9 +26,9 @@ import { AgentData } from './agent-data.js' export { AgentData } -const HOST = 'https://w3access-staging.protocol-labs.workers.dev' +const HOST = 'https://access.web3.storage' const PRINCIPAL = DID.parse( - 'did:key:z6MkwTYX2JHHd8bmaEuDdS1LJjrpFspirjDcQ4DvAiDP49Gm' + 'did:key:z6MkqdncRZ1wj8zxCTDUQ8CRT8NQWd63T7mZRvZUX8B7XDFi' ) /** @@ -73,9 +73,6 @@ export function connection(options = {}) { * ``` */ export class Agent { - /** @type {Ucanto.Principal<"key">|undefined} */ - #service - /** @type {import('./agent-data').AgentData} */ #data @@ -92,7 +89,6 @@ export class Agent { url: this.url, }) this.#data = data - this.#service = undefined } /** @@ -129,16 +125,6 @@ export class Agent { return this.#data.spaces } - async service() { - if (this.#service) { - return this.#service - } - const rsp = await fetch(this.url + 'version') - const { did } = await rsp.json() - this.#service = DID.parse(did) - return this.#service - } - did() { return this.#data.principal.did() } @@ -295,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}`) }, @@ -312,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}`), }, @@ -403,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) { @@ -418,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(), }, }) @@ -643,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/test/agent.test.js b/packages/access-client/test/agent.test.js index 302ce99a6..b1b2c6397 100644 --- a/packages/access-client/test/agent.test.js +++ b/packages/access-client/test/agent.test.js @@ -137,13 +137,9 @@ describe('Agent', function () { it('should get space info', async function () { const server = createServer() const agent = await Agent.create(undefined, { - connection: connection({ channel: server }), + 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) From 958fb23a93ecdc5ddd66b15e5995057a004d69df Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 6 Dec 2022 16:44:59 +0000 Subject: [PATCH 13/13] feat: drivers --- packages/access-client/package.json | 4 + .../access-client/src/cli/cmd-create-space.js | 1 - packages/access-client/src/cli/cmd-link.js | 1 - packages/access-client/src/cli/cmd-whoami.js | 1 - packages/access-client/src/cli/index.js | 4 - packages/access-client/src/drivers/conf.js | 60 ++++++ .../access-client/src/drivers/indexeddb.js | 193 ++++++++++++++++++ packages/access-client/src/drivers/types.ts | 25 +++ .../access-client/src/stores/store-conf.js | 52 +---- .../src/stores/store-indexeddb.js | 185 +---------------- packages/access-client/src/stores/types.ts | 25 --- packages/access-client/src/types.ts | 11 +- .../stores/store-indexeddb.browser.test.js | 4 +- 13 files changed, 291 insertions(+), 275 deletions(-) create mode 100644 packages/access-client/src/drivers/conf.js create mode 100644 packages/access-client/src/drivers/indexeddb.js create mode 100644 packages/access-client/src/drivers/types.ts delete mode 100644 packages/access-client/src/stores/types.ts 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/cli/cmd-create-space.js b/packages/access-client/src/cli/cmd-create-space.js index 5fef0734a..f84ca1d1e 100644 --- a/packages/access-client/src/cli/cmd-create-space.js +++ b/packages/access-client/src/cli/cmd-create-space.js @@ -11,7 +11,6 @@ import { getService } from './utils.js' */ export async function cmdCreateSpace(opts) { const { url } = await getService(opts.env) - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() diff --git a/packages/access-client/src/cli/cmd-link.js b/packages/access-client/src/cli/cmd-link.js index ba2f05e55..791586c1c 100644 --- a/packages/access-client/src/cli/cmd-link.js +++ b/packages/access-client/src/cli/cmd-link.js @@ -16,7 +16,6 @@ import { getService } from './utils.js' */ export async function cmdLink(channel, opts) { const { url } = await getService(opts.env) - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() diff --git a/packages/access-client/src/cli/cmd-whoami.js b/packages/access-client/src/cli/cmd-whoami.js index fce37e34f..101b0fe97 100644 --- a/packages/access-client/src/cli/cmd-whoami.js +++ b/packages/access-client/src/cli/cmd-whoami.js @@ -8,7 +8,6 @@ import { NAME } from './config.js' * @param {{ profile: any; env : string }} opts */ export async function cmdWhoami(opts) { - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() if (data) { diff --git a/packages/access-client/src/cli/index.js b/packages/access-client/src/cli/index.js index d762031fb..0c87f5af9 100755 --- a/packages/access-client/src/cli/index.js +++ b/packages/access-client/src/cli/index.js @@ -40,7 +40,6 @@ prog .command('space') .describe('Space info.') .action(async (opts) => { - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() const { url } = await getService(opts.env) @@ -64,7 +63,6 @@ prog .describe('Delegation capabilities.') .option('--file', 'File to write the delegation into.') .action(async (opts) => { - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() const { url } = await getService(opts.env) @@ -135,7 +133,6 @@ prog .describe('Import delegation.') .option('--delegation') .action(async (opts) => { - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() const { url } = await getService(opts.env) @@ -156,7 +153,6 @@ prog .command('recover') .describe('Recover spaces with email.') .action(async (opts) => { - /** @type {StoreConf} */ const store = new StoreConf({ profile: opts.profile }) const data = await store.load() const { url } = await getService(opts.env) 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 50f2d452d..5244b06aa 100644 --- a/packages/access-client/src/stores/store-conf.js +++ b/packages/access-client/src/stores/store-conf.js @@ -1,10 +1,4 @@ -import Conf from 'conf' -import * as JSON from '../utils/json.js' - -/** - * @template T - * @typedef {import('./types').IStore} Store - */ +import { ConfDriver } from '../drivers/conf.js' /** * Store implementation with "[conf](https://github.com/sindresorhus/conf)" @@ -15,46 +9,6 @@ import * as JSON from '../utils/json.js' * import { StoreConf } from '@web3-storage/access/stores/store-conf' * ``` * - * @template {Record} T - * @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, - 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 - } -} +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 534e37620..362fa8165 100644 --- a/packages/access-client/src/stores/store-indexeddb.js +++ b/packages/access-client/src/stores/store-indexeddb.js @@ -1,12 +1,4 @@ -import defer from 'p-defer' - -/** - * @template T - * @typedef {import('./types').IStore} Store - */ - -const STORE_NAME = 'AccessStore' -const DATA_ID = 1 +import { IndexedDBDriver } from '../drivers/indexeddb.js' /** * Store implementation for the browser. @@ -17,177 +9,6 @@ const DATA_ID = 1 * import { StoreIndexedDB } from '@web3-storage/access/stores/store-indexeddb' * ``` * - * @template T - * @implements {Store} - */ -export class StoreIndexedDB { - /** @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 + * @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/types.ts b/packages/access-client/src/stores/types.ts deleted file mode 100644 index 188003b16..000000000 --- a/packages/access-client/src/stores/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Store interface that all stores need to implement - */ -export interface IStore { - /** - * Open store - */ - open: () => Promise - /** - * Clean up and close store - */ - close: () => Promise - /** - * Persist data to the store's backend - */ - save: (data: T) => Promise - /** - * Loads data from the store's backend - */ - load: () => Promise - /** - * Clean all the data in the store's backend - */ - reset: () => Promise -} diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index cba58145a..4036b7c4a 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -33,10 +33,10 @@ import type { Top, } from '@web3-storage/capabilities/types' 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 @@ -162,14 +162,7 @@ export interface AgentOptions { } export interface AgentDataOptions { - store?: StorageDriver -} - -export interface StorageDriver { - /** - * Data is in a format that is safe to be passed to structuredClone(). - */ - save: (data: T) => Promise | void + store?: Driver } export type InvokeOptions< 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 be39dfc4f..abe57c0b2 100644 --- a/packages/access-client/test/stores/store-indexeddb.browser.test.js +++ b/packages/access-client/test/stores/store-indexeddb.browser.test.js @@ -11,7 +11,6 @@ describe('IndexedDB store', () => { principal: await RSASigner.generate({ extractable: false }), }) - /** @type {StoreIndexedDB} */ const store = new StoreIndexedDB('test-access-db-' + Date.now()) await store.open() await store.save(data.export()) @@ -35,7 +34,6 @@ describe('IndexedDB store', () => { }) it('should allow custom store name', async () => { - /** @type {StoreIndexedDB} */ const store = new StoreIndexedDB('test-access-db-' + Date.now(), { dbStoreName: `store-${Date.now()}`, }) @@ -63,12 +61,12 @@ describe('IndexedDB store', () => { await store.close() // 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 () => { - /** @type {StoreIndexedDB} */ const store = new StoreIndexedDB('test-access-db-' + Date.now()) await store.open()