diff --git a/package.json b/package.json index 7a6e512ab..ad07e8c55 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "react": "^18.2.0", "typedoc": "^0.25.3", "typedoc-plugin-markdown": "^3.17.0", - "typescript": "5.2.2" + "typescript": "^5.4.2" }, "prettier": { "trailingComma": "es5", diff --git a/packages/access-client/package.json b/packages/access-client/package.json index a5a6b3f2e..2d645f79a 100644 --- a/packages/access-client/package.json +++ b/packages/access-client/package.json @@ -130,7 +130,7 @@ "mocha": "^10.2.0", "playwright-test": "^12.3.4", "sinon": "^15.0.3", - "typescript": "5.2.2", + "typescript": "^5.4.2", "watch": "^1.0.2" }, "eslintConfig": { diff --git a/packages/access-client/src/drivers/conf.js b/packages/access-client/src/drivers/conf.js index ddf27bc10..e875011e1 100644 --- a/packages/access-client/src/drivers/conf.js +++ b/packages/access-client/src/drivers/conf.js @@ -38,7 +38,7 @@ export class ConfDriver { this.path = this.#config.path } - async open() {} + async connect() {} async close() {} diff --git a/packages/access-client/src/drivers/indexeddb.js b/packages/access-client/src/drivers/indexeddb.js index 6991d4afe..a96717cb5 100644 --- a/packages/access-client/src/drivers/indexeddb.js +++ b/packages/access-client/src/drivers/indexeddb.js @@ -54,13 +54,13 @@ export class IndexedDBDriver { async #getOpenDB() { if (!this.#db) { if (!this.#autoOpen) throw new Error('Store is not open') - await this.open() + await this.connect() } // @ts-expect-error open sets this.#db return this.#db } - async open() { + async connect() { const db = this.#db if (db) return diff --git a/packages/access-client/src/drivers/memory.js b/packages/access-client/src/drivers/memory.js index 9b89f185c..4a7b98a7a 100644 --- a/packages/access-client/src/drivers/memory.js +++ b/packages/access-client/src/drivers/memory.js @@ -25,7 +25,7 @@ export class MemoryDriver { this.#data = undefined } - async open() {} + async connect() {} async close() {} diff --git a/packages/access-client/src/drivers/types.ts b/packages/access-client/src/drivers/types.ts index 193f1dffa..a40dda43c 100644 --- a/packages/access-client/src/drivers/types.ts +++ b/packages/access-client/src/drivers/types.ts @@ -5,7 +5,7 @@ export interface Driver { /** * Open driver */ - open: () => Promise + connect: () => Promise /** * Clean up and close driver */ diff --git a/packages/access-client/src/provider.js b/packages/access-client/src/provider.js index 42717175f..9e1095fcb 100644 --- a/packages/access-client/src/provider.js +++ b/packages/access-client/src/provider.js @@ -8,8 +8,7 @@ export const { Provider: ProviderDID, AccountDID } = Provider * that delegation from the account authorizing agent is either stored in the * agent proofs or provided explicitly. * - * @template {Record} [S=API.Service] - * @param {API.Agent} agent + * @param {API.Agent} agent * @param {object} input * @param {API.AccountDID} input.account - Account provisioning the space. * @param {API.SpaceDID} input.consumer - Space been provisioned. diff --git a/packages/access-client/test/helpers/fixtures.js b/packages/access-client/test/helpers/fixtures.js index 5f03c4314..e8cd42530 100644 --- a/packages/access-client/test/helpers/fixtures.js +++ b/packages/access-client/test/helpers/fixtures.js @@ -1,9 +1,10 @@ import { Signer } from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = Signer.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) + /** did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob */ export const bob = Signer.parse( 'MgCYbj5AJfVvdrjkjNCxB3iAUwx7RQHVQ7H1sKyHy46Iose0BEevXgL1V73PD9snOCIoONgb+yQ9sycYchQC8kygR4qY=' @@ -13,6 +14,7 @@ export const mallory = Signer.parse( 'MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkO0B0elFYkl3Ulf3Q3A/EvcVY0utb4etiSE8e6pi4H0FEmU=' ) +/** did:key:z6MkrZ1r5XBFZjBU34qyD8fueMbMRkKw17BZaq2ivKFjnz2z */ export const service = Signer.parse( 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' ) 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 06a4bb132..e9fa383ed 100644 --- a/packages/access-client/test/stores/store-indexeddb.browser.test.js +++ b/packages/access-client/test/stores/store-indexeddb.browser.test.js @@ -12,7 +12,7 @@ describe('IndexedDB store', () => { }) const store = new StoreIndexedDB('test-access-db-' + Date.now()) - await store.open() + await store.connect() await store.save(data.export()) const exportData = await store.load() @@ -40,13 +40,13 @@ describe('IndexedDB store', () => { const store = new StoreIndexedDB('test-access-db-' + Date.now(), { dbStoreName: `store-${Date.now()}`, }) - await store.open() + await store.connect() const data0 = await AgentData.create() await store.save(data0.export()) await store.close() - await store.open() + await store.connect() const exportedData = await store.load() assert(exportedData) @@ -59,7 +59,7 @@ describe('IndexedDB store', () => { const store = new StoreIndexedDB('test-access-db-' + Date.now(), { autoOpen: false, }) - await store.open() + await store.connect() await store.load() await store.close() @@ -71,7 +71,7 @@ describe('IndexedDB store', () => { it('should round trip delegations', async () => { const store = new StoreIndexedDB('test-access-db-' + Date.now()) - await store.open() + await store.connect() const data0 = await AgentData.create() const signer = await EdSigner.generate() @@ -107,7 +107,7 @@ describe('IndexedDB store', () => { const data = await AgentData.create({ principal }) const store = new StoreIndexedDB('test-access-db-' + Date.now()) - await store.open() + await store.connect() await store.save(data.export()) const exportData = await store.load() diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index d06571cfd..36856a9aa 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -99,7 +99,7 @@ "mocha": "^10.2.0", "playwright-test": "^12.3.4", "type-fest": "^3.3.0", - "typescript": "5.2.2", + "typescript": "^5.4.2", "watch": "^1.0.2" }, "eslintConfig": { diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js index cd1508d81..ef0608b08 100644 --- a/packages/capabilities/src/access.js +++ b/packages/capabilities/src/access.js @@ -64,7 +64,7 @@ export const access = capability({ */ export const authorize = capability({ can: 'access/authorize', - with: DID.match({ method: 'key' }), + with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })), /** * Authorization request describing set of desired capabilities */ diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index d80fbff46..253b083a5 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -20,6 +20,9 @@ import * as UCAN from './ucan.js' import * as Plan from './plan.js' import * as Usage from './usage.js' +export * from './types.js' +export { capability, Schema } from '@ucanto/validator' + export { Access, Provider, diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 2c17f5d11..565542c5d 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -36,6 +36,7 @@ import * as AdminCaps from './admin.js' import * as UCANCaps from './ucan.js' import * as PlanCaps from './plan.js' import * as UsageCaps from './usage.js' +import * as ConsoleCaps from './console.js' export type ISO8601Date = string @@ -80,6 +81,9 @@ export interface AccessAuthorizeSuccess { } export interface AccessAuthorizeFailure extends Ucanto.Failure {} +export interface AccessDenied extends Ucanto.Failure { + name: 'AccessDenied' +} export type AccessClaim = InferInvokedCapability export interface AccessClaimSuccess { @@ -239,6 +243,24 @@ export type RateLimitListFailure = Ucanto.Failure // Space export type Space = InferInvokedCapability export type SpaceInfo = InferInvokedCapability +export type SpaceInfoSuccess = { + did: SpaceDID + providers: ProviderDID[] +} +export type SpaceInfoFailure = Failure | SpaceUnknown + +export interface SpaceUnknown extends Failure { + name: 'SpaceUnknown' +} + +export type ConsoleLog = InferInvokedCapability +export type ConsoleLogOk = {} +export type ConsoleError = InferInvokedCapability +export type ConsoleErrorError = { + name: 'Error' + message: string + cause: unknown +} // filecoin export interface DealMetadata { diff --git a/packages/capabilities/test/helpers/fixtures.js b/packages/capabilities/test/helpers/fixtures.js index a4ae2d1b4..602b6e1f8 100644 --- a/packages/capabilities/test/helpers/fixtures.js +++ b/packages/capabilities/test/helpers/fixtures.js @@ -2,7 +2,7 @@ import { parseLink } from '@ucanto/core' import { Absentee } from '@ucanto/principal' import { Signer } from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = Signer.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) @@ -28,10 +28,10 @@ export const mallory = Signer.parse( export const malloryAccount = Absentee.from({ id: 'did:mailto:test.web3.storage:mallory', }) - +/** did:key:z6MkrZ1r5XBFZjBU34qyD8fueMbMRkKw17BZaq2ivKFjnz2z */ export const service = Signer.parse( 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' -).withDID('did:web:test.web3.storage') +) export const readmeCID = parseLink( 'bafybeihqfdg2ereoijjoyrqzr2x2wsasqm2udurforw7pa3tvbnxhojao4' diff --git a/packages/did-mailto/package.json b/packages/did-mailto/package.json index 9e544ab2e..c59dd56d1 100644 --- a/packages/did-mailto/package.json +++ b/packages/did-mailto/package.json @@ -41,7 +41,7 @@ "@types/mocha": "^10.0.1", "@web3-storage/eslint-config-w3up": "workspace:^", "mocha": "^10.2.0", - "typescript": "5.2.2" + "typescript": "^5.4.2" }, "eslintConfig": { "extends": [ diff --git a/packages/filecoin-api/package.json b/packages/filecoin-api/package.json index d6a1a4207..ea388f152 100644 --- a/packages/filecoin-api/package.json +++ b/packages/filecoin-api/package.json @@ -174,7 +174,7 @@ "multiformats": "^12.1.2", "one-webcrypto": "git://github.com/web3-storage/one-webcrypto", "p-wait-for": "^5.0.2", - "typescript": "5.2.2" + "typescript": "^5.4.2" }, "eslintConfig": { "extends": [ diff --git a/packages/filecoin-client/package.json b/packages/filecoin-client/package.json index 878201d95..17df98a87 100644 --- a/packages/filecoin-client/package.json +++ b/packages/filecoin-client/package.json @@ -76,7 +76,7 @@ "multiformats": "^12.1.2", "npm-run-all": "^4.1.5", "playwright-test": "^12.3.4", - "typescript": "5.2.2" + "typescript": "^5.4.2" }, "eslintConfig": { "extends": [ diff --git a/packages/upload-api/package.json b/packages/upload-api/package.json index 78d4a2c86..7114167c7 100644 --- a/packages/upload-api/package.json +++ b/packages/upload-api/package.json @@ -139,7 +139,7 @@ "is-subset": "^0.1.1", "mocha": "^10.2.0", "one-webcrypto": "git://github.com/web3-storage/one-webcrypto", - "typescript": "5.2.2" + "typescript": "^5.4.2" }, "eslintConfig": { "extends": [ diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 38b8bb582..5c8fada63 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -32,6 +32,7 @@ export type ValidationEmailSend = { url: string } +export type { SpaceInfoSuccess, SpaceInfoFailure, SpaceUnknown } export type SpaceDID = DIDKey export type ServiceDID = DID<'web'> export type ServiceSigner = Signer @@ -117,6 +118,9 @@ import { ProviderAddSuccess, ProviderAddFailure, SpaceInfo, + SpaceInfoSuccess, + SpaceInfoFailure, + SpaceUnknown, ProviderDID, StoreGetFailure, UploadGetFailure, @@ -446,12 +450,6 @@ export interface UploadTable { ) => Promise> } -export type SpaceInfoSuccess = { - did: SpaceDID - providers: ProviderDID[] -} -export type SpaceInfoFailure = Failure | SpaceUnknown - export interface UnknownProvider extends Failure { name: 'UnknownProvider' } @@ -513,9 +511,6 @@ export interface TestSpaceRegistry { export interface LinkJSON { '/': ToString } -export interface SpaceUnknown extends Failure { - name: 'SpaceUnknown' -} export type Input>> = ProviderInput & ParsedCapability> diff --git a/packages/upload-client/package.json b/packages/upload-client/package.json index e12ebf590..c6a36320e 100644 --- a/packages/upload-client/package.json +++ b/packages/upload-client/package.json @@ -96,7 +96,7 @@ "mocha": "^10.2.0", "npm-run-all": "^4.1.5", "playwright-test": "^12.3.4", - "typescript": "5.2.2" + "typescript": "^5.4.2" }, "eslintConfig": { "extends": [ diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index 5dfbcffd6..6745ebbc6 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -24,9 +24,18 @@ }, "exports": { ".": { - "types": "./dist/src/index.d.ts", - "node": "./src/index.node.js", - "import": "./src/index.js" + "types": "./dist/src/lib.ts", + "import": "./src/lib.js" + }, + "./store": { + "types": "./dist/src/store.d.ts", + "node": "./src/store.node.js", + "import": "./src/store.js" + }, + "./agent/signer": { + "types": "./dist/src/agent/signer.d.ts", + "browser": "./src/agent/signer.browser.js", + "import": "./src/agent/signer.js" }, "./account": { "types": "./dist/src/account.d.ts", @@ -100,11 +109,15 @@ "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", + "datalogia": "^0.4.0", + "conf": "11.0.2", "@web3-storage/access": "workspace:^", "@web3-storage/capabilities": "workspace:^", "@web3-storage/did-mailto": "workspace:^", "@web3-storage/filecoin-client": "workspace:^", - "@web3-storage/upload-client": "workspace:^" + "@web3-storage/upload-client": "workspace:^", + "@scure/bip39": "^1.2.1", + "uint8arrays": "^4.0.9" }, "devDependencies": { "@ipld/car": "^5.1.1", @@ -123,7 +136,7 @@ "npm-run-all": "^4.1.5", "playwright-test": "^12.3.4", "typedoc": "^0.25.3", - "typescript": "^5.2.2" + "typescript": "^5.4.2" }, "eslintConfig": { "extends": [ diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js new file mode 100644 index 000000000..94f0def51 --- /dev/null +++ b/packages/w3up-client/src/access.js @@ -0,0 +1,396 @@ +import * as DIDMailto from '@web3-storage/did-mailto' + +import * as API from './types.js' + +export { DIDMailto } + +import * as Access from '@web3-storage/capabilities/access' +import { Failure, DID } from '@ucanto/core' +import { bytesToDelegations } from './agent/encoding.js' +import * as DB from './agent/db.js' +import * as Agent from './agent.js' +import * as Task from './task.js' +import * as Session from './session.js' + +/** + * Takes array of delegations and propagates them to their respective audiences + * through a given space (or the current space if none is provided). + * + * Returns error result if agent has no current space and no space was provided. + * Also returns error result if invocation fails. + * + * @param {API.Session} session - w3up service session. + * @param {object} input + * @param {API.Delegation[]} input.delegations - Delegations to propagate. + * @param {API.SpaceDID} input.subject - Space to propagate through. + * @returns {Task.Task, API.AccessDenied | API.OfflineError>} + */ +export function* delegate(session, { delegations, subject }) { + const entries = Object.values(delegations).map((proof) => [ + proof.cid.toString(), + proof.cid, + ]) + + const { proofs } = yield* Agent.authorize(session.agent, { + subject, + can: { 'access/delegate': [] }, + }) + + const task = Access.delegate.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: subject, + nb: { + delegations: Object.fromEntries(entries), + }, + // must be embedded here because it's referenced by cid in .nb.delegations + proofs: [...proofs, ...delegations], + }) + + return yield* Session.execute(session, task).receipt() +} + +/** + * Requests specified `access` level from specified `account`. It invokes + * `access/authorize` capability, if invocation succeeds it will return a + * `PendingAccessRequest` object that can be used to poll for the requested + * delegation through `access/claim` capability. + * + * @param {API.Session} session + * @param {object} input + * @param {API.AccountDID} input.account - Account from which access is requested. + * @param {API.DIDKey|API.DidMailto} [input.authority] - Principal requesting access. + * @param {API.ProviderDID} [input.provider] - Provider that will receive the invocation. + * @param {API.Can} [input.can] - Capabilities been requested. + + */ +export function* request( + session, + { + account, + authority = /** @type {API.DIDKey} */ (session.agent.signer.did()), + provider = /** @type {API.ProviderDID} */ (session.connection.id.did()), + can = spaceAccess, + } +) { + // Find proofs that allows this agent to invoke `access/authorize` capability + // on behalf of the principal requesting access. + const { proofs } = yield* Agent.authorize(session.agent, { + subject: authority, + can: { 'access/authorize': [] }, + }) + + // Build an invocation and execute it. + const task = Access.authorize.invoke({ + issuer: session.agent.signer, + audience: DID.parse(provider), + with: authority, + nb: { + iss: account, + // New ucan spec moved to recap style layout for capabilities and new + // `access/request` will use similar format as opposed to legacy one, + // in the meantime we translate new format to legacy format here. + att: [...toCapabilities(can)], + }, + proofs, + }) + + const receipt = yield* Session.execute(session, task).receipt() + if (!receipt.out.ok) { + return yield* Task.fail(receipt.out.error) + } + + const { request, expiration } = receipt.out.ok + + return new PendingAccessRequest({ + request, + receipt, + expiration, + authority, + session, + provider, + }) +} + +/** + * Claims access that has been delegated to the given `authority`, which by + * default is the agent's DID. + * + * @param {API.Session} session + * @param {object} input + * @param {API.DIDKey|API.DidMailto} [input.authority] - Principal claiming an access. + * @param {API.ProviderDID} [input.provider] - Provider handling the invocation. + */ +export function* claim( + session, + { + provider = /** @type {API.ProviderDID} */ (session.connection.id.did()), + authority = /** @type {API.DIDKey} */ (session.agent.signer.did()), + } = {} +) { + const auth = yield* Agent.authorize(session.agent, { + subject: authority, + can: { 'access/claim': [] }, + }) + + const task = Access.claim.invoke({ + issuer: session.agent.signer, + audience: DID.parse(provider), + with: authority, + proofs: auth.proofs, + }) + + const receipt = yield* Session.execute(session, task).receipt() + + const { delegations } = yield* Task.ok(receipt.out) + + const proofs = /** @type {API.Tuple} */ ( + Object.values(delegations).flatMap((proof) => bytesToDelegations(proof)) + ) + + return new GrantedAccess({ agent: session.agent, receipt, proofs }) +} + +/** + * @typedef {object} PendingAccessRequestModel + * @property {API.Session} session - Session with a service. + * @property {API.Receipt} receipt - Receipt of the `access/authorize` invocation. + * @property {API.ProviderDID} provider - Provider handling request. + * @property {API.UTCUnixTimestamp} expiration - Seconds in UTC. + * @property {API.DIDKey|API.DidMailto} authority - Principal requesting an access. + * @property {API.Link} request - Link to the `access/authorize` invocation. + */ + +/** + * Represents a pending access request. It can be used to poll for the requested + * delegation. + * + */ +class PendingAccessRequest { + /** + + * + * @param {PendingAccessRequestModel} model + */ + constructor(model) { + this.model = model + } + + get session() { + return this.model.session + } + get expiration() { + return new Date(this.model.expiration * 1000) + } + + get request() { + return this.model.request + } + + get authority() { + return this.model.authority + } + + get provider() { + return this.model.provider + } + + receipt() { + return this.model.receipt + } + + /** + * Low level method and most likely you want to use `.claim` instead. This method will poll + * fetch delegations **just once** and will return proofs matching to this request. Please note + * that there may not be any matches in which case result will be `{ ok: [] }`. + * + * If you do want to continuously poll until request is approved or expired, you should use + * `.claim` method instead. + * + * @returns {Task.Invocation} + */ + poll() { + return Task.perform(PendingAccessRequest.poll(this)) + } + + /** + * Continuously polls delegations until this request is approved or expired. Returns + * a `GrantedAccess` object (view over the delegations) that can be used in the + * invocations or can be saved in the agent (store) using `.save()` method. + * + * @param {object} [options] + * @param {number} [options.interval] + * @param {AbortSignal} [options.signal] + * @returns {Task.Invocation} + */ + claim(options) { + return Task.perform(PendingAccessRequest.claim(this, options)) + } + + /** + * @param {PendingAccessRequest} self + */ + static *poll(self) { + const { session, provider, expiration, authority } = self.model + const timeout = expiration * 1000 - Date.now() + if (timeout <= 0) { + return yield* Task.fail(new RequestExpired(self.model)) + } else { + return yield* claim(session, { authority, provider }) + } + } + + /** + * @param {PendingAccessRequest} self + * @param {object} options + * @param {number} [options.interval] + * @param {AbortSignal} [options.signal] + * @returns {Task.Task} + */ + static *claim(self, { signal, interval = 250 } = {}) { + while (signal?.aborted !== true) { + const access = yield* this.poll(self) + + const proofs = /** @type {API.Tuple} */ ( + access.proofs.filter((proof) => isRequestedAccess(proof, self.model)) + ) + + // If we got some matching proofs, return them. + if (proofs.length > 0) { + return new GrantedAccess({ + agent: self.session.agent, + proofs, + receipt: access.receipt(), + }) + } + + yield* Task.sleep(interval) + } + + return yield* Task.fail( + /** @type {Error & {reason:unknown}} */ ( + new Error('Aborted'), { reason: signal.reason } + ) + ) + } +} + +/** + * Error returned when pending access request expires. + */ +class RequestExpired extends Failure { + /** + * @param {PendingAccessRequestModel} model + */ + constructor(model) { + super() + this.model = model + } + + get name() { + return 'RequestExpired' + } + + get request() { + return this.model.request + } + get expiredAt() { + return new Date(this.model.expiration * 1000) + } + + describe() { + return `Access request expired at ${this.expiredAt} for ${this.request} request.` + } +} + +/** + * View over the UCAN Delegations that grant access to a specific principal. + */ +export class GrantedAccess { + /** + * @typedef {object} GrantedAccessModel + * @property {API.Agent} agent - Agent that processed the request. + * @property {API.Tuple} proofs - Delegations that grant access. + * @property {API.Receipt} receipt + * + * @param {GrantedAccessModel} model + */ + constructor(model) { + this.model = model + } + + receipt() { + return this.model.receipt + } + get proofs() { + return this.model.proofs + } + + /** + * Saves access into the agents proofs store so that it can be retained + * between sessions. + * + * @param {object} input + * @param {API.Agent} [input.agent] + */ + save({ agent = this.model.agent } = {}) { + return DB.transact( + agent.db, + this.proofs.map((proof) => DB.assert({ proof })) + ) + } +} + +/** + * Checks if the given delegation is caused by the passed `request` for access. + * + * @param {API.Delegation} delegation + * @param {object} selector + * @param {API.Link} selector.request + * @returns + */ +const isRequestedAccess = (delegation, { request }) => + // `access/confirm` handler adds facts to the delegation issued by the account + // so that principal requesting access can identify correct delegation when + // access is granted. + delegation.facts.some((fact) => `${fact['access/request']}` === `${request}`) + +/** + * Maps access object that uses UCAN 0.10 capabilities format as opposed + * to legacy UCAN 0.9 format used by w3up which predates new format. + * + * @param {API.Can} access + * @returns {{ can: API.Ability }[]} + */ +export const toCapabilities = (access) => { + const abilities = [] + const entries = /** @type {[API.Ability, API.Unit][]} */ ( + Object.entries(access) + ) + + for (const [can, details] of entries) { + if (details) { + abilities.push({ can }) + } + } + return abilities +} + +/** + * Set of capabilities required by the agent to manage a space. + */ +export const spaceAccess = { + 'space/*': [], + 'store/*': [], + 'upload/*': [], + 'access/*': [], + 'filecoin/*': [], + 'usage/*': [], +} + +/** + * Set of capabilities required for by the agent to manage an account. + */ +export const accountAccess = { + '*': [], +} diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 60689f352..53ff0a9b5 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -1,224 +1,110 @@ import * as API from './types.js' -import * as Access from './capability/access.js' -import * as Plan from './capability/plan.js' -import * as Subscription from './capability/subscription.js' -import { Delegation, importAuthorization } from '@web3-storage/access/agent' -import { add as provision, AccountDID } from '@web3-storage/access/provider' -import { fromEmail, toEmail } from '@web3-storage/did-mailto' +import * as DB from './agent/db.js' +import * as DIDMailto from '@web3-storage/did-mailto' +import * as Space from './space.js' +import * as Task from './task.js' +import * as Account from './account/account.js' -export { fromEmail } +export { DIDMailto } -/** - * @typedef {import('@web3-storage/did-mailto').EmailAddress} EmailAddress - */ +export { login, list, get } from './account/account.js' /** - * List all accounts that agent has stored access to. Returns a dictionary - * of accounts keyed by their `did:mailto` identifier. - * - * @param {{agent: API.Agent}} client - * @param {object} query - * @param {API.DID<'mailto'>} [query.account] + * @template {API.AccountProtocol & API.AccessRequestProvider} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @returns {API.AccountManager} */ -export const list = ({ agent }, { account } = {}) => { - const query = /** @type {API.CapabilityQuery} */ ({ - with: account ?? /did:mailto:.*/, - can: '*', - }) - - const proofs = agent.proofs([query]) - /** @type {Record} */ - const accounts = {} - /** @type {Record} */ - const attestations = {} - for (const proof of proofs) { - const access = Delegation.allows(proof) - for (const [resource, abilities] of Object.entries(access)) { - if (AccountDID.is(resource) && abilities['*']) { - const id = /** @type {API.DidMailto} */ (resource) - - const account = - accounts[id] || - (accounts[id] = new Account({ id, agent, proofs: [] })) - account.addProof(proof) - } - - for (const settings of /** @type {{proof?:API.Link}[]} */ ( - abilities['ucan/attest'] || [] - )) { - const id = settings.proof - if (id) { - attestations[`${id}`] = proof - } - } - } - } - - for (const account of Object.values(accounts)) { - for (const proof of account.proofs) { - const attestation = attestations[`${proof.cid}`] - if (attestation) { - account.addProof(attestation) - } - } - } - - return accounts -} +export const view = (session) => new AccountsView(session) /** - * Attempts to obtains an account access by performing an authentication with - * the did:mailto account corresponding to given email. Process involves out - * of bound email verification, so this function returns a promise that will - * resolve to an account only after access has been granted by the email owner - * by clicking on the link in the email. If the link is not clicked within the - * authorization session time bounds (currently 15 minutes), the promise will - * resolve to an error. - * - * @param {{agent: API.Agent}} client - * @param {EmailAddress} email - * @param {object} [options] - * @param {AbortSignal} [options.signal] - * @returns {Promise>} + * @template {API.AccountProtocol & API.AccessRequestProvider} [Protocol=API.W3UpProtocol] + * @implements {API.AccountManager} */ -export const login = async ({ agent }, email, options = {}) => { - const account = fromEmail(email) - - // If we already have a session for this account we - // skip the authentication process, otherwise we will - // end up adding more UCAN proofs and attestations to - // the store which we then will be sending when using - // this account. - // Note: This is not a robust solution as there may be - // reasons to re-authenticate e.g. previous session is - // no longer valid because it was revoked. But dropping - // revoked UCANs from store is something we should do - // anyway. - const session = list({ agent }, { account })[account] - if (session) { - return { ok: session } - } - - const result = await Access.request( - { agent }, - { - account, - access: Access.accountAccess, - } - ) - - const { ok: access, error } = result - /* c8 ignore next 2 - don't know how to test this */ - if (error) { - return { error } - } else { - const { ok, error } = await access.claim({ signal: options.signal }) - /* c8 ignore next 2 - don't know how to test this */ - if (error) { - return { error } - } else { - return { ok: new Account({ id: account, proofs: ok.proofs, agent }) } - } - } -} - -/** - * @typedef {object} Model - * @property {API.DidMailto} id - * @property {API.Agent} agent - * @property {API.Delegation[]} proofs - */ - -export class Account { +export class AccountsView { /** - * @param {Model} model + * @param {API.Session} session */ - constructor(model) { - this.model = model - this.plan = new AccountPlan(model) - } - get agent() { - return this.model.agent - } - get proofs() { - return this.model.proofs - } + constructor(session) { + this.session = session - did() { - return this.model.id + this.spaces = Space.view(/** @type {API.Session} */ (this.session)) } - toEmail() { - return toEmail(this.did()) + *[Symbol.iterator]() { + yield* Object.values(Account.list(this.session)) } /** - * @param {API.Delegation} proof + * @param {object} source + * @param {API.EmailAddress} source.email + * @param {AbortSignal} [source.signal] */ - addProof(proof) { - this.proofs.push(proof) - } - - toJSON() { - return { - id: this.did(), - proofs: this.proofs - // we sort proofs to get a deterministic JSON representation. - .sort((a, b) => a.cid.toString().localeCompare(b.cid.toString())) - .map((proof) => proof.toJSON()), - } + login(source) { + return Task.perform(Account.login(this.session, source)) } /** - * Provisions given `space` with this account. - * - * @param {API.SpaceDID} space - * @param {object} input - * @param {API.ProviderDID} [input.provider] - * @param {API.Agent} [input.agent] + * Returns iterable of all the accounts saved in the agent's database. */ - provision(space, input = {}) { - return provision(this.agent, { - ...input, - account: this.did(), - consumer: space, - proofs: this.proofs, - }) + list() { + return Account.list(this.session) } /** - * Saves account in the agent store so it can be accessed across sessions. + * Gets an account view for the login with a given email address stored in the + * agent's database. Returns `undefined` if no matching login is found. * - * @param {object} input - * @param {API.Agent} [input.agent] + * @param {API.EmailAddress} email */ - async save({ agent = this.agent } = {}) { - return await importAuthorization(agent, this) + get(email) { + return Account.get(this.session, email) } -} -export class AccountPlan { /** - * @param {Model} model + * @param {API.AccountView} account */ - constructor(model) { - this.model = model + add(account) { + return Task.perform(add(this.session, account)) } /** - * Gets information about the plan associated with this account. + * @param {API.AccountView} account */ - async get() { - return await Plan.get(this.model, { - account: this.model.id, - proofs: this.model.proofs, - }) + remove(account) { + return Task.perform(remove(this.session, account)) } +} - async subscriptions() { - return await Subscription.list(this.model, { - account: this.model.id, - proofs: this.model.proofs, - }) - } +/** + * Stores account into in the agent's database so it is retained between + * sessions. + * + * ⚠️ If agent provided is not the agent authorized by the account stored + * account will not be listed until session is created with an authorized agent. + * + * @param {API.Session} session + * @param {API.AccountView} account + */ +export function* add({ agent }, account) { + yield* DB.transact( + agent.db, + [...account.proofs].map((proof) => DB.assert({ proof })) + ) + + return {} +} + +/** + * Removes access to this account from the agent's database. + * + * @param {API.Session} session + * @param {API.AccountView} account + */ +export function* remove({ agent }, account) { + yield* DB.transact( + agent.db, + [...account.proofs].map((proof) => DB.retract({ proof })) + ) + + return {} } diff --git a/packages/w3up-client/src/account/account.js b/packages/w3up-client/src/account/account.js new file mode 100644 index 000000000..3b33e4390 --- /dev/null +++ b/packages/w3up-client/src/account/account.js @@ -0,0 +1,187 @@ +import * as API from '../types.js' +import * as Query from './query.js' +import * as Access from '../access.js' +import * as DB from '../agent/db.js' +import * as DIDMailto from '@web3-storage/did-mailto' +import * as Plan from '../account/plan.js' +import * as Space from '../space.js' +import * as Task from '../task.js' + +export { DIDMailto } + +/** + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @param {object} source + * @param {object} source.login + * @param {API.DidMailto} source.login.id + * @param {Map} source.login.proofs + * @param {Map} source.login.attestations + * @param {API.Session} source.session + */ +export const view = ({ login, session }) => + new AccountView({ + id: login.id, + session: { + agent: { + signer: session.agent.signer, + db: DB.fromProofs([ + ...login.proofs.values(), + ...login.attestations.values(), + ]), + }, + connection: session.connection, + }, + }) + +/** + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {object} source + * @param {API.EmailAddress} source.email + * @param {AbortSignal} [source.signal] + * @returns {Task.Invocation, API.AccessDenied|API.InvocationError|API.AccessAuthorizeFailure>} + */ +export const login = (session, { email, signal }) => + Task.spawn(function* () { + const account = get(session, email) + if (account) { + return account + } + const id = DIDMailto.fromEmail(email) + const access = yield* Access.request(session, { + account: id, + can: Access.accountAccess, + }) + + const { proofs } = yield* access.claim({ signal }) + + const login = { id, attestations: new Map(), proofs: new Map() } + for (const proof of proofs) { + if (proof.capabilities?.[0].can === 'ucan/attest') { + login.attestations.set(`${proof.cid}`, proof) + } else { + login.proofs.set(`${proof.cid}`, proof) + } + } + + return view({ session, login }) + }) + +/** + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @returns {Record>} + */ +export const list = (session) => { + const matches = Query.select( + session.agent.db, + DB.query( + session.agent.db.index, + Query.query({ audience: session.agent.signer.did() }) + ) + ) + + return Object.fromEntries( + [...matches].map(([account, login]) => [account, view({ session, login })]) + ) +} + +/** + * Gets the account view for the login with a given email address. Returns + * `undefined` if no matching login is found. + * + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {API.EmailAddress} email + */ +export const get = (session, email) => { + const account = DIDMailto.fromEmail(email) + const [login] = Query.select( + session.agent.db, + DB.query( + session.agent.db.index, + Query.query({ audience: session.agent.signer.did(), account }) + ) + ).values() + + return login ? view({ session, login }) : undefined +} + +/** + * Stores account into in the agent's database so it is retained between + * sessions. + * + * ⚠️ If agent provided is not the agent authorized by the account stored + * account will not be listed until session is created with an authorized agent. + * + * @param {API.Session} session + * @param {API.AccountView} account + */ +export function* add({ agent }, account) { + yield* DB.transact( + agent.db, + [...account.proofs].map((proof) => DB.assert({ proof })) + ) + + return {} +} + +/** + * Removes access to this account from the agent's database. + * + * @param {API.Session} session + * @param {API.AccountView} account + */ +export function* remove({ agent }, account) { + yield* DB.transact( + agent.db, + [...account.proofs].map((proof) => DB.retract({ proof })) + ) + + return {} +} + +/** + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.AccountView} + * @implements {API.AccountSession} + */ +class AccountView { + /** + * @param {object} source + * @param {API.DidMailto} source.id + * @param {API.Session} source.session + */ + constructor(source) { + this.model = source + + this.plans = Plan.from(this) + this.spaces = Space.view(/** @type {API.Session} */ (this.session)) + } + get session() { + return this.model.session + } + did() { + return this.model.id + } + + /** + * @returns {API.EmailAddress} + */ + toEmail() { + return DIDMailto.toEmail(this.did()) + } + + get proofs() { + return [...this.model.session.agent.db.proofs.values()].map( + ($) => $.delegation + ) + } + + toJSON() { + return { + email: this.toEmail(), + proofs: [...this.proofs], + } + } +} diff --git a/packages/w3up-client/src/account/plan.js b/packages/w3up-client/src/account/plan.js new file mode 100644 index 000000000..63ed226a9 --- /dev/null +++ b/packages/w3up-client/src/account/plan.js @@ -0,0 +1,114 @@ +import * as API from '../types.js' +import { Plan } from '@web3-storage/capabilities' +import * as Subscriptions from './subscription.js' +import * as Agent from '../agent.js' +import * as Task from '../task.js' +import * as Session from '../session.js' + +/** + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @param {API.AccountSession} account + * @returns {API.AccountPlans} + */ +export const from = (account) => new AccountPlans(account) + +/** + * @param {API.AccountSession} account + */ +export function* list(account) { + const { session } = account + const auth = yield* Agent.authorize(account.session.agent, { + subject: account.did(), + can: { + 'plan/get': [], + }, + }) + + const task = Plan.get.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: account.did(), + proofs: auth.proofs, + }) + + const receipt = yield* Session.execute(session, task).receipt() + + /** @type {API.AccountPlanList} */ + const plans = /** @type {any} */ (new AccountPlanList()) + if (receipt.out.ok) { + plans[receipt.out.ok.product] = new BillingPlan({ + account: account, + // We really should add the provider info into the plan response instead + // of assuming that it is the DID of the service. + provider: /** @type {API.ProviderDID} */ (session.connection.id.did()), + plan: receipt.out.ok, + receipt, + }) + + return plans + } else if (receipt.out.error.name === 'PlanNotFound') { + return plans + } else { + return yield* Task.fail( + /** @type {API.InvocationError & { receipt: API.Receipt }} */ + ( + Object.assign(new Error(receipt.out.error.message), { + name: receipt.out.error, + receipt, + }) + ) + ) + } +} + +/** + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.AccountPlans} + */ +class AccountPlans { + /** + * @param {API.AccountSession} account + */ + constructor(account) { + this.account = account + } + list() { + return Task.perform(list(this.account)) + } +} + +class AccountPlanList { + *[Symbol.iterator]() { + yield* Object.values(this) + } +} + +/** + * @template {API.SubscriptionProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.BillingPlan} + */ +class BillingPlan { + /** + * @param {object} source + * @param {API.AccountSession} source.account + * @param {API.ProviderDID} source.provider + * @param {API.PlanGetSuccess} source.plan + * @param {API.Receipt} source.receipt + */ + constructor(source) { + this.model = source + this.subscriptions = Subscriptions.from(this) + } + get account() { + return this.model.account + } + + get customer() { + return this.model.account.did() + } + get provider() { + return /** @type {API.ProviderDID} */ ( + this.model.account.session.connection.id.did() + ) + } +} diff --git a/packages/w3up-client/src/account/query.js b/packages/w3up-client/src/account/query.js new file mode 100644 index 000000000..bf3b926d0 --- /dev/null +++ b/packages/w3up-client/src/account/query.js @@ -0,0 +1,110 @@ +import * as API from '../types.js' +import * as Text from '../agent/db/text.js' +import * as DB from 'datalogia' +import * as Authorization from '../authorization/query.js' + +/** + * @typedef {object} Match + * @property {DB.Link} proof + * @property {DB.Link} [attestation] + * @property {API.DidMailto} account + */ + +/** + * @param {object} selector + * @param {DB.Term} [selector.can] + * @param {DB.Term} [selector.account] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.attestation] + * @param {DB.Term} [selector.time] + * @returns {API.Query<{ account: DB.Term; proof: DB.Term, attestation: DB.Term }>} + */ +export const query = ({ + time = Date.now() / 1000, + account = DB.string(), + attestation = DB.link(), + ...selector +}) => { + const proof = DB.link() + return { + select: { + account, + proof, + attestation, + }, + where: [match(proof, { account, attestation, ...selector })], + } +} + +/** + * @typedef {object} Model + * @property {API.DidMailto} id + * @property {Map} proofs + * @property {Map} attestations + */ + +/** + * Takes matches and builds up a map of models. + * + * @param {API.Database} db + * @param {Match[]} matches + * @returns {Map} + */ +export const select = (db, matches) => { + /** @type {Map} */ + const selection = new Map() + for (const match of matches) { + const account = selection.get(match.account) ?? { + id: match.account, + proofs: new Map(), + attestations: new Map(), + } + + const proof = /** @type {{delegation: API.Delegation}} */ ( + db.proofs.get(String(match.proof)) + ) + account.proofs.set(proof.delegation.cid.toString(), proof.delegation) + + if (match.attestation) { + const attestation = /** @type {{delegation: API.Delegation}} */ ( + db.proofs.get(String(match.attestation)) + ) + + account.attestations.set( + attestation.delegation.cid.toString(), + attestation.delegation + ) + } + + selection.set(account.id, account) + } + + return selection +} + +/** + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.account] + * @param {DB.Term} [constraints.attestation] + * @param {DB.Term} [constraints.can] + */ +export const match = ( + ucan, + { + time = Date.now() / 1000, + audience = DB.string(), + account = DB.string(), + attestation = DB.link(), + can = DB.string(), + } +) => + Authorization.match(ucan, { + time, + audience, + subject: account, + can, + attestation, + }).and(Text.match(account, { glob: 'did:mailto:*' })) diff --git a/packages/w3up-client/src/account/subscription.js b/packages/w3up-client/src/account/subscription.js new file mode 100644 index 000000000..7a2a0f6b2 --- /dev/null +++ b/packages/w3up-client/src/account/subscription.js @@ -0,0 +1,116 @@ +import * as API from '../types.js' +import { Provider, Subscription } from '@web3-storage/capabilities' +import * as Agent from '../agent.js' +import * as Session from '../session.js' +import * as Task from '../task.js' + +/** + * @param {API.BillingPlanSession} source + * @returns {API.AccountSubscriptions} + */ +export const from = (source) => new AccountSubscriptions(source) + +/** + * @param {API.BillingPlanSession} session + * @param {object} subscription + * @param {API.SpaceDID} subscription.consumer + * @param {API.Limit} [subscription.limit] + */ +export function* add({ account, provider }, { consumer }) { + const { session } = account + const auth = yield* Agent.authorize(account.session.agent, { + subject: account.did(), + can: { + 'provider/add': [], + }, + }) + + const task = Provider.add.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: account.did(), + nb: { + provider: Provider.Provider.from(provider), + consumer, + }, + proofs: auth.proofs, + }) + + return yield* Session.execute(session, task).receipt() +} + +/** + * @param {API.BillingPlanSession} session + */ +export function* list({ account }) { + const { session } = account + + const customer = account.did() + const auth = yield* Agent.authorize(account.session.agent, { + subject: customer, + can: { + 'subscription/list': [], + }, + }) + + const task = Subscription.list.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: customer, + proofs: auth.proofs, + nb: {}, + }) + + const { results } = yield* Session.execute(session, task) + + /** @type {API.Subscriptions} */ + // Note we cast to any because there is no way to make TS accept that + // subscriptions is dictionary. + const subscriptions = /** @type {any} */ (new Subscriptions()) + for (const { provider, consumers } of results) { + for (const consumer of consumers) { + subscriptions[`${consumer}:${customer}@${provider}`] = { + customer, + consumer, + provider, + limit: {}, + } + } + } + + return subscriptions +} + +class Subscriptions { + *[Symbol.iterator]() { + yield* Object.values(this) + } +} + +/** + * @implements {API.AccountSubscriptions} + */ +class AccountSubscriptions { + /** + * @param {API.BillingPlanSession} model + */ + constructor(model) { + this.model = model + } + get account() { + return this.model.account.did() + } + + /** + * @param {object} subscription + * @param {API.SpaceDID} subscription.consumer + * @param {API.Limit} [subscription.limit] + */ + add(subscription) { + return Session.perform(add(this.model, subscription)) + } + + list() { + return Task.perform(list(this.model)) + } +} diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js new file mode 100644 index 000000000..6589e0f5d --- /dev/null +++ b/packages/w3up-client/src/agent.js @@ -0,0 +1,166 @@ +import * as DB from './agent/db.js' +import { Signer } from '@ucanto/principal' +import { DID } from '@ucanto/core' +import * as KeyPair from '@web3-storage/w3up-client/agent/signer' + +import * as API from './types.js' +import * as Session from './session.js' +import * as Connection from './agent/connection.js' +import * as Authorization from './authorization.js' +import * as Task from './task.js' +import * as Memory from './store/memory.js' +export * from './types.js' + +export { DB, Connection, DID } + +export const ephemeral = Memory.open() + +/** + * @param {API.AgentFrom} source + */ +export const from = (source) => + Task.spawn(function* () { + if (source.create) { + return yield* create(source.create) + } else if (source.load) { + return yield* load(source.load) + } else if (source.open) { + return yield* open(source.open) + } else { + return Task.fail(new TypeError('Invalid source')) + } + }) + +/** + * @param {API.AgentOpen} source + */ +export const open = ({ store, as }) => + Task.spawn(function* () { + const db = yield* DB.open({ store }) + + if (as) { + return new Agent({ db, signer: as }) + } else if (db.signer) { + return new Agent({ + signer: Signer.from(db.signer), + db, + }) + } else { + const signer = yield* Task.wait(KeyPair.generate()) + + yield* DB.transact(db, [DB.assert({ signer: signer.toArchive() })]) + + return new Agent({ db, signer }) + } + }) + +/** + * @param {API.AgentLoad} source + */ +export const load = ({ store, as }) => + Task.spawn(function* () { + const db = yield* DB.open({ store }) + + if (as != null) { + return new Agent({ db, signer: as }) + } else if (db?.signer != null) { + const signer = Signer.from( + /** @type {API.SignerArchive} */ (db.signer) + ) + + return new Agent({ db, signer }) + } else { + return yield* Task.fail( + new SignerLoadError('Signer key material is not stored in storage') + ) + } + }) + +/** + * @param {API.AgentCreate} source + */ +export const create = ({ store, as }) => + Task.spawn(function* () { + const db = yield* DB.open({ store }) + let signer = as + if (!signer) { + signer = yield* Task.wait(KeyPair.generate()) + const archive = signer.toArchive() + yield* DB.transact(db, [DB.assert({ signer: archive })]) + } + + return new Agent({ db, signer }) + }) + +/** + * @param {API.Agent} agent + * @param {object} access + * @param {API.DID} access.subject + * @param {API.Can} access.can + * @returns {Task.Task} + */ +export function* authorize(agent, { subject, can }) { + const result = Authorization.get(agent.db, { + authority: agent.signer.did(), + subject, + can, + }) + + return yield* Task.ok(result) +} + +/** + * @param {object} source + * @param {API.Signer} source.signer + * @param {API.Database} source.db + * @returns {API.AgentView} + */ +export const view = (source) => new Agent(source) + +/** + * @implements {API.AgentView} + */ +class Agent { + /** + * @param {object} source + * @param {API.Signer} source.signer + * @param {API.Database} source.db + */ + constructor(source) { + this.model = source + } + + did() { + return this.model.signer.did() + } + + get signer() { + return this.model.signer + } + + get db() { + return this.model.db + } + + /** + * @param {object} access + * @param {API.DID} access.subject + * @param {API.Can} access.can + */ + authorize(access) { + return Task.perform(authorize(this, access)) + } + + /** + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} [connection] + * @returns {API.W3UpSession} + */ + connect(connection = Connection.open()) { + return Session.create({ agent: this, connection }) + } +} + +class SignerLoadError extends Error { + name = /** @type {const} */ ('SignerLoadError') +} diff --git a/packages/w3up-client/src/agent/attestation.js b/packages/w3up-client/src/agent/attestation.js new file mode 100644 index 000000000..949d1c1b2 --- /dev/null +++ b/packages/w3up-client/src/agent/attestation.js @@ -0,0 +1,41 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Capability from './capability.js' +import * as Delegation from './delegation.js' + +/** + * Creates constraint for the `ucan` that will match only the ones that are + * attestations of `constraints.proof` issued on behalf of + * `constraints.authority` to `constraints.audience` and that are valid + * at `constraints.time`. + * + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} constraints.proof + * @param {DB.Term} constraints.time + * @param {DB.Term} [constraints.capability] + * @param {DB.Term} [constraints.subject] + * @param {DB.Term} [constraints.audience] + */ +export const match = ( + ucan, + { + capability = DB.link(), + subject = DB.string(), + audience = DB.string(), + proof, + time, + } +) => + Capability.match(capability, { + subject, + can: 'ucan/attest', + }) + .and(DB.match([capability, 'capability/nb/proof', proof])) + .and( + Delegation.match(ucan, { + capability, + audience, + time, + }) + ) diff --git a/packages/w3up-client/src/agent/block.js b/packages/w3up-client/src/agent/block.js new file mode 100644 index 000000000..bd9d25ae4 --- /dev/null +++ b/packages/w3up-client/src/agent/block.js @@ -0,0 +1,33 @@ +import * as API from '../types.js' +import { parseLink } from '@ucanto/core' + +/** + * An {@link API.IPLDBlock} formatted for storage, making it compatible with + * `structuredClone()` used by `indexedDB`. + * + * @typedef {object} Archive + * @property {API.CIDString} cid + * @property {Uint8Array} bytes + */ + +/** + * Formats {@link API.IPLDBlock} into {@link Archive}. + * + * @param {API.IPLDBlock} block + * @returns {Archive} + */ +export const toArchive = ({ cid, bytes }) => ({ + cid: `${cid}`, + bytes, +}) + +/** + * Formats {@link Archive} into {@link API.IPLDBlock}. + * + * @param {Archive} archive + * @returns {API.IPLDBlock} + */ +export const fromArchive = ({ cid, bytes }) => ({ + cid: parseLink(cid).toV1(), + bytes, +}) diff --git a/packages/w3up-client/src/agent/capability.js b/packages/w3up-client/src/agent/capability.js new file mode 100644 index 000000000..181b468f0 --- /dev/null +++ b/packages/w3up-client/src/agent/capability.js @@ -0,0 +1,67 @@ +import * as DB from 'datalogia' +import * as API from '../types.js' + +/** + * Creates clause that matches `query.capability` only if + * it has `query.ability`. + * + * @param {DB.Term} capability + * @param {string} can + */ +export const matchAbility = (capability, can) => { + const ability = DB.string() + return DB.match([capability, 'capability/can', ability]).and( + // can is a glob pattern that we try to match against + // ability - store/* + // can - store/add + DB.glob(can, ability) + ) +} + +/** + * Creates clause that matches `query.capability` only if + * it has `query.ability`. + * + * @param {DB.Term} capability + * @param {DB.Term} can + */ +export const hasAbility = (capability, can) => + DB.match([capability, 'capability/can', can]) + +/** + * Creates clause that matches `capability` only if it has `query.subject`. + * + * @param {DB.Term} capability + * @param {DB.Term} subject + */ +export const hasSubject = (capability, subject) => + DB.match([capability, 'capability/with', subject]) + +/** + * Returns capability that matches given constraints, specifically that it is + * for the given subject and poses `constraint.can` ability. + * + * @param {DB.Term} capability + * @param {object} constraints + * @param {DB.Term} [constraints.subject] + * @param {DB.Term} [constraints.can] + */ +export const match = ( + capability, + { subject = DB.string(), can = DB.string() } +) => hasSubject(capability, subject).and(hasAbility(capability, can)) + +/** + * Matches forwarding capability, that is a capability where the subject + * (`with`) is `ucan:*`. Forwarding capability allows re-delegation of + * the capabilities matching `can` field from all the subjects. This is + * typically used during the login process where account re-delegates + * everything delegated to it to it the logged in agent. + * + * @param {DB.Term} capability + * @param {object} constraints + * @param {DB.Term} [constraints.subject] + * @param {DB.Term} [constraints.can] + */ +export const forwards = (capability, { can = DB.string() }) => + match(capability, { subject: 'ucan:*', can }) diff --git a/packages/w3up-client/src/agent/connection.js b/packages/w3up-client/src/agent/connection.js new file mode 100644 index 000000000..7bae83c22 --- /dev/null +++ b/packages/w3up-client/src/agent/connection.js @@ -0,0 +1,53 @@ +import * as API from '../types.js' +import * as Agent from '@ucanto/client' +import * as CAR from '@ucanto/transport/car' +import * as HTTP from '@ucanto/transport/http' +import { DID } from '@ucanto/core' +export const url = new URL('https://up.web3.storage') +export const id = DID.parse('did:web:web3.storage') + +export * as Address from './connection/address.js' + +/** + * @template {API.UnknownProtocol} Protocol + * @typedef {API.Connection} Connection + */ + +/** + * Opens ucanto connection with a service at the given address. If optional + * `fetch` implementation is passed it will be used instead of global `fetch` + * function. In runtime where `fetch` global is not available this option MUST + * be provided. + * + * @template {Record} [Protocol=API.W3UpProtocol] + * @param {object} source + * @param {API.Address} [source.address] + * @param {typeof fetch} [source.fetch] - Fetch implementation to use + * @returns {API.Connection} + */ +export const open = ({ + address = { id, url }, + fetch = globalThis.fetch.bind(globalThis), +} = {}) => + Object.assign( + Agent.connect({ + id: address.id, + codec: CAR.outbound, + channel: HTTP.open({ + url: address.url, + method: 'POST', + fetch, + }), + }), + { + address, + } + ) + +/** + * @type {API.Offline} + */ +export const offline = { + id: id, + address: { id, url }, +} diff --git a/packages/w3up-client/src/agent/connection/address.js b/packages/w3up-client/src/agent/connection/address.js new file mode 100644 index 000000000..e6d9c5cbf --- /dev/null +++ b/packages/w3up-client/src/agent/connection/address.js @@ -0,0 +1,22 @@ +import * as API from '../../types.js' +import { DID } from '@ucanto/core' + +/** + * @template {API.UnknownProtocol} Protocol + * @param {API.Address} address + * @returns {API.AddressArchive} + */ +export const toArchive = (address) => ({ + id: address.id.did(), + url: address.url.href, +}) + +/** + * @template {API.UnknownProtocol} Protocol + * @param {API.AddressArchive} archive + * @returns {API.Address} + */ +export const fromArchive = (archive) => ({ + id: DID.parse(archive.id), + url: new URL(archive.url), +}) diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js new file mode 100644 index 000000000..b43c301bc --- /dev/null +++ b/packages/w3up-client/src/agent/db.js @@ -0,0 +1,250 @@ +import * as Datalogia from 'datalogia' +import * as API from '../types.js' +import * as Delegation from './delegation.js' +import * as Delegations from './delegations.js' +export * from 'datalogia' +export * as Text from './db/text.js' +import * as Task from '../task.js' + +/** + * + * @param {Datalogia.Clause} clause + * @returns + */ +export const optional = (clause) => Datalogia.or(clause, Datalogia.not(clause)) + +/** + * @param {API.Variant<{ + * proofs: Iterable, + * archive: API.DatabaseArchive + * }>} source + * @returns {API.Database} + */ +export const from = (source) => + source.proofs ? fromProofs(source.proofs) : fromArchive(source.archive) + +/** + * @param {API.Database} db + * @returns {API.DatabaseArchive} + */ +export const toArchive = (db) => { + const delegations = new Map() + for (const [key, { meta, delegation }] of db.proofs) { + delegations.set(key, { + meta, + delegation: Delegation.toArchive(delegation), + }) + } + + return { principal: db.signer, meta: db.meta, delegations } +} + +/** + * @param {Partial} archive + * @returns {API.Database} + */ +export const fromArchive = ({ + principal, + meta = { name: 'agent', type: 'device' }, + delegations = new Map(), +}) => { + const proofs = new Map() + + for (const { meta, delegation } of delegations.values()) { + const proof = Delegation.fromArchive(delegation) + proofs.set(`${proof.cid}`, { delegation: proof, meta }) + } + + const db = Datalogia.Memory.create(Delegations.facts(proofs.values())) + + return { + meta, + signer: principal, + proofs, + index: db, + transactor: db, + } +} + +/** + * Builds a database from the given set of proofs. + * + * @param {Iterable} source + * @returns {API.Database} + */ +export const fromProofs = (source) => { + const proofs = new Map( + [...source].map((proof) => [ + `${proof.cid}`, + { + meta: {}, + delegation: proof, + }, + ]) + ) + + const db = Datalogia.Memory.create(Delegations.facts(proofs.values())) + return { + meta: { name: 'agent', type: 'device' }, + proofs, + signer: undefined, + index: db, + transactor: db, + } +} + +/** + * @param {object} source + * @param {API.DataStore} [source.store] + * @returns {Task.Invocation} + */ +export const open = ({ store }) => + Task.spawn(function* () { + try { + const archive = store ? yield* Task.wait(store.load()) : null + const db = fromArchive(archive ?? {}) + return { ...db, store } + } catch (cause) { + return yield* Task.fail( + new DataStoreOpenError('Failed to open a datastore', { + cause, + }) + ) + } + }) + +/** + * @param {API.Database} db + * @returns {Task.Invocation} + */ +export const save = (db) => + Task.spawn(function* () { + const archive = toArchive(db) + if (db.store) { + try { + yield* Task.wait(db.store.save(archive)) + } catch (cause) { + return Task.fail( + new DataStoreSaveError('Failed to store data', { cause }) + ) + } + } + + return {} + }) + +/** + * @param {API.Database} db + * @param {API.DBTransaction} transaction + * @returns {Task.Task} + */ +export const transact = (db, transaction) => + Task.spawn(function* () { + const assertions = [] + let reindex = false + for (const { assert, retract } of transaction) { + if (assert) { + const { proof, signer } = assert + if (proof) { + db.proofs.set(`${proof.cid}`, { meta: {}, delegation: proof }) + for (const fact of Delegation.facts(proof)) { + assertions.push({ Associate: fact }) + } + } else if (signer) { + db.signer = signer + } else { + return yield* Task.fail( + new DatabaseTransactionError( + `Transaction contains unknown assertion`, + { cause: assert, transaction } + ) + ) + } + } + + if (retract) { + const { proof, signer } = retract + // Note we do not delete delegation proofs from the database as they + // may be referenced by other proofs. In fact we should probably just + // mark this proof as retracted instead of deleting them, and re-indexing + // but for now this will do. + if (proof) { + db.proofs.delete(`${proof.cid}`) + reindex = true + } else if (signer) { + delete db.signer + } else { + return yield* Task.fail( + new DatabaseTransactionError( + `Transaction contains unknown retraction`, + { cause: retract, transaction } + ) + ) + } + } + + const commit = yield* Task.wait(db.transactor.transact(assertions)) + if (commit.error) { + return yield* Task.fail( + new DatabaseTransactionError(commit.error.message, { + cause: commit.error, + transaction, + }) + ) + } + } + + // If we end up removing some proofs we need to rebuild index in order to + // prune facts that are no longer valid. + if (reindex) { + const state = Datalogia.Memory.create( + Delegations.facts(db.proofs.values()) + ) + db.index = state + db.transactor = state + } + + // Finally we save changes in the database store. + yield* save(db) + + return db + }) + +/** + * Creates a retraction instruction. + * + * @param {API.DBAssertion} assertion + * @returns {API.DBInstruction} + */ +export const retract = (assertion) => ({ retract: assertion }) + +/** + * Creates an assertion instruction. + * + * @param {API.DBAssertion} assertion + * @returns {API.DBInstruction} + */ +export const assert = (assertion) => ({ assert: assertion }) + +class DataStoreOpenError extends Error { + name = /** @type {const} */ ('DataStoreOpenError') +} + +class DataStoreSaveError extends Error { + name = /** @type {const} */ ('DataStoreSaveError') +} + +class DatabaseTransactionError extends Error { + name = /** @type {const} */ ('DatabaseTransactionError') + /** + * @param {string} message + * @param {object} options + * @param {API.DBTransaction} options.transaction + * @param {Error} options.cause + */ + constructor(message, { transaction, cause }) { + super(message) + this.transaction = transaction + this.cause = cause + } +} diff --git a/packages/w3up-client/src/agent/db/association.js b/packages/w3up-client/src/agent/db/association.js new file mode 100644 index 000000000..3f190b68c --- /dev/null +++ b/packages/w3up-client/src/agent/db/association.js @@ -0,0 +1,41 @@ +import * as DB from 'datalogia' +import { isLink } from '@ucanto/core' + +/** + * Derives facts from the given `source` object. For each scalar value in the + * object it produces [entity, [...path, key].join('/'), value] triple and + * for non scalar value it descends and produces [entity, [...path, key, nestedKey].join('/'), value] triples. + * + * If `options.entity` is provided asserts facts about it, otherwise derives + * a new entity from the `source` object. If `options.path` is provided it is + * used as a prefix for the path of the facts. + * + * @param {{}} source + * @param {object} options + * @param {string[]} [options.path] + * @param {DB.Entity} [options.entity] + * @returns {Iterable} + */ +export const assert = function* ( + source, + { entity = DB.Memory.entity(source), path = [] } = {} +) { + for (const [key, value] of Object.entries(source)) { + switch (typeof value) { + case 'number': + case 'bigint': + case 'string': + case 'boolean': + yield [entity, [...path, key].join('/'), value] + break + case 'object': { + if (isLink(value)) { + yield [entity, [...path, key].join('/'), value] + } else if (value) { + yield* assert(value, { entity, path: [...path, key] }) + } + break + } + } + } +} diff --git a/packages/w3up-client/src/agent/db/text.js b/packages/w3up-client/src/agent/db/text.js new file mode 100644 index 000000000..bbd2f3726 --- /dev/null +++ b/packages/w3up-client/src/agent/db/text.js @@ -0,0 +1,18 @@ +import { glob, like, Constraint, API as DB } from 'datalogia' +import * as API from '../../types.js' + +/** + * Creates a clause that matches `source` only if it satisfies given + * `pattern`. + * + * @param {DB.Term} source + * @param {API.TextConstraint} pattern + */ +export const match = (source, pattern) => + pattern.glob != null + ? glob(source, pattern.glob) + : pattern.like != null + ? like(source, pattern.like) + : pattern['='] != null + ? Constraint.is(source, pattern['=']) + : Constraint.is(source, pattern) diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js new file mode 100644 index 000000000..c939f298e --- /dev/null +++ b/packages/w3up-client/src/agent/delegation.js @@ -0,0 +1,205 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Block from './block.js' +import { importDAG, isDelegation } from '@ucanto/core/delegation' +import * as Association from './db/association.js' +import * as Meta from './meta.js' +import { Capability } from '../authorization/query.js' + +/** + * @param {DB.Term} ucan + * @param {DB.Term} issuer + * @returns + */ +export const issuedBy = (ucan, issuer) => + DB.match([ucan, 'ucan/issuer', issuer]) + +/** + * @param {DB.Term} delegation + * @param {DB.Term} proof + */ +export const hasProof = (delegation, proof) => { + const principal = DB.string() + + return DB.match([delegation, 'ucan/proof', proof]) + .and(DB.match([delegation, 'ucan/issuer', principal])) + .and(DB.match([proof, 'ucan/audience', principal])) +} + +/** + * Composes the clause that matches given `ucan` only if it has expired, + * that is it has `exp` field set and is less than given `query.time`. + * + * @param {DB.Term} ucan + * @param {DB.API.Term} time + * @returns {DB.Clause} + */ +export const isExpired = (ucan, time) => { + const expiration = DB.integer() + return DB.match([ucan, 'ucan/expiration', expiration]).and( + DB.Constraint.greater(time, expiration) + ) +} + +/** + * + * @param {DB.Term} ucan + * @param {Record} selector + */ +export const hasMeta = (ucan, selector) => { + const meta = DB.link() + return DB.match([ucan, 'ucan/meta', meta]).and(Meta.match(meta, selector)) +} + +/** + * Composes the clause that will match a `ucan` only if is not active yet, + * that is it's `nbf` field is set and greater than given `query.time`. + * + * @param {DB.Term} ucan + * @param {DB.Term} time + * @returns {DB.Clause} + */ +export const isTooEarly = (ucan, time) => { + const notBefore = DB.integer() + return DB.match([ucan, 'ucan/notBefore', notBefore]).and( + DB.Constraint.less(time, notBefore) + ) +} + +/** + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.capability] + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.issuer] + */ +export const match = ( + ucan, + { + capability = DB.link(), + audience = DB.string(), + issuer = DB.string(), + time = DB.integer(), + } +) => + DB.match([ucan, 'ucan/capability', capability]) + .and(DB.match([ucan, 'ucan/audience', audience])) + .and(DB.match([ucan, 'ucan/issuer', issuer])) + .and(DB.not(isExpired(ucan, time))) + .and(DB.not(isTooEarly(ucan, time))) + +/** + * Matches forwarding delegations a.k.a power line delegation where `issuer` + * delegates `ucan:*` resource to the `audience` implying that it re-delegates + * all resources delegated to it. + * + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.capability] + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.issuer] + * @param {DB.Term} [constraints.can] + */ +export const forwards = ( + ucan, + { + audience = DB.string(), + issuer = DB.string(), + time = DB.integer(), + can = DB.string(), + } +) => { + const capability = DB.link() + return Capability.forwards(capability, { can }).and( + match(ucan, { capability, issuer, audience, time }) + ) +} + +/** + * Derives set of facts about the given delegation. + * + * @param {API.Delegation} delegation + * @returns {Iterable} + */ +export const facts = function* (delegation) { + const entity = /** @type {API.Link & DB.Entity} */ (delegation.cid) + yield [entity, 'ucan/issuer', delegation.issuer.did()] + yield [entity, 'ucan/audience', delegation.audience.did()] + if (delegation.expiration < Infinity) { + yield [entity, 'ucan/expiration', delegation.expiration] + } + + for (const { can, with: uri, nb = {} } of delegation.capabilities) { + const capability = { with: uri, can, nb } + const id = DB.Memory.entity(capability) + yield* Association.assert(capability, { entity: id, path: ['capability'] }) + yield [entity, 'ucan/capability', id] + } + + // for (const [uri, can] of Object.entries(allows(delegation))) { + // for (const [ability, constraints] of Object.entries(can)) { + // for (const constraint of /** @type {{}[]} */ (constraints)) { + // const capability = { + // with: uri, + // can: ability, + // nb: constraint, + // } + // const id = DB.Memory.entity(capability) + + // yield* Association.assert(capability, { + // entity: id, + // path: ['capability'], + // }) + + // yield [entity, 'ucan/capability', id] + // } + // } + // } + + for (const fact of delegation.facts) { + const id = DB.Memory.entity(fact) + yield* Association.assert(fact, { entity: id, path: ['meta'] }) + yield [entity, 'ucan/meta', id] + } + + for (const proof of delegation.proofs) { + if (isDelegation(proof)) { + yield* facts(proof) + + yield [ + entity, + 'ucan/proof', + /** @type {API.Link & DB.Entity} */ (proof.cid), + ] + } else { + yield [entity, 'ucan/proof', /** @type {API.Link & DB.Entity} */ (proof)] + } + } +} + +/** + * A {@link API.Delegation} formatted for storage, making it compatible with + * `structuredClone()` used by `indexedDB`. + * + * @typedef {Block.Archive[]} Archive + */ + +/** + * Takes {@link Archive} and returns {@link API.Delegation}. + * + * @param {Archive} archive + */ +export const fromArchive = (archive) => + importDAG(archive.map(Block.fromArchive)) + +/** + * Takes {@link API.Delegation} and returns {@link Archive} so it can be stored + * in the database. + * + * @param {API.Delegation} delegation + * @returns {Archive} + */ +export const toArchive = (delegation) => + [...delegation.export()].map(Block.toArchive) diff --git a/packages/w3up-client/src/agent/delegations.js b/packages/w3up-client/src/agent/delegations.js new file mode 100644 index 000000000..e4b7f255d --- /dev/null +++ b/packages/w3up-client/src/agent/delegations.js @@ -0,0 +1,13 @@ +import * as API from '../types.js' +import * as Delegation from './delegation.js' +import * as Datalogia from 'datalogia' + +/** + * @param {Iterable} proofs + * @returns {Iterable} + */ +export const facts = function* (proofs) { + for (const { delegation } of proofs) { + yield* Delegation.facts(delegation) + } +} diff --git a/packages/w3up-client/src/agent/encoding.js b/packages/w3up-client/src/agent/encoding.js new file mode 100644 index 000000000..6e3ee507b --- /dev/null +++ b/packages/w3up-client/src/agent/encoding.js @@ -0,0 +1,155 @@ +/** + * Encoding utilities + * + * It is recommended that you import directly with: + * ```js + * import * as Encoding from '@web3-storage/access/encoding' + * + * // or + * + * import { encodeDelegations } from '@web3-storage/access/encoding' + * ``` + * + * @module + */ +import { CarBufferReader } from '@ipld/car/buffer-reader' +import * as CarBufferWriter from '@ipld/car/buffer-writer' +import { Delegation } from '@ucanto/core/delegation' +import * as u8 from 'uint8arrays' +import * as API from '../types.js' + +/** + * Encode delegations as bytes + * + * @param {API.Delegation[]} delegations + */ +export function delegationsToBytes(delegations) { + if (!Array.isArray(delegations) || delegations.length === 0) { + throw new Error('Delegations required to be an non empty array.') + } + + const roots = delegations.map( + (d) => /** @type {CarBufferWriter.CID} */ (d.root.cid) + ) + const cids = new Set() + /** @type {CarBufferWriter.Block[]} */ + const blocks = [] + let byteLength = 0 + + for (const delegation of delegations) { + for (const block of delegation.export()) { + const cid = block.cid.toV1().toString() + if (!cids.has(cid)) { + byteLength += CarBufferWriter.blockLength( + /** @type {CarBufferWriter.Block} */ (block) + ) + blocks.push(/** @type {CarBufferWriter.Block} */ (block)) + cids.add(cid) + } + } + } + const headerLength = CarBufferWriter.estimateHeaderLength(roots.length) + const writer = CarBufferWriter.createWriter( + new ArrayBuffer(headerLength + byteLength), + { roots } + ) + for (const block of blocks) { + writer.write(block) + } + + return writer.close() +} + +/** + * Decode bytes into Delegations + * + * @template {API.Capabilities} [T=API.Capabilities] + * @param {API.BytesDelegation} bytes + */ +export function bytesToDelegations(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length === 0) { + throw new TypeError('Input should be a non-empty Uint8Array.') + } + const reader = CarBufferReader.fromBytes(bytes) + const roots = reader.getRoots() + + /** @type {API.Delegation[]} */ + const delegations = [] + + for (const root of roots) { + const rootBlock = reader.get(root) + + if (rootBlock) { + const blocks = new Map() + for (const block of reader.blocks()) { + if (block.cid.toString() !== root.toString()) + blocks.set(block.cid.toString(), block) + } + + // @ts-ignore + delegations.push(new Delegation(rootBlock, blocks)) + } else { + throw new Error('Failed to find root from raw delegation.') + } + } + + return delegations +} + +/** + * @param {API.Delegation[]} delegations + * @param {import('uint8arrays/to-string').SupportedEncodings} encoding + */ +export function delegationsToString(delegations, encoding = 'base64url') { + const bytes = delegationsToBytes(delegations) + + return u8.toString(bytes, encoding) +} + +/** + * Encode one {@link API.Delegation Delegation} into a string + * + * @param {API.Delegation} delegation + * @param {import('uint8arrays/to-string').SupportedEncodings} [encoding] + */ +export function delegationToString(delegation, encoding) { + return delegationsToString([delegation], encoding) +} + +/** + * Decode string into {@link API.Delegation Delegation} + * + * @template {API.Capabilities} [T=API.Capabilities] + * @param {API.EncodedDelegation} raw + * @param {import('uint8arrays/to-string').SupportedEncodings} [encoding] + */ +export function stringToDelegations(raw, encoding = 'base64url') { + const bytes = u8.fromString(raw, encoding) + + return bytesToDelegations(bytes) +} + +/** + * Decode string into a {@link API.Delegation Delegation} + * + * @template {API.Capabilities} [T=API.Capabilities] + * @param {API.EncodedDelegation} raw + * @param {import('uint8arrays/to-string').SupportedEncodings} [encoding] + */ +export function stringToDelegation(raw, encoding) { + const delegations = stringToDelegations(raw, encoding) + + return /** @type {API.Delegation} */ (delegations[0]) +} + +/** + * @param {number} [expiration] + */ +export function expirationToDate(expiration) { + const expires = + expiration === Infinity || !expiration + ? undefined + : new Date(expiration * 1000) + + return expires +} diff --git a/packages/w3up-client/src/agent/ex-store.js b/packages/w3up-client/src/agent/ex-store.js new file mode 100644 index 000000000..d6569b5b9 --- /dev/null +++ b/packages/w3up-client/src/agent/ex-store.js @@ -0,0 +1,220 @@ +import * as API from '../types.js' +import { Schema, ok, error, Delegation } from '@ucanto/core' + +export { Schema, Delegation } from '@ucanto/core' + +export const { + literal, + text, + did, + link, + uri, + integer, + float, + boolean, + uint64, + struct, + variant, + tuple, + dictionary, + unknown, +} = Schema +export const now = () => Math.floor(Date.now() / 1000) + +/** + * @template {API.Ability} Ability + * @extends {Schema.API} + */ +class AbilitySchema extends Schema.API { + /** + * @param {string} source + * @param {Ability} ability + */ + readWith(source, ability) { + // If same ability then it can be derived + if (source === ability) { + return { ok: ability } + } + + // if source is is wildcard then `ability` can be derived + if (source === '*') { + return { ok: ability } + } + + // Source contains this ability + if (source.endsWith('/*') && ability.startsWith(source.slice(0, -1))) { + return { ok: ability } + } + + return { + error: new RangeError( + `Ability '${ability}' can not be derived from '${source}'` + ), + } + } + + /** + * @param {string} source + */ + static parse(source) { + const [namespace, ...segments] = source.split('/') + return { namespace, segments } + } +} + +/** + * @param {API.Ability} ability + */ +export const ability = (ability) => new AbilitySchema(ability) + +/** + * @typedef {object} Model + * @property {Map} proofs + */ + +/** + * @param {object} source + * @param {Iterable} source.proofs + * @returns {Model} + */ +export const from = (source) => { + const proofs = new Map() + + for (const proof of source.proofs) { + proofs.set(proof.cid.toString(), proof) + } + + return { proofs } +} + +/** + * Type describes a query that could be used to query ucan store with. + * + * @typedef {object} Query + * @property {API.Reader} [issuer] - Issuer of the delegation. + * @property {API.Reader} [audience] - Audience of the delegation. + * @property {API.Reader} [expiration] - Expiration time. + * @property {API.Reader} [notBefore] - Not before time. + * @property {API.Reader} [can] - Ability delegated. + * @property {API.Reader} [with] - Resource delegated. + * @property {API.Reader<{}>} [nb] - Caveats of the delegation. + */ + +/** + * @param {Model} model + * @param {Query} selector + * @returns {IterableIterator} + */ +export const query = function* (model, selector) { + for (const [, proof] of model.proofs) { + const result = match(proof, selector) + if (result.ok) { + yield result.ok + } + } +} + +/** + * Return `proof` if the proof matches given `query` otherwise returns `null`. + * + * @template {API.Delegation} Proof + * @param {Proof} proof + * @param {Query} query + * @returns {API.Result} + */ +export const match = ( + proof, + { issuer, audience, expiration, notBefore, can, with: subject, nb } +) => { + if (issuer) { + const result = issuer.read(proof.issuer.did()) + if (result.error) { + return result + } + } + + if (audience) { + const result = audience.read(proof.audience.did()) + if (result.error) { + return result + } + } + + if (expiration) { + const result = expiration.read(proof.expiration) + if (result.error) { + return result + } + } + + if (notBefore) { + const result = notBefore.read(proof.notBefore) + if (result.error) { + return result + } + } + + const access = Delegation.allows(proof) + for (const [resource, abilities] of Object.entries(access)) { + if (subject && !subject.read(resource).ok) { + continue + } + + for (const [ability, constraint] of Object.entries(abilities)) { + if (can && !can.read(ability).ok) { + continue + } + + if ( + nb && + /** @type {API.Caveats[]} */ (constraint).every( + (caveats) => nb && !nb.read(caveats).ok + ) + ) { + continue + } + + // If we got this far we found a capability in the current proof that + // meets the query criteria. + return ok(proof) + } + } + + return error(new RangeError('No matching capability found.')) +} + +/** + * @template {Record>} Selector + * @param {Selector} selector + */ +export const select = (selector) => new Select(selector) + +/** + * @template {Record>} Selector + * @param {Selector} selector + */ +class Select { + /** + * + * @param {Selector} selector + */ + constructor(selector) { + this.selector = selector + } + /** + * @param {Selector} variables + */ + where(variables) {} +} + +/** + * Triples + * + * [cid, issuer, "did:key:zAlice"] + * [cid, audience, "did:key:zBob"] + * [cid, expiration, 1702413523] + * [cid, notBefore, undefined] + * [cid, can, "store/add"] + * [cid, with "did:key:zAlice"] + * + */ diff --git a/packages/w3up-client/src/agent/login.js b/packages/w3up-client/src/agent/login.js new file mode 100644 index 000000000..739e3ed05 --- /dev/null +++ b/packages/w3up-client/src/agent/login.js @@ -0,0 +1,141 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Delegation from './delegation.js' +import * as Text from './db/text.js' +import * as Attestation from './attestation.js' + +export { Attestation } + +/** + * @typedef {object} Match + * @property {DB.Link} proof + * @property {DB.Link} attestation + * @property {API.DidMailto} account + */ + +/** + * Creates constraint on the `ucan` that will match only delegations + * representing account logins. That is, it will match only the `ucan` that + * delegates `*` capabilities on `constraints.subject` to the + * `constraints.audience` and that are valid at `constraints.time`. + * + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.account] + * @param {DB.Term} constraints.authority + * @param {DB.Term} constraints.time + * @returns {DB.Clause} + */ +export const match = (ucan, { account = DB.string(), authority, time }) => { + const capability = DB.link() + return Delegation.match(ucan, { + capability: capability, + audience: authority, + time, + }) + .and(DB.match([capability, 'capability/with', 'ucan:*'])) + .and(DB.match([capability, 'capability/can', '*'])) + .and(DB.match([ucan, 'ucan/issuer', account])) + .and(Text.match(account, { glob: 'did:mailto:*' })) +} + +/** + * @param {object} selector + * @param {API.TextConstraint} selector.authority + * @param {API.TextConstraint} [selector.provider] - Attestation provider + * @param {API.TextConstraint} [selector.account] + * @param {API.UTCUnixTimestamp} [selector.time] + * @returns {API.Query<{ account: DB.Term; proof: DB.Term, attestation: DB.Term }>} + */ +export const query = ({ time = Date.now() / 1000, ...selector }) => { + const account = DB.string() + const authority = DB.string() + const proof = DB.link() + const attestation = DB.link() + const provider = DB.string() + return { + select: { + account, + proof, + attestation, + }, + where: [ + match(proof, { account, authority, time }), + Attestation.match(attestation, { + subject: provider, + audience: authority, + time, + proof, + }), + Text.match(authority, selector.authority), + Text.match(account, selector.account ?? { glob: 'did:mailto:*' }), + Text.match(provider, selector.provider ?? { glob: 'did:web:*' }), + ], + } +} + +/** + * Takes matches and builds up a map of logins. + * + * @param {API.Database} db + * @param {Match[]} matches + * @returns {Map} + */ +export const select = (db, matches) => { + const logins = new Map() + for (const { account, ...match } of matches) { + const proof = /** @type {{delegation: API.Delegation}} */ ( + db.proofs.get(String(match.proof)) + ) + + const attestation = /** @type {{delegation: API.Delegation}} */ ( + db.proofs.get(String(match.attestation)) + ) + + const login = logins.get(account) ?? from({ account }) + + login.proofs.set(proof.delegation.cid.toString(), proof.delegation) + login.attestations.set( + attestation.delegation.cid.toString(), + attestation.delegation + ) + logins.set(account, login) + } + + return logins +} + +/** + * @param {object} source + * @param {API.DidMailto} source.account + * @param {Map} [source.proofs] + * @param {Map} [source.attestations] + */ +export const from = ({ + account, + proofs = new Map(), + attestations = new Map(), +}) => new Login({ account, proofs, attestations }) + +class Login { + /** + * @param {object} source + * @param {API.DidMailto} source.account + * @param {Map} source.proofs + * @param {Map} source.attestations + */ + constructor(source) { + this.model = source + } + + get id() { + return this.model.account + } + + get attestations() { + return this.model.attestations + } + get proofs() { + return this.model.proofs + } +} diff --git a/packages/w3up-client/src/agent/meta.js b/packages/w3up-client/src/agent/meta.js new file mode 100644 index 000000000..eab88890b --- /dev/null +++ b/packages/w3up-client/src/agent/meta.js @@ -0,0 +1,14 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Text from './db/text.js' + +/** + * @param {DB.Term} meta + * @param {Record} constraints + */ +export const match = (meta, constraints) => + DB.and( + ...Object.entries(constraints).map(([key, value]) => + DB.match([meta, key, value]) + ) + ) diff --git a/packages/w3up-client/src/agent/signer.browser.js b/packages/w3up-client/src/agent/signer.browser.js new file mode 100644 index 000000000..a8b772ce0 --- /dev/null +++ b/packages/w3up-client/src/agent/signer.browser.js @@ -0,0 +1 @@ +export * from '@ucanto/principal/rsa' diff --git a/packages/w3up-client/src/agent/signer.js b/packages/w3up-client/src/agent/signer.js new file mode 100644 index 000000000..7c22cbca9 --- /dev/null +++ b/packages/w3up-client/src/agent/signer.js @@ -0,0 +1 @@ +export * from '@ucanto/principal/ed25519' diff --git a/packages/w3up-client/src/agent/store.js b/packages/w3up-client/src/agent/store.js new file mode 100644 index 000000000..1c2b451f3 --- /dev/null +++ b/packages/w3up-client/src/agent/store.js @@ -0,0 +1,133 @@ +import * as API from '../types.js' +import * as Connection from './connection.js' +import * as DB from './db.js' +import { Signer, ed25519 } from '@ucanto/principal' + +/** @type {Connection.Archive} */ +const ADDRESS = { + id: 'did:web:web3.storage', + url: 'https://up.web3.storage', +} + +/** + * @typedef {object} Model + * @property {Connection.Address} address + * @property {API.Signer} principal + * @property {DB.DB} delegations + * @property {API.AgentMeta} meta + * @property {API.DIDKey} [currentSpace] + */ + +/** + * @typedef {object} Store + * @property {Model} state + * @property {API.Storage} storage + */ + +/** + * + * @param {object} store + * @param {Model} store.state + * @returns {Archive} + */ +export const toArchive = ({ state }) => ({ + connection: Connection.toArchive(state.address), + meta: state.meta, + principal: state.principal.toArchive(), + delegations: DB.toArchive(state.delegations), + currentSpace: state.currentSpace, +}) + +/** + * @typedef {object} Archive + * @property {Connection.Archive} [connection] + * @property {API.AgentMeta} meta + * @property {API.SignerArchive} principal + * @property {DB.Archive} delegations + * @property {API.DIDKey} [currentSpace] + */ + +/** + * @param {API.Storage} storage + * @param {object} options + * @param {API.Signer} [options.principal] + * @param {API.Delegation[]} [options.proofs] + */ +export const open = async (storage, options = {}) => { + try { + const archive = await storage.load() + if (archive) { + const state = { + meta: archive.meta, + principal: options.principal ?? Signer.from(archive.principal), + delegations: DB.fromArchive(archive.delegations), + currentSpace: archive.currentSpace, + address: Connection.fromArchive(archive.connection ?? ADDRESS), + } + + if (options.proofs) { + await assert({ storage, state }, { delegations: options.proofs }) + } + + return { ok: { storage, state } } + } else { + const state = { + meta: {}, + principal: options.principal ?? (await ed25519.generate()), + delegations: DB.fromProofs(options.proofs ?? []), + currentSpace: undefined, + address: Connection.fromArchive(ADDRESS), + } + + return { ok: { storage, state } } + } + } catch (error) { + return { error: new Error('Failed to load agent data from storage') } + } +} + +export const load = async (storage) => { + const result = await open(storage, options) + if (result.ok) { + return result.ok + } else { + throw result.error + } +} + +/** + * @param {Store} store + * @param {API.Variant<{ + * meta: API.AgentMeta + * delegations: API.Delegation[] + * currentSpace: API.DIDKey + * }>} fact + */ +export const assert = async ({ storage, state }, fact) => { + if (fact.meta) { + state.meta = { ...state.meta, ...fact.meta } + } else if (fact.currentSpace) { + state.currentSpace = fact.currentSpace + } else if (fact.delegations) { + for (const delegation of fact.delegations) { + await DB.assert(state.delegations, delegation) + } + } + await storage.save(toArchive({ state })) +} + +/** + * @param {Store} store + * @param {API.Variant<{ + * delegations: API.Delegation[] + * }>} fact + */ +export const retract = async ({ state, storage }, fact) => { + if (fact.delegations) { + for (const delegation of fact.delegations) { + state.delegations.proofs.delete(`${delegation.cid}`) + } + state.delegations = DB.reindex(state.delegations) + } + await storage.save(toArchive({ state })) +} diff --git a/packages/w3up-client/src/agent/use-cases.js b/packages/w3up-client/src/agent/use-cases.js new file mode 100644 index 000000000..8f695bcff --- /dev/null +++ b/packages/w3up-client/src/agent/use-cases.js @@ -0,0 +1,347 @@ +import { addSpacesFromDelegations, AgentView as AccessAgent } from '../agent.js' +import * as Access from '@web3-storage/capabilities/access' +import { bytesToDelegations } from './encoding.js' +import { Provider, Plan } from '@web3-storage/capabilities' +import * as w3caps from '@web3-storage/capabilities' +import { Schema, delegate } from '@ucanto/core' +import { AgentData, isAttestation } from './data.js' +import * as DidMailto from '@web3-storage/did-mailto' +import * as API from '../types.js' + +const DIDWeb = Schema.DID.match({ method: 'web' }) + +/** + * Request access by a session allowing this agent to issue UCANs + * signed by the account. + * + * @param {AccessAgent} access + * @param {API.Principal} account + * @param {Iterable<{ can: API.Ability }>} capabilities + */ +export async function requestAccess(access, account, capabilities) { + const res = await access.invokeAndExecute(Access.authorize, { + audience: access.connection.id, + with: access.issuer.did(), + nb: { + iss: account.did(), + att: [...capabilities], + }, + }) + if (res?.out.error) { + throw res.out.error + } +} + +/** + * claim delegations delegated to an audience + * + * @param {AccessAgent} access + * @param {API.DID} [audienceOfClaimedDelegations] - audience of claimed delegations. defaults to access.connection.id.did() + * @param {object} opts + * @param {boolean} [opts.addProofs] - whether to addProof to access agent + * @returns + */ +export async function claimAccess( + access, + audienceOfClaimedDelegations = access.connection.id.did(), + { addProofs = false } = {} +) { + const res = await access.invokeAndExecute(Access.claim, { + audience: access.connection.id, + with: audienceOfClaimedDelegations, + }) + if (res.out.error) { + throw res.out.error + } + const delegations = Object.values(res.out.ok.delegations).flatMap((bytes) => + bytesToDelegations(bytes) + ) + if (addProofs) { + for (const d of delegations) { + await access.addProof(d) + } + + await addSpacesFromDelegations(access, delegations) + } + + return delegations +} + +/** + * @param {object} opts + * @param {AccessAgent} opts.access + * @param {API.SpaceDID} opts.space + * @param {API.Principal} opts.account + * @param {API.ProviderDID} opts.provider - e.g. 'did:web:staging.web3.storage' + */ +export async function addProvider({ access, space, account, provider }) { + const result = await access.invokeAndExecute(Provider.add, { + audience: access.connection.id, + with: account.did(), + nb: { + provider, + consumer: space, + }, + }) + if (result.out.error) { + throw result.out.error + } +} + +/** + * @typedef {(delegations: API.Delegation[]) => boolean} DelegationsChecker + */ + +/** + * @type DelegationsChecker + */ +export function delegationsIncludeSessionProof(delegations) { + return delegations.some((d) => isAttestation(d)) +} + +/** + * @param {DelegationsChecker} delegationsMatch + * @param {AccessAgent} access + * @param {API.DID} delegee + * @param {object} [opts] + * @param {number} [opts.interval] + * @param {AbortSignal} [opts.signal] + * @returns {Promise>} + */ +export async function pollAccessClaimUntil( + delegationsMatch, + access, + delegee, + opts +) { + const interval = opts?.interval || 250 + // eslint-disable-next-line no-constant-condition + while (true) { + if (opts?.signal?.aborted) + throw opts.signal.reason ?? new Error('operation aborted') + const res = await access.invokeAndExecute(w3caps.Access.claim, { + with: delegee, + }) + if (res.out.error) throw res.out.error + const claims = Object.values(res.out.ok.delegations).flatMap((d) => + bytesToDelegations(d) + ) + if (delegationsMatch(claims)) return claims + await new Promise((resolve) => setTimeout(resolve, interval)) + } +} + +/** + * @template [T={}] + * @typedef {{ signal?: AbortSignal } & T} AuthorizationWaiterOpts + */ +/** + * @template [U={}] + * @typedef {(accessAgent: AccessAgent, opts: AuthorizationWaiterOpts) => Promise>} AuthorizationWaiter + */ + +/** + * Wait for authorization process to complete by polling executions of the + * `access/claim` capability and waiting for the result to include + * a session delegation. + * + * @type AuthorizationWaiter<{interval?: number}> + */ +export async function waitForAuthorizationByPolling(access, opts = {}) { + const claimed = await pollAccessClaimUntil( + delegationsIncludeSessionProof, + access, + access.issuer.did(), + { + signal: opts?.signal, + interval: opts?.interval, + } + ) + return [...claimed] +} + +/** + * Request authorization of a session allowing this agent to issue UCANs + * signed by the passed email address. + * + * @param {AccessAgent} access + * @param {`${string}@${string}`} email + * @param {object} [opts] + * @param {AbortSignal} [opts.signal] + * @param {boolean} [opts.dontAddProofs] - whether to skip adding proofs to the agent + * @param {Iterable<{ can: API.Ability }>} [opts.capabilities] + * @param {AuthorizationWaiter} [opts.expectAuthorization] - function that will resolve once account has confirmed the authorization request + */ +export async function authorizeAndWait(access, email, opts = {}) { + const expectAuthorization = + opts.expectAuthorization || waitForAuthorizationByPolling + const account = { did: () => DidMailto.fromEmail(email) } + await requestAccess( + access, + account, + opts?.capabilities || [ + { can: 'space/*' }, + { can: 'store/*' }, + { can: 'provider/add' }, + { can: 'subscription/list' }, + { can: 'upload/*' }, + { can: 'ucan/*' }, + { can: 'plan/*' }, + { can: 'usage/*' }, + { can: 'w3up/*' }, + ] + ) + const sessionDelegations = [...(await expectAuthorization(access, opts))] + if (!opts?.dontAddProofs) { + await Promise.all(sessionDelegations.map(async (d) => access.addProof(d))) + } +} + +/** + * Request authorization of a session allowing this agent to issue UCANs + * signed by the passed email address. + * + * @param {AccessAgent} accessAgent + * @param {`${string}@${string}`} email + * @param {object} [opts] + * @param {AbortSignal} [opts.signal] + * @param {Iterable<{ can: API.Ability }>} [opts.capabilities] + * @param {boolean} [opts.addProofs] + * @param {AuthorizationWaiter} [opts.expectAuthorization] - function that will resolve once account has confirmed the authorization request + */ +export async function authorizeWaitAndClaim(accessAgent, email, opts) { + await authorizeAndWait(accessAgent, email, opts) + await claimAccess(accessAgent, accessAgent.issuer.did(), { + addProofs: opts?.addProofs ?? true, + }) +} + +/** + * Provisions space with the specified account and sets up a recovery with the + * same account. + * + * @param {AccessAgent} access + * @param {AgentData} agentData + * @param {string} email + * @param {object} [opts] + * @param {AbortSignal} [opts.signal] + * @param {API.DID<'key'>} [opts.space] + * @param {API.ProviderDID} [opts.provider] - provider to register - defaults to this.connection.id + */ +export async function addProviderAndDelegateToAccount( + access, + agentData, + email, + opts +) { + const space = opts?.space || access.currentSpace() + const spaceMeta = space ? agentData.spaces.get(space) : undefined + const provider = + opts?.provider || + (() => { + const service = access.connection.id.did() + if (DIDWeb.is(service)) { + // connection.id did is a valid provider value. Try using that. + return service + } + throw new Error( + `unable to determine provider to use to addProviderAndDelegateToAccount using access.connection.id did ${service}. expected a did:web:` + ) + })() + + if (!space || !spaceMeta) { + throw new Error('No space selected') + } + + if (spaceMeta) { + throw new Error('Space already registered with web3.storage.') + } + const account = { did: () => DidMailto.fromEmail(DidMailto.email(email)) } + await addProvider({ access, space, account, provider }) + const delegateSpaceAccessResult = await delegateSpaceAccessToAccount( + access, + space, + account + ) + if (delegateSpaceAccessResult.out.error) { + throw delegateSpaceAccessResult.out.error + } + + await agentData.addSpace(space, spaceMeta) +} + +/** + * @param {AccessAgent} access + * @param {API.SpaceDID} space + * @param {API.Principal} account + */ +async function delegateSpaceAccessToAccount(access, space, account) { + const issuerSaysAccountCanAdminSpace = + await createIssuerSaysAccountCanAdminSpace( + access.issuer, + space, + account, + undefined, + access.proofs([{ with: space, can: '*' }]), + // we want to sign over control of this space forever + Infinity + ) + return access.invokeAndExecute(Access.delegate, { + audience: access.connection.id, + with: space, + expiration: Infinity, + nb: { + delegations: { + [issuerSaysAccountCanAdminSpace.cid.toString()]: + issuerSaysAccountCanAdminSpace.cid, + }, + }, + proofs: [ + // must be embedded here because it's referenced by cid in .nb.delegations + issuerSaysAccountCanAdminSpace, + ], + }) +} + +/** + * @param {API.Signer} issuer + * @param {API.SpaceDID} space + * @param {API.Principal} account + * @param {API.Capabilities} capabilities + * @param {API.Delegation[]} proofs + * @param {number} expiration + * @returns + */ +async function createIssuerSaysAccountCanAdminSpace( + issuer, + space, + account, + capabilities = [ + { + can: '*', + with: space, + }, + ], + proofs = [], + expiration +) { + return delegate({ + issuer, + audience: account, + capabilities, + proofs, + expiration, + }) +} + +/** + * + * @param {AccessAgent} agent + * @param {API.AccountDID} account + */ +export async function getAccountPlan(agent, account) { + const receipt = await agent.invokeAndExecute(Plan.get, { + with: account, + }) + return receipt.out +} diff --git a/packages/w3up-client/src/authorization.js b/packages/w3up-client/src/authorization.js new file mode 100644 index 000000000..86e23e0fc --- /dev/null +++ b/packages/w3up-client/src/authorization.js @@ -0,0 +1,2 @@ +export * from './authorization/view.js' +export * as Query from './authorization/query.js' diff --git a/packages/w3up-client/src/authorization/query.js b/packages/w3up-client/src/authorization/query.js new file mode 100644 index 000000000..d3c9aec62 --- /dev/null +++ b/packages/w3up-client/src/authorization/query.js @@ -0,0 +1,228 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Capability from '../agent/capability.js' +import * as Delegation from '../agent/delegation.js' +import * as Text from '../agent/db/text.js' +import * as Attestation from '../agent/attestation.js' + +export { Capability, Delegation, Text } + +/** + * @typedef {object} ProofSelector + * @property {DB.Term} can + * @property {DB.Term} proof + * @property {DB.Term} [attestation] + * @property {string} [need] + * + * @typedef {object} Selector + * @property {DB.Term} audience + * @property {DB.Term} subject + * @property {ProofSelector[]} proofs + */ + +/** + * Creates query that select set of proofs that would allow the + * `selector.audience` to invoke abilities described in `selector.can` on the + * `selector.subject` when time is `selector.time`. + * + * @param {object} selector + * @param {API.TextConstraint} selector.audience + * @param {API.Can} [selector.can] + * @param {API.TextConstraint} [selector.subject] + * @param {API.UTCUnixTimestamp} [selector.time] + * @returns {API.Query} + */ +export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { + const subject = DB.string() + const audience = DB.string() + // Get all abilities we will try to find proofs for, at the moment we do not + // allow passing constraints which is why we simply use keys. + const need = Object.keys(can) + // For each requested ability we generate group of corresponding variables + // that we will try to resolve, however if no abilities were requested we + // will generate a single group without `need` field. + const proofs = (need.length > 0 ? need : [undefined]).map((need) => ({ + // Issuer of the proof + issuer: DB.string(), + // Ability that is delegated + can: DB.string(), + // Proof that delegates needed ability + proof: DB.link(), + // Attestation for the given proof if it was issued by an account did. + attestation: DB.link(), + // Omit need if it was not provided + ...(need && { need }), + })) + + // Here we generate selector clause for each proof that we will try to match + const where = proofs.map(({ proof, issuer, need, can, attestation }) => + DB.and( + // main clause will find a relevant proof. + match(proof, { + subject, + can, + audience, + issuer, + time, + attestation, + }), + + // If `need` was provided we constraint `can` of the proof by it, if it + // was not provided we are looking for all proofs so we do not restrict it. + // We also join primary clause with attestation clause so that only proofs + // matched either do not require attestations or are accompanied by them. + ...(need ? [DB.glob(need, can)] : []) + ) + ) + + return { + select: { + proofs, + subject, + audience, + }, + where: [ + ...where, + // If subject pattern was provided we constraint matches by it. + Text.match(subject, selector.subject ?? { glob: '*' }), + // If audience was provided we constraint matches by it. + Text.match(audience, selector.audience), + ], + } +} + +/** + * Matches a delegation that authorizes the `selector.audience` with an ability + * to invoke `selector.can` on `selector.subject` at `selector.time`. Please + * note it will not match forwarding delegations using `ucan:*` subject. For + * later consider using {@link forwards} function and to match both use + * {@link match} instead. + * + * @param {DB.Term} delegation + * @param {object} selector + * @param {DB.Term} [selector.time] + * @param {DB.Term} [selector.can] + * @param {DB.Term} [selector.subject] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.issuer] + */ +export const delegates = ( + delegation, + { + issuer = DB.string(), + audience = DB.string(), + subject = DB.string(), + can = DB.string(), + time = DB.integer(), + } +) => { + const capability = DB.link() + + return Capability.match(capability, { can, subject }).and( + Delegation.match(delegation, { + capability, + audience, + issuer, + time, + }) + ) +} + +/** + * Matches forwarding delegations that grants `selector.audience` capability + * to invoke `selector.can` on `selector.subject` at `selector.time`. Please + * that it will only match forwarding delegations, that is delegations where + * subject is `ucan:*` and issuer is either `selector.subject` or delegation + * has a proof which delegates ability containing `selector.can` on + * `selector.subject`. + * + * @param {DB.Term} delegation + * @param {object} selector + * @param {DB.Term} [selector.time] + * @param {DB.Term} [selector.can] + * @param {DB.Term} [selector.subject] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.issuer] + * @returns {DB.Clause} + */ +export const forwards = ( + delegation, + { + subject = DB.string(), + can = DB.string(), + time = DB.integer(), + audience = DB.string(), + issuer = DB.string(), + } +) => { + const proof = DB.link() + return DB.and( + Delegation.forwards(delegation, { + issuer, + audience, + can, + time, + }), + DB.or( + /** + * iss: "did:key:zAlice" + * with: "ucan:*" + */ + Delegation.issuedBy(delegation, subject), + /** + * iss: "did:key:zBob" + * with: "ucan:*" + * "prf": [{ iss: "did:key:zAlice", can: "store/*" }] + */ + DB.and( + Delegation.hasProof(delegation, proof), + DB.or( + delegates(proof, { audience: issuer, subject, can, time }) + // TODO: Add support for recursive forwarding delegation + // forwards(proof, { subject, can, time, audience }) + ) + ) + ) + ) +} + +/** + * Matches a delegation that authorizes the `selector.audience` with an ability + * to invoke `selector.can` on `selector.subject` at `selector.time`. It will + * match both explicit and implicit authorizations. + * + * @param {DB.Term} ucan + * @param {object} selector + * @param {DB.Term} [selector.time] + * @param {DB.Term} [selector.can] + * @param {DB.Term} [selector.subject] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.issuer] + * @param {DB.Term} [selector.attestation] + */ +export const match = ( + ucan, + { + attestation = DB.link(), + audience = DB.string(), + subject = DB.string(), + issuer = DB.string(), + can = DB.string(), + time = DB.integer(), + } +) => + DB.and( + DB.or( + delegates(ucan, { issuer, audience, can, subject, time }), + forwards(ucan, { issuer, audience, can, subject, time }) + ), + // We try to find an attestations for the proof, however attestations + // are required only for proofs issued by `did:mailto:`, there for we + // compose this confusing `or` clause that succeeds either when proof was + // not issued by `did:mailto:` principal or when we have an attestation + // for this proof. + DB.or( + DB.not(DB.Constraint.glob(issuer, 'did:mailto:*')), + Attestation.match(attestation, { proof: ucan, audience, time }) + ) + ) diff --git a/packages/w3up-client/src/authorization/view.js b/packages/w3up-client/src/authorization/view.js new file mode 100644 index 000000000..8fa499f1d --- /dev/null +++ b/packages/w3up-client/src/authorization/view.js @@ -0,0 +1,172 @@ +import * as API from '../types.js' +import * as DB from '../agent/db.js' +import * as Query from './query.js' + +/** + * @param {API.Authorization} model + */ +export const from = (model) => { + const db = DB.from(model) + + return new Authorization({ + authority: model.authority, + subject: model.subject, + can: model.can, + db, + }) +} + +/** + * @param {API.Database} db + * @param {object} query + * @param {API.DID} query.authority - Authority authorization is claimed for. + * @param {API.DID} query.subject - Subject space authorization is claimed for. + * @param {API.Can} query.can - Abilities claimed to be authorized. + * @returns {API.Result} + */ +export const get = (db, { authority, subject, can }) => { + // If subject of the claim is same DID as the authority, claiming there + // no proofs required. + if (authority === subject) { + return { + ok: from({ + authority, + can: { + '*': [], + }, + subject, + proofs: [], + }), + } + } else { + const result = find(db, { + audience: authority, + subject, + can, + }) + if (result.length > 0) { + return { ok: result[0] } + } else { + return { + error: new AccessDenied( + `The ${authority} has no access to ${JSON.stringify( + can + )} on ${subject}` + ), + } + } + } +} + +/** + * Returns authorizations that match the given query, that is they provide + * abilities to the given audience. + * + * @param {API.Database} db + * @param {object} query + * @param {API.TextConstraint} query.audience + * @param {API.TextConstraint} [query.subject] + * @param {API.Can} [query.can] + * @param {API.UTCUnixTimestamp} [query.time] + * @returns {Authorization[]} + */ +export const find = ( + db, + { subject = { glob: '*' }, audience, time = Date.now() / 1000, can } +) => + DB.query( + db.index, + Query.query({ + can, + subject, + audience, + time, + }) + ).map((match) => select(db, match)) + +/** + * @param {API.Database} db + * @param {DB.InferBindings} match + */ +export const select = (db, { audience, subject, proofs }) => { + // query engine will provide proof for each requested capability, so we may + // have duplicates here, which we prune. + const [, ...keys] = new Set([ + '', + ...new Set(proofs.map(({ proof }) => String(proof))), + ...proofs.map(({ attestation }) => `${attestation ?? ''}`), + ]) + + return from({ + authority: /** @type {API.DID} */ (audience), + subject: /** @type {API.SpaceDID} */ (subject), + can: Object.fromEntries(proofs.map(({ can, need }) => [need ?? can, []])), + // Dereference proofs from the store. + proofs: keys.map( + ($) => /** @type {API.Delegation} */ (db.proofs.get($)?.delegation) + ), + }) +} + +class AccessDenied extends Error { + name = /** @type {const} */ ('AccessDenied') +} + +/** + * + * @param {Authorization} authorization + * @param {object} access + * @param {API.Can} access.can + */ +export const authorize = (authorization, { can }) => { + get(authorization.model.db, { + authority: authorization.authority, + subject: authorization.subject, + can, + }) +} + +/** + * @typedef {object} Model + * @property {API.SpaceDID} subject + * @property {API.Delegation[]} proofs + * @property {API.DID} authority + */ +class Authorization { + /** + * @param {object} model + * @param {API.Can} model.can + * @param {API.Database} model.db + * @param {API.DID} model.authority + * @param {API.DID} model.subject + */ + constructor(model) { + this.model = model + } + get can() { + return this.model.can + } + get proofs() { + return [...this] + } + *[Symbol.iterator]() { + for (const { delegation } of this.model.db.proofs.values()) { + yield delegation + } + } + get authority() { + return this.model.authority + } + get subject() { + return this.model.subject + } + + toJSON() { + return { + authority: this.authority, + subject: this.subject, + can: this.can, + proofs: this.proofs, + } + } +} diff --git a/packages/w3up-client/src/base.js b/packages/w3up-client/src/base.js deleted file mode 100644 index 3d820a8d2..000000000 --- a/packages/w3up-client/src/base.js +++ /dev/null @@ -1,61 +0,0 @@ -import { Agent } from '@web3-storage/access/agent' -import { serviceConf, receiptsEndpoint } from './service.js' - -export class Base { - /** - * @type {Agent} - * @protected - */ - _agent - - /** - * @type {import('./types.js').ServiceConf} - * @protected - */ - _serviceConf - - /** - * @param {import('@web3-storage/access').AgentData} agentData - * @param {object} [options] - * @param {import('./types.js').ServiceConf} [options.serviceConf] - * @param {URL} [options.receiptsEndpoint] - */ - constructor(agentData, options = {}) { - this._serviceConf = options.serviceConf ?? serviceConf - this._agent = new Agent(agentData, { - servicePrincipal: this._serviceConf.access.id, - // @ts-expect-error I know but it will be HTTP for the forseeable. - url: this._serviceConf.access.channel.url, - connection: this._serviceConf.access, - }) - this._receiptsEndpoint = options.receiptsEndpoint ?? receiptsEndpoint - } - - /** - * The current user agent (this device). - * - * @type {Agent} - */ - get agent() { - return this._agent - } - - /** - * @protected - * @param {import('./types.js').Ability[]} abilities - */ - async _invocationConfig(abilities) { - const resource = this._agent.currentSpace() - if (!resource) { - throw new Error( - 'missing current space: use createSpace() or setCurrentSpace()' - ) - } - const issuer = this._agent.issuer - const proofs = await this._agent.proofs( - abilities.map((can) => ({ can, with: resource })) - ) - const audience = this._serviceConf.upload.id - return { issuer, with: resource, proofs, audience } - } -} diff --git a/packages/w3up-client/src/capability/access.js b/packages/w3up-client/src/capability/access.js index ad97c99be..cad0fcd78 100644 --- a/packages/w3up-client/src/capability/access.js +++ b/packages/w3up-client/src/capability/access.js @@ -1,106 +1,343 @@ -import { Base } from '../base.js' -import * as Agent from '@web3-storage/access/agent' import * as DIDMailto from '@web3-storage/did-mailto' -import * as Result from '../result.js' import * as API from '../types.js' export { DIDMailto } +import * as Access from '@web3-storage/capabilities/access' +import { Failure, fail, DID } from '@ucanto/core' +import { bytesToDelegations } from '../agent/encoding.js' + +/** + * Takes array of delegations and propagates them to their respective audiences + * through a given space (or the current space if none is provided). + * + * Returns error result if agent has no current space and no space was provided. + * Also returns error result if invocation fails. + * + * @param {API.AgentView} agent - Agent connected to the w3up service. + * @param {object} input + * @param {API.Delegation[]} input.delegations - Delegations to propagate. + * @param {API.SpaceDID} [input.space] - Space to propagate through. + * @param {API.Delegation[]} [input.proofs] - Optional set of proofs to be + * included in the invocation. + */ +export const delegate = async ( + agent, + { delegations, proofs = [], space = agent.data.currentSpace } +) => { + if (!space) { + return fail('Space must be specified') + } + + const entries = Object.values(delegations).map((proof) => [ + proof.cid.toString(), + proof.cid, + ]) + + const { out } = await agent.invokeAndExecute(Access.delegate, { + with: space, + nb: { + delegations: Object.fromEntries(entries), + }, + // must be embedded here because it's referenced by cid in .nb.delegations + proofs: [...delegations, ...proofs], + }) + + return out +} + +/** + * Requests specified `access` level from specified `account`. It invokes + * `access/authorize` capability, if invocation succeeds it will return a + * `PendingAccessRequest` object that can be used to poll for the requested + * delegation through `access/claim` capability. + * + * @param {API.AgentView} agent + * @param {object} input + * @param {API.AccountDID} input.account - Account from which access is requested. + * @param {API.ProviderDID} [input.provider] - Provider that will receive the invocation. + * @param {API.DID} [input.audience] - Principal requesting an access. + * @param {API.Access} [input.access] - Access been requested. + * @returns {Promise>} + */ +export const request = async ( + agent, + { + account, + provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()), + audience: audience = agent.did(), + access = spaceAccess, + } +) => { + // Request access from the account. + const { out: result } = await agent.invokeAndExecute(Access.authorize, { + audience: DID.parse(provider), + with: audience, + nb: { + iss: account, + // New ucan spec moved to recap style layout for capabilities and new + // `access/request` will use similar format as opposed to legacy one, + // in the meantime we translate new format to legacy format here. + att: [...toCapabilities(access)], + }, + }) + + return result.error + ? result + : { + ok: new PendingAccessRequest({ + ...result.ok, + agent, + audience, + provider, + }), + } +} + /** - * Client for interacting with the `access/*` capabilities. + * Claims access that has been delegated to the given audience, which by + * default is the agent's DID. + * + * @param {API.AgentView} agent + * @param {object} input + * @param {API.DID} [input.audience] - Principal requesting an access. + * @param {API.ProviderDID} [input.provider] - Provider handling the invocation. + * @returns {Promise>} */ -export class AccessClient extends Base { - /* c8 ignore start - testing websocket code is hard */ +export const claim = async ( + agent, + { + provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()), + audience = agent.did(), + } = {} +) => { + const { out: result } = await agent.invokeAndExecute(Access.claim, { + audience: DID.parse(provider), + with: audience, + }) + + if (result.error) { + return result + } else { + const delegations = Object.values(result.ok.delegations) + + const proofs = /** @type {API.Tuple} */ ( + delegations.flatMap((proof) => bytesToDelegations(proof)) + ) + + return { ok: new GrantedAccess({ agent, proofs }) } + } +} + +/** + * Represents a pending access request. It can be used to poll for the requested + * delegation. + */ +class PendingAccessRequest { + /** + * @typedef {object} PendingAccessRequestModel + * @property {API.AgentView} agent - Agent handling interaction. + * @property {API.DID} audience - Principal requesting an access. + * @property {API.ProviderDID} provider - Provider handling request. + * @property {API.UTCUnixTimestamp} expiration - Seconds in UTC. + * @property {API.Link} request - Link to the `access/authorize` invocation. + * + * @param {PendingAccessRequestModel} model + */ + constructor(model) { + this.model = model + } + + get agent() { + return this.model.agent + } + get audience() { + return this.model.audience + } + get expiration() { + return new Date(this.model.expiration * 1000) + } + + get request() { + return this.model.request + } + + get provider() { + return this.model.provider + } + /** - * Authorize the current agent to use capabilities granted to the passed - * email account. + * Low level method and most likely you want to use `.claim` instead. This method will poll + * fetch delegations **just once** and will return proofs matching to this request. Please note + * that there may not be any matches in which case result will be `{ ok: [] }`. * - * @deprecated Use `request` instead. + * If you do want to continuously poll until request is approved or expired, you should use + * `.claim` method instead. + * + * @returns {Promise>} + */ + async poll() { + const { agent, audience, provider, expiration } = this.model + const timeout = expiration * 1000 - Date.now() + if (timeout <= 0) { + return { error: new RequestExpired(this.model) } + } else { + const result = await claim(agent, { audience, provider }) + return result.error + ? result + : { + ok: result.ok.proofs.filter((proof) => + isRequestedAccess(proof, this.model) + ), + } + } + } + + /** + * Continuously polls delegations until this request is approved or expired. Returns + * a `GrantedAccess` object (view over the delegations) that can be used in the + * invocations or can be saved in the agent (store) using `.save()` method. * - * @param {`${string}@${string}`} email - * @param {object} [options] + * @param {object} options + * @param {number} [options.interval] * @param {AbortSignal} [options.signal] - * @param {Iterable<{ can: API.Ability }>} [options.capabilities] + * @returns {Promise>} */ - async authorize(email, options) { - const account = DIDMailto.fromEmail(email) - const authorization = Result.unwrap(await request(this, { account })) - const access = Result.unwrap(await authorization.claim(options)) - await Result.unwrap(await access.save()) + async claim({ signal, interval = 250 } = {}) { + while (signal?.aborted !== true) { + const result = await this.poll() + // If polling failed, return the error. + if (result.error) { + return result + } + // If we got some matching proofs, return them. + else if (result.ok.length > 0) { + return { + ok: new GrantedAccess({ + agent: this.agent, + proofs: /** @type {API.Tuple} */ (result.ok), + }), + } + } - return access.proofs + await new Promise((resolve) => setTimeout(resolve, interval)) + } + + return { + error: Object.assign(new Error('Aborted'), { reason: signal.reason }), + } } - /* c8 ignore stop */ +} +/** + * Error returned when pending access request expires. + */ +class RequestExpired extends Failure { /** - * Claim delegations granted to the account associated with this agent. - * - * @param {object} [input] - * @param {API.DID} [input.audience] + * @param {PendingAccessRequestModel} model */ - async claim(input) { - const access = Result.unwrap(await claim(this, input)) - await Result.unwrap(await access.save()) - return access.proofs + constructor(model) { + super() + this.model = model + } + + get name() { + return 'RequestExpired' + } + + get request() { + return this.model.request + } + get expiredAt() { + return new Date(this.model.expiration * 1000) } + describe() { + return `Access request expired at ${this.expiredAt} for ${this.request} request.` + } +} + +/** + * View over the UCAN Delegations that grant access to a specific principal. + */ +export class GrantedAccess { /** - * Requests specified `access` level from the account from the given account. + * @typedef {object} GrantedAccessModel + * @property {API.AgentView} agent - Agent that processed the request. + * @property {API.Tuple} proofs - Delegations that grant access. * - * @param {object} input - * @param {API.AccountDID} input.account - * @param {API.Access} [input.access] - * @param {AbortSignal} [input.signal] + * @param {GrantedAccessModel} model */ - async request(input) { - return await request(this, input) + constructor(model) { + this.model = model + } + get proofs() { + return this.model.proofs } /** - * Shares access with delegates. + * Saves access into the agents proofs store so that it can be retained + * between sessions. * * @param {object} input - * @param {API.Delegation[]} input.delegations - * @param {API.SpaceDID} [input.space] - * @param {API.Delegation[]} [input.proofs] + * @param {API.AgentView<{}>} [input.agent] */ - async delegate(input) { - return await delegate(this, input) + save({ agent = this.model.agent } = {}) { + return importAuthorization(agent, this) } } /** - * @param {{agent: API.Agent}} client - * @param {object} [input] - * @param {API.DID} [input.audience] + * Checks if the given delegation is caused by the passed `request` for access. + * + * @param {API.Delegation} delegation + * @param {object} selector + * @param {API.Link} selector.request + * @returns */ -export const claim = async ({ agent }, input) => - Agent.Access.claim(agent, input) +const isRequestedAccess = (delegation, { request }) => + // `access/confirm` handler adds facts to the delegation issued by the account + // so that principal requesting access can identify correct delegation when + // access is granted. + delegation.facts.some((fact) => `${fact['access/request']}` === `${request}`) /** - * Requests specified `access` level from specified `account`. It will invoke - * `access/authorize` capability and keep polling `access/claim` capability - * until access is granted or request is aborted. + * Maps access object that uses UCAN 0.10 capabilities format as opposed + * to legacy UCAN 0.9 format used by w3up which predates new format. * - * @param {{agent: API.Agent}} agent - * @param {object} input - * @param {API.AccountDID} input.account - * @param {API.Access} [input.access] - * @param {API.DID} [input.audience] + * @param {API.Access} access + * @returns {{ can: API.Ability }[]} */ -export const request = async ({ agent }, input) => - Agent.Access.request(agent, input) +export const toCapabilities = (access) => { + const abilities = [] + const entries = /** @type {[API.Ability, API.Unit][]} */ ( + Object.entries(access) + ) + + for (const [can, details] of entries) { + if (details) { + abilities.push({ can }) + } + } + return abilities +} /** - * - * @param {{agent: API.Agent}} agent - * @param {object} input - * @param {API.Delegation[]} input.delegations - * @param {API.SpaceDID} [input.space] - * @param {API.Delegation[]} [input.proofs] + * Set of capabilities required by the agent to manage a space. */ -export const delegate = async ({ agent }, input) => - Agent.Access.delegate(agent, input) +export const spaceAccess = { + 'space/*': {}, + 'store/*': {}, + 'upload/*': {}, + 'access/*': {}, + 'filecoin/*': {}, + 'usage/*': {}, +} -export const { spaceAccess, accountAccess } = Agent.Access +/** + * Set of capabilities required for by the agent to manage an account. + */ +export const accountAccess = { + '*': {}, +} diff --git a/packages/w3up-client/src/capability/plan.js b/packages/w3up-client/src/capability/plan.js index aa729ee05..78eaad865 100644 --- a/packages/w3up-client/src/capability/plan.js +++ b/packages/w3up-client/src/capability/plan.js @@ -4,7 +4,7 @@ import * as Plan from '@web3-storage/capabilities/plan' /** * Gets the plan currently associated with the account. * - * @param {{agent: API.Agent}} client + * @param {{agent: API.AgentView}} client * @param {object} options * @param {API.AccountDID} options.account * @param {API.Delegation[]} [options.proofs] diff --git a/packages/w3up-client/src/capability/provider.js b/packages/w3up-client/src/capability/provider.js new file mode 100644 index 000000000..e77fe646f --- /dev/null +++ b/packages/w3up-client/src/capability/provider.js @@ -0,0 +1,44 @@ +import * as Provider from '@web3-storage/capabilities/provider' +import * as API from '../types.js' + +export const { Provider: ProviderDID, AccountDID } = Provider + +/** + * Provisions specified `space` with the specified `account`. It is expected + * that delegation from the account authorizing agent is either stored in the + * agent proofs or provided explicitly. + * + * @param {API.AgentView} agent + * @param {object} input + * @param {API.AccountDID} input.account - Account provisioning the space. + * @param {API.SpaceDID} input.consumer - Space been provisioned. + * @param {API.ProviderDID} [input.provider] - Provider been provisioned. + * @param {API.Delegation[]} [input.proofs] - Delegation from the account + * authorizing agent to call `provider/add` capability. + */ +export const add = async ( + agent, + { + account, + consumer, + provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()), + proofs, + } +) => { + if (!ProviderDID.is(provider)) { + throw new Error( + `Unable to determine provider from agent.connection.id did ${provider}. expected a did:web:` + ) + } + + const { out } = await agent.invokeAndExecute(Provider.add, { + with: account, + nb: { + provider, + consumer, + }, + proofs, + }) + + return out +} diff --git a/packages/w3up-client/src/capability/space.js b/packages/w3up-client/src/capability/space.js index f2c077d28..5db9fb9d0 100644 --- a/packages/w3up-client/src/capability/space.js +++ b/packages/w3up-client/src/capability/space.js @@ -1,15 +1,403 @@ -import { Base } from '../base.js' +import * as ED25519 from '@ucanto/principal/ed25519' +import { delegate, Schema, UCAN, error, fail } from '@ucanto/core' +import * as BIP39 from '@scure/bip39' +import { wordlist } from '@scure/bip39/wordlists/english' +import * as API from '../types.js' +import * as Access from './access.js' +import * as Provider from './provider.js' +import * as Space from '@web3-storage/capabilities/space' +import * as Authorization from '../authorization/query.js' +import * as Database from '../agent/db.js' +import * as Agent from '../agent.js' /** - * Client for interacting with the `space/*` capabilities. + * + * Get Space information from Access service + * + * @param {API.Session} session + * @param {object} source + * @param {API.SpaceDID} source.id */ -export class SpaceClient extends Base { +export const info = async (session, { id }) => { + const auth = session.agent.authorize({ + subject: id, + can: { 'space/info': [] }, + }) + + if (auth.error) { + return auth + } + + const { out: result } = await Space.info + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: id, + proofs: auth.ok.proofs, + }) + .execute(session.connection) + + return result +} + +/** + * Data model for the (owned) space. + * + * @typedef {object} Model + * @property {ED25519.EdSigner} signer + * @property {string} name + * @property {API.Session} [session] + */ + +/** + * Generates a new space. + * + * @param {object} options + * @param {string} options.name + * @param {API.Session} [options.session] + */ +export const generate = async ({ name, session }) => { + const { signer } = await ED25519.generate() + + return new OwnedSpace({ signer, name, session }) +} + +/** + * Recovers space from the saved mnemonic. + * + * @param {string} mnemonic + * @param {object} options + * @param {string} options.name - Name to give to the recovered space. + * @param {API.Session} [options.session] + */ +export const fromMnemonic = async (mnemonic, { name, session }) => { + const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) + const signer = await ED25519.derive(secret) + return new OwnedSpace({ signer, name, session }) +} + +/** + * Turns (owned) space into a BIP39 mnemonic that later can be used to recover + * the space using `fromMnemonic` function. + * + * @param {object} space + * @param {ED25519.EdSigner} space.signer + */ +export const toMnemonic = ({ signer }) => { + /** @type {Uint8Array} */ + // @ts-expect-error - Field is defined but not in the interface + const secret = signer.secret + + return BIP39.entropyToMnemonic(secret, wordlist) +} + +/** + * Creates a (UCAN) delegation that gives full access to the space to the + * specified `account`. At the moment we only allow `did:mailto` principal + * to be used as an `account`. + * + * @param {Model} space + * @param {API.AccountDID} account + */ +export const createRecovery = (space, account) => + createAuthorization(space, { + agent: space.signer.withDID(account), + access: Access.accountAccess, + expiration: Infinity, + }) + +// Default authorization session is valid for 1 year +export const SESSION_LIFETIME = 60 * 60 * 24 * 365 + +/** + * Creates (UCAN) delegation that gives specified `agent` an access to + * specified ability (passed as `access.can` field) on this space. + * Optionally, you can specify `access.expiration` field to set the + * expiration time for the authorization. By default the authorization + * is valid for 1 year and gives access to all capabilities on the space + * that are needed to use the space. + * + * @param {Model} space + * @param {object} options + * @param {API.Principal} options.agent + * @param {API.Access} [options.access] + * @param {API.UTCUnixTimestamp} [options.expiration] + */ +export const createAuthorization = async ( + { signer, name }, + { + agent, + access = Access.spaceAccess, + expiration = UCAN.now() + SESSION_LIFETIME, + } +) => { + return await delegate({ + issuer: signer, + audience: agent, + capabilities: toCapabilities({ + [signer.did()]: access, + }), + ...(expiration ? { expiration } : {}), + facts: [{ space: { name } }], + }) +} + +/** + * @param {Record} allow + * @returns {API.Capabilities} + */ +const toCapabilities = (allow) => { + const capabilities = [] + for (const [subject, access] of Object.entries(allow)) { + const entries = /** @type {[API.Ability, API.Unit][]} */ ( + Object.entries(access) + ) + + for (const [can, details] of entries) { + if (details) { + capabilities.push({ can, with: subject }) + } + } + } + + return /** @type {API.Capabilities} */ (capabilities) +} + +/** + * Represents an owned space, meaning a space for which we have a private key + * and consequently have full authority over. + */ +class OwnedSpace { /** - * Get information about a space. + * @param {Model} model + */ + constructor(model) { + this.model = model + } + + get signer() { + return this.model.signer + } + + get name() { + return this.model.name + } + + did() { + return this.signer.did() + } + + /** + * Creates a renamed version of this space. + * + * @param {string} name + */ + withName(name) { + return new OwnedSpace({ signer: this.signer, name }) + } + + /** + * Saves account in the agent store so it can be accessed across sessions. * - * @param {import('../types.js').DID} space - DID of the space to retrieve info about. + * @param {object} input + * @param {API.AgentView} [input.agent] + * @returns {Promise>} + */ + async save({ agent = this.model.session?.agent } = {}) { + if (!agent) { + return fail('Please provide an agent to save the space into') + } + + const proof = await createAuthorization(this, { agent }) + await agent.importSpaceFromDelegation(proof) + await agent.data.setCurrentSpace(this.did()) + + return { ok: {} } + } + + /** + * @param {Authorization} authorization + * @param {object} options + * @param {API.AgentView} [options.agent] */ - async info(space) { - return await this._agent.getSpaceInfo(space) + provision({ proofs }, { agent = this.model.agent } = {}) { + if (!agent) { + return fail('Please provide an agent to save the space into') + } + + return provision(this, { proofs, agent }) + } + + /** + * Creates a (UCAN) delegation that gives full access to the space to the + * specified `account`. At the moment we only allow `did:mailto` principal + * to be used as an `account`. + * + * @param {API.AccountDID} account + */ + async createRecovery(account) { + return createRecovery(this, account) + } + + /** + * Creates (UCAN) delegation that gives specified `agent` an access to + * specified ability (passed as `access.can` field) on the this space. + * Optionally, you can specify `access.expiration` field to set the + * + * @param {API.Principal} agent + * @param {object} [input] + * @param {API.Access} [input.access] + * @param {API.UTCUnixTimestamp} [input.expiration] + */ + createAuthorization(agent, input) { + return createAuthorization(this, { ...input, agent }) + } + + /** + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} connection + */ + async connect(connection) { + return this.open().connect(connection) + } + + open() { + return Agent.view({ + signer: this.signer, + db: Database.from({ proofs: [] }), + }) + } + /** + * Derives BIP39 mnemonic that can be used to recover the space. + * + * @returns {string} + */ + toMnemonic() { + return toMnemonic(this) + } +} + +const SpaceDID = Schema.did({ method: 'key' }) + +/** + * Creates a (shared) space from given delegation. + * + * @param {API.Delegation} delegation + */ +export const fromDelegation = (delegation) => { + const result = SpaceDID.read(delegation.capabilities[0].with) + if (result.error) { + throw Object.assign( + new Error( + `Invalid delegation, expected capabilities[0].with to be DID, ${result.error}` + ), + { + cause: result.error, + } + ) + } + + /** @type {{name?:string}} */ + const meta = delegation.facts[0]?.space ?? {} + + return new SharedSpace({ id: result.ok, proofs: [delegation], meta }) +} + +/** + * @typedef {object} Authorization + * @property {API.Delegation[]} proofs + * + * @typedef {object} Space + * @property {() => API.SpaceDID} did + */ + +/** + * @param {Space} space + * @param {object} options + * @param {API.Delegation[]} options.proofs + * @param {API.AgentView} options.agent + */ +export const provision = async (space, { proofs, agent }) => { + const [capability] = proofs[0].capabilities + + const { ok: account, error: reason } = Provider.AccountDID.read( + capability.with + ) + if (reason) { + return error(reason) + } + + return await Provider.add(agent, { + consumer: space.did(), + account, + proofs, + }) +} + +/** + * Represents a shared space, meaning a space for which we have a delegation + * and consequently have limited authority over. + */ +class SharedSpace { + /** + * @typedef {object} SharedSpaceModel + * @property {API.SpaceDID} id + * @property {API.Tuple} proofs + * @property {{name?:string}} meta + * @property {API.AgentView} [agent] + * + * @param {SharedSpaceModel} model + */ + constructor(model) { + this.model = model + } + + get proofs() { + return this.model.proofs + } + + get meta() { + return this.model.meta + } + + get name() { + return this.meta.name ?? '' + } + + did() { + return this.model.id + } + + /** + * @param {string} name + */ + withName(name) { + return new SharedSpace({ + ...this.model, + meta: { ...this.meta, name }, + }) + } +} + +/** + * @template {API.UnknownProtocol} Protocol + */ +class SpaceSession { + /** + * @param {object} model + * @param {API.Connection} model.connection + * @param {ED25519.EdSigner} model.signer + */ + constructor(model) { + this.model = model + } + did() { + return this.model.signer.did() + } + get connection() { + return this.model.connection + } + get agent() { + return this } } diff --git a/packages/w3up-client/src/capability/subscription.js b/packages/w3up-client/src/capability/subscription.js index 8f6a13a63..02e83dc14 100644 --- a/packages/w3up-client/src/capability/subscription.js +++ b/packages/w3up-client/src/capability/subscription.js @@ -1,44 +1,34 @@ -import { Subscription as SubscriptionCapabilities } from '@web3-storage/capabilities' +import { Subscription } from '@web3-storage/capabilities' +import * as Result from '../result.js' import * as API from '../types.js' -import { Base } from '../base.js' -/** - * Client for interacting with the `subscription/*` capabilities. - */ -export class SubscriptionClient extends Base { - /** - * List subscriptions for the passed account. - * - * @param {import('@web3-storage/access').AccountDID} account - */ - async list(account) { - const out = await list({ agent: this.agent }, { account }) - if (!out.ok) { - throw new Error( - `failed ${SubscriptionCapabilities.list.can} invocation`, - { - cause: out.error, - } - ) - } - - return out.ok - } -} +export { Subscription } /** * Gets subscriptions associated with the account. * - * @param {{agent: API.Agent}} client + * @param {API.AgentView} agent * @param {object} options * @param {API.AccountDID} options.account * @param {API.Delegation[]} [options.proofs] + * @returns {Promise>} */ -export const list = async ({ agent }, { account, proofs = [] }) => { - const receipt = await agent.invokeAndExecute(SubscriptionCapabilities.list, { +export const list = async (agent, { account, proofs = [] }) => { + const task = await issueInvocation(agent, Subscription.list, { with: account, proofs, nb: {}, }) - return receipt.out + + const { out } = await task.execute(agent.connection) + + if (out.error) { + return Result.error( + new Error(`failed ${Subscription.list.can} invocation`, { + cause: out.error, + }) + ) + } + + return out } diff --git a/packages/w3up-client/src/capability/ucan.js b/packages/w3up-client/src/capability/ucan.js new file mode 100644 index 000000000..e7d7f8bb3 --- /dev/null +++ b/packages/w3up-client/src/capability/ucan.js @@ -0,0 +1,58 @@ +import { UCAN } from '@web3-storage/capabilities' +import * as Result from '../result.js' +import * as Agent from '../agent.js' +import * as API from '../types.js' + +/** + * Revoke a delegation by CID. + * + * If the delegation was issued by this agent (and therefore is stored in the + * delegation store) you can just pass the CID. If not, or if the current agent's + * delegation store no longer contains the delegation, you MUST pass a chain of + * proofs that proves your authority to revoke this delegation as `options.proofs`. + * + * @param {object} agent + * @param {API.Signer} agent.issuer + * @param {API.AgentData} agent.data + * @param {API.ConnectionView} agent.connection + * @param {API.UCANLink} delegationCID + * @param {object} [options] + * @param {API.Delegation[]} [options.proofs] + */ +export const revoke = async ( + { issuer, data, connection }, + delegationCID, + options = {} +) => { + const additionalProofs = options.proofs ?? [] + // look for the identified delegation in the delegation store and the passed proofs + const delegation = [ + ...Agent.selectIssuedDelegations(data), + ...additionalProofs, + ].find((delegation) => delegation.cid.equals(delegationCID)) + if (!delegation) { + return Result.error( + new Error( + `could not find delegation ${delegationCID.toString()} - please include the delegation in options.proofs` + ) + ) + } + + const invocation = await Agent.issueInvocation( + { issuer, data, connection }, + UCAN.revoke, + { + // per https://github.com/web3-storage/w3up/blob/main/packages/capabilities/src/ucan.js#L38C6-L38C6 the resource here should be + // the current issuer - using the space DID here works for simple cases but falls apart when a delegee tries to revoke a delegation + // they have re-delegated, since they don't have "ucan/revoke" capabilities on the space + with: issuer.did(), + nb: { + ucan: delegation.cid, + }, + proofs: [delegation, ...additionalProofs], + } + ) + + const receipt = await invocation.execute(connection) + return receipt.out +} diff --git a/packages/w3up-client/src/capability/upload.js b/packages/w3up-client/src/capability/upload.js deleted file mode 100644 index 976a71b34..000000000 --- a/packages/w3up-client/src/capability/upload.js +++ /dev/null @@ -1,56 +0,0 @@ -import { Upload } from '@web3-storage/upload-client' -import { Upload as UploadCapabilities } from '@web3-storage/capabilities' -import { Base } from '../base.js' - -/** - * Client for interacting with the `upload/*` capabilities. - */ -export class UploadClient extends Base { - /** - * Register an "upload" to the resource. - * - * @param {import('../types.js').UnknownLink} root - Root data CID for the DAG that was stored. - * @param {import('../types.js').CARLink[]} shards - CIDs of CAR files that contain the DAG. - * @param {import('../types.js').RequestOptions} [options] - */ - async add(root, shards, options = {}) { - const conf = await this._invocationConfig([UploadCapabilities.add.can]) - options.connection = this._serviceConf.upload - return Upload.add(conf, root, shards, options) - } - - /** - * Get details of an "upload". - * - * @param {import('../types.js').UnknownLink} root - Root data CID for the DAG that was stored. - * @param {import('../types.js').RequestOptions} [options] - */ - async get(root, options = {}) { - const conf = await this._invocationConfig([UploadCapabilities.add.can]) - options.connection = this._serviceConf.upload - return Upload.get(conf, root, options) - } - - /** - * List uploads registered to the resource. - * - * @param {import('../types.js').ListRequestOptions} [options] - */ - async list(options = {}) { - const conf = await this._invocationConfig([UploadCapabilities.list.can]) - options.connection = this._serviceConf.upload - return Upload.list(conf, options) - } - - /** - * Remove an upload by root data CID. - * - * @param {import('../types.js').UnknownLink} root - Root data CID to remove. - * @param {import('../types.js').RequestOptions} [options] - */ - async remove(root, options = {}) { - const conf = await this._invocationConfig([UploadCapabilities.remove.can]) - options.connection = this._serviceConf.upload - return Upload.remove(conf, root, options) - } -} diff --git a/packages/w3up-client/src/capability/usage.js b/packages/w3up-client/src/capability/usage.js index aeccc715c..6102eecd0 100644 --- a/packages/w3up-client/src/capability/usage.js +++ b/packages/w3up-client/src/capability/usage.js @@ -1,41 +1,20 @@ -import { Usage as UsageCapabilities } from '@web3-storage/capabilities' +import { Usage } from '@web3-storage/capabilities' import * as API from '../types.js' -import { Base } from '../base.js' -/** - * Client for interacting with the `usage/*` capabilities. - */ -export class UsageClient extends Base { - /** - * Get a usage report for the passed space in the given time period. - * - * @param {import('../types.js').SpaceDID} space - * @param {{ from: Date, to: Date }} period - */ - async report(space, period) { - const out = await report({ agent: this.agent }, { space, period }) - if (!out.ok) { - throw new Error(`failed ${UsageCapabilities.report.can} invocation`, { - cause: out.error, - }) - } - - return out.ok - } -} +export { Usage } /** * Get a usage report for the period. * - * @param {{agent: API.Agent}} client + * @param {API.AgentView} agent * @param {object} options * @param {API.SpaceDID} options.space * @param {{ from: Date, to: Date }} options.period * @param {API.Delegation[]} [options.proofs] * @returns {Promise>} */ -export const report = async ({ agent }, { space, period, proofs = [] }) => { - const receipt = await agent.invokeAndExecute(UsageCapabilities.report, { +export const report = async (agent, { space, period, proofs = [] }) => { + const receipt = await agent.invokeAndExecute(Usage.report, { with: space, proofs, nb: { diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js deleted file mode 100644 index c77d6d61d..000000000 --- a/packages/w3up-client/src/client.js +++ /dev/null @@ -1,314 +0,0 @@ -import { - uploadFile, - uploadDirectory, - uploadCAR, -} from '@web3-storage/upload-client' -import { - Store as StoreCapabilities, - Upload as UploadCapabilities, -} from '@web3-storage/capabilities' -import { CAR } from '@ucanto/transport' -import { Base } from './base.js' -import * as Account from './account.js' -import { Space } from './space.js' -import { Delegation as AgentDelegation } from './delegation.js' -import { StoreClient } from './capability/store.js' -import { UploadClient } from './capability/upload.js' -import { SpaceClient } from './capability/space.js' -import { SubscriptionClient } from './capability/subscription.js' -import { UsageClient } from './capability/usage.js' -import { AccessClient } from './capability/access.js' -import { FilecoinClient } from './capability/filecoin.js' -import { CouponAPI } from './coupon.js' -export * as Access from './capability/access.js' -import * as Result from './result.js' - -export { - AccessClient, - FilecoinClient, - StoreClient, - SpaceClient, - SubscriptionClient, - UploadClient, - UsageClient, -} - -export class Client extends Base { - /** - * @param {import('@web3-storage/access').AgentData} agentData - * @param {object} [options] - * @param {import('./types.js').ServiceConf} [options.serviceConf] - * @param {URL} [options.receiptsEndpoint] - */ - constructor(agentData, options) { - super(agentData, options) - this.capability = { - access: new AccessClient(agentData, options), - filecoin: new FilecoinClient(agentData, options), - space: new SpaceClient(agentData, options), - store: new StoreClient(agentData, options), - subscription: new SubscriptionClient(agentData, options), - upload: new UploadClient(agentData, options), - usage: new UsageClient(agentData, options), - } - this.coupon = new CouponAPI(agentData, options) - } - - did() { - return this._agent.did() - } - - /* c8 ignore start - testing websockets is hard */ - /** - * @deprecated - Use client.login instead. - * - * Authorize the current agent to use capabilities granted to the passed - * email account. - * - * @param {`${string}@${string}`} email - * @param {object} [options] - * @param {AbortSignal} [options.signal] - * @param {Iterable<{ can: import('./types.js').Ability }>} [options.capabilities] - */ - async authorize(email, options) { - await this.capability.access.authorize(email, options) - } - - /** - * @param {Account.EmailAddress} email - * @param {object} [options] - * @param {AbortSignal} [options.signal] - */ - async login(email, options = {}) { - const account = Result.unwrap(await Account.login(this, email, options)) - Result.unwrap(await account.save()) - return account - } - /* c8 ignore stop */ - - /** - * List all accounts that agent has stored access to. Returns a dictionary - * of accounts keyed by their `did:mailto` identifier. - */ - accounts() { - return Account.list(this) - } - - /** - * Uploads a file to the service and returns the root data CID for the - * generated DAG. - * - * @param {import('./types.js').BlobLike} file - File data. - * @param {import('./types.js').UploadOptions} [options] - */ - async uploadFile(file, options = {}) { - const conf = await this._invocationConfig([ - StoreCapabilities.add.can, - UploadCapabilities.add.can, - ]) - options.connection = this._serviceConf.upload - return uploadFile(conf, file, options) - } - - /** - * Uploads a directory of files to the service and returns the root data CID - * for the generated DAG. All files are added to a container directory, with - * paths in file names preserved. - * - * @param {import('./types.js').FileLike[]} files - File data. - * @param {import('./types.js').UploadDirectoryOptions} [options] - */ - async uploadDirectory(files, options = {}) { - const conf = await this._invocationConfig([ - StoreCapabilities.add.can, - UploadCapabilities.add.can, - ]) - options.connection = this._serviceConf.upload - return uploadDirectory(conf, files, options) - } - - /** - * Uploads a CAR file to the service. - * - * The difference between this function and `capability.store.add` is that the - * CAR file is automatically sharded and an "upload" is registered, linking - * the individual shards (see `capability.upload.add`). - * - * Use the `onShardStored` callback to obtain the CIDs of the CAR file shards. - * - * @param {import('./types.js').BlobLike} car - CAR file. - * @param {import('./types.js').UploadOptions} [options] - */ - async uploadCAR(car, options = {}) { - const conf = await this._invocationConfig([ - StoreCapabilities.add.can, - UploadCapabilities.add.can, - ]) - options.connection = this._serviceConf.upload - return uploadCAR(conf, car, options) - } - - /** - * Get a receipt for an executed task by its CID. - * - * @param {import('multiformats').UnknownLink} taskCid - */ - async getReceipt(taskCid) { - // Fetch receipt from endpoint - const workflowResponse = await fetch( - new URL(taskCid.toString(), this._receiptsEndpoint) - ) - /* c8 ignore start */ - if (!workflowResponse.ok) { - throw new Error( - `no receipt available for requested task ${taskCid.toString()}` - ) - } - /* c8 ignore stop */ - // Get receipt from Message Archive - const agentMessageBytes = new Uint8Array( - await workflowResponse.arrayBuffer() - ) - // Decode message - const agentMessage = await CAR.request.decode({ - body: agentMessageBytes, - headers: {}, - }) - // Get receipt from the potential multiple receipts in the message - return agentMessage.receipts.get(taskCid.toString()) - } - - /** - * Return the default provider. - */ - defaultProvider() { - return this._agent.connection.id.did() - } - - /** - * The current space. - */ - currentSpace() { - const agent = this._agent - const id = agent.currentSpace() - if (!id) return - const meta = agent.spaces.get(id) - return new Space({ id, meta, agent }) - } - - /** - * Use a specific space. - * - * @param {import('./types.js').DID} did - */ - async setCurrentSpace(did) { - await this._agent.setCurrentSpace(/** @type {`did:key:${string}`} */ (did)) - } - - /** - * Spaces available to this agent. - */ - spaces() { - return [...this._agent.spaces].map(([id, meta]) => { - // @ts-expect-error id is not did:key - return new Space({ id, meta, agent: this._agent }) - }) - } - - /** - * Create a new space with a given name. - * - * @param {string} name - */ - async createSpace(name) { - return await this._agent.createSpace(name) - } - /* c8 ignore stop */ - - /** - * Add a space from a received proof. - * - * @param {import('./types.js').Delegation} proof - */ - async addSpace(proof) { - return await this._agent.importSpaceFromDelegation(proof) - } - - /** - * Get all the proofs matching the capabilities. - * - * Proofs are delegations with an _audience_ matching the agent DID. - * - * @param {import('./types.js').Capability[]} [caps] - Capabilities to - * filter by. Empty or undefined caps with return all the proofs. - */ - proofs(caps) { - return this._agent.proofs(caps) - } - - /** - * Add a proof to the agent. Proofs are delegations with an _audience_ - * matching the agent DID. - * - * @param {import('./types.js').Delegation} proof - */ - async addProof(proof) { - await this._agent.addProof(proof) - } - - /** - * Get delegations created by the agent for others. - * - * @param {import('./types.js').Capability[]} [caps] - Capabilities to - * filter by. Empty or undefined caps with return all the delegations. - */ - delegations(caps) { - const delegations = [] - for (const { delegation, meta } of this._agent.delegationsWithMeta(caps)) { - delegations.push( - new AgentDelegation(delegation.root, delegation.blocks, meta) - ) - } - return delegations - } - - /** - * Create a delegation to the passed audience for the given abilities with - * the _current_ space as the resource. - * - * @param {import('./types.js').Principal} audience - * @param {import('./types.js').Abilities[]} abilities - * @param {Omit & { audienceMeta?: import('./types.js').AgentMeta }} [options] - */ - async createDelegation(audience, abilities, options = {}) { - const audienceMeta = options.audienceMeta ?? { - name: 'agent', - type: 'device', - } - const { root, blocks } = await this._agent.delegate({ - ...options, - abilities, - audience, - audienceMeta, - }) - return new AgentDelegation(root, blocks, { audience: audienceMeta }) - } - - /** - * Revoke a delegation by CID. - * - * If the delegation was issued by this agent (and therefore is stored in the - * delegation store) you can just pass the CID. If not, or if the current agent's - * delegation store no longer contains the delegation, you MUST pass a chain of - * proofs that proves your authority to revoke this delegation as `options.proofs`. - * - * @param {import('@ucanto/interface').UCANLink} delegationCID - * @param {object} [options] - * @param {import('@ucanto/interface').Delegation[]} [options.proofs] - */ - async revokeDelegation(delegationCID, options = {}) { - return this._agent.revoke(delegationCID, { - proofs: options.proofs, - }) - } -} diff --git a/packages/w3up-client/src/client/access.js b/packages/w3up-client/src/client/access.js new file mode 100644 index 000000000..c08a9a913 --- /dev/null +++ b/packages/w3up-client/src/client/access.js @@ -0,0 +1,106 @@ +import { Client } from './client.js' +import * as DIDMailto from '@web3-storage/did-mailto' +import * as Result from '../result.js' +import * as Access from '../capability/access.js' + +import * as API from '../types.js' + +export { DIDMailto } + +/** + * Client for interacting with the `access/*` capabilities. + * + * @extends {Client} + */ +export class AccessClient extends Client { + /* c8 ignore start - testing websocket code is hard */ + /** + * Authorize the current agent to use capabilities granted to the passed + * email account. + * + * @deprecated Use `request` instead. + * + * @param {`${string}@${string}`} email + * @param {object} [options] + * @param {AbortSignal} [options.signal] + * @param {Iterable<{ can: API.Ability }>} [options.capabilities] + */ + async authorize(email, options) { + const account = DIDMailto.fromEmail(email) + const authorization = Result.unwrap(await request(this, { account })) + const access = Result.unwrap(await authorization.claim(options)) + await Result.unwrap(await access.save()) + + return access.proofs + } + /* c8 ignore stop */ + + /** + * Claim delegations granted to the account associated with this agent. + * + * @param {object} [input] + * @param {API.DID} [input.audience] + */ + async claim(input) { + const access = Result.unwrap(await claim(this, input)) + await Result.unwrap(await access.save()) + return access.proofs + } + + /** + * Requests specified `access` level from the account from the given account. + * + * @param {object} input + * @param {API.AccountDID} input.account + * @param {API.Access} [input.access] + * @param {AbortSignal} [input.signal] + */ + async request(input) { + return await request(this, input) + } + + /** + * Shares access with delegates. + * + * @param {object} input + * @param {API.Delegation[]} input.delegations + * @param {API.SpaceDID} [input.space] + * @param {API.Delegation[]} [input.proofs] + */ + async delegate(input) { + return await delegate(this, input) + } +} + +/** + * @param {{agent: API.AgentView}} client + * @param {object} [input] + * @param {API.DID} [input.audience] + */ +export const claim = async ({ agent }, input) => Access.claim(agent, input) + +/** + * Requests specified `access` level from specified `account`. It will invoke + * `access/authorize` capability and keep polling `access/claim` capability + * until access is granted or request is aborted. + * + * @param {{agent: API.AgentView}} agent + * @param {object} input + * @param {API.AccountDID} input.account + * @param {API.Access} [input.access] + * @param {API.DID} [input.audience] + */ +export const request = async ({ agent }, input) => Access.request(agent, input) + +/** + * + * @param {{agent: API.AgentView}} agent + * @param {object} input + * @param {API.Delegation[]} input.delegations + * @param {API.SpaceDID} [input.space] + * @param {API.Delegation[]} [input.proofs] + */ +export const delegate = async ({ agent }, input) => + Access.delegate(agent, input) + +export const { spaceAccess, accountAccess } = Access diff --git a/packages/w3up-client/src/client/client.js b/packages/w3up-client/src/client/client.js new file mode 100644 index 000000000..a2b14e585 --- /dev/null +++ b/packages/w3up-client/src/client/client.js @@ -0,0 +1,50 @@ +import { AgentView } from '../agent.js' +import * as API from '../types.js' + +/** + * @template {Record} [Service={}] + */ +export class Client { + /** + * @type {AgentView} + * @protected + */ + _agent + + /** + * @param {API.AgentView} agent + */ + constructor(agent) { + this._agent = agent + } + + /** + * The current user agent (this device). + * + * @type {AgentView} + */ + get agent() { + return this._agent + } + + /** + * @protected + * @param {API.Ability[]} abilities + */ + async _invocationConfig(abilities) { + const resource = this.agent.data.currentSpace + if (!resource) { + throw new Error( + 'missing current space: use createSpace() or setCurrentSpace()' + ) + } + const issuer = this._agent.issuer + const proofs = await this._agent.proofs( + abilities.map((can) => ({ can, with: resource })) + ) + + const { connection } = this.agent + const audience = this.agent.connection.id + return { issuer, with: resource, proofs, audience, connection } + } +} diff --git a/packages/w3up-client/src/capability/filecoin.js b/packages/w3up-client/src/client/filecoin.js similarity index 62% rename from packages/w3up-client/src/capability/filecoin.js rename to packages/w3up-client/src/client/filecoin.js index 9a5c8e8d1..328164624 100644 --- a/packages/w3up-client/src/capability/filecoin.js +++ b/packages/w3up-client/src/client/filecoin.js @@ -1,22 +1,23 @@ import { Storefront } from '@web3-storage/filecoin-client' import { Filecoin as FilecoinCapabilities } from '@web3-storage/capabilities' -import { Base } from '../base.js' +import { Client } from './client.js' +import * as API from '../types.js' /** * Client for interacting with the `filecoin/*` capabilities. + * + * @extends {Client} */ -export class FilecoinClient extends Base { +export class FilecoinClient extends Client { /** * Offer a Filecoin "piece" to the resource. * - * @param {import('multiformats').UnknownLink} content - * @param {import('@web3-storage/capabilities/types').PieceLink} piece + * @param {API.UnknownLink} content + * @param {API.PieceLink} piece */ async offer(content, piece) { const conf = await this._invocationConfig([FilecoinCapabilities.offer.can]) - return Storefront.filecoinOffer(conf, content, piece, { - connection: this._serviceConf.filecoin, - }) + return Storefront.filecoinOffer(conf, content, piece, this.agent) } /** @@ -24,10 +25,11 @@ export class FilecoinClient extends Base { * * @param {import('@web3-storage/capabilities/types').PieceLink} piece */ - async info(piece) { + async info(piece, options = {}) { const conf = await this._invocationConfig([FilecoinCapabilities.info.can]) return Storefront.filecoinInfo(conf, piece, { - connection: this._serviceConf.filecoin, + connection: this.agent.connection, + ...options, }) } } diff --git a/packages/w3up-client/src/capability/store.js b/packages/w3up-client/src/client/store.js similarity index 70% rename from packages/w3up-client/src/capability/store.js rename to packages/w3up-client/src/client/store.js index c1cd72630..7f74a5e01 100644 --- a/packages/w3up-client/src/capability/store.js +++ b/packages/w3up-client/src/client/store.js @@ -1,11 +1,14 @@ import { Store } from '@web3-storage/upload-client' import { Store as StoreCapabilities } from '@web3-storage/capabilities' -import { Base } from '../base.js' +import { Client } from './client.js' +import * as API from '../types.js' /** * Client for interacting with the `store/*` capabilities. + * + * @extends {Client} */ -export class StoreClient extends Base { +export class StoreClient extends Client { /** * Store a DAG encoded as a CAR file. * @@ -14,8 +17,10 @@ export class StoreClient extends Base { */ async add(car, options = {}) { const conf = await this._invocationConfig([StoreCapabilities.add.can]) - options.connection = this._serviceConf.upload - return Store.add(conf, car, options) + return Store.add(conf, car, { + connection: this.agent.connection, + ...options, + }) } /** @@ -26,8 +31,10 @@ export class StoreClient extends Base { */ async get(link, options = {}) { const conf = await this._invocationConfig([StoreCapabilities.get.can]) - options.connection = this._serviceConf.upload - return Store.get(conf, link, options) + return Store.get(conf, link, { + connection: this.agent.connection, + ...options, + }) } /** @@ -37,8 +44,10 @@ export class StoreClient extends Base { */ async list(options = {}) { const conf = await this._invocationConfig([StoreCapabilities.add.can]) - options.connection = this._serviceConf.upload - return Store.list(conf, options) + return Store.list(conf, { + connection: this.agent.connection, + ...options, + }) } /** @@ -49,7 +58,9 @@ export class StoreClient extends Base { */ async remove(link, options = {}) { const conf = await this._invocationConfig([StoreCapabilities.remove.can]) - options.connection = this._serviceConf.upload - return Store.remove(conf, link, options) + return Store.remove(conf, link, { + connection: this.agent.connection, + ...options, + }) } } diff --git a/packages/w3up-client/src/client/upload.js b/packages/w3up-client/src/client/upload.js new file mode 100644 index 000000000..58b44b06b --- /dev/null +++ b/packages/w3up-client/src/client/upload.js @@ -0,0 +1,129 @@ +import { Upload } from '@web3-storage/upload-client' +import { + Upload as UploadCapabilities, + Store as StoreCapabilities, +} from '@web3-storage/capabilities' +import { Client } from './client.js' +import * as API from '../types.js' +import { + uploadFile, + uploadDirectory, + uploadCAR, +} from '@web3-storage/upload-client' + +/** + * Client for interacting with the `upload/*` capabilities. + * + * @extends {Client} + */ +export class UploadClient extends Client { + /** + * Register an "upload" to the resource. + * + * @param {API.UnknownLink} root - Root data CID for the DAG that was stored. + * @param {API.CARLink[]} shards - CIDs of CAR files that contain the DAG. + * @param {API.RequestOptions} [options] + */ + async add(root, shards, options = {}) { + const conf = await this._invocationConfig([UploadCapabilities.add.can]) + return Upload.add(conf, root, shards, { + connection: this.agent.connection, + ...options, + }) + } + + /** + * Get details of an "upload". + * + * @param {import('../types.js').UnknownLink} root - Root data CID for the DAG that was stored. + * @param {import('../types.js').RequestOptions} [options] + */ + async get(root, options = {}) { + const conf = await this._invocationConfig([UploadCapabilities.add.can]) + return Upload.get(conf, root, { + connection: this.agent.connection, + ...options, + }) + } + + /** + * List uploads registered to the resource. + * + * @param {import('../types.js').ListRequestOptions} [options] + */ + async list(options = {}) { + const conf = await this._invocationConfig([UploadCapabilities.list.can]) + return Upload.list(conf, { + connection: this.agent.connection, + ...options, + }) + } + + /** + * Remove an upload by root data CID. + * + * @param {import('../types.js').UnknownLink} root - Root data CID to remove. + * @param {import('../types.js').RequestOptions} [options] + */ + async remove(root, options = {}) { + const conf = await this._invocationConfig([UploadCapabilities.remove.can]) + return Upload.remove(conf, root, { + connection: this.agent.connection, + ...options, + }) + } + + /** + * Uploads a file to the service and returns the root data CID for the + * generated DAG. + * + * @param {API.BlobLike} file - File data. + * @param {API.UploadOptions} [options] + */ + async uploadFile(file, options = {}) { + const conf = await this._invocationConfig([ + StoreCapabilities.add.can, + UploadCapabilities.add.can, + ]) + + return uploadFile(conf, file, options) + } + + /** + * Uploads a directory of files to the service and returns the root data CID + * for the generated DAG. All files are added to a container directory, with + * paths in file names preserved. + * + * @param {API.FileLike[]} files - File data. + * @param {API.UploadDirectoryOptions} [options] + */ + async uploadDirectory(files, options = {}) { + const conf = await this._invocationConfig([ + StoreCapabilities.add.can, + UploadCapabilities.add.can, + ]) + + return uploadDirectory(conf, files, options) + } + + /** + * Uploads a CAR file to the service. + * + * The difference between this function and `capability.store.add` is that the + * CAR file is automatically sharded and an "upload" is registered, linking + * the individual shards (see `capability.upload.add`). + * + * Use the `onShardStored` callback to obtain the CIDs of the CAR file shards. + * + * @param {API.BlobLike} car - CAR file. + * @param {API.UploadOptions} [options] + */ + async uploadCAR(car, options = {}) { + const conf = await this._invocationConfig([ + StoreCapabilities.add.can, + UploadCapabilities.add.can, + ]) + + return uploadCAR(conf, car, options) + } +} diff --git a/packages/w3up-client/src/client/usage.js b/packages/w3up-client/src/client/usage.js new file mode 100644 index 000000000..55d4e6a1c --- /dev/null +++ b/packages/w3up-client/src/client/usage.js @@ -0,0 +1,27 @@ +import { report, Usage } from '../capability/usage.js' +import { Client } from './client.js' +import * as API from '../types.js' + +/** + * Client for interacting with the `usage/*` capabilities. + * + * @extends {Client} + */ +export class UsageClient extends Client { + /** + * Get a usage report for the passed space in the given time period. + * + * @param {API.SpaceDID} space + * @param {{ from: Date, to: Date }} period + */ + async report(space, period) { + const out = await report(this.agent, { space, period }) + if (!out.ok) { + throw new Error(`failed ${Usage.report.can} invocation`, { + cause: out.error, + }) + } + + return out.ok + } +} diff --git a/packages/w3up-client/src/coupon.js b/packages/w3up-client/src/coupon.js index 0ca379c0d..4223167ef 100644 --- a/packages/w3up-client/src/coupon.js +++ b/packages/w3up-client/src/coupon.js @@ -1,148 +1,129 @@ -import * as API from '@web3-storage/access/types' -import { sha256, delegate, Delegation } from '@ucanto/core' -import { ed25519 } from '@ucanto/principal' -import * as Result from './result.js' -import { GrantedAccess } from '@web3-storage/access/access' -import { Base } from './base.js' - -export class CouponAPI extends Base { - /** - * Redeems coupon from the the the archive. Throws an error if the coupon - * password is invalid or if provided archive is not a valid. - * - * @param {Uint8Array} archive - * @param {object} [options] - * @param {string} [options.password] - */ - async redeem(archive, options = {}) { - const { agent } = this - const coupon = Result.unwrap(await extract(archive)) - return Result.unwrap(await redeem(coupon, { ...options, agent })) - } - - /** - * Issues a coupon for the given delegation. - * - * @param {Omit} options - */ - async issue({ proofs = [], ...options }) { - const { agent } = this - return await issue({ - ...options, - issuer: agent.issuer, - proofs: [...proofs, ...agent.proofs(options.capabilities)], - }) - } -} +import * as API from './types.js' +import * as Agent from './agent.js' +import * as Task from './task.js' +import * as Coupon from './coupon/coupon.js' /** - * Extracts coupon from the archive. - * - * @param {Uint8Array} archive - * @returns {Promise>} + * @template {API.UnknownProtocol} Protocol + * @param {API.Session} session + * @returns {API.CouponAPI} */ -export const extract = async (archive) => { - const { ok, error } = await Delegation.extract(archive) - return ok ? Result.ok(new Coupon({ proofs: [ok] })) : Result.error(error) -} +export const view = (session) => new CouponAPI(session) /** - * Encodes coupon into an archive. + * Redeems coupon from the the the archive. Throws an error if the coupon + * password is invalid or if provided archive is not a valid. * - * @param {Model} coupon + * @template {API.UnknownProtocol} Protocol + * @param {API.Session} session + + * @param {object} options + * @param {Uint8Array} options.archive + * @param {string} [options.secret] + * @returns {Task.Task, Error>} */ -export const archive = async (coupon) => { - const [delegation] = coupon.proofs - return await Delegation.archive(delegation) +export function* redeem(session, { archive, secret = '' }) { + const coupon = yield* Coupon.open(archive, { secret }) + return yield* Coupon.redeem(coupon, { session }) } /** - * Issues a coupon for the given delegation. - * - * @typedef {Omit, 'audience'> & { password?: string }} CouponOptions - * @param {CouponOptions} options + * @template {API.UnknownProtocol} Protocol + * @param {API.Session} session + * @param {Coupon.Access} access + * @returns {Task.Task, Error>} */ -export const issue = async ({ password = '', ...options }) => { - const audience = await deriveSigner(password) - const delegation = await delegate({ - ...options, - audience, - }) - - return new Coupon({ proofs: [delegation] }) +export function* issue({ agent, connection }, access) { + const coupon = yield* Coupon.issue(agent, access) + return coupon.connect(connection) } /** - * @typedef {object} Model - * @property {[API.Delegation]} proofs + * @param {{agent: API.Agent}} session + * @param {API.Coupon} coupon + * @returns {Task.Task} */ +export function* remove({ agent }, coupon) { + yield* Agent.DB.transact( + agent.db, + coupon.proofs.map((proof) => Agent.DB.retract({ proof })) + ) + + return {} +} /** - * Redeems granted access with the given agent from the given coupon. - * - * @param {Model} coupon - * @param {object} options - * @param {API.Agent} options.agent - * @param {string} [options.password] - * @returns {Promise>} + * @param {{agent: API.Agent}} session + * @param {API.Coupon} coupon + * @returns {Task.Task} */ -export const redeem = async (coupon, { agent, password = '' }) => { - const audience = await deriveSigner(password) - const [delegation] = coupon.proofs +export function* add({ agent }, coupon) { + if (coupon.signer.did() === agent.signer.did()) { + yield* Agent.DB.transact( + agent.db, + coupon.proofs.map((proof) => Agent.DB.assert({ proof })) + ) - if (delegation.audience.did() !== audience.did()) { - return Result.error( + return {} + } else { + return yield* Task.fail( new RangeError( - password === '' - ? 'Extracting account requires a password' - : 'Provided password is invalid' + `Coupon audience is ${coupon.signer.did()} not ${agent.signer.did()}` ) ) - } else { - const authorization = await delegate({ - issuer: audience, - audience: agent, - capabilities: delegation.capabilities, - expiration: delegation.expiration, - notBefore: delegation.notBefore, - proofs: [delegation], - }) - - return Result.ok(new GrantedAccess({ agent, proofs: [authorization] })) } } /** - * @param {string} password + * @template {API.UnknownProtocol} Protocol + * @implements {API.CouponAPI} */ -const deriveSigner = async (password) => { - const { digest } = await sha256.digest(new TextEncoder().encode(password)) - return await ed25519.Signer.derive(digest) -} - -export class Coupon { +class CouponAPI { /** - * @param {Model} model + * @param {API.Session} session */ - constructor(model) { - this.model = model + constructor(session) { + this.session = session } - get proofs() { - return this.model.proofs + /** + * Redeems coupon from the the the archive. Throws an error if the coupon + * password is invalid or if provided archive is not a valid. + * + * @param {Uint8Array} archive + * @param {object} [options] + * @param {string} [options.secret] + * @returns {Task.Invocation, Error>} + */ + redeem(archive, options = {}) { + return Task.perform(redeem(this.session, { archive, ...options })) } /** + * Issues a coupon for the given delegation. * - * @param {API.Agent} agent - * @param {object} [options] - * @param {string} [options.password] + * @param {object} access + * @param {API.DID} access.subject + * @param {API.Can} access.can + * @param {API.UTCUnixTimestamp} [access.expiration] + * @param {API.UTCUnixTimestamp} [access.notBefore] + * @param {string} [access.secret] + * @returns {Task.Invocation, Error>} */ - redeem(agent, options = {}) { - return redeem(this, { ...options, agent }) + issue(access) { + return Task.perform(issue(this.session, access)) } - archive() { - return archive(this) + /** + * @param {API.Coupon} coupon + */ + add(coupon) { + return Task.perform(add(this.session, coupon)) + } + /** + * @param {API.Coupon} coupon + */ + remove(coupon) { + return Task.perform(remove(this.session, coupon)) } } diff --git a/packages/w3up-client/src/coupon/coupon.js b/packages/w3up-client/src/coupon/coupon.js new file mode 100644 index 000000000..da4ac2b21 --- /dev/null +++ b/packages/w3up-client/src/coupon/coupon.js @@ -0,0 +1,271 @@ +import * as API from '../types.js' +import * as Task from '../task.js' +import { sha256, delegate, Delegation } from '@ucanto/core' +import { ed25519 } from '@ucanto/principal' +import * as Space from '../space.js' +import * as Account from '../account.js' +import * as Agent from '../agent.js' + +/** + * @param {string} password + * @returns {Task.Task} + */ +const deriveSigner = function* (password) { + const { digest } = yield* Task.wait( + sha256.digest(new TextEncoder().encode(password)) + ) + + return yield* Task.wait(ed25519.Signer.derive(digest)) +} + +/** + * Encodes coupon into an archive. + * + * @param {API.Coupon} coupon + * @returns {Task.Task} + */ +export function* archive(coupon) { + const [delegation] = coupon.proofs + return yield* Task.ok.wait(Delegation.archive(delegation)) +} + +/** + * Extracts a coupon from provided `archive`. If issued coupon used a `secret` + * it must be provided. + * + * @param {Uint8Array} archive + * @param {object} [options] + * @param {string} [options.secret] + * @returns {Task.Task} + */ +export function* open(archive, { secret = '' } = {}) { + const proof = yield* Task.ok.wait(Delegation.extract(archive)) + const signer = yield* deriveSigner(secret) + + if (proof.audience.did() !== signer.did()) { + return yield* Task.fail( + new RangeError( + secret === '' + ? 'Redeeming a coupon requires a secret' + : 'Provided secret is invalid' + ) + ) + } + + return CouponView.from({ signer, proofs: [proof] }) +} + +/** + * Redeems granted access with the given agent from the given coupon. + * + * @template {API.UnknownProtocol} Protocol + * @param {API.Coupon} coupon + * @param {object} options + * @param {API.Session} options.session + * @returns {Task.Task, Error | Task.AbortError>} + */ +export function* redeem(coupon, { session }) { + if (coupon.signer.did() === session.agent.signer.did()) { + const { agent } = CouponView.from(coupon) + + return new CouponSession({ + agent, + connection: session.connection, + }) + } else { + const [delegation] = coupon.proofs + + const proof = yield* Task.wait( + delegate({ + issuer: coupon.signer, + audience: session.agent.signer, + capabilities: delegation.capabilities, + expiration: delegation.expiration, + notBefore: delegation.notBefore, + proofs: [delegation], + }) + ) + + const { agent } = CouponView.from({ + signer: session.agent.signer, + proofs: [proof], + }) + + return new CouponSession({ + agent, + connection: session.connection, + }) + } +} + +/** + * Describes capabilities granted by the coupon. + * + * @typedef {object} Access + * @property {API.DID} subject + * @property {API.Can} can + * @property {API.UTCUnixTimestamp} [expiration] + * @property {API.UTCUnixTimestamp} [notBefore] + * @property {string} [secret] + */ + +/** + * Issues a coupon for the given delegation. + * + * @param {API.Agent} agent + * @param {Access} access + * @returns {Task.Task} + */ +export function* issue(agent, { secret = '', subject, can, ...options }) { + const authorization = yield* Agent.authorize(agent, { + subject, + can, + }) + + const audience = yield* deriveSigner(secret) + + const capabilities = /** @type {API.Capabilities} */ ( + Object.entries(can).map(([can, policy]) => ({ + with: subject, + can, + nb: policy, + })) + ) + + const delegation = yield* Task.wait( + delegate({ + ...options, + issuer: agent.signer, + audience, + capabilities, + proofs: authorization.proofs, + }) + ) + + return new CouponView({ + signer: audience, + db: Agent.DB.from({ proofs: [delegation] }), + }) +} + +/** + * @template {API.UnknownProtocol} Protocol + * @param {API.Agent} agent + * @param {API.Connection} connection + * @returns {API.CouponSession} + */ +export const connect = (agent, connection) => { + return new CouponSession({ + agent, + connection, + }) +} + +/** + * @implements {API.Coupon} + * @implements {API.CouponView} + */ +export class CouponView { + /** + * @param {API.Coupon} coupon + */ + static from(coupon) { + if (coupon instanceof CouponView) { + return coupon + } else { + return new this({ + signer: coupon.signer, + db: Agent.DB.from({ proofs: coupon.proofs }), + }) + } + } + /** + * @param {API.Agent} model + */ + constructor(model) { + this.model = model + } + get agent() { + return this.model + } + get signer() { + return this.model.signer + } + + get proofs() { + return /** @type {[API.Delegation]} */ ( + [...this.model.db.proofs.values()].map(({ delegation }) => delegation) + ) + } + + /** + * + * @returns {Task.Invocation} + */ + archive() { + return Task.perform(archive(this)) + } + + /** + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} connection + * @returns {API.CouponSession} + */ + connect(connection) { + return connect(this.model, connection) + } + + /** + * @template {API.UnknownProtocol} Protocol + * @param {API.Session} session + * @returns {Task.Invocation, Error | Task.AbortError>} + */ + redeem(session) { + return Task.perform(redeem(this, { session })) + } +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.CouponSession} + */ +class CouponSession { + /** + * @param {API.Session} model + */ + constructor(model) { + this.model = model + this.spaces = Space.view(/** @type {API.Session} */ (this.model)) + this.accounts = Account.view(/** @type {API.Session} */ (this.model)) + } + get signer() { + return this.model.agent.signer + } + get connection() { + return this.model.connection + } + get agent() { + return this.model.agent + } + + get proofs() { + return /** @type {[API.Delegation]} */ ( + [...this.model.agent.db.proofs.values()].map( + ({ delegation }) => delegation + ) + ) + } + + archive() { + return Task.perform(archive(this)) + } + + /** + * @param {object} options + * @param {API.Agent} options.agent + * @param {API.Connection} [options.connection] + */ + redeem({ agent, connection = this.connection }) { + return Task.perform(redeem(this, { session: { agent, connection } })) + } +} diff --git a/packages/w3up-client/src/index.js b/packages/w3up-client/src/index.js index ebfdecf31..c3703d875 100644 --- a/packages/w3up-client/src/index.js +++ b/packages/w3up-client/src/index.js @@ -10,7 +10,7 @@ import { StoreIndexedDB } from '@web3-storage/access/stores/store-indexeddb' import { generate } from '@ucanto/principal/rsa' import { Client } from './client.js' export * as Result from './result.js' -export * as Account from './account.js' +export * as Account from './view/account.js' /** * Create a new w3up client. diff --git a/packages/w3up-client/src/index.node.js b/packages/w3up-client/src/index.node.js deleted file mode 100644 index 604b65247..000000000 --- a/packages/w3up-client/src/index.node.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @hidden - * @module - */ -import { AgentData } from '@web3-storage/access/agent' -import { StoreConf } from '@web3-storage/access/stores/store-conf' -import { generate } from '@ucanto/principal/ed25519' -import { Client } from './client.js' -export * as Result from './result.js' -export * as Account from './account.js' - -/** - * Create a new w3up client. - * - * If no backing store is passed one will be created that is appropriate for - * the environment. - * - * If the backing store is empty, a new signing key will be generated and - * persisted to the store. In the browser an unextractable RSA key will be - * generated by default. In other environments an Ed25519 key is generated. - * - * If the backing store already has data stored, it will be loaded and used. - * - * @type {import('./types.js').ClientFactory} - */ -export async function create(options = {}) { - const store = options.store ?? new StoreConf({ profile: 'w3up-client' }) - const raw = await store.load() - if (raw) { - const data = AgentData.fromExport(raw, { store }) - if (options.principal && data.principal.did() !== options.principal.did()) { - throw new Error( - `store cannot be used with ${options.principal.did()}, stored principal and passed principal must match` - ) - } - return new Client(data, options) - } - const principal = options.principal ?? (await generate()) - const data = await AgentData.create({ principal }, { store }) - return new Client(data, options) -} - -export { Client } diff --git a/packages/w3up-client/src/lib.js b/packages/w3up-client/src/lib.js new file mode 100644 index 000000000..b40933d27 --- /dev/null +++ b/packages/w3up-client/src/lib.js @@ -0,0 +1,2 @@ +export * from './w3up.js' +export * as Store from '@web3-storage/w3up-client/store' diff --git a/packages/w3up-client/src/result.js b/packages/w3up-client/src/result.js index 1df360b81..c99348187 100644 --- a/packages/w3up-client/src/result.js +++ b/packages/w3up-client/src/result.js @@ -15,8 +15,3 @@ export const unwrap = ({ ok, error }) => { return /** @type {T} */ (ok) } } - -/** - * Also expose as `Result.try` which is arguably more clear. - */ -export { unwrap as try } diff --git a/packages/w3up-client/src/service.js b/packages/w3up-client/src/service.js index 896579da9..bc6e035ab 100644 --- a/packages/w3up-client/src/service.js +++ b/packages/w3up-client/src/service.js @@ -16,6 +16,7 @@ export const accessServiceConnection = connect({ export const uploadServiceURL = new URL('https://up.web3.storage') export const uploadServicePrincipal = DID.parse('did:web:web3.storage') +export const receiptsEndpoint = new URL('https://up.web3.storage/receipt/') export const uploadServiceConnection = connect({ id: uploadServicePrincipal, @@ -44,5 +45,3 @@ export const serviceConf = { upload: uploadServiceConnection, filecoin: filecoinServiceConnection, } - -export const receiptsEndpoint = 'https://up.web3.storage/receipt/' diff --git a/packages/w3up-client/src/session.js b/packages/w3up-client/src/session.js new file mode 100644 index 000000000..de7d5ce33 --- /dev/null +++ b/packages/w3up-client/src/session.js @@ -0,0 +1,195 @@ +import * as API from './types.js' +import * as Space from './space.js' +import * as Account from './account.js' +import * as Coupon from './coupon.js' +import * as Task from './task.js' + +/** + * Invocation is like an advanced Promise for the UCAN invocation result. When + * awaited it is either resolved to `.out.ok` of the invocation receipt, or is + * rejected with `.out.error`. Additionally it also provides `.receipt()` method + * allowing you to await for the receipt instead. This gives you a convenient + * default with an option to get receipt in more advanced cases. + * + * In addition invocation also implements {@link Task.Invocation} interface and + * can be used in other tasks using `yield*` operator to either get unwrapped + * result by default or `yield x.receipt()` to get the receipt instead. + * + * @template {{}} Ok + * @template {Error} Err + * @template {Error} Fail + * @template {Task.Suspend | Task.Join | Task.Throw} Command + * @implements {Task.Invocation} + */ +class Invocation { + /** + * @param {Task.Task, Err|Fail, Command>} task + */ + constructor(task) { + this.invocation = Task.perform(task) + } + + *[Symbol.iterator]() { + const receipt = yield* this.invocation + if (receipt.out.ok) { + return /** @type {Ok} */ (receipt.out.ok) + } else { + throw receipt.out.error + } + } + + /** + * + * @param {unknown} reason + */ + abort(reason) { + return this.invocation.abort(reason) + } + + receipt() { + return this.invocation + } + + /** + * @returns {Promise>} + */ + result() { + return this.invocation.then((receipt) => receipt.out) + } + + /** + * @type {Promise['then']} + */ + then(onFulfilled, onRejected) { + return this.invocation + .then((receipt) => { + if (receipt.out.ok) { + return receipt.out.ok + } else { + throw receipt.out.error + } + }) + .then(onFulfilled, onRejected) + } + + /** + * @type {Promise['catch']} + */ + catch(reject) { + return this.then().catch(reject) + } + /** + * @type {Promise['finally']} + */ + finally(onFinally) { + return this.then().finally(onFinally) + } + + [Symbol.toStringTag] = 'Invocation' +} + +/** + * Takes a session and UCAN invocation and executes it with the service session + * is connected to. It returns an `Invocation` object that when awaited will + * either resolve to the invocation result (receipt.out.ok) or fail with an + * error (receipt.out.error). Returned invocation has `.receipt()` method that + * can be awaited instead to get invocation receipt without unwrapping it. + * + * @template {API.Capability} C + * @template {API.UnknownProtocol} [P=API.W3UpProtocol] + * @param {API.Session

} session + * @param {API.IssuedInvocationView} invocation + * @returns {API.TaskInvocation, API.InferReceiptError, API.OfflineError>} + + */ +export const execute = (session, invocation) => + /** @type {any} */ (perform(run(session, invocation))) + +/** + * @template {API.Capability} Capability + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {API.IssuedInvocationView} invocation + * @returns {Task.Task, API.OfflineError>} + */ +function* run(session, invocation) { + if (!session.connection.channel) { + return yield* Task.fail( + new OfflineError('Session has no service connection') + ) + } + + const [receipt] = yield* Task.wait( + session.connection.execute( + /** @type {API.IssuedInvocationView & API.ServiceInvocation} */ ( + invocation + ) + ) + ) + + return receipt +} + +/** + * Spawns a task that returns a UCAN receipt, and succeeds with either `.out.ok` + * or fails with `.out.error`. Other than unwrapping the receipt it is almost + * identical to {@link Task.spawn}, except returned `Invocation` object also + * provides `.receipt()` method that gives access to an unwrapped receipt in + * cases where that is desired. + * + * @template {{}} Ok + * @template {Error} Err + * @template {Error} Fail + * * @template {Task.Suspend | Task.Join | Task.Throw} Command + * @param {() => Task.Task, Fail, Command>} work + */ +export const spawn = (work) => perform(work()) + +/** + * @template {API.Capability} Capability + * @template {API.UnknownProtocol} Protocol + * @template {API.Receipt} Receipt + * @template {Error} Fail + * @template {Task.Suspend | Task.Join | Task.Throw} Command + * @param {Task.Task} task + * @returns {API.TaskInvocation, API.InferReceiptError, Exclude, Task.AbortError>>} + */ +export const perform = (task) => + /** @type {API.TaskInvocation<*, *, any>} */ (new Invocation(task)) + +/** + * @implements {API.OfflineError} + */ +class OfflineError extends Error { + name = /** @type {const} */ ('OfflineError') +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} model + * @returns {API.W3UpSession} + */ +export const create = (model) => + new Session(/** @type {API.Session} */ (model)) + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.Session} + */ +class Session { + /** + * @param {API.Session} model + */ + constructor(model) { + this.model = model + this.spaces = Space.view(/** @type {API.Session} */ (this.model)) + this.accounts = Account.view(/** @type {API.Session} */ (this.model)) + this.coupons = Coupon.view(/** @type {API.Session} */ (this.model)) + } + get connection() { + return this.model.connection + } + get agent() { + return /** @type {API.AgentView} */ (this.model.agent) + } +} diff --git a/packages/w3up-client/src/space.js b/packages/w3up-client/src/space.js index b3bb2aa04..b6218034e 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -1,99 +1,169 @@ -export * from '@web3-storage/access/space' -import * as Usage from './capability/usage.js' import * as API from './types.js' +import * as DB from './agent/db.js' +import * as Query from './space/query.js' +import * as Space from './space/space.js' +import * as Task from './task.js' + +export * from './space/space.js' /** - * @typedef {object} Model - * @property {API.SpaceDID} id - * @property {{name?:string}} [meta] - * @property {API.Agent} agent + * @param {API.Session} session + * @returns {API.SpaceManager} */ +export const view = (session) => new SessionSpaces(session) -export class Space { - #model +/** + * @param {API.Session} session + */ +export const list = (session) => { + const results = DB.query( + session.agent.db.index, + Query.query({ audience: session.agent.signer.did() }) + ) - /** - * @param {Model} model - */ - constructor(model) { - this.#model = model - this.usage = new StorageUsage(model) + return build(session, results) +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {API.SpaceView} space + * @returns {Task.Task} + */ +export function* add(session, space) { + if (space.authority === session.agent.signer.did()) { + return yield* DB.transact( + session.agent.db, + space.proofs.map((proof) => DB.assert({ proof })) + ) + } else { + return yield* Task.fail( + new PrincipalAlignmentError({ + message: `Space is shared with ${ + space.authority + } not ${session.agent.signer.did()}`, + expect: space.authority, + actual: session.agent.signer.did(), + }) + ) + } +} + +/** + * Removes shared space authorization from the agent's database. If there are + * more authorizations for the space, space will continue to show up in the + * list of spaces, but only capabilities delegated through those authorizations + * will be available. + * + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {API.SpaceView} space + * @returns {Task.Task} + */ +export function* remove(session, space) { + yield* DB.transact( + session.agent.db, + space.proofs.map((proof) => DB.retract({ proof })) + ) + + return {} +} + +/** + * @template {Space.SpaceProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {object} source + * @param {string} source.mnemonic + * @param {string} source.name + * @returns {Task.Task} + */ +export const fromMnemonic = (session, { mnemonic, name }) => + Space.fromMnemonic(mnemonic, { name, session }) + +/** + * @template {Space.SpaceProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {{space:API.DIDKey, name?: string, proof: DB.Link}[]} spaces + */ +const build = (session, spaces) => { + const { proofs } = session.agent.db + /** @type {Record>} */ + const result = {} + for (const { space: subject, name = '', proof } of spaces) { + const { delegation } = /** @type {{delegation: API.Delegation}} */ ( + proofs.get(proof.toString()) + ) + + if (!result[subject]) { + result[subject] = { + subject, + signer: /** @type {API.Signer} */ (session.agent.signer), + name, + proofs: [delegation], + session, + } + } else { + result[subject].proofs.push(delegation) + } } + return Object.fromEntries( + Object.entries(result).map(([k, model]) => [k, Space.view(model)]) + ) +} + +/** + * @implements {API.SpaceManager} + */ +class SessionSpaces { /** - * The given space name. + * @param {API.Session} session */ - get name() { - /* c8 ignore next */ - return String(this.#model.meta?.name ?? '') + constructor(session) { + this.session = session + } + list() { + return list(this.session) } - /** - * The DID of the space. + * @param {API.SpaceView} space */ - did() { - return this.#model.id + add(space) { + return Task.perform(add(this.session, space)) } - /** - * User defined space metadata. + * @param {API.SpaceView} space */ - meta() { - return this.#model.meta + remove(space) { + return Task.perform(remove(this.session, space)) } -} -export class StorageUsage { - #model + *[Symbol.iterator]() { + yield* Object.values(this.list()) + } /** - * @param {Model} model + * @param {object} source + * @param {string} source.name + * @returns {Task.Invocation} */ - constructor(model) { - this.#model = model + create(source) { + return Task.perform(Space.create({ ...source, session: this.session })) } +} +class PrincipalAlignmentError extends Error { /** - * Get the current usage in bytes. + * + * @param {object} options + * @param {string} options.message + * @param {API.DID} options.expect + * @param {API.DID} options.actual */ - async get() { - const { agent } = this.#model - const space = this.#model.id - const now = new Date() - const period = { - // we may not have done a snapshot for this month _yet_, so get report - // from last month -> now - from: startOfLastMonth(now), - to: now, - } - const result = await Usage.report({ agent }, { space, period }) - /* c8 ignore next */ - if (result.error) return result - - const provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()) - const report = result.ok[provider] - - return { - /* c8 ignore next */ - ok: report?.size.final == null ? undefined : BigInt(report.size.final), - } + constructor({ message, expect, actual }) { + super(message) + this.expect = expect + this.actual = actual } -} - -/** @param {string|number|Date} now */ -const startOfMonth = (now) => { - const d = new Date(now) - d.setUTCDate(1) - d.setUTCHours(0) - d.setUTCMinutes(0) - d.setUTCSeconds(0) - d.setUTCMilliseconds(0) - return d -} - -/** @param {string|number|Date} now */ -const startOfLastMonth = (now) => { - const d = startOfMonth(now) - d.setUTCMonth(d.getUTCMonth() - 1) - return d + name = /** @type {const} */ ('PrincipalAlignmentError') } diff --git a/packages/w3up-client/src/space/blob.js b/packages/w3up-client/src/space/blob.js new file mode 100644 index 000000000..cc16d42e3 --- /dev/null +++ b/packages/w3up-client/src/space/blob.js @@ -0,0 +1 @@ +import * as API from '../types.js' diff --git a/packages/w3up-client/src/space/delegations.js b/packages/w3up-client/src/space/delegations.js new file mode 100644 index 000000000..87cc551e8 --- /dev/null +++ b/packages/w3up-client/src/space/delegations.js @@ -0,0 +1,36 @@ +import * as API from '../types.js' +import * as Access from '../access.js' +import * as Session from '../session.js' + +/** + * @param {API.Session} session + * @returns {API.SpaceDelegationsView} + */ +export const view = (session) => new Delegations(session) + +/** + * @implements {API.SpaceDelegationsView} + */ +class Delegations { + /** + * @param {API.Session} session + */ + constructor(session) { + this.session = session + } + + /** + * + * @param {API.Authorization} authorization + */ + add(authorization) { + const task = Access.delegate(this.session, { + delegations: authorization.proofs, + subject: /** @type {API.DIDKey} */ (this.session.agent.signer.did()), + }) + + return Session.perform(task) + } + + // TODO: We really should allow deleting and listing delegations also. +} diff --git a/packages/w3up-client/src/space/filecoin.js b/packages/w3up-client/src/space/filecoin.js new file mode 100644 index 000000000..489ec0023 --- /dev/null +++ b/packages/w3up-client/src/space/filecoin.js @@ -0,0 +1,80 @@ +import * as API from '../types.js' +import * as Agent from '../agent.js' +import * as Filecoin from '@web3-storage/capabilities/filecoin' +import * as Session from '../session.js' + +/** + * @param {API.SpaceSession} session + * @returns {API.SpaceFilecoinView} + */ +export const view = (session) => new FilecoinAPI(session) + +/** + * @param {API.SpaceSession} session + * @param {API.FilecoinOffer} offer + */ +export function* offer(session, offer) { + const { proofs } = yield* Agent.authorize(session.agent, { + subject: session.did(), + can: { 'filecoin/offer': [] }, + }) + + const task = Filecoin.offer.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: session.did(), + nb: offer, + proofs, + }) + + return yield* Session.execute(session, task).receipt() +} + +/** + * @param {API.SpaceSession} session + * @param {API.FilecoinInfo} input + */ +export function* info(session, { piece }) { + const { proofs } = yield* Agent.authorize(session.agent, { + subject: session.did(), + can: { 'filecoin/info': [] }, + }) + + const task = Filecoin.info.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: session.did(), + nb: { piece }, + proofs, + }) + + return yield* Session.execute(session, task).receipt() +} + +/** + * @implements {API.SpaceFilecoinView} + */ +class FilecoinAPI { + /** + * + * @param {API.SpaceSession} session + */ + + constructor(session) { + this.session = session + } + + /** + * @param {API.FilecoinOffer} input + */ + offer(input) { + return Session.perform(offer(this.session, input)) + } + + /** + * @param {API.FilecoinInfo} input + */ + info(input) { + return Session.perform(info(this.session, input)) + } +} diff --git a/packages/w3up-client/src/space/query.js b/packages/w3up-client/src/space/query.js new file mode 100644 index 000000000..7e05f927d --- /dev/null +++ b/packages/w3up-client/src/space/query.js @@ -0,0 +1,149 @@ +import * as API from '../types.js' +import * as Delegation from '../agent/delegation.js' +import * as Text from '../agent/db/text.js' +import * as DB from 'datalogia' +import * as Authorization from '../authorization/query.js' +import { optional } from '../agent/db.js' + +/** + * @param {object} constraints + * @param {typeof match | typeof implicit | typeof explicit} [constraints.match] + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.can] + * @param {DB.Term} [constraints.name] + * @param {boolean} [constraints.implicit] + * @returns {API.Query<{ proof: DB.Term; space: DB.Term; name?: DB.Term }>} + */ +export const query = ({ + space = DB.string(), + name = DB.string(), + ...constraints +}) => { + const ucan = DB.link() + return { + select: { + space, + name, + proof: ucan, + }, + where: [ + (constraints.match ?? match)(ucan, { name, space, ...constraints }), + ], + } +} + +/** + * @param {DB.Term} ucan + * @param {DB.Term} name + */ +export const named = (ucan, name) => + optional(Delegation.hasMeta(ucan, { 'meta/space/name': name })) + +/** + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.can] + * @param {DB.Term} [constraints.name] + */ +export const explicit = ( + ucan, + { + time = Date.now() / 1000, + audience = DB.string(), + space = DB.string(), + can = DB.string(), + name = DB.string(), + } +) => { + return DB.and( + Authorization.delegates(ucan, { + audience, + can, + subject: space, + time, + }), + named(ucan, name), + Text.match(space, { glob: 'did:key:*' }) + ) +} + +/** + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.can] + * @param {DB.Term} [constraints.name] + */ +export const match = ( + ucan, + { + time = Date.now() / 1000, + audience = DB.string(), + space = DB.string(), + can = DB.string(), + name = DB.string(), + } +) => { + const proof = DB.link() + return DB.or( + // It may be a an explicit delegation + DB.and( + Authorization.delegates(ucan, { + audience, + can, + subject: space, + time, + }), + named(ucan, name) + ), + // Or it could be an implicit delegation issued by the space + DB.and( + Delegation.forwards(ucan, { audience, time }), + Delegation.issuedBy(ucan, space), + named(ucan, name) + ), + // or it could be an delegation that forwards explicit proof + DB.and( + Delegation.forwards(ucan, { audience, time }), + Delegation.hasProof(ucan, proof), + Authorization.delegates(proof, { subject: space, time }), + named(proof, name) + ) + ).and(Text.match(space, { glob: 'did:key:*' })) +} + +/** + * @param {DB.Term} ucan + * @param {object} constraints + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.name] + * @param {DB.Term} [constraints.audience] + * @param {DB.Term} [constraints.account] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.name] + */ +export const implicit = ( + ucan, + { + time = Date.now() / 1000, + audience = DB.string(), + space = DB.string(), + name = DB.string(), + } +) => { + const proof = DB.link() + return DB.and( + Delegation.forwards(ucan, { audience, time }), + Delegation.hasProof(ucan, proof), + Authorization.delegates(proof, { subject: space, time }), + Text.match(space, { glob: 'did:key:*' }), + named(proof, name) + ) +} diff --git a/packages/w3up-client/src/space/space.js b/packages/w3up-client/src/space/space.js new file mode 100644 index 000000000..c0bc17768 --- /dev/null +++ b/packages/w3up-client/src/space/space.js @@ -0,0 +1,423 @@ +import * as API from '../types.js' +import * as DB from '../agent/db.js' +import * as Usage from './usage.js' +import * as Delegations from './delegations.js' +import * as Filecoin from './filecoin.js' +import * as Session from '../session.js' +import * as Task from '../task.js' +import * as Authorization from '../authorization.js' +import * as Agent from '../agent.js' +import * as Space from '@web3-storage/capabilities/space' +import { delegate, UCAN } from '@ucanto/core' +import * as Access from '../access.js' +import * as ED25519 from '@ucanto/principal/ed25519' +import * as BIP39 from '@scure/bip39' +import { wordlist } from '@scure/bip39/wordlists/english' +import * as Connection from '../agent/connection.js' + +const offline = { + connection: /** @type {API.Connection} */ (Connection.offline), +} + +/** + * @template {SpaceProtocol} Protocol + * @typedef {object} Model + * @property {API.Session} session + * @property {API.DIDKey} subject + * @property {string} name + * @property {API.Signer} signer + * @property {API.Delegation[]} proofs + */ + +/** + * @template {SpaceProtocol} Protocol + * @param {Model} model + * @returns {API.SpaceView} + */ +export const view = ({ name, subject, session, signer, proofs }) => + new SpaceView({ + name, + subject, + session: { + connection: session.connection, + agent: { signer, db: DB.from({ proofs }) }, + }, + }) + +/** + * @template {SpaceProtocol} Protocol + * @param {object} options + * @param {string} options.name + * @param {{connection: API.Connection}} [options.session] + * @returns {Task.Task} + */ +export function* create({ name, session = offline }) { + const signer = yield* Task.wait(ED25519.generate()) + const agent = { signer, db: DB.from({ proofs: [] }) } + return new OwnSpace({ + name, + session: { agent, connection: session.connection }, + }) +} + +/** + * Recovers space from the saved mnemonic. + * + * @template {SpaceProtocol} Protocol + * @param {string} mnemonic + * @param {object} options + * @param {string} options.name - Name to give to the recovered space. + * @param {{connection: API.Connection}} options.session + * @returns {Task.Task} + */ +export function* fromMnemonic(mnemonic, { name, session = offline }) { + const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) + const signer = yield* Task.wait(ED25519.derive(secret)) + const agent = { signer, db: DB.from({ proofs: [] }) } + return new OwnSpace({ + name, + session: { agent, connection: session.connection }, + }) +} + +/** + * Turns (owned) space into a BIP39 mnemonic that later can be used to recover + * the space using `fromMnemonic` function. + * + * @param {object} space + * @param {ED25519.EdSigner} space.signer + */ +export const toMnemonic = ({ signer }) => { + /** @type {Uint8Array} */ + // @ts-expect-error - Field is defined but not in the interface + const secret = signer.secret + + return BIP39.entropyToMnemonic(secret, wordlist) +} + +/** + * Protocol that session endpoint should implement to provide all of the + * functionality. + * + * @typedef {API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol & API.FilecoinProtocol} SpaceProtocol + */ + +/** + * @template {SpaceProtocol} Protocol + * @param {API.SpaceSession} space + * @param {API.ShareAccess} access + * @returns {Task.Task} + */ +export function* share(space, access) { + const { proofs } = yield* authorize(space, access) + return new SpaceView({ + name: space.name, + subject: space.did(), + session: { + agent: { + signer: access.audience, + db: DB.from({ proofs }), + }, + connection: space.connection, + }, + }) +} + +export const SESSION_LIFETIME = 60 * 60 * 24 * 365 + +/** + * + * Get Space information from Access service + * + * @param {API.SpaceSession} session + * @nreturns {Task.Task, API.AccessDenied | API.OfflineError>} + */ +export function* info(session) { + const { proofs } = yield* Agent.authorize(session.agent, { + subject: session.did(), + can: { 'space/info': [] }, + }) + + const task = Space.info.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: session.did(), + proofs, + }) + + return yield* Session.execute(session, task).receipt() +} + +/** + * Creates authorization that gives specified `access.authority` an access to + * specified ability (passed as `access.can` field) on the given space. + * + * Optionally, you can specify `access.expiration` field to set the + * expiration time for issued authorization. By default the authorization + * is valid for 1 year and gives access to all {@link API.W3UpProtocol} + * capabilities on the space that are needed to use this space. + * + * @param {API.SpaceSession} space + * @param {API.SpaceAccess} access + * @returns {Task.Task} + */ +export function* authorize( + space, + { + audience, + can = Access.spaceAccess, + expiration = UCAN.now() + SESSION_LIFETIME, + notBefore, + } +) { + const proofs = [] + + // If the issuer different from the space did, we need to find proofs + // for the issuer to be able to delegate access to the space. + if (space.did() !== space.agent.signer.did()) { + const authorization = yield* Agent.authorize(space.agent, { + subject: space.did(), + can: can, + }) + proofs.push(...authorization.proofs) + } + + const proof = yield* Task.wait( + delegate({ + issuer: space.agent.signer, + audience, + capabilities: toCapabilities({ subject: space.did(), can }), + expiration, + notBefore, + facts: [{ space: { name: space.name } }], + proofs, + }) + ) + + return Authorization.from({ + authority: audience.did(), + subject: space.did(), + can, + proofs: [proof], + }) +} + +/** + * Creates authorization that gives specified `access.agent` an access to + * specified ability (passed as `access.can` field) on this space. + * Optionally, you can specify `access.expiration` field to set the + * expiration time for issued authorization. By default the authorization + * is valid for 1 year and gives access to all {@link API.W3UpProtocol} + * capabilities on the space that are needed to use the space. + * + * @template {SpaceProtocol} Protocol + * @param {API.SpaceSession} space + * @param {API.SpaceRecovery} access + + * @returns {Task.Task} + */ +export function* createRecovery(space, access) { + return yield* authorize(space, { + ...access, + can: Access.accountAccess, + }) +} + +/** + * @param {object} access + * @param {API.DID} access.subject + * @param {API.Can} access.can + * @returns {API.Capabilities} + */ +const toCapabilities = (access) => { + const capabilities = [] + for (const [can, details] of Object.entries(access.can)) { + if (details) { + capabilities.push({ can, with: access.subject }) + } + } + + return /** @type {API.Capabilities} */ (capabilities) +} + +/** + * @template {SpaceProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.SpaceView} + * @implements {API.SpaceSession} + */ +class SpaceView { + /** + * @param {object} model + * @param {API.DIDKey} model.subject + * @param {string} model.name + * @param {API.Session} model.session + */ + constructor(model) { + this.model = model + this.usage = Usage.view(this) + this.delegations = Delegations.view(this) + this.filecoin = Filecoin.view(this) + } + get connection() { + return this.model.session.connection + } + get agent() { + return this.model.session.agent + } + get authority() { + return this.agent.signer.did() + } + get name() { + return this.model.name + } + did() { + return this.model.subject + } + + /** + * + * @param {API.SpaceAccess} access + */ + authorize(access) { + return Task.perform(authorize(this, access)) + } + + /** + * Returns replica of this space connected to the given connection. + * + * @template {SpaceProtocol} Protocol + * @param {API.Connection} connection + * @returns {API.SpaceView} + */ + connect(connection) { + return new SpaceView({ + ...this.model, + session: { ...this.model.session, connection }, + }) + } + + /** + * @param {API.ShareAccess} access + */ + share(access) { + return Task.perform(share(this, access)) + } + + get proofs() { + return [...this.agent.db.proofs.values()].map(($) => $.delegation) + } + + info() { + return Session.perform(info(this)) + } +} + +/** + * Represents an owned space, meaning a space for which we have a private key + * and consequently have full authority over. + * + * @template {SpaceProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.OwnSpaceView} + * @implements {API.SpaceView} + * @implements {API.SpaceSession} + */ +class OwnSpace { + /** + * @param {object} model + * @param {string} model.name + * @param {API.Session & { agent: {signer: ED25519.EdSigner} }} model.session + */ + constructor(model) { + this.model = model + + this.usage = Usage.view(this) + this.delegations = Delegations.view(this) + this.filecoin = Filecoin.view(this) + } + get connection() { + return this.model.session.connection + } + get agent() { + return this.model.session.agent + } + get authority() { + return this.agent.signer.did() + } + + get name() { + return this.model.name + } + + did() { + return /** @type {API.DIDKey} */ (this.agent.signer.did()) + } + + /** + * Creates a renamed version of this space. + * + * @param {string} name + */ + rename(name) { + return new OwnSpace({ ...this.model, name }) + } + + /** + * Derives BIP39 mnemonic that can be used to recover the space. + * + * @returns {string} + */ + toMnemonic() { + return toMnemonic(this.model.session.agent) + } + + /** + * Connects to a remote replica of the owned space so that it can be used to + * query state of the replica and invoke actions on it. + * + * @template {SpaceProtocol} Protocol + * @param {API.Connection} connection + * @returns {API.OwnSpaceView} + */ + connect(connection) { + return new OwnSpace({ + ...this.model, + session: { + ...this.model.session, + connection, + }, + }) + } + + /** + * Shares access to this space with a session agent and returns a session + * with a same connection and agent but scoped to this space with desired + * access level. + * + * @param {API.ShareAccess} access + */ + share(access) { + return Task.perform(share(this, access)) + } + + /** + * @param {API.SpaceRecovery} access + */ + createRecovery(access) { + return Task.perform(createRecovery(this, access)) + } + + /** + * @param {API.SpaceAccess} access + */ + authorize(access) { + return Task.perform(authorize(this, access)) + } + + info() { + return Session.perform(info(this)) + } + + get proofs() { + return [...this.agent.db.proofs.values()].map(($) => $.delegation) + } +} diff --git a/packages/w3up-client/src/space/usage.js b/packages/w3up-client/src/space/usage.js new file mode 100644 index 000000000..3132f7dba --- /dev/null +++ b/packages/w3up-client/src/space/usage.js @@ -0,0 +1,111 @@ +import { Usage } from '@web3-storage/capabilities' +import * as API from '../types.js' +import * as Task from '../task.js' +import * as Agent from '../agent.js' +import * as Session from '../session.js' + +/** + * @param {API.Session} session + * @returns + */ +export const view = (session) => new UsageSession(session) + +/** + * Get a usage report for the period. + * + * @param {API.Session} session + * @param {object} options + * @param {API.SpaceDID} options.space + * @param {{ from: Date, to: Date }} options.period + * @param {API.Delegation[]} [options.proofs] + */ +export function* report(session, { space, period }) { + const { proofs } = yield* Agent.authorize(session.agent, { + subject: space, + can: { 'usage/report': [] }, + }) + + const task = Usage.report.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: space, + proofs, + nb: { + period: { + from: Math.floor(period.from.getTime() / 1000), + to: Math.ceil(period.to.getTime() / 1000), + }, + }, + }) + + return yield* Session.execute(session, task).receipt() +} + +/** + * @param {API.Session} session + */ +export function* get(session) { + const space = /** @type {API.DIDKey} */ (session.agent.signer.did()) + const now = new Date() + const period = { + // we may not have done a snapshot for this month _yet_, so get report + // from last month -> now + from: startOfLastMonth(now), + to: now, + } + + const result = yield* Session.perform(report(session, { space, period })) + + const provider = /** @type {API.ProviderDID} */ (session.connection.id.did()) + const usage = result[provider] + + /* c8 ignore next */ + return BigInt(usage.size.final ?? -1) +} + +/** + * @implements {API.SpaceUsageView} + */ +class UsageSession { + /** + * @param {API.Session} session + */ + constructor(session) { + this.session = session + } + + /** + * @returns {Task.Invocation} + */ + get() { + return Task.perform(get(this.session)) + } + + /** + * Get a usage report for the passed space in the given time period. + * + * @param {{from: Date, to: Date}} period + */ + report(period) { + const space = /** @type {API.DIDKey} */ (this.session.agent.signer.did()) + return Session.perform(report(this.session, { space, period })) + } +} + +/** @param {string|number|Date} now */ +const startOfMonth = (now) => { + const d = new Date(now) + d.setUTCDate(1) + d.setUTCHours(0) + d.setUTCMinutes(0) + d.setUTCSeconds(0) + d.setUTCMilliseconds(0) + return d +} + +/** @param {string|number|Date} now */ +const startOfLastMonth = (now) => { + const d = startOfMonth(now) + d.setUTCMonth(d.getUTCMonth() - 1) + return d +} diff --git a/packages/w3up-client/src/store.js b/packages/w3up-client/src/store.js new file mode 100644 index 000000000..b253be160 --- /dev/null +++ b/packages/w3up-client/src/store.js @@ -0,0 +1 @@ +export * from './store/indexed-db.js' diff --git a/packages/w3up-client/src/store.node.js b/packages/w3up-client/src/store.node.js new file mode 100644 index 000000000..5a6d33bdc --- /dev/null +++ b/packages/w3up-client/src/store.node.js @@ -0,0 +1 @@ +export * from './store/conf.js' diff --git a/packages/w3up-client/src/store/conf.js b/packages/w3up-client/src/store/conf.js new file mode 100644 index 000000000..189d14154 --- /dev/null +++ b/packages/w3up-client/src/store/conf.js @@ -0,0 +1,120 @@ +import * as API from '../types.js' +import Conf from 'conf' + +/** + * @typedef {object} Options + * @property {string} name + * @property {string} [projectName] + * @property {string} [projectSuffix] + */ + +/** + * Opens data store persisted via [conf](https://github.com/sindresorhus/conf) + * + * @example + * ```js + * import * as Store from '@web3-storage/w3up-client/store/conf' + * const store = Store.open({ name: 'default' }) + * ``` + * + * @template {Record} Model + * @param {Options} options + * @returns {API.DataStore} + */ +export const open = ({ name, projectName = 'w3access', projectSuffix = '' }) => + new ConfStore({ name, projectName, projectSuffix }) + +/** + * @template {Record} Model + * @implements {API.DataStore} + */ +export class ConfStore { + /** + * @type {Conf} + */ + #config + + /** + * @param {Required} options + */ + constructor(options) { + this.#config = new Conf({ + projectName: options.projectName, + projectSuffix: options.projectSuffix, + configName: options.name, + serialize, + deserialize, + }) + this.path = this.#config.path + } + + async connect() {} + + async close() {} + + async reset() { + this.#config.clear() + } + + /** @param {Model} data */ + async save(data) { + if (typeof data === 'object') { + data = { ...data } + for (const [k, v] of Object.entries(data)) { + if (v === undefined) { + delete data[k] + } + } + } + this.#config.set(data) + } + + /** @returns {Promise} */ + async load() { + const data = this.#config.store ?? {} + if (Object.keys(data).length === 0) return + return data + } +} + +// JSON.stringify and JSON.parse with URL, Map and Uint8Array type support. + +/** + * @param {string} k + * @param {any} v + */ +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()] } + } else if (v?.type === 'Buffer' && Array.isArray(v.data)) { + return { $bytes: v.data } + } + return v +} + +/** + * @param {string} k + * @param {any} v + */ +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 {unknown} value + * @param {number|string} [space] + */ +const serialize = (value, space) => JSON.stringify(value, replacer, space) + +/** + * @param {string} value + */ +const deserialize = (value) => JSON.parse(value, reviver) diff --git a/packages/w3up-client/src/store/indexed-db.js b/packages/w3up-client/src/store/indexed-db.js new file mode 100644 index 000000000..4d89ef3da --- /dev/null +++ b/packages/w3up-client/src/store/indexed-db.js @@ -0,0 +1,193 @@ +import * as API from '../types.js' + +// We use existing name otherwise we'll loose all the data. +const STORE_NAME = 'AccessStore' +const DATA_ID = 1 + +/** + * @typedef {object} Options + * @property {string} name + * @property {number} [version] + * @property {string} [storeName] + * @property {boolean} [autoOpen] + */ + +/** + * Data store that persists data in the IndexedDB. + * + * @example + * ```js + * import * as Store from '@web3-storage/w3up-client/store/indexed-db' + * const store = Store.open({ name: 'w3access' }) + * ``` + * + * @template Model + * @param {Options} options + * @returns {API.DataStore} + */ +export const open = (options) => new IndexedDBStore(options) + +/** + * @template Model + * @implements {API.DataStore} + */ +export class IndexedDBStore { + /** @type {string} */ + #dbName + + /** @type {number|undefined} */ + #dbVersion + + /** @type {string} */ + #dbStoreName + + /** @type {IDBDatabase|undefined} */ + #db + + /** @type {boolean} */ + #autoOpen + + /** + * @param {Options} options + */ + constructor(options) { + this.#dbName = options.name + this.#dbVersion = options.version + this.#dbStoreName = options.storeName ?? 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.connect() + } + // @ts-expect-error open sets this.#db + return this.#db + } + + async connect() { + const db = this.#db + if (db) return + + return new Promise((resolve, reject) => { + 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(undefined) + }) + + openReq.addEventListener('error', () => reject(openReq.error)) + }) + } + + async close() { + const db = this.#db + if (!db) throw new Error('Store is not open') + + db.close() + this.#db = undefined + } + + /** @param {Model} data */ + async save(data) { + const db = await this.#getOpenDB() + + const putData = withObjectStore( + db, + 'readwrite', + this.#dbStoreName, + async (store) => + new Promise((resolve, reject) => { + const putReq = store.put({ id: DATA_ID, ...data }) + putReq.addEventListener('success', () => resolve(undefined)) + putReq.addEventListener('error', () => + reject(new Error('failed to query DB', { cause: putReq.error })) + ) + }) + ) + + return await putData() + } + + async load() { + const db = await this.#getOpenDB() + + const getData = withObjectStore( + db, + 'readonly', + this.#dbStoreName, + async (store) => + new Promise((resolve, reject) => { + 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 await getData() + } + + async reset() { + const db = await this.#getOpenDB() + + const clear = withObjectStore( + db, + 'readwrite', + this.#dbStoreName, + (s) => + new Promise((resolve, reject) => { + const req = s.clear() + req.addEventListener('success', () => { + resolve(undefined) + }) + + req.addEventListener('error', () => + reject(new Error('failed to query DB', { cause: req.error })) + ) + }) + ) + + await clear() + } +} + +/** + * @template T + * @param {IDBDatabase} db + * @param {IDBTransactionMode} txnMode + * @param {string} storeName + * @param {(s: IDBObjectStore) => Promise} fn + * @returns + */ +const withObjectStore = (db, txnMode, storeName, fn) => () => + // eslint-disable-next-line no-async-promise-executor + new Promise(async (resolve, reject) => { + const tx = db.transaction(storeName, txnMode) + + /** @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() + } + }) diff --git a/packages/w3up-client/src/store/memory.js b/packages/w3up-client/src/store/memory.js new file mode 100644 index 000000000..a61d866d9 --- /dev/null +++ b/packages/w3up-client/src/store/memory.js @@ -0,0 +1,53 @@ +import * as API from '../types.js' + +/** + * Opens in-memory data store. + * + * @example + * ```js + * import * as Memory from '@web3-storage/w3up-client/store/memory' + * const store = Memory.open() + * ``` + * + * @template Model + * @returns {API.DataStore} + */ +export const open = () => + /** @type {API.DataStore} */ (new MemoryStore()) + +/** + * @template {Record} Model + * @implements {API.DataStore} + */ +class MemoryStore { + /** + * @type {Model|undefined} + */ + #data + + constructor() { + this.#data = undefined + } + + async connect() {} + + async close() {} + + async reset() { + this.#data = undefined + } + + /** + * @param {Model} data + */ + async save(data) { + this.#data = { ...data } + } + + /** @returns {Promise} */ + async load() { + if (this.#data === undefined) return + if (Object.keys(this.#data).length === 0) return + return this.#data + } +} diff --git a/packages/w3up-client/src/task.js b/packages/w3up-client/src/task.js new file mode 100644 index 000000000..8124f64d2 --- /dev/null +++ b/packages/w3up-client/src/task.js @@ -0,0 +1,370 @@ +/* eslint-disable no-constant-condition */ +/* eslint-disable require-yield */ +import * as Task from './task/task.js' +import { SUSPEND, RESUME } from './task/task.js' + +export * from './task/task.js' + +/** + * @template T + * @param {unknown|PromiseLike} value + * @returns {value is PromiseLike} + */ +const isPromiseLike = (value) => + value != null && + typeof (/** @type {{then?:unknown}} */ (value).then) === 'function' + +/** + * Takes a `Promise` value and returns a task that suspends until the promise + * is resolved and then returns the resolved value. If you pass a non-promise + * value it will return it back immediately, however typescript inference may + * get confused. + * + * @template U + * @param {U} source + * @returns {Task.Task>} + */ +export function* wait(source) { + if (isPromiseLike(source)) { + const invocation = yield* fork(suspend()) + let ok + void source.then( + (out) => { + ok = out + invocation.abort(RESUME) + }, + (error) => { + if (error instanceof AbortError) { + invocation.abort(error.reason) + } else { + invocation.abort(error) + } + } + ) + + yield* invocation + + return /** @type {any} */ (ok) + } else { + return /** @type {any} */ (source) + } +} + +/** + * Returns a task that is suspended for a given duration in milliseconds. + * + * @param {number} duration + * @returns {Task.Task} + */ +export const sleep = function* (duration) { + let id = null + try { + const invocation = yield* fork(suspend()) + id = setTimeout(() => invocation.abort(RESUME), duration) + yield* invocation + } finally { + if (id != null) { + clearTimeout(id) + } + } +} + +export const ok = Object.assign( + /** + * Takes a {@link Task.Result} value and returns a task that return `ok` value of + * the successful result or throws the `error` of the failed result. + * + * @template {unknown} Ok + * @template {{}} Fail + * @param {Task.Result} source + * @returns {Task.Task} + */ + function* ok(source) { + const { ok, error } = yield* wait(source) + if (ok) { + return ok + } else { + throw error + } + }, + { + /** + * Takes a `Promise` of the {@link Task.Result} value and returns a task that + * return `ok` value of the successful result or throws the `error` of the + * failed result. It suspends the task until the promise is resolved. + * + * @template {unknown} Ok + * @template {unknown} Fail + * @param {PromiseLike>} source + * @returns {Task.Task} + */ + *wait(source) { + const result = yield* wait(source) + if (result.ok) { + return result.ok + } else { + throw result.error + } + }, + } +) + +/** + * @template {globalThis.Error} Error + * @param {Error} error + * @returns {Task.Task} + */ +export const fail = function* (error) { + throw error +} + +/** + * Spawns a concurrent task and returns a + * + * @template Ok + * @template {globalThis.Error} Fail + * @template {Task.Suspend|Task.Join|Task.Throw} Command + * @param {() => Task.Task} work + * @returns {Task.Invocation>} + */ +export const spawn = (work) => perform(work()) + +/** + * @template Ok + * @template {globalThis.Error} Fail + * @template {Task.Suspend|Task.Join|Task.Throw} Command + * @param {Task.Task} task + * @returns {Task.Invocation>} + */ +export const perform = (task) => + /** @type {Task.Invocation>} */ ( + new Invocation(/** @type {Task.Task} */ (task)) + ) + +/** + * @template Ok + * @template {globalThis.Error} Fail + * @template {Task.Suspend|Task.Join|Task.Throw} Command + * @param {Task.Task} task + * @returns {Task.Task>>} + */ +export function* fork(task) { + return perform(task) +} + +/** + * @returns {Task.Task} + */ +export function* suspend() { + try { + while (true) { + yield SUSPEND + } + } catch (cause) { + if (/** @type {Task.AbortError} */ (cause).reason !== RESUME) { + throw cause + } + } +} + +/** + * @template Ok + * @template {globalThis.Error} Fail + * @template {Task.Suspend|Task.Join|Task.Throw} Command + * @implements {Task.Task} + */ +class Continue { + /** + * + * @param {Task.Execution} task + */ + constructor(task) { + this.task = task + } + [Symbol.iterator]() { + return this.task + } +} + +/** + * @template Ok + * @template {globalThis.Error} Fail + * @template {Task.Suspend|Task.Join|Task.Throw} [Command=Task.Suspend|Task.Join|Task.Throw] + * @implements {Task.Invocation} + * @implements {Task.Execution} + * @implements {Task.Join} + * @implements {Promise} + */ +class Invocation { + /** + * @param {Task.Task} task + */ + constructor(task) { + /** @type {Array>} */ + this.queue = [] + + this.job = task[Symbol.iterator]() + /** @type {Promise} */ + this.outcome = new Promise((succeed, fail) => { + this.succeed = succeed + this.fail = fail + }) + + /** @type {Task.Wake} */ + this.group = this + + // start a task execution on next tick + setImmediate(() => this.resume(), null) + } + + /** + * @returns {Task.Step} + */ + next() { + const { job, queue } = this + const command = queue.shift() + if (!command) { + return job.next() + } else if (command === SUSPEND) { + return { done: false, value: /** @type {Command} */ (SUSPEND) } + } else if ('throw' in /** @type {Task.Throw} */ (command)) { + return job.throw(/** @type {Task.InferError} */ (command.throw)) + } else { + throw new TypeError('Invalid command') + } + } + + /** + * @param {Ok} ok + * @returns + */ + return(ok) { + return this.job.return(ok) + } + + /** + * + * @param {Task.InferError} error + * @returns {Task.Step} + */ + throw(error) { + this.queue.push(/** @type {any} */ ({ throw: error })) + return this.next() + } + + wake() { + if (this.group === this) { + this.resume() + } else { + this.group.wake() + } + } + + resume() { + while (true) { + try { + const state = this.next() + if (state.done) { + return this.succeed(state.value) + } else if (state.value === SUSPEND) { + return + } else if (state.value?.join) { + state.value.join(this) + } else if (state.value?.throw) { + this.throw( + /** @type {Task.InferError} */ (state.value.throw) + ) + } else { + throw new RangeError('Invalid command') + } + } catch (error) { + return this.fail(error) + } + } + } + + /** + * @type {Promise['then']} + */ + then(resolve, reject) { + return this.outcome.then(resolve, reject) + } + /** + * @type {Promise['catch']} + */ + catch(reject) { + return this.outcome.catch(reject) + } + /** + * @type {Promise['finally']} + */ + finally(onFinally) { + return this.outcome.finally(onFinally) + } + + [Symbol.toStringTag] = 'TaskInvocation' + + /** + * @returns {Task.Invocation>>} + */ + result() { + return perform( + wait( + this.then( + (ok) => ({ ok }), + (error) => ({ error }) + ) + ) + ) + } + + /** + * + * @param {unknown} reason + */ + abort(reason) { + this.queue.push( + /** @type {Task.Throw} */ ({ + throw: new AbortError(reason), + }) + ) + this.wake() + } + + /** + * @param {Task.Wake} group + */ + join(group) { + this.group = group + } + + /** + * Joins the task into the currently running task. + * + * @returns {Task.Execution} + */ + *[Symbol.iterator]() { + // eslint-disable-next-line jsdoc/no-undefined-types + yield /** @type {Command} */ (/** @type {Task.Join} */ (this)) + // We wrap this in a `Continue` because yield* will call [Symbol.iterator] + // to get an iterator to iterate over. We wrap it in a `Continue` to avoid + // infinite loop. + return yield* new Continue(this) + } +} + +export class AbortError extends Error { + /** + * @param {unknown} reason + */ + constructor(reason) { + super(`Task was aborted\n${String(reason)}`) + this.reason = reason + } + name = /** @type {const} */ ('AbortError') +} + +/** @type {(callback: (context:T) => void, context:T) => unknown} */ +const setImmediate = + /* c8 ignore next */ + globalThis.setImmediate || ((fn, arg) => Promise.resolve(arg).then(fn)) diff --git a/packages/w3up-client/src/task/constant.js b/packages/w3up-client/src/task/constant.js new file mode 100644 index 000000000..7c08b5ce1 --- /dev/null +++ b/packages/w3up-client/src/task/constant.js @@ -0,0 +1,2 @@ +export const SUSPEND = Symbol.for('Task.suspend') +export const RESUME = Symbol.for('Task.resume') diff --git a/packages/w3up-client/src/task/task.js b/packages/w3up-client/src/task/task.js new file mode 100644 index 000000000..646b52571 --- /dev/null +++ b/packages/w3up-client/src/task/task.js @@ -0,0 +1 @@ +export * from './constant.js' diff --git a/packages/w3up-client/src/task/task.ts b/packages/w3up-client/src/task/task.ts new file mode 100644 index 000000000..6c9687e58 --- /dev/null +++ b/packages/w3up-client/src/task/task.ts @@ -0,0 +1,79 @@ +import type { Result } from '@ucanto/interface' +import { SUSPEND, RESUME } from './constant.js' + +export { SUSPEND, RESUME, Result } +export type Suspend = typeof SUSPEND + +export interface Throw { + join?: never + throw: Error | AbortError +} + +export interface Join { + join(group: Wake): void +} + +export type Command = Suspend | Throw | Join + +export interface Task< + Ok, + Error extends globalThis.Error = never, + Command extends Suspend | Join | Throw = Suspend | Join | Throw +> { + [Symbol.iterator](): Execution +} + +export interface Execution< + Ok extends unknown, + Command extends Suspend | Join | Throw +> { + throw(error: InferError): Step + return(ok: Ok): Step + next(): Step + [Symbol.iterator](): Execution +} + +/** + * Wake handler which can be used to wake the suspended task. + */ +export interface Wake { + wake(): void +} + +export type Step< + Ok extends unknown, + Command extends Suspend | Join | Throw +> = IteratorResult + +export type InferError = Command extends Throw + ? Error + : never + +/** + * Future is a type safe promise as it captures both success and error types and + * provides functionality to move from try/catch operating mode into `Result` + * based one. + */ +export interface Future + extends Promise { + /** + * Returns a promise for the `Result` that captures both success or error + * cases. + */ + result(): Invocation> +} + +export interface AbortError extends Error { + name: 'AbortError' + reason: unknown +} + +export interface Invocation< + Ok extends unknown, + Fail extends globalThis.Error = never +> extends Future, + Task { + abort(reason: unknown): void + + [Symbol.iterator](): Execution> +} diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 239b23f70..714c2f7c7 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -1,9 +1,46 @@ import { type Driver } from '@web3-storage/access/drivers/types' +import { Querier, Transactor, Selector, Clause } from 'datalogia' + import { - type Service as AccessService, - type AgentDataExport, -} from '@web3-storage/access/types' -import { type Service as UploadService } from '@web3-storage/upload-client/types' + StoreAdd, + StoreAddSuccess, + StoreAddSuccessUpload, + StoreAddSuccessDone, + StoreGet, + StoreGetFailure, + StoreList, + StoreListSuccess, + StoreListItem, + StoreRemove, + StoreRemoveSuccess, + StoreRemoveFailure, + UploadAdd, + UploadAddSuccess, + UploadList, + UploadListSuccess, + UploadListItem, + UploadRemove, + UploadRemoveSuccess, + ListResponse, + CARLink, + PieceLink, + StoreGetSuccess, + UploadGet, + UploadGetSuccess, + UploadGetFailure, + UsageReport, + UsageReportSuccess, + UsageReportFailure, + PlanNotFound, + FilecoinOffer as FilecoinOfferCapability, + FilecoinOfferSuccess, + FilecoinOfferFailure, + FilecoinInfo as FilecoinInfoCapability, + FilecoinInfoSuccess, + FilecoinInfoFailure, +} from '@web3-storage/capabilities/types' + +export type { Querier, Transactor } import type { ConnectionView, Signer, @@ -11,29 +48,273 @@ import type { Ability, Resource, Unit, + Phantom, + Principal, + Capabilities, + Delegation, + Fact, + SignerArchive, + ServiceMethod, + SigAlg, + Link, + ToString, + Failure, + Caveats, + UnknownMatch, + Match, + CapabilityParser, + InferInvokedCapability, + Variant, + Result, + IPLDBlock, + DIDKey, + Protocol, + Capability, + InvocationError, + MultihashDigest, + Receipt, + Tuple, + InferReceipt, } from '@ucanto/interface' + +import type { + Abilities, + AccessAuthorize, + AccessAuthorizeSuccess, + AccessAuthorizeFailure, + AccessClaim, + AccessClaimSuccess, + AccessClaimFailure, + AccessConfirm, + AccessConfirmSuccess, + AccessConfirmFailure, + AccessDelegate, + AccessDelegateSuccess, + AccessDelegateFailure, + ProviderAdd, + ProviderAddSuccess, + ProviderAddFailure, + SpaceInfo, + SpaceInfoFailure, + SubscriptionList, + SubscriptionListSuccess, + SubscriptionListFailure, + PlanGet, + PlanGetSuccess, + PlanGetFailure, + UCANRevoke, + UCANRevokeSuccess, + UCANRevokeFailure, + ConsoleLog, + ConsoleLogOk, + ConsoleError, + ConsoleErrorError, + AccountDID, + ProviderDID, + AccessDenied, + SpaceDID, + UsageData, + Space, +} from '@web3-storage/capabilities' import { type Client } from './client.js' -import { StorefrontService } from '@web3-storage/filecoin-client/storefront' +import { StorefrontService } from '@web3-storage/filecoin-client/types' +import * as Task from './task/task.js' +export { Task } + +import { CID } from 'multiformats' +import { Block } from '@ipld/car/buffer-reader' +import { EmailAddress, DidMailto } from '@web3-storage/did-mailto' +import { UTCUnixTimestamp, Signer as UCANSigner } from '@ipld/dag-ucan' +import { Storefront } from '@web3-storage/filecoin-client' +import { AbortError } from './task.js' +import exp from 'constants' +import { info } from 'console' + +export * from '@ipld/dag-ucan' export * from '@ucanto/interface' export * from '@web3-storage/did-mailto' -export type { Agent, CapabilityQuery } from '@web3-storage/access/agent' +export * from '@web3-storage/capabilities' + +// specify exact exports to avoid ambiguity export type { - Access, - AccountDID, - ProviderDID, - SpaceDID, -} from '@web3-storage/access/types' + Link, + Signer, + Await, + Tuple, + Verifier, + ToString, + View, +} from '@ucanto/interface' + +export type { UCAN } from '@web3-storage/capabilities' + +export type { Driver as Storage } export type ProofQuery = Record> -export type Service = AccessService & UploadService & StorefrontService +/** + * Indicates failure executing ability that requires access to a space that is not well-known enough to be handled. + * e.g. it's a space that's never been seen before, + * or it's a seen space that hasn't been fully registered such that the service can serve info about the space. + */ +export interface SpaceUnknown extends Failure { + name: 'SpaceUnknown' +} + +export interface SpaceInfoSuccess { + // space did + did: DID<'key'> + providers: Array> +} + +export interface UCANProtocol { + ucan: { + revoke: ServiceMethod + } +} + +export interface AccessAuthorizeProvider { + access: { + authorize: ServiceMethod< + AccessAuthorize, + AccessAuthorizeSuccess, + AccessAuthorizeFailure + > + } +} + +export interface AccessRequestProvider { + access: { + authorize: AccessAuthorizeProvider['access']['authorize'] + claim: AccessClaimProvider['access']['claim'] + } +} + +export interface AccessClaimProvider { + access: { + claim: ServiceMethod + } +} + +export interface AccessDelegateProvider { + access: { + delegate: ServiceMethod< + AccessDelegate, + AccessDelegateSuccess, + AccessDelegateFailure + > + } +} + +export interface AccessConfirmProvider { + access: { + confirm: ServiceMethod< + AccessConfirm, + AccessConfirmSuccess, + AccessConfirmFailure + > + } +} + +export interface PlanProtocol { + plan: { + get: ServiceMethod + } +} + +export interface AccessProtocol { + access: AccessAuthorizeProvider['access'] & + AccessClaimProvider['access'] & + AccessDelegateProvider['access'] & + AccessConfirmProvider['access'] +} + +export interface SpaceProtocol { + space: { + info: ServiceMethod + } +} + +export interface ProviderProtocol { + provider: { + add: ServiceMethod + } +} + +export interface SubscriptionProtocol { + subscription: { + list: ServiceMethod< + SubscriptionList, + SubscriptionListSuccess, + SubscriptionListFailure + > + } +} + +export interface ConsoleProtocol { + console: { + log: ServiceMethod + error: ServiceMethod + } +} + +export interface FilecoinProtocol { + filecoin: { + offer: StorefrontService['filecoin']['offer'] + info: StorefrontService['filecoin']['info'] + } +} + +export type { + FilecoinOfferSuccess, + FilecoinOfferFailure, + FilecoinInfoSuccess, + FilecoinInfoFailure, +} + +export type FilecoinOffer = FilecoinOfferCapability['nb'] +export type FilecoinInfo = FilecoinInfoCapability['nb'] + +export interface StoreProtocol { + store: { + add: ServiceMethod + get: ServiceMethod + remove: ServiceMethod + list: ServiceMethod + } +} + +export interface UploadProtocol { + upload: { + add: ServiceMethod + get: ServiceMethod + remove: ServiceMethod + list: ServiceMethod + } +} -export interface ServiceConf { - access: ConnectionView - upload: ConnectionView - filecoin: ConnectionView +export interface UsageProtocol { + usage: { + report: ServiceMethod + } } +/** + * Access api service definition type + */ +export interface W3UpProtocol + extends UCANProtocol, + AccessProtocol, + PlanProtocol, + SpaceProtocol, + ProviderProtocol, + SubscriptionProtocol, + StoreProtocol, + UploadProtocol, + UsageProtocol, + ConsoleProtocol, + FilecoinProtocol {} + export interface ClientFactoryOptions { /** * A storage driver that persists exported agent data. @@ -42,7 +323,7 @@ export interface ClientFactoryOptions { /** * Service DID and URL configuration. */ - serviceConf?: ServiceConf + // serviceConf?: ServiceConf /** * Use this principal to sign UCANs. Note: if the store is non-empty and the * principal saved in the store is not the same principal as the one passed @@ -85,27 +366,17 @@ export type { PlanGet, PlanGetSuccess, PlanGetFailure, - FilecoinOffer, - FilecoinOfferSuccess, - FilecoinOfferFailure, FilecoinSubmit, FilecoinSubmitSuccess, FilecoinSubmitFailure, FilecoinAccept, FilecoinAcceptSuccess, FilecoinAcceptFailure, - FilecoinInfo, - FilecoinInfoSuccess, - FilecoinInfoFailure, + UsageData, + UsageReportSuccess, + UsageReportFailure, } from '@web3-storage/capabilities/types' -export type { - AgentDataModel, - AgentDataExport, - AgentMeta, - DelegationMeta, -} from '@web3-storage/access/types' - export type { StoreAddSuccess, StoreGetSuccess, @@ -119,8 +390,6 @@ export type { UploadRemoveSuccess, UploadListSuccess, UploadListItem, - UsageReportSuccess, - UsageReportFailure, ListResponse, AnyLink, CARLink, @@ -136,7 +405,1010 @@ export type { ShardStoringOptions, UploadOptions, UploadDirectoryOptions, - FileLike, - BlobLike, ProgressStatus, } from '@web3-storage/upload-client/types' + +export type EncodedDelegation = string & + Phantom + +export type BytesDelegation = + Uint8Array & Phantom> + +export type ResourceQuery = Resource | RegExp + +/** + * Agent class types + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface AgentOptions> { + url?: URL + connection?: ConnectionView + servicePrincipal?: Principal + receiptsEndpoint?: URL +} + +export interface AgentDataOptions { + store?: Driver +} + +/** + * Space metadata + */ +export interface SpaceMeta { + /** + * Human readable name for the space + */ + name: string +} + +/** + * Agent metadata used to describe an agent ("audience") + * with a more human and UI friendly data + */ +export interface AgentMeta { + name: string + description?: string + url?: URL + image?: URL + type: 'device' | 'app' | 'service' +} + +/** + * Utility types + */ + +export interface UCANBasicOptions { + /** + * Audience Principal (DID), + * Defaults to "Access service DID" + * + * @see {@link https://github.com/ucan-wg/spec#321-principals Spec} + */ + audience?: Principal + /** + * UCAN lifetime in seconds + */ + lifetimeInSeconds?: number + /** + * Unix timestamp when the UCAN is no longer valid + * + * Expiration overrides `lifetimeInSeconds` + * + * @see {@link https://github.com/ucan-wg/spec#322-time-bounds Spec} + */ + expiration?: number + /** + * Unix timestamp when the UCAN becomas valid + * + * @see {@link https://github.com/ucan-wg/spec#322-time-bounds Spec} + */ + notBefore?: number + /** + * Nonce, a randomly generated string, used to ensure the uniqueness of the UCAN. + * + * @see {@link https://github.com/ucan-wg/spec#323-nonce Spec} + */ + nonce?: string + /** + * Facts, an array of extra facts or information to attach to the UCAN + * + * @see {@link https://github.com/ucan-wg/spec#324-facts Spec} + */ + facts?: Fact[] +} + +export type DelegateOptions = UCANBasicOptions & { + audience: Principal + /** + * Abilities to delegate + */ + abilities: Abilities[] + /** + * Metadata about the audience + */ + audienceMeta: AgentMeta +} + +export type InvokeOptions< + A extends Ability, + R extends Resource, + CAP extends CapabilityParser< + Match<{ can: A; with: R & Resource; nb: Caveats }, UnknownMatch> + > +> = UCANBasicOptions & + InferNb['nb']> & { + /** + * Resource for the capability, normally a Space DID + * Defaults to the current selected Space + */ + with?: R + + /** + * Extra proofs to be added to the invocation + */ + proofs?: Delegation[] + } + +/** + * Given an inferred capability infers if the nb field is optional or not + */ +export type InferNb | undefined> = + keyof C extends never + ? { + nb?: never + } + : { + /** + * Non-normative fields for the capability + * + * Check the capability definition for more details on the `nb` field. + * + * @see {@link https://github.com/ucan-wg/spec#241-nb-non-normative-fields Spec} + */ + nb: C + } + +/** + * Delegation metadata + */ +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 +} + +/** + * Data schema used internally by the agent. + */ +export interface AgentDataModel { + meta: AgentMeta + principal: Signer> + /** @deprecated */ + currentSpace?: DID<'key'> + /** @deprecated */ + spaces: Map + delegations: Map +} + +/** + * Agent data that is safe to pass to structuredClone() and persisted by stores. + */ +export type AgentDataExport = Pick< + AgentDataModel, + 'meta' | 'currentSpace' | 'spaces' +> & { + principal: SignerArchive + delegations: Map< + CIDString, + { + meta: DelegationMeta + delegation: Array<{ cid: CIDString; bytes: Uint8Array }> + } + > +} + +/** + * Schema types + * + * Interfaces for data structures used in the client + * + */ + +export type CIDString = ToString + +export interface CapabilityQuery { + with: ResourceQuery + can?: AbilityQuery + nb?: unknown +} + +export type AbilityQuery = Ability | RegExp + +/** + * Describes level of access to a resource. + */ +export type Access = + // This complicates type workarounds the issue with TS which will would have + // complained about missing `*` key if we have used `Record` + // instead. + Record, Unit> & { + ['*']?: Unit + } + +export type LikePattern = string + +export type GlobPattern = string + +export type TextConstraint = + | Variant<{ + like: LikePattern + glob: GlobPattern + '=': string + }> + | (string & { like?: undefined; glob?: undefined; ['=']?: undefined }) + +/** + * In the future, we want to implement AccessRequestSchema per spec, but for + * now we do not support passing any clauses. + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export type CapabilityConstraint = Variant<{}> + +/** + * Describes level of access to a resource. + */ +export type Can = + // This complicates type workarounds the issue with TS which will would have + // complained about missing `*` key if we have used `Record` + // instead. + Record, CapabilityConstraint[]> & { + ['*']?: CapabilityConstraint[] + } + +export interface DataStore { + /** + * Open driver + */ + connect: () => Promise + /** + * Clean up and close driver + */ + close: () => Promise + /** + * Persist data to the driver's backend + */ + save: (data: Model) => Promise + /** + * Loads data from the driver's backend + */ + load: () => Promise + /** + * Clean all the data in the driver's backend + */ + reset: () => Promise +} + +export interface StoredDelegation { + meta: DelegationMeta + delegation: Delegation +} +export interface StoredProofs extends Map {} + +/** + * An {@link IPLDBlock} formatted for storage, making it compatible with + * `structuredClone()` used by `indexedDB`. + */ +export interface BlockArchive { + cid: CIDString + bytes: Uint8Array +} + +/** + * A {@link Delegation} formatted for storage, making it compatible with + * `structuredClone()` used by `indexedDB`. + */ +export interface DelegationArchive extends Array {} + +/** + * {@link StoredDelegation} formatted for storage, making it compatible with + * `structuredClone()` used by `indexedDB`. + */ +export interface StoredDelegationArchive { + meta: DelegationMeta + delegation: DelegationArchive +} + +/** + * Snapshot of the agent database state that can be persisted into a store. + */ +export interface DatabaseArchive { + meta?: AgentMeta + principal?: SignerArchive + delegations: Map +} + +/** + * Database consists of `proofs` and an `index` of those proofs used for + * querying. We may drop `proofs` in the future and persist `index` directly, + * but right now we keep them both around. + * + * For legacy reason database also stores key material and an agent metadata. + */ +export interface Database { + meta: AgentMeta + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer?: SignerArchive + proofs: StoredProofs + + index: Querier + transactor: Transactor + + store?: DataStore +} + +export interface Query< + Select extends Selector, + Where extends Clause[] = Clause[] +> { + select: Select + where: Where +} + +/** + * Database transaction is a list of instructions that update database state. + */ +export interface DBTransaction extends Iterable {} + +/** + * Database instruction is either a single assertion (insert) or (retraction) + * that either adds or removes facts into the database. + */ +export type DBInstruction = Variant<{ + assert: DBAssertion + retract: DBAssertion +}> + +/** + * Database assertion describes set of facts to be added to the database. It can + * either be a `proof` assertion that adds {@link Delegation} and associated + * facts to the database or a `signer` assertion that overrides signer keypair + * material stored in database. + */ +export type DBAssertion = Variant<{ + proof: Delegation + signer: SignerArchive +}> + +export interface Address + extends Phantom { + id: Principal + url: URL +} + +export interface AddressArchive< + Protocol extends UnknownProtocol = UnknownProtocol +> extends Phantom { + id: DID + url: ToString +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface UnknownProtocol extends Record {} + +export interface AgentOpen { + as?: Signer + store: DataStore +} + +export interface AgentLoad { + as?: Signer + store: DataStore +} + +export interface AgentCreate { + as?: Signer + store: DataStore +} +export type AgentFrom = Variant<{ + load: AgentLoad + open: AgentOpen + create: AgentCreate +}> + +export interface W3UpOpen + extends AgentOpen { + connection?: Connection +} + +export interface W3UpLoad + extends AgentLoad { + connection?: Connection +} + +export interface W3UpCreate + extends AgentCreate { + connection?: Connection +} + +export type W3UpFrom = + Variant<{ + load: W3UpLoad + open: W3UpOpen + create: W3UpCreate + }> + +/** + * W3Up is the interface that main library module implements. + */ +export interface W3Up { + /** + * Restores an archived agent from the given {@link DataStore} or creates a + * new one if storage contains no agent data yet. If optional {@link Signer} + * is provided agent will act on its behalf, otherwise it will create a new + * keypair and use it as the signing authority. + * + * If {@link Signer} is provided, it will attempt to load the keypair from the + * store and if store does not contain a keypair, it will generate a new one + * and store it in the store. If you do provide a {@link Signer} it will not + * be persisted in the store. + * + * If {@link DataStore} is not provided, ephemeral agent is returned, meaning + * no keypair or delegations will be persisted. + * + * If you want to restore archived agent without creating one you should use + * the `load` method instead. + */ + open(source: W3UpOpen): AgentView + + /** + * Loads archived agent from the given {@link DataStore}. If optional + * {@link Signer} is provided returned agent will act on its behalf, but + * corresponding keypair will not be persisted in the agent store. + * + * If you do not pass a signer and one is not persisted in the store load will + * fail. + */ + load(source: W3UpLoad): AgentView + + /** + * Creates a new agent that will be persisted in the given {@link DataStore}. + * If you do not pass an optional {@link Signer}, new one will be generated, + * either way {@link Signer} will be persisted in the given store. + * + * If you want to create an ephemeral agent use `open` method without passing + * the store. + * + * ⚠️ Please note that if store already contains a principal calling this + * method will overwrite it. + */ + create(source: W3UpCreate): AgentView + + /** + * General function that does `load`, `create` or `open` based on input. + */ + from(source: W3UpFrom): AgentView +} + +export interface Agent { + signer: Signer + + /** + * DB used to persist agent delegations and signing authority. + */ + db: Database +} + +/** + * Agent is effectively a signing authority coupled with a persisted or + * ephemeral database of (UCAN) delegations. It can be used to query + * capabilities or issue authorizations. It's primary use case is to + * create sessions with service providers that can be used to invoke + * provided capabilities on behalf of the signing authority. + */ +export interface AgentView extends Agent { + did(): DIDKey + + /** + * Connects to a service provider and returns a session that can be used to + * invoke capabilities provided by the service. + */ + connect( + connection?: Connection + ): W3UpSession + + authorize(access: { + subject: DID + can: Can + }): Task.Invocation +} + +export type ConnectError = + | SignerLoadError + | DataStoreOpenError + | DataStoreSaveError + | DatabaseTransactionError + +/** + * Error occurs when session is loaded from session store that does not store + * a principal. + */ +export interface SignerLoadError extends Failure { + name: 'SignerLoadError' +} + +export interface DataStoreOpenError extends Failure { + name: 'DataStoreOpenError' +} + +export interface DataStoreSaveError extends Failure { + name: 'DataStoreSaveError' +} + +export interface DatabaseTransactionError extends Failure { + name: 'DatabaseTransactionError' +} + +/** + * Session an agent has with a service provider. + */ +export interface Session { + agent: Agent + connection: Connection +} + +export type Connection = + | Online + | Offline + +export interface Online + extends ConnectionView { + address: Address +} + +export interface Offline + extends Phantom { + id: Principal + address: Address + channel?: never +} + +export interface OfflineError extends Error { + name: 'OfflineError' +} + +export type InferReceiptOk< + C extends Capability, + Protocol extends UnknownProtocol = W3UpProtocol, + R = InferReceipt +> = R extends Receipt ? Ok : never + +export type InferReceiptError< + C extends Capability, + Protocol extends UnknownProtocol = W3UpProtocol, + R = InferReceipt +> = R extends Receipt + ? Exclude + : never + +export interface TaskInvocation< + Ok extends {}, + Err extends Error, + Fail extends Error +> extends Task.Invocation { + receipt(): Task.Invocation, Fail> +} + +export interface W3UpSession + extends Session { + agent: AgentView + spaces: SpaceManager + accounts: AccountManager + + coupons: CouponAPI +} + +export interface CouponSession + extends Session, + Coupon { + spaces: SpaceManager + accounts: AccountManager + + redeem( + session: Session + ): Task.Invocation, Error | Task.AbortError> + + archive(): Task.Invocation +} + +export interface CouponAPI { + issue(access: { + subject: DID + can: Can + expiration?: UTCUnixTimestamp + notBefore?: UTCUnixTimestamp + secret?: string + }): Task.Invocation, Error> + redeem( + coupon: Uint8Array, + options?: { secret?: string } + ): Task.Invocation, Error> + + add( + coupon: Coupon + ): Task.Invocation< + Unit, + DatabaseTransactionError | DataStoreSaveError | RangeError + > + remove( + coupon: Coupon + ): Task.Invocation +} + +export interface Coupon { + signer: Signer + proofs: [Delegation] +} + +export interface CouponView extends Coupon { + archive(): Task.Invocation + + connect( + connection: Connection + ): CouponSession + + redeem( + session: Session + ): Task.Invocation, Error> +} + +export interface SpaceManager extends Iterable { + create(source: { name: string }): Task.Invocation + list(): Record + + add(space: SpaceView): Task.Invocation + remove(space: SpaceView): Task.Invocation +} + +export type SpaceStoreError = + | PrincipalAlignmentError + | DatabaseTransactionError + | DataStoreSaveError + +export interface PrincipalAlignmentError extends Error { + name: 'PrincipalAlignmentError' + expect: DID + actual: DID +} + +export interface AccountManager extends Iterable { + login(source: { + email: EmailAddress + signal?: AbortSignal + }): Task.Invocation< + AccountView, + AccessDenied | InvocationError | AccessAuthorizeFailure + > + + list(): Record + + get(email: EmailAddress): AccountView | undefined + + add( + account: AccountView + ): Task.Invocation< + Unit, + DataStoreSaveError | DatabaseTransactionError | AbortError + > + + remove( + account: AccountView + ): Task.Invocation +} + +export interface Authorization { + /** + * Principal that is authorized. + */ + authority: DID + /** + * Resource that principal is authorized to invoke capabilities on. + */ + subject: DID + /** + * Capabilities that `authority` has been granted authorization on the + * `subject`. + */ + can: Can + + /** + * Set of proofs representing this authorization. + */ + proofs: Delegation[] +} + +/** + * Describes limits of the subscription e.g. how much content can be stored + * in billing cycle. + * + * At the moment we do not support any limits which is why only allowed value + * is an empty object. + */ +export interface Limit extends Record {} + +export type AccountProtocol = { + access: AccessRequestProvider['access'] + plan: PlanProtocol['plan'] + provider: ProviderProtocol['provider'] + subscription: SubscriptionProtocol['subscription'] +} + +export interface AccountSession< + Protocol extends UnknownProtocol = UnknownProtocol +> { + did(): AccountDID + session: Session +} + +export interface AccountView { + did(): AccountDID + proofs: Delegation[] + + toEmail(): EmailAddress + + plans: AccountPlans + spaces: SpaceManager +} + +export interface AccountPlans { + list(): Task.Invocation< + AccountPlanList, + | AccessDenied + | PlanNotFound + | OfflineError + | InvocationError + | Task.AbortError + > +} + +export interface AccountPlanList extends Iterable { + [key: string]: BillingPlan +} + +export interface BillingPlan { + customer: AccountDID + provider: ProviderDID + + subscriptions: AccountSubscriptions +} + +export interface BillingPlanSession< + Protocol extends SubscriptionProtocol & + ProviderProtocol = SubscriptionProtocol & ProviderProtocol +> { + provider: ProviderDID + account: AccountSession +} + +export interface AccountSubscriptions { + add(subscription: { + consumer: SpaceDID + limit?: Limit + }): TaskInvocation< + Unit, + ProviderAddFailure | InvocationError, + OfflineError | AccessDenied + > + + list(): Task.Invocation< + Subscriptions, + SubscriptionListFailure | InvocationError | AccessDenied | OfflineError + > +} + +export interface Subscription { + provider: ProviderDID + customer: AccountDID + consumer: SpaceDID + limit: Limit +} + +export interface Subscriptions extends Iterable { + [key: string]: Subscription +} + +export interface SpaceSession + extends Session { + name: string + did(): DIDKey +} + +export interface SpaceUploadsView { + create(source: UploadSource): UploadSession + + add(upload: Upload): Promise> + remove(upload: Upload): Promise> + list(): Promise, Upload>, never>> +} + +export interface BlobLike { + /** + * Returns a ReadableStream which yields the Blob data. + */ + stream: () => ReadableStream +} + +export interface FileLike extends BlobLike { + /** + * Name of the file. May include path information. + */ + name: string +} + +export type UploadSource = Variant<{ + blob: BlobLike + directory: FileLike[] +}> + +export interface Upload { + shard: Link[] + root: Link +} + +export interface UploadSession { + store(): Promise> + + upload(): Promise> +} + +export interface SpaceBlobsView { + allocate(source: { + hash: MultihashDigest + size: number + }): Promise< + Result< + Allocation | Nope, + AccessDenied | InvocationError | DataStoreSaveError + > + > + + list(): Promise< + Result< + Record, BlobInfo>, + AccessDenied | InvocationError + > + > + remove( + hash: MultihashDigest + ): Promise> +} + +export interface BlobInfo {} + +export interface Uploader { + /** + * Writes contents of the upload to the space without adding it to the upload + * list. + */ + store(space?: SpaceView): Promise + /** + * Writes content of the upload to the space and adds it to the upload list. + */ + upload(space?: SpaceView): Promise +} + +interface FileUploader extends Uploader {} + +interface DirectoryUploader extends Uploader {} + +interface ArchiveUploader extends Uploader {} + +interface Nope { + status: 'done' +} + +interface Allocation { + status: 'pending' + size: number + + write( + blob: StreambleBytes + ): Promise> +} + +interface StreambleBytes { + stream(): ReadableStream +} + +export interface SpaceDelegationsView { + add( + authorization: Authorization + ): TaskInvocation< + Unit, + AccessDelegateFailure | InvocationError, + AccessDenied | OfflineError + > +} + +export interface SpaceFilecoinView { + offer( + input: FilecoinOffer + ): TaskInvocation< + FilecoinOfferSuccess, + FilecoinOfferFailure | InvocationError, + AccessDenied | OfflineError + > + + info( + input: FilecoinInfo + ): TaskInvocation< + FilecoinInfoSuccess, + FilecoinInfoFailure | InvocationError, + AccessDenied | OfflineError + > +} + +export interface SpaceUsageView { + report(period: { + from: Date + to: Date + }): TaskInvocation< + UsageReportSuccess, + UsageReportFailure | InvocationError, + AccessDenied | OfflineError + > + get(): Task.Invocation< + bigint, + UsageReportFailure | InvocationError | AccessDenied | OfflineError + > +} + +export interface SpaceAccess { + audience: Principal + can?: Can + expiration?: UTCUnixTimestamp + notBefore?: UTCUnixTimestamp +} + +export interface SpaceRecovery { + audience: Principal + expiration?: UTCUnixTimestamp + notBefore?: UTCUnixTimestamp +} + +export interface ShareAccess extends SpaceAccess { + audience: Signer +} + +export interface SpaceView { + authority: DID + name: string + + did(): DIDKey + info(): TaskInvocation< + SpaceInfoSuccess, + SpaceInfoFailure | InvocationError, + AccessDenied | OfflineError + > + + authorize(access: SpaceAccess): Task.Invocation + + share(access: SpaceAccess): Task.Invocation + + connect< + Protocol extends SpaceProtocol & + UsageProtocol & + AccessProtocol & + FilecoinProtocol + >( + connection: Connection + ): SpaceView + + proofs: Delegation[] + + // APIs + usage: SpaceUsageView + delegations: SpaceDelegationsView + + filecoin: SpaceFilecoinView + // blobs: SpaceBlobsView +} + +export interface OwnSpaceView extends SpaceView { + rename(name: string): OwnSpaceView + toMnemonic(): string + createRecovery( + access: SpaceRecovery + ): Task.Invocation + + connect< + Protocol extends SpaceProtocol & + UsageProtocol & + AccessProtocol & + FilecoinProtocol + >( + connection: Connection + ): OwnSpaceView +} diff --git a/packages/w3up-client/src/view/account.js b/packages/w3up-client/src/view/account.js new file mode 100644 index 000000000..b17baf7a8 --- /dev/null +++ b/packages/w3up-client/src/view/account.js @@ -0,0 +1,237 @@ +import * as API from '../types.js' +import * as Access from '../capability/access.js' +import * as Plan from '../capability/plan.js' +import * as Subscription from '../capability/subscription.js' +import { add as provision, AccountDID } from '../capability/provider.js' +import { fromEmail, toEmail } from '@web3-storage/did-mailto' +import { Delegation } from '@ucanto/core' +import * as Result from '../result.js' + +export { fromEmail } + +class View { + /** + * @param {API.Session} session + */ + constructor(session) { + this.session = session + } + list() { + return list(this.session) + } + login(email, options) { + return login(this.session, email, options) + } +} + +/** + * List all accounts that agent has stored access to. Returns a dictionary + * of accounts keyed by their `did:mailto` identifier. + * + * @param {API.Session} session + * @param {object} query + * @param {API.DID<'mailto'>} [query.account] + */ +export const list = (session, { account } = {}) => { + const query = /** @type {API.CapabilityQuery} */ ({ + with: account ?? /did:mailto:.*/, + can: '*', + }) + + const proofs = agent.proofs([query]) + /** @type {Record} */ + const accounts = {} + /** @type {Record} */ + const attestations = {} + for (const proof of proofs) { + const access = Delegation.allows(proof) + for (const [resource, abilities] of Object.entries(access)) { + if (AccountDID.is(resource) && abilities['*']) { + const id = /** @type {API.DidMailto} */ (resource) + + const account = + accounts[id] || + (accounts[id] = new Account({ id, agent, proofs: [] })) + account.addProof(proof) + } + + for (const settings of /** @type {{proof?:API.Link}[]} */ ( + abilities['ucan/attest'] || [] + )) { + const id = settings.proof + if (id) { + attestations[`${id}`] = proof + } + } + } + } + + for (const account of Object.values(accounts)) { + for (const proof of account.proofs) { + const attestation = attestations[`${proof.cid}`] + if (attestation) { + account.addProof(attestation) + } + } + } + + return accounts +} + +/** + * Attempts to obtains an account access by performing an authentication with + * the did:mailto account corresponding to given email. Process involves out + * of bound email verification, so this function returns a promise that will + * resolve to an account only after access has been granted by the email owner + * by clicking on the link in the email. If the link is not clicked within the + * authorization session time bounds (currently 15 minutes), the promise will + * resolve to an error. + * + * @param {API.Session} session + * @param {API.EmailAddress} email + * @param {object} [options] + * @param {AbortSignal} [options.signal] + * @returns {Promise>} + */ +export const login = async (session, email, options = {}) => { + const account = fromEmail(email) + + // If we already have a session for this account we + // skip the authentication process, otherwise we will + // end up adding more UCAN proofs and attestations to + // the store which we then will be sending when using + // this account. + // Note: This is not a robust solution as there may be + // reasons to re-authenticate e.g. previous session is + // no longer valid because it was revoked. But dropping + // revoked UCANs from store is something we should do + // anyway. + const login = list(session, { account })[account] + if (login) { + return { ok: login } + } + + const result = await Access.request(session, { + account, + access: Access.accountAccess, + }) + + const { ok: access, error } = result + /* c8 ignore next 2 - don't know how to test this */ + if (error) { + return { error } + } else { + const { ok, error } = await access.claim({ signal: options.signal }) + /* c8 ignore next 2 - don't know how to test this */ + if (error) { + return { error } + } else { + return { ok: new Account({ proofs: ok.proofs, session }) } + } + } +} + +/** + * @typedef {object} Model + * @property {API.Session} session + * @property {API.Tuple} proofs + */ + +export class Account { + /** + * @param {Model} model + */ + constructor(model) { + this.model = model + this.plan = new AccountPlan(model) + } + get session() { + return this.model.session + } + get agent() { + return this.model.session.agent + } + get proofs() { + return this.model.proofs + } + + did() { + return /** @type {API.DidMailto} */ (this.model.proofs[0].issuer.did()) + } + + toEmail() { + return toEmail(this.did()) + } + + /** + * @param {API.Delegation} proof + */ + addProof(proof) { + this.proofs.push(proof) + } + + toJSON() { + return { + id: this.did(), + proofs: this.proofs + // we sort proofs to get a deterministic JSON representation. + .sort((a, b) => a.cid.toString().localeCompare(b.cid.toString())) + .map((proof) => proof.toJSON()), + } + } + + /** + * Provisions given `space` with this account. + * + * @param {API.SpaceDID} space + * @param {object} input + * @param {API.ProviderDID} [input.provider] + * @param {API.AgentView} [input.agent] + */ + provision(space, input = {}) { + return provision(this.agent, { + ...input, + account: this.did(), + consumer: space, + proofs: this.proofs, + }) + } + + /** + * Saves account in the agent store so it can be accessed across sessions. + * + * @param {object} input + * @param {API.Agent} [input.agent] + */ + async save({ agent = this.session.agent } = {}) { + return await importAuthorization(agent, this) + } +} + +export class AccountPlan { + /** + * @param {Model} model + */ + constructor(model) { + this.model = model + } + + /** + * Gets information about the plan associated with this account. + */ + async get() { + return await Plan.get(this.model, { + account: this.model.id, + proofs: this.model.proofs, + }) + } + + async subscriptions() { + return Result.unwrap( + await Subscription.list(this.model.agent, { + account: this.model.id, + proofs: this.model.proofs, + }) + ) + } +} diff --git a/packages/w3up-client/src/view/space.js b/packages/w3up-client/src/view/space.js new file mode 100644 index 000000000..1659c820e --- /dev/null +++ b/packages/w3up-client/src/view/space.js @@ -0,0 +1,332 @@ +import * as ED25519 from '@ucanto/principal/ed25519' +import { delegate, Schema, UCAN, error, fail } from '@ucanto/core' +import * as BIP39 from '@scure/bip39' +import { wordlist } from '@scure/bip39/wordlists/english' +import * as API from '../types.js' +import * as Access from '../capability/access.js' +import * as Provider from '../capability/provider.js' + +/** + * Data model for the (owned) space. + * + * @typedef {object} Model + * @property {ED25519.EdSigner} signer + * @property {string} name + * @property {API.AgentView} [agent] + */ + +/** + * Generates a new space. + * + * @param {object} options + * @param {string} options.name + * @param {API.AgentView} [options.agent] + */ +export const generate = async ({ name, agent }) => { + const { signer } = await ED25519.generate() + + return new OwnedSpace({ signer, name, agent }) +} + +/** + * Recovers space from the saved mnemonic. + * + * @param {string} mnemonic + * @param {object} options + * @param {string} options.name - Name to give to the recovered space. + * @param {API.AgentView} [options.agent] + */ +export const fromMnemonic = async (mnemonic, { name, agent }) => { + const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) + const signer = await ED25519.derive(secret) + return new OwnedSpace({ signer, name, agent }) +} + +/** + * Turns (owned) space into a BIP39 mnemonic that later can be used to recover + * the space using `fromMnemonic` function. + * + * @param {object} space + * @param {ED25519.EdSigner} space.signer + */ +export const toMnemonic = ({ signer }) => { + /** @type {Uint8Array} */ + // @ts-expect-error - Field is defined but not in the interface + const secret = signer.secret + + return BIP39.entropyToMnemonic(secret, wordlist) +} + +/** + * Creates a (UCAN) delegation that gives full access to the space to the + * specified `account`. At the moment we only allow `did:mailto` principal + * to be used as an `account`. + * + * @param {Model} space + * @param {API.AccountDID} account + */ +export const createRecovery = (space, account) => + createAuthorization(space, { + agent: space.signer.withDID(account), + access: Access.accountAccess, + expiration: Infinity, + }) + +// Default authorization session is valid for 1 year +export const SESSION_LIFETIME = 60 * 60 * 24 * 365 + +/** + * Creates (UCAN) delegation that gives specified `agent` an access to + * specified ability (passed as `access.can` field) on this space. + * Optionally, you can specify `access.expiration` field to set the + * expiration time for the authorization. By default the authorization + * is valid for 1 year and gives access to all capabilities on the space + * that are needed to use the space. + * + * @param {Model} space + * @param {object} options + * @param {API.Principal} options.agent + * @param {API.Access} [options.access] + * @param {API.UTCUnixTimestamp} [options.expiration] + */ +export const createAuthorization = async ( + { signer, name }, + { + agent, + access = Access.spaceAccess, + expiration = UCAN.now() + SESSION_LIFETIME, + } +) => { + return await delegate({ + issuer: signer, + audience: agent, + capabilities: toCapabilities({ + [signer.did()]: access, + }), + ...(expiration ? { expiration } : {}), + facts: [{ space: { name } }], + }) +} + +/** + * @param {Record} allow + * @returns {API.Capabilities} + */ +const toCapabilities = (allow) => { + const capabilities = [] + for (const [subject, access] of Object.entries(allow)) { + const entries = /** @type {[API.Ability, API.Unit][]} */ ( + Object.entries(access) + ) + + for (const [can, details] of entries) { + if (details) { + capabilities.push({ can, with: subject }) + } + } + } + + return /** @type {API.Capabilities} */ (capabilities) +} + +/** + * Represents an owned space, meaning a space for which we have a private key + * and consequently have full authority over. + */ +class OwnedSpace { + /** + * @param {Model} model + */ + constructor(model) { + this.model = model + } + + get signer() { + return this.model.signer + } + + get name() { + return this.model.name + } + + did() { + return this.signer.did() + } + + /** + * Creates a renamed version of this space. + * + * @param {string} name + */ + withName(name) { + return new OwnedSpace({ signer: this.signer, name }) + } + + /** + * Saves account in the agent store so it can be accessed across sessions. + * + * @param {object} input + * @param {API.AgentView} [input.agent] + * @returns {Promise>} + */ + async save({ agent = this.model.agent } = {}) { + if (!agent) { + return fail('Please provide an agent to save the space into') + } + + const proof = await createAuthorization(this, { agent }) + await agent.importSpaceFromDelegation(proof) + await agent.data.setCurrentSpace(this.did()) + + return { ok: {} } + } + + /** + * @param {Authorization} authorization + * @param {object} options + * @param {API.AgentView} [options.agent] + */ + provision({ proofs }, { agent = this.model.agent } = {}) { + if (!agent) { + return fail('Please provide an agent to save the space into') + } + + return provision(this, { proofs, agent }) + } + + /** + * Creates a (UCAN) delegation that gives full access to the space to the + * specified `account`. At the moment we only allow `did:mailto` principal + * to be used as an `account`. + * + * @param {API.AccountDID} account + */ + async createRecovery(account) { + return createRecovery(this, account) + } + + /** + * Creates (UCAN) delegation that gives specified `agent` an access to + * specified ability (passed as `access.can` field) on the this space. + * Optionally, you can specify `access.expiration` field to set the + * + * @param {API.Principal} agent + * @param {object} [input] + * @param {API.Access} [input.access] + * @param {API.UTCUnixTimestamp} [input.expiration] + */ + createAuthorization(agent, input) { + return createAuthorization(this, { ...input, agent }) + } + + /** + * Derives BIP39 mnemonic that can be used to recover the space. + * + * @returns {string} + */ + toMnemonic() { + return toMnemonic(this) + } +} + +const SpaceDID = Schema.did({ method: 'key' }) + +/** + * Creates a (shared) space from given delegation. + * + * @param {API.Delegation} delegation + */ +export const fromDelegation = (delegation) => { + const result = SpaceDID.read(delegation.capabilities[0].with) + if (result.error) { + throw Object.assign( + new Error( + `Invalid delegation, expected capabilities[0].with to be DID, ${result.error}` + ), + { + cause: result.error, + } + ) + } + + /** @type {{name?:string}} */ + const meta = delegation.facts[0]?.space ?? {} + + return new SharedSpace({ id: result.ok, delegation, meta }) +} + +/** + * @typedef {object} Authorization + * @property {API.Delegation[]} proofs + * + * @typedef {object} Space + * @property {() => API.SpaceDID} did + */ + +/** + * @param {Space} space + * @param {object} options + * @param {API.Delegation[]} options.proofs + * @param {API.AgentView} options.agent + */ +export const provision = async (space, { proofs, agent }) => { + const [capability] = proofs[0].capabilities + + const { ok: account, error: reason } = Provider.AccountDID.read( + capability.with + ) + if (reason) { + return error(reason) + } + + return await Provider.add(agent, { + consumer: space.did(), + account, + proofs, + }) +} + +/** + * Represents a shared space, meaning a space for which we have a delegation + * and consequently have limited authority over. + */ +class SharedSpace { + /** + * @typedef {object} SharedSpaceModel + * @property {API.SpaceDID} id + * @property {API.Delegation} delegation + * @property {{name?:string}} meta + * @property {API.AgentView} [agent] + * + * @param {SharedSpaceModel} model + */ + constructor(model) { + this.model = model + } + + get delegation() { + return this.model.delegation + } + + get meta() { + return this.model.meta + } + + get name() { + return this.meta.name ?? '' + } + + did() { + return this.model.id + } + + /** + * @param {string} name + */ + withName(name) { + return new SharedSpace({ + ...this.model, + meta: { ...this.meta, name }, + }) + } +} diff --git a/packages/w3up-client/src/w3up.js b/packages/w3up-client/src/w3up.js new file mode 100644 index 000000000..52de22f94 --- /dev/null +++ b/packages/w3up-client/src/w3up.js @@ -0,0 +1,117 @@ +import * as API from './types.js' +import * as Agent from './agent.js' +import * as Task from './task.js' + +export * from './types.js' +export { DB, Connection, ephemeral } from './agent.js' + +/** + * Generic function that will either {@link create}, {@link load} or + * {@link open} an agent session based on the provided `source`. + * + * @template {API.UnknownProtocol} Protocol + * @param {API.W3UpFrom} source + */ +export const from = (source) => + Task.spawn(function* () { + if (source.create) { + return yield* create(source.create) + } else if (source.load) { + return yield* load(source.load) + } else if (source.open) { + return yield* open(source.open) + } else { + return Task.fail(new TypeError('Invalid source')) + } + }) + +/** + * Restores an agent session from the specified store or creates a new on if + * none is stored. If `as` signer is provided it will be used as signing + * principal instead of one stored in the store. If `as` signer is not + * provided and no signing key material is persisted in the store, a new + * keypair will be generated and persisted in store. Provided `connection` + * will be used to invoke capabilities on a remote (service) agent. + * + * @example + * ```js + * import * as W3Up from '@web3-storage/w3up-client' + * + * const demo = async () => { + * const session = await W3Up.open({ + * store: new W3Up.Store.open({ name: 'w3up-client-demo' }) + * }) + * } + * ``` + * + * @template {API.UnknownProtocol} Protocol + * @param {API.W3UpOpen} source + */ +export const open = (source) => + Task.spawn(function* () { + const agent = yield* Agent.open(source) + return agent.connect(source.connection) + }) + +/** + * Loads an agent session from the specified store. If no agent information is + * stored in the store, operation will fail. Optionally, `as` signing principal + * can be provided to override the one in persisted in the store. Provided + * `connection` will be used to invoke capabilities on a remote (service) agent. + * + * ⚠️ Please note that this function will fail if no agent information is stored + * in the store. If that is not the desired behavior, consider using {@link open} + * instead which will load agent information from the store when available and + * otherwise generate one and persist it in the store. + * + * @example + * ```js + * import * as W3Up from '@web3-storage/w3up-client' + * + * const demo = async () => { + * const session = await W3Up.load({ + * store: new W3Up.Store.open({ name: 'w3up-client-demo' }) + * }) + * } + * ``` + * + * @template {API.UnknownProtocol} Protocol + * @param {API.W3UpLoad} source + */ +export const load = (source) => + Task.spawn(function* () { + const agent = yield* Agent.load(source) + return agent.connect(source.connection) + }) + +/** + * Creates a new agent session and persists it in the specified store. If `as` + * singing principal is provided it will be used but will not be persisted in + * the store. If no signing principal is provided a new keypair will be generated + * and persisted in the store. Provided `connection` will be used to invoke + * capabilities on a remote (service) agent. + * + * ⚠️ Please note that this function will overwrite any existing agent session + * already present in the store. If that is not the desired behavior, consider + * using {@link open} instead which will only create a new agent if one is not + * already stored. + * + * @example + * ```js + * import * as W3Up from '@web3-storage/w3up-client' + * + * const demo = async () => { + * const session = await W3Up.create({ + * store: new W3Up.Store.open({ name: 'w3up-client-demo' }) + * }) + * } + * ``` + * + * @template {API.UnknownProtocol} Protocol + * @param {API.W3UpCreate} source + */ +export const create = (source) => + Task.spawn(function* () { + const agent = yield* Agent.create(source) + return agent.connect(source.connection) + }) diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index 9cdf83f91..58ccb0666 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -1,37 +1,123 @@ import * as Test from './test.js' -import * as Access from '../src/capability/access.js' -import * as Result from '../src/result.js' +import * as Access from '../src/access.js' +import * as Authorization from '../src/authorization.js' +import * as Space from '../src/space.js' +import * as API from '../src/types.js' +import * as DB from '../src/agent/db.js' +import * as Task from '../src/task.js' /** * @type {Test.Suite} */ export const testAccess = { - 'capability.access.request': async ( - assert, - { client, mail, grantAccess } - ) => { - const email = 'alice@web.mail' - - const account = Access.DIDMailto.fromEmail(email) - const request = Result.try( - await client.capability.access.request({ account }) - ) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - - assert.deepEqual(request.audience, client.did()) - assert.ok(request.expiration.getTime() >= Date.now()) - - const access = Result.try(await request.claim()) - assert.ok(access.proofs.length > 0) - - const proofs = client.proofs() - assert.deepEqual(proofs.length, 0) - - await access.save() - assert.ok(client.proofs().length > 0) - }, + 'access.request': (assert, { session, mail, grantAccess }) => + Task.spawn(function* () { + const email = 'alice@web.mail' + + const account = Access.DIDMailto.fromEmail(email) + const request = yield* Access.request(session, { account }) + const message = yield* Task.wait(mail.take()) + assert.deepEqual(message.to, email) + yield* Task.wait(grantAccess(message)) + + assert.deepEqual(request.authority, session.agent.signer.did()) + assert.ok(request.expiration.getTime() >= Date.now()) + + const access = yield* request.claim() + assert.ok(access.proofs.length > 0) + + const results = Authorization.find(session.agent.db, { + audience: session.agent.did(), + can: { 'store/add': [] }, + }) + + assert.deepEqual(results, []) + + yield* access.save() + const [login] = Authorization.find(session.agent.db, { + audience: session.agent.did(), + can: { 'store/add': [] }, + }) + assert.ok(login) + assert.equal(login.authority, session.agent.did()) + assert.equal(login.subject, 'ucan:*') + assert.deepEqual(login.can, { 'store/add': [] }) + assert.ok(login.proofs.length > 0) + + const [auth] = Authorization.find(session.agent.db, { + can: { 'store/add': [] }, + audience: session.agent.did(), + subject: account, + }) + + assert.ok(auth) + assert.equal(auth.authority, session.agent.did()) + assert.equal(auth.subject, account) + assert.ok(auth.proofs.length > 0) + }), + + 'access delegate and claim': (assert, { session, provisionsStorage }) => + Task.spawn(function* () { + const space = yield* Space.create({ name: 'main', session }) + const { proofs } = yield* space.share({ audience: session.agent.signer }) + yield* DB.transact( + session.agent.db, + proofs.map((proof) => DB.assert({ proof })) + ) + + yield* Task.wait( + provisionsStorage.put({ + // @ts-ignore + cause: null, + consumer: space.did(), + customer: 'did:mailto:mail.com:user', + provider: /** @type {API.ProviderDID} */ ( + session.connection.id.did() + ), + }) + ) + + const shared = yield* Space.create({ name: 'shared', session }) + const { proofs: delegations } = yield* shared.share({ + audience: session.agent.signer, + }) + + yield* Access.delegate(session, { + delegations, + subject: space.did(), + }) + + const claim = yield* Access.claim(session) + assert.deepEqual(claim.proofs, delegations) + const none = Authorization.find(session.agent.db, { + audience: session.agent.did(), + subject: shared.did(), + can: { 'store/add': [] }, + }) + assert.deepEqual( + none, + [], + 'claimed access has not been added to an agent' + ) + yield* claim.save() + + const [auth] = Authorization.find(session.agent.db, { + audience: session.agent.did(), + subject: shared.did(), + can: { 'store/add': [] }, + }) + + assert.deepEqual( + auth, + Authorization.from({ + authority: session.agent.did(), + subject: shared.did(), + can: { 'store/add': [] }, + proofs: delegations, + }), + 'claimed access has been added to an agent' + ) + }), } Test.test({ Access: testAccess }) diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index c5e5fdf0a..5c59dd9c5 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -1,281 +1,292 @@ import * as Test from './test.js' import * as Account from '../src/account.js' import * as Space from '../src/space.js' -import * as Result from '../src/result.js' +import * as Task from '../src/task.js' /** * @type {Test.Suite} */ export const testAccount = { - 'list accounts': async (assert, { client, mail, grantAccess }) => { - const email = 'alice@web.mail' - - assert.deepEqual(Account.list(client), {}, 'no accounts yet') - - const login = Account.login(client, email) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const session = await login - assert.equal(session.error, undefined) - assert.equal(session.ok?.did(), Account.fromEmail(email)) - assert.equal(session.ok?.toEmail(), email) - assert.equal(session.ok?.proofs.length, 2) - - assert.deepEqual(Account.list(client), {}, 'no accounts have been saved') - await session.ok?.save() - const accounts = Account.list(client) - - assert.deepEqual(Object.values(accounts).length, 1) - assert.ok(accounts[Account.fromEmail(email)]) - - const account = accounts[Account.fromEmail(email)] - assert.equal(account.toEmail(), email) - assert.equal(account.did(), Account.fromEmail(email)) - assert.equal(account.proofs.length, 2) - }, - - 'two logins': async (assert, { client, mail, grantAccess }) => { - const aliceEmail = 'alice@web.mail' - const bobEmail = 'bob@web.mail' - - assert.deepEqual(Account.list(client), {}, 'no accounts yet') - const aliceLogin = Account.login(client, aliceEmail) - await grantAccess(await mail.take()) - const alice = await aliceLogin - assert.deepEqual(alice.ok?.toEmail(), aliceEmail) - - assert.deepEqual(Account.list(client), {}, 'no accounts have been saved') - const saveAlice = await alice.ok?.save() - assert.equal(saveAlice?.error, undefined) - - const one = Account.list(client) - assert.deepEqual(Object.values(one).length, 1) - assert.ok(one[Account.fromEmail(aliceEmail)], 'alice in the account list') - - const bobLogin = Account.login(client, bobEmail) - await grantAccess(await mail.take()) - const bob = await bobLogin - assert.deepEqual(bob.ok?.toEmail(), bobEmail) - await bob.ok?.save() - - const two = Account.list(client) - - assert.deepEqual(Object.values(two).length, 2) - - assert.ok(two[Account.fromEmail(aliceEmail)].toEmail(), aliceEmail) - assert.ok(two[Account.fromEmail(bobEmail)].toEmail(), bobEmail) - }, - - 'login idempotence': async (assert, { client, mail, grantAccess }) => { - const email = 'alice@web.mail' - const login = client.login(email) - await grantAccess(await mail.take()) - const alice = await login - - assert.deepEqual( - Object.keys(client.accounts()), - [alice.did()], - 'no accounts have been saved' - ) - - const retry = await client.login(email) - assert.deepEqual( - alice.toJSON(), - retry.toJSON(), - 'same account view is returned' - ) - - const loginResult = await Account.login(client, email) - assert.deepEqual( - alice.toJSON(), - loginResult.ok?.toJSON(), - 'same account is returned with low level API' - ) - }, - - 'client.login': async (assert, { client, mail, grantAccess }) => { - const account = client.login('alice@web.mail') - - await grantAccess(await mail.take()) - - const alice = await account - assert.deepEqual(alice.toEmail(), 'alice@web.mail') - - const accounts = client.accounts() - assert.deepEqual(Object.keys(accounts), [alice.did()]) - }, + 'list accounts': async (assert, { session, mail, grantAccess }) => + Task.spawn(function* () { + const email = 'alice@web.mail' + + assert.deepEqual(session.accounts.list(), {}, 'no accounts yet') + assert.deepEqual([...session.accounts], [], 'is iterable') + + const login = session.accounts.login({ email }) + const message = yield* Task.wait(mail.take()) + assert.deepEqual(message.to, email) + yield* Task.wait(grantAccess(message)) + const account = yield* login + assert.equal(account.did(), Account.DIDMailto.fromEmail(email)) + assert.equal(account.toEmail(), email) + assert.equal([...account.proofs].length, 2) + + assert.deepEqual(Account.list(session), {}, 'no accounts have been saved') + yield* session.accounts.add(account) + const accounts = session.accounts.list() + + assert.deepEqual(Object.values(accounts).length, 1) + assert.ok(accounts[Account.DIDMailto.fromEmail(email)]) + + const savedAccount = accounts[Account.DIDMailto.fromEmail(email)] + assert.equal(savedAccount.toEmail(), email) + assert.equal(savedAccount.did(), Account.DIDMailto.fromEmail(email)) + assert.equal([...savedAccount.proofs].length, 2) + }), + + 'two logins': async (assert, { session, mail, grantAccess }) => + Task.spawn(function* () { + const aliceEmail = 'alice@web.mail' + const bobEmail = 'bob@web.mail' + + assert.deepEqual(session.accounts.list(), {}, 'no accounts yet') + const aliceLogin = session.accounts.login({ email: aliceEmail }) + const aliceConfirm = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(aliceConfirm)) + const alice = yield* aliceLogin + assert.deepEqual(alice.toEmail(), aliceEmail) + + assert.deepEqual( + session.accounts.list(), + {}, + 'no accounts have been saved' + ) + yield* session.accounts.add(alice) + + const [one] = session.accounts + assert.equal(one.did(), alice.did(), 'alice in the account list') + + const bobLogin = Account.login(session, { email: bobEmail }) + const bobConfirm = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(bobConfirm)) + const bob = yield* bobLogin + assert.deepEqual(bob.toEmail(), bobEmail) + yield* session.accounts.add(bob) + + const two = Account.list(session) + assert.deepEqual(Object.values(two).length, 2) + + assert.ok( + two[Account.DIDMailto.fromEmail(aliceEmail)].toEmail(), + aliceEmail + ) + assert.ok(two[Account.DIDMailto.fromEmail(bobEmail)].toEmail(), bobEmail) + }), + + 'login idempotence': (assert, { session, mail, grantAccess }) => + Task.spawn(function* () { + const email = 'alice@web.mail' + const login = Account.login(session, { email }) + const message = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(message)) + const alice = yield* login + + yield* session.accounts.add(alice) + + assert.deepEqual( + Object.keys(Account.list(session)), + [alice.did()], + 'account was saved' + ) + + const retry = yield* Account.login(session, { email }) + assert.deepEqual( + alice.toJSON(), + retry.toJSON(), + 'same account view is returned' + ) + + return { ok: {} } + }), + 'account login': async (assert, { session, mail, grantAccess }) => + Task.spawn(function* () { + const login = session.accounts.login({ email: 'alice@web.mail' }) + + const message = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(message)) + + const alice = yield* login + assert.deepEqual(alice.toEmail(), 'alice@web.mail') + yield* session.accounts.add(alice) + + const accounts = session.accounts.list() + assert.deepEqual(Object.keys(accounts), [alice.did()]) + }), 'create account and provision space': async ( assert, - { client, mail, grantAccess } - ) => { - const space = await client.createSpace('test') - const mnemonic = space.toMnemonic() - const { signer } = await Space.fromMnemonic(mnemonic, { name: 'import' }) - assert.deepEqual( - space.signer.encode(), - signer.encode(), - 'arrived to same signer' - ) - - const email = 'alice@web.mail' - const login = Account.login(client, email) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const account = Result.try(await login) - - const result = await account.provision(space.did()) - assert.equal(result.error, undefined) - - // authorize agent to use space - const proof = await space.createAuthorization(client.agent, { - access: { 'space/info': {} }, - expiration: Infinity, - }) - - await client.addSpace(proof) - - const info = await client.capability.space.info(space.did()) - assert.deepEqual(info, { - did: space.did(), - providers: [client.agent.connection.id.did()], - }) - }, - - 'multi device workflow': async (asserts, { connect, mail, grantAccess }) => { - const laptop = await connect() - const space = await laptop.createSpace('main') - - // want to provision space ? - const email = 'alice@web.mail' - const login = Account.login(laptop, email) - // confirm by clicking a link - await grantAccess(await mail.take()) - const account = Result.try(await login) - - // Authorized account can provision space - Result.try(await account.provision(space.did())) - - // Want to setup a recovery for this space ? - const recovery = await space.createRecovery(account.did()) - // Authorize laptop to use the space, we need to do it in order - // to be able to store the recovery delegation in the space. - await laptop.addSpace(await space.createAuthorization(laptop.agent)) - - // Store delegation to the account so it can be used for recovery - await laptop.capability.access.delegate({ - delegations: [recovery], - }) - - // now connect with a second device - const phone = await connect() - const phoneLogin = Account.login(phone, email) - // confirm by clicking a link - await grantAccess(await mail.take()) - const session = Result.try(await phoneLogin) - // save session on the phone - Result.try(await session.save()) - - const result = await phone.capability.space.info(space.did()) - asserts.deepEqual(result.did, space.did()) - }, - 'setup recovery': async (assert, { client, mail, grantAccess }) => { - const space = await client.createSpace('test') - - const email = 'alice@web.mail' - const login = Account.login(client, email) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const account = Result.try(await login) - - Result.try(await account.provision(space.did())) - - const recovery = await space.createRecovery(account.did()) - const share = await client.capability.access.delegate({ - space: space.did(), - delegations: [recovery], - proofs: [await space.createAuthorization(client)], - }) - assert.equal(share.error, undefined) - assert.deepEqual(client.spaces(), []) - - assert.deepEqual(client.spaces().length, 0, 'no spaces had been added') - - // waiting for a sec so that request CID will come out different - // otherwise we will find previous authorization which does not - // have the space delegation yet. - await new Promise((resolve) => setTimeout(resolve, 1000)) - - // This is not a great flow but to fix this we need a new to upgrade - // ucanto and then pull delegations for each account. - const secondLogin = Account.login(client, email) - await grantAccess(await mail.take()) - const secondAccount = Result.try(await secondLogin) - - Result.try(await secondAccount.save()) - - assert.deepEqual(client.spaces().length, 1, 'spaces had been added') - }, + { session, mail, grantAccess, plansStorage } + ) => + Task.spawn(function* () { + const space = yield* session.spaces.create({ name: 'test' }) + const mnemonic = space.toMnemonic() + + const imported = yield* Space.fromMnemonic(session, { + name: 'import', + mnemonic, + }) + + assert.deepEqual(imported.did(), space.did()) + assert.deepEqual(imported.authority, space.did()) + + const email = 'alice@web.mail' + const login = session.accounts.login({ email }) + const message = yield* Task.wait(mail.take()) + assert.deepEqual(message.to, email) + yield* Task.wait(grantAccess(message)) + const account = yield* login + + yield* Task.wait( + plansStorage.set(account.did(), 'did:web:free.web3.storage') + ) + const plans = yield* account.plans.list() + const [{ subscriptions }] = Object.values(plans) + + yield* subscriptions.add({ consumer: space.did() }) + + // authorize agent to use space + + const shared = yield* space.share({ + audience: session.agent.signer, + can: { 'space/info': [] }, + }) + + const info = yield* shared.info() + + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + }), + + 'multi device workflow': async ( + assert, + { connection, mail, grantAccess, plansStorage } + ) => + Task.spawn(function* () { + const laptop = yield* Test.connect(connection) + const space = yield* laptop.spaces.create({ name: 'main' }) + + // want to provision space ? + const email = 'alice@web.mail' + const login = laptop.accounts.login({ email }) + // confirm by clicking a link + const laptopMessage = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(laptopMessage)) + const account = yield* login + + // setup billing + yield* Task.ok.wait( + plansStorage.set(account.did(), 'did:web:free.web3.storage') + ) + + // Authorized account can provision space + const plans = yield* account.plans.list() + const [{ subscriptions }] = Object.values(plans) + + yield* subscriptions.add({ consumer: space.did() }) + + // Want to setup a recovery for this space ? + const recovery = yield* space.createRecovery({ audience: account }) + + // Store space delegation in the space so that account can claim it. + yield* space.delegations.add(recovery) + + // now connect with a second device + const phone = yield* Task.wait(Test.connect(connection)) + const phoneLogin = phone.accounts.login({ email }) + // confirm by clicking a link + const phoneMessage = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(phoneMessage)) + const phoneAccount = yield* phoneLogin + + const [phoneSpace] = phoneAccount.spaces + assert.deepEqual(phoneSpace.did(), space.did()) + }), 'check account plan': async ( assert, - { client, mail, grantAccess, plansStorage } - ) => { - const login = Account.login(client, 'alice@web.mail') - await grantAccess(await mail.take()) - const account = Result.try(await login) + { session, mail, grantAccess, plansStorage } + ) => + Task.spawn(function* () { + const login = session.accounts.login({ email: 'alice@web.mail' }) - const { error } = await account.plan.get() - assert.ok(error) + const message = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(message)) - Result.unwrap( - await plansStorage.set(account.did(), 'did:web:free.web3.storage') - ) + const account = yield* login - const { ok: plan } = await account.plan.get() + const plans = yield* account.plans.list() + assert.deepEqual(plans, {}, 'no plans yet') - assert.ok(plan?.product, 'did:web:free.web3.storage') - }, + yield* Task.wait( + plansStorage.set(account.did(), 'did:web:free.web3.storage') + ) + const updatePlans = yield* account.plans.list() + assert.deepEqual(Object.keys(updatePlans), ['did:web:free.web3.storage']) + }), 'check account subscriptions': async ( assert, - { client, mail, grantAccess } - ) => { - const space = await client.createSpace('test') - - const email = 'alice@web.mail' - const login = Account.login(client, email) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const account = Result.try(await login) - - Result.try(await account.provision(space.did())) - - const subs = Result.unwrap(await account.plan.subscriptions()) - - assert.equal(subs.results.length, 1) - assert.equal(subs.results[0].provider, client.defaultProvider()) - assert.deepEqual(subs.results[0].consumers, [space.did()]) - assert.equal(typeof subs.results[0].subscription, 'string') - }, - - 'space.save': async (assert, { client, mail, grantAccess }) => { - const space = await client.createSpace('test') - assert.deepEqual(client.spaces(), []) - - const result = await space.save() - assert.ok(result.ok) - - const spaces = client.spaces() - assert.deepEqual(spaces.length, 1) - assert.deepEqual(spaces[0].did(), space.did()) - - assert.deepEqual(client.currentSpace()?.did(), space.did()) - }, + { session, mail, grantAccess, plansStorage } + ) => + Task.spawn(function* () { + const space = yield* session.spaces.create({ name: 'test' }) + + const email = 'alice@web.mail' + const login = session.accounts.login({ email }) + // confirm by clicking a link + const message = yield* Task.wait(mail.take()) + assert.deepEqual(message.to, email) + yield* Task.wait(grantAccess(message)) + const account = yield* login + + // setup billing + yield* Task.ok.wait( + plansStorage.set(account.did(), 'did:web:test.web3.storage') + ) + // Authorized account can provision space + const plans = yield* account.plans.list() + const [{ subscriptions }] = Object.values(plans) + + yield* subscriptions.add({ consumer: space.did() }) + + const [...subs] = yield* subscriptions.list() + + assert.deepEqual(subs, [ + { + customer: account.did(), + consumer: space.did(), + provider: 'did:web:test.web3.storage', + limit: {}, + }, + ]) + + const second = yield* session.spaces.create({ name: 'second' }) + + yield* subscriptions.add({ consumer: second.did() }) + + const [...subs2] = yield* subscriptions.list() + + assert.deepEqual(subs2, [ + { + customer: account.did(), + consumer: space.did(), + provider: 'did:web:test.web3.storage', + limit: {}, + }, + { + customer: account.did(), + consumer: second.did(), + provider: 'did:web:test.web3.storage', + limit: {}, + }, + ]) + + return { ok: {} } + }), } Test.test({ Account: testAccount }) diff --git a/packages/w3up-client/test/agent.test.js b/packages/w3up-client/test/agent.test.js new file mode 100644 index 000000000..290c6817a --- /dev/null +++ b/packages/w3up-client/test/agent.test.js @@ -0,0 +1,87 @@ +import * as Test from './test.js' +import { alice, bob, mallory, service } from './fixtures/principals.js' +import * as Agent from '../src/agent.js' +import * as Result from '../src/result.js' + +/** + * @type {Test.BasicSuite} + */ +export const testAgent = { + 'agent has did method': async (assert) => { + const agent = await Agent.open({ + as: alice, + store: Agent.ephemeral, + }) + + const session = agent.connect() + + assert.ok(session.agent) + assert.ok(session.connection) + + assert.deepEqual(session.agent.did(), alice.did()) + + assert.deepEqual(session.connection.address, { + id: Agent.DID.parse('did:web:web3.storage'), + url: new URL('https://up.web3.storage'), + }) + }, + + 'agent fails load if no principal': async (assert) => { + const result = await Agent.load({ store: Agent.ephemeral }).result() + + assert.equal(result?.error?.name, 'SignerLoadError') + }, + + 'agent loads from store': async (assert) => { + const agent = await Agent.load({ + store: { + ...Agent.ephemeral, + async load() { + return { + principal: alice.toArchive(), + delegations: new Map(), + currentSpace: undefined, + address: {}, + } + }, + }, + }) + + assert.deepEqual(agent.did(), alice.did()) + }, + 'load from store but use different signer': async (assert) => { + const agent = await Agent.load({ + as: bob, + store: { + ...Agent.ephemeral, + async load() { + return { + principal: alice.toArchive(), + delegations: new Map(), + currentSpace: undefined, + address: {}, + } + }, + }, + }) + + assert.deepEqual(agent.did(), bob.did()) + assert.deepEqual( + agent.db.signer?.id, + alice.did(), + 'signer in db remains same' + ) + + const tr = await Agent.DB.transact(agent.db, [ + { + assert: { + signer: bob.toArchive(), + }, + }, + ]) + + assert.deepEqual(agent.db.signer?.id, bob.did(), 'signer was updated') + }, +} + +Test.basic({ Agent: testAgent }) diff --git a/packages/w3up-client/test/capability/access.test.js b/packages/w3up-client/test/capability/access.test.js deleted file mode 100644 index 29044500b..000000000 --- a/packages/w3up-client/test/capability/access.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'assert' -import { create as createServer, provide } from '@ucanto/server' -import * as CAR from '@ucanto/transport/car' -import * as Signer from '@ucanto/principal/ed25519' -import * as AccessCapabilities from '@web3-storage/capabilities/access' -import { AgentData } from '@web3-storage/access/agent' -import { mockService, mockServiceConf } from '../helpers/mocks.js' -import { Client } from '../../src/client.js' -import { validateAuthorization } from '../helpers/utils.js' - -describe('AccessClient', () => { - describe('claim', () => { - it('should claim delegations', async () => { - const service = mockService({ - access: { - claim: provide(AccessCapabilities.claim, ({ invocation }) => { - assert.equal(invocation.issuer.did(), alice.agent.did()) - assert.equal(invocation.capabilities.length, 1) - const invCap = invocation.capabilities[0] - assert.equal(invCap.can, AccessCapabilities.claim.can) - return { - ok: { - delegations: {}, - }, - } - }), - }, - }) - - const server = createServer({ - id: await Signer.generate(), - service, - codec: CAR.inbound, - validateAuthorization, - }) - - const alice = new Client(await AgentData.create(), { - // @ts-ignore - serviceConf: await mockServiceConf(server), - }) - - const delegations = await alice.capability.access.claim() - - assert(service.access.claim.called) - assert.equal(service.access.claim.callCount, 1) - assert.deepEqual(delegations, []) - }) - }) -}) diff --git a/packages/w3up-client/test/capability/filecoin.test.js b/packages/w3up-client/test/capability/filecoin.nottest.js similarity index 100% rename from packages/w3up-client/test/capability/filecoin.test.js rename to packages/w3up-client/test/capability/filecoin.nottest.js diff --git a/packages/w3up-client/test/capability/space.test.js b/packages/w3up-client/test/capability/space.test.js deleted file mode 100644 index 179dc0e3a..000000000 --- a/packages/w3up-client/test/capability/space.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'assert' -import { create as createServer, provide } from '@ucanto/server' -import * as CAR from '@ucanto/transport/car' -import * as Signer from '@ucanto/principal/ed25519' -import * as SpaceCapabilities from '@web3-storage/capabilities/space' -import { AgentData } from '@web3-storage/access/agent' -import { mockService, mockServiceConf } from '../helpers/mocks.js' -import { Client } from '../../src/client.js' -import { validateAuthorization } from '../helpers/utils.js' - -describe('SpaceClient', () => { - describe('info', () => { - it('should retrieve space info', async () => { - const service = mockService({ - space: { - info: provide(SpaceCapabilities.info, ({ invocation }) => { - assert.equal(invocation.issuer.did(), alice.agent.did()) - assert.equal(invocation.capabilities.length, 1) - const invCap = invocation.capabilities[0] - assert.equal(invCap.can, SpaceCapabilities.info.can) - assert.equal(invCap.with, space.did()) - return { - ok: { - did: /** @type {`did:key:${string}`} */ (space.did()), - providers: [], - }, - } - }), - }, - }) - - const server = createServer({ - id: await Signer.generate(), - service, - codec: CAR.inbound, - validateAuthorization, - }) - - const alice = new Client(await AgentData.create(), { - // @ts-ignore - serviceConf: await mockServiceConf(server), - }) - - const space = await alice.createSpace('test') - const auth = await space.createAuthorization(alice, { - access: { 'space/info': {} }, - expiration: Infinity, - }) - await alice.addSpace(auth) - await alice.setCurrentSpace(space.did()) - - const info = await alice.capability.space.info(space.did()) - - assert(service.space.info.called) - assert.equal(service.space.info.callCount, 1) - - assert.equal(info.did, space.did()) - assert.deepEqual(info.providers, []) - }) - }) -}) diff --git a/packages/w3up-client/test/capability/store.test.js b/packages/w3up-client/test/capability/store.nottest.js similarity index 100% rename from packages/w3up-client/test/capability/store.test.js rename to packages/w3up-client/test/capability/store.nottest.js diff --git a/packages/w3up-client/test/capability/subscription.test.js b/packages/w3up-client/test/capability/subscription.test.js deleted file mode 100644 index e6e8c86c5..000000000 --- a/packages/w3up-client/test/capability/subscription.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import assert from 'assert' -import { create as createServer, provide } from '@ucanto/server' -import * as CAR from '@ucanto/transport/car' -import * as Signer from '@ucanto/principal/ed25519' -import { Absentee } from '@ucanto/principal' -import * as SubscriptionCapabilities from '@web3-storage/capabilities/subscription' -import { AgentData } from '@web3-storage/access/agent' -import { mockService, mockServiceConf } from '../helpers/mocks.js' -import { Client } from '../../src/client.js' -import { createAuthorization, validateAuthorization } from '../helpers/utils.js' - -describe('SubscriptionClient', () => { - describe('list', () => { - it('should list subscriptions', async () => { - const space = await Signer.generate() - /** @type {import('@web3-storage/capabilities/types').SubscriptionListItem} */ - const subscription = { - provider: 'did:web:web3.storage', - subscription: 'test', - consumers: [space.did()], - } - const account = Absentee.from({ id: 'did:mailto:example.com:alice' }) - const service = mockService({ - subscription: { - list: provide(SubscriptionCapabilities.list, ({ capability }) => { - assert.equal(capability.with, account.did()) - return { - ok: { - results: [subscription], - }, - } - }), - }, - }) - - const serviceSigner = await Signer.generate() - const server = createServer({ - id: serviceSigner, - service, - codec: CAR.inbound, - validateAuthorization, - }) - - const alice = new Client(await AgentData.create(), { - // @ts-ignore - serviceConf: await mockServiceConf(server), - }) - - const auths = await createAuthorization({ - account, - service: serviceSigner, - agent: alice.agent.issuer, - }) - await alice.agent.addProofs(auths) - - const subs = await alice.capability.subscription.list(account.did()) - - assert(service.subscription.list.called) - assert.equal(service.subscription.list.callCount, 1) - assert.deepEqual(subs, { results: [subscription] }) - }) - - it('should throw on service failure', async () => { - const account = Absentee.from({ id: 'did:mailto:example.com:alice' }) - const service = mockService({ - subscription: { - list: provide(SubscriptionCapabilities.list, ({ capability }) => { - assert.equal(capability.with, account.did()) - return { error: new Error('boom') } - }), - }, - }) - - const serviceSigner = await Signer.generate() - const server = createServer({ - id: serviceSigner, - service, - codec: CAR.inbound, - validateAuthorization, - }) - - const alice = new Client(await AgentData.create(), { - // @ts-ignore - serviceConf: await mockServiceConf(server), - }) - - const auths = await createAuthorization({ - account, - service: serviceSigner, - agent: alice.agent.issuer, - }) - await alice.agent.addProofs(auths) - - await assert.rejects(alice.capability.subscription.list(account.did()), { - message: 'failed subscription/list invocation', - }) - - assert(service.subscription.list.called) - assert.equal(service.subscription.list.callCount, 1) - }) - }) -}) diff --git a/packages/w3up-client/test/capability/upload.test.js b/packages/w3up-client/test/capability/upload.nottest.js similarity index 100% rename from packages/w3up-client/test/capability/upload.test.js rename to packages/w3up-client/test/capability/upload.nottest.js diff --git a/packages/w3up-client/test/capability/usage.test.js b/packages/w3up-client/test/capability/usage.test.js deleted file mode 100644 index d814c05dd..000000000 --- a/packages/w3up-client/test/capability/usage.test.js +++ /dev/null @@ -1,96 +0,0 @@ -import assert from 'assert' -import { create as createServer, provide } from '@ucanto/server' -import * as CAR from '@ucanto/transport/car' -import * as Signer from '@ucanto/principal/ed25519' -import * as UsageCapabilities from '@web3-storage/capabilities/usage' -import { AgentData } from '@web3-storage/access/agent' -import { mockService, mockServiceConf } from '../helpers/mocks.js' -import { Client } from '../../src/client.js' -import { validateAuthorization } from '../helpers/utils.js' - -describe('UsageClient', () => { - describe('report', () => { - it('should fetch usage report', async () => { - const service = mockService({ - usage: { - report: provide(UsageCapabilities.report, () => { - return { ok: { [report.provider]: report } } - }), - }, - }) - - const server = createServer({ - id: await Signer.generate(), - service, - codec: CAR.inbound, - validateAuthorization, - }) - - const alice = new Client(await AgentData.create(), { - // @ts-ignore - serviceConf: await mockServiceConf(server), - }) - - const space = await alice.createSpace('test') - const auth = await space.createAuthorization(alice) - await alice.addSpace(auth) - - const period = { from: new Date(0), to: new Date() } - /** @type {import('@web3-storage/capabilities/types').UsageData} */ - const report = { - provider: 'did:web:web3.storage', - space: space.did(), - size: { initial: 0, final: 0 }, - period: { - from: period.from.toISOString(), - to: period.to.toISOString(), - }, - events: [], - } - - const subs = await alice.capability.usage.report(space.did(), period) - - assert(service.usage.report.called) - assert.equal(service.usage.report.callCount, 1) - assert.deepEqual(subs, { [report.provider]: report }) - }) - - it('should throw on service failure', async () => { - const service = mockService({ - usage: { - report: provide(UsageCapabilities.report, ({ capability }) => { - return { error: new Error('boom') } - }), - }, - }) - - const serviceSigner = await Signer.generate() - const server = createServer({ - id: serviceSigner, - service, - codec: CAR.inbound, - validateAuthorization, - }) - - const alice = new Client(await AgentData.create(), { - // @ts-ignore - serviceConf: await mockServiceConf(server), - }) - - const space = await alice.createSpace('test') - const auth = await space.createAuthorization(alice) - await alice.addSpace(auth) - - await assert.rejects( - () => { - const period = { from: new Date(), to: new Date() } - return alice.capability.usage.report(space.did(), period) - }, - { message: 'failed usage/report invocation' } - ) - - assert(service.usage.report.called) - assert.equal(service.usage.report.callCount, 1) - }) - }) -}) diff --git a/packages/w3up-client/test/client-accounts.test.js b/packages/w3up-client/test/client-accounts.test.js deleted file mode 100644 index c627f6805..000000000 --- a/packages/w3up-client/test/client-accounts.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import * as Test from './test.js' -import * as Account from '../src/account.js' - -/** - * @type {Test.Suite} - */ -export const testClientAccounts = { - 'list accounts': async (assert, { client, mail, grantAccess }) => { - const email = 'alice@web.mail' - - assert.deepEqual(client.accounts(), {}, 'no accounts yet') - - const login = Account.login(client, email) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const session = await login - assert.equal(session.error, undefined) - assert.equal(session.ok?.did(), Account.fromEmail(email)) - assert.equal(session.ok?.toEmail(), email) - assert.equal(session.ok?.proofs.length, 2) - - assert.deepEqual(client.accounts(), {}, 'no accounts have been saved') - await session.ok?.save() - const accounts = client.accounts() - - assert.deepEqual(Object.values(accounts).length, 1) - assert.ok(accounts[Account.fromEmail(email)]) - - const account = accounts[Account.fromEmail(email)] - assert.equal(account.toEmail(), email) - assert.equal(account.did(), Account.fromEmail(email)) - assert.equal(account.proofs.length, 2) - }, -} - -Test.test({ 'Client accounts': testClientAccounts }) diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.ignoretest.js similarity index 100% rename from packages/w3up-client/test/client.test.js rename to packages/w3up-client/test/client.ignoretest.js diff --git a/packages/w3up-client/test/coupon.test.js b/packages/w3up-client/test/coupon.test.js index 5e5d1bea8..911e8b12a 100644 --- a/packages/w3up-client/test/coupon.test.js +++ b/packages/w3up-client/test/coupon.test.js @@ -1,89 +1,142 @@ import * as Test from './test.js' -import * as Result from '../src/result.js' +import * as Coupon from '../src/coupon/coupon.js' +import * as Task from '../src/task.js' +import * as API from '../src/types.js' +import { parseLink } from '@ucanto/core' +import { alice } from './fixtures/principals.js' /** * @type {Test.Suite} */ export const testCoupon = { 'account.coupon': async ( assert, - { client, mail, connect, grantAccess, plansStorage } + { mail, session, grantAccess, plansStorage } ) => { + const now = (Date.now() / 1000) | 0 // First we login to the workshop account - const login = client.login('workshop@web3.storage') + const login = session.accounts.login({ email: 'workshop@web3.storage' }) const message = await mail.take() await grantAccess(message) const account = await login + await session.accounts.add(account) // Then we setup a billing for this account await plansStorage.set(account.did(), 'did:web:test.web3.storage') // Then we use the account to issue a coupon for the workshop - const coupon = await client.coupon.issue({ - capabilities: [ - { - with: account.did(), - can: 'provider/add', + const issued = await Task.perform( + Coupon.issue(session.agent, { + subject: account.did(), + can: { + 'plan/get': [], + 'provider/add': [], }, - ], - lifetimeInSeconds: 60 * 60 * 24, - }) + expiration: now + 60 * 60 * 24, + }) + ) // We encode coupon and share it with the participants - const archive = Result.unwrap(await coupon.archive()) + const archive = await issued.archive() + + const agent = await Task.perform(Coupon.open(archive)) + + const coupon = await agent.connect(session.connection) + const [...accounts] = coupon.accounts + const [...spaces] = coupon.spaces + + assert.deepEqual(accounts.length, 1) + assert.deepEqual(spaces.length, 0) + + const [redeemedAccount] = accounts - // alice join the workshop and redeem the coupon - const alice = await connect() - const access = await alice.coupon.redeem(archive) + assert.deepEqual(accounts[0].did(), account.did()) - // creates a space and provision it with redeemed coupon - const space = await alice.createSpace('home') - const result = await space.provision(access) - await space.save() + const [plan] = await redeemedAccount.plans.list() - assert.ok(result.ok) + const space = await coupon.spaces.create({ name: 'home' }) + await plan.subscriptions.add({ consumer: space.did() }) - const info = await alice.capability.space.info(space.did()) - assert.deepEqual(info.did, space.did()) - assert.deepEqual(info.providers, ['did:web:test.web3.storage']) + assert.deepEqual(await space.info(), { + did: space.did(), + providers: ['did:web:test.web3.storage'], + }) }, - 'coupon with password': async ( - assert, - { client, mail, connect, grantAccess, plansStorage } - ) => { - const coupon = await client.coupon.issue({ - capabilities: [ - { - with: client.did(), - can: 'store/list', + 'saving a coupon': async (assert, { session, provisionsStorage }) => + Task.spawn(function* () { + const now = (Date.now() / 1000) | 0 + const space = yield* session.spaces.create({ name: 'test' }) + yield* Task.wait( + provisionsStorage.put({ + provider: /** @type {API.ProviderDID} */ ( + session.connection.id.did() + ), + customer: 'did:mailto:web.mail:alice', + consumer: space.did(), + cause: parseLink('bafkqaaa'), + }) + ) + const coupon = yield* Coupon.issue(space.agent, { + subject: space.did(), + can: { 'space/*': [] }, + }) + + const [...none] = session.spaces + assert.deepEqual([], none) + + const result = yield* session.coupons.add(coupon).result() + assert.match(result.error?.message ?? '', /Coupon audience is/) + + const archive = yield* coupon.archive() + + const redeemed = yield* session.coupons.redeem(archive) + + yield* session.coupons.add(redeemed) + + const [one, ...rest] = session.spaces + assert.deepEqual(rest.length, 0) + assert.deepEqual(one.did(), space.did()) + + const info = yield* one.info() + + assert.deepEqual(info, { + did: space.did(), + providers: ['did:web:test.web3.storage'], + }) + }), + + 'coupon with secret': async (assert, { session }) => + Task.spawn(function* () { + const coupon = yield* session.coupons.issue({ + subject: session.agent.did(), + can: { + 'store/list': [], }, - ], - password: 'secret', - }) + secret: 'secret', + }) - const archive = Result.unwrap(await coupon.archive()) + const archive = yield* coupon.archive() - const wrongPassword = await client.coupon - .redeem(archive, { password: 'wrong' }) - .catch((e) => e) + const wrongPassword = yield* session.coupons + .redeem(archive, { secret: 'wrong' }) + .result() - assert.match(String(wrongPassword), /password is invalid/) + assert.match(String(wrongPassword.error), /secret is invalid/) - const requiresPassword = await client.coupon.redeem(archive).catch((e) => e) + const requiresPassword = yield* session.coupons.redeem(archive).result() - assert.match(String(requiresPassword), /requires a password/) + assert.match(String(requiresPassword.error), /requires a secret/) - const redeem = await coupon.redeem(client.agent, { password: 'secret' }) - assert.ok(redeem.ok) - }, + const redeem = yield* coupon.redeem(session) + }), - 'corrupt coupon': async (assert, { client, mail, connect, grantAccess }) => { - const fail = await client.coupon + 'corrupt coupon': async (assert, { session }) => { + const result = await session.coupons .redeem(new Uint8Array(32).fill(1)) - .catch((e) => e) + .result() - assert.match(fail.message, /Invalid CAR header format/) + assert.match(String(result.error), /Invalid CAR header format/) }, } diff --git a/packages/w3up-client/test/db.test.js b/packages/w3up-client/test/db.test.js new file mode 100644 index 000000000..d1afed65d --- /dev/null +++ b/packages/w3up-client/test/db.test.js @@ -0,0 +1,877 @@ +import * as API from '../src/types.js' +import * as DB from '../src/agent/db.js' +import * as Test from './test.js' +import * as Space from '../src/space.js' +import * as Spaces from '../src/space/query.js' +import * as Account from '../src/account/query.js' +import { delegate } from '@ucanto/core' +import { Absentee } from '@ucanto/principal' +import * as Capability from '@web3-storage/capabilities' +import { fromEmail } from '@web3-storage/did-mailto' +import * as Result from '../src/result.js' +import * as Task from '../src/task.js' +import * as W3Up from '../src/w3up.js' + +import { alice, bob, mallory, w3up } from './fixtures/principals.js' +import * as Authorization from '../src/authorization.js' + +/** + * @type {Test.BasicSuite} + */ +export const testDB = { + 'test find space': (assert) => + Task.spawn(function* () { + const space = yield* Space.create({ + name: 'beet-box', + }) + + const { proofs } = yield* space.share({ audience: alice }) + const db = DB.from({ proofs }) + + const result = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + }) + + assert.deepEqual(result, [ + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: space.did(), + proofs, + }), + ]) + }), + + 'test find several spaces': (assert) => + Task.spawn(function* () { + const beetBox = yield* Space.create({ + name: 'beet-box', + }) + + const beetBoxAuth = yield* beetBox.share({ audience: bob }) + + const plumBox = yield* Space.create({ + name: 'plum-box', + }) + + const plumBoxAuth = yield* plumBox.share({ audience: bob }) + + // create db from the proofs from both shares. + const db = DB.from({ + proofs: [...beetBoxAuth.proofs, ...plumBoxAuth.proofs], + }) + + const result = Authorization.find(db, { + can: { 'store/add': [], 'store/remove': [] }, + audience: bob.did(), + }) + + assert.deepEqual(result, [ + Authorization.from({ + authority: bob.did(), + can: { 'store/add': [], 'store/remove': [] }, + subject: beetBox.did(), + proofs: beetBoxAuth.proofs, + }), + Authorization.from({ + authority: bob.did(), + can: { 'store/add': [], 'store/remove': [] }, + subject: plumBox.did(), + proofs: plumBoxAuth.proofs, + }), + ]) + }), + + 'test finds authorization across multiple ucans': (assert) => + Task.spawn(function* () { + const spaceInfo = yield* Task.wait( + Capability.Space.info.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + }) + ) + + const uploadList = yield* Task.wait( + Capability.Upload.list.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + }) + ) + + const db = DB.from({ proofs: [spaceInfo, uploadList] }) + + const result = Authorization.find(db, { + can: { 'space/info': [], 'upload/list': [] }, + audience: bob.did(), + }) + + assert.deepEqual(result, [ + Authorization.from({ + authority: bob.did(), + can: { 'space/info': [], 'upload/list': [] }, + subject: alice.did(), + proofs: [spaceInfo, uploadList], + }), + ]) + }), + + 'test find accounts': (assert) => + Task.spawn(function* () { + const localSpace = yield* Space.create({ name: 'local-box' }) + + const localAuth = yield* localSpace.share({ audience: alice }) + + const { + login, + attestation, + account, + space: remoteSpace, + } = yield* setupAccount() + + const db = DB.from({ + proofs: [login, attestation, ...localAuth.proofs], + }) + + const result = Authorization.find(db, { + subject: { glob: 'did:mailto:*' }, + can: { '*': [] }, + audience: alice.did(), + }) + + assert.deepEqual(result, [ + Authorization.from({ + subject: account.did(), + authority: alice.did(), + can: { '*': [] }, + proofs: [login, attestation], + }), + ]) + + const [first, second, ...rest] = Authorization.find(db, { + subject: { glob: 'did:key:*' }, + can: { 'store/add': [] }, + audience: alice.did(), + }) + + assert.deepEqual(first.toJSON(), { + authority: alice.did(), + can: { 'store/add': [] }, + subject: localSpace.did(), + proofs: localAuth.proofs, + }) + + assert.deepEqual(second.toJSON(), { + authority: alice.did(), + can: { 'store/add': [] }, + subject: remoteSpace.did(), + proofs: [login, attestation], + }) + + assert.deepEqual(rest, []) + }), + + 'test find accounts and attestations': (assert) => + Task.spawn(function* () { + const { login, attestation } = yield* setupAccount() + + const db = DB.from({ proofs: [login, attestation] }) + + const loginProof = DB.link() + const loginCan = DB.link() + const attestProof = DB.link() + const attestCan = DB.link() + + const result = DB.query(db.index, { + select: { + loginProof, + attestProof, + }, + where: [ + DB.match([loginProof, 'ucan/audience', alice.did()]), + DB.match([loginProof, 'ucan/capability', loginCan]), + DB.match([loginCan, 'capability/with', 'ucan:*']), + + DB.match([attestProof, 'ucan/audience', alice.did()]), + DB.match([attestProof, 'ucan/capability', attestCan]), + DB.match([attestCan, 'capability/can', 'ucan/attest']), + DB.match([attestCan, 'capability/nb/proof', loginProof]), + ], + }) + + assert.deepEqual(result, [ + { + loginProof: login.cid, + attestProof: attestation.cid, + }, + ]) + }), + + 'does not match expired ucans': (assert) => + Task.spawn(function* () { + const space = yield* Space.create({ name: 'space' }) + const time = (Date.now() / 1000) | 0 + const expired = yield* space.share({ + audience: alice, + expiration: time - 60 * 60 * 24, + }) + + const valid = yield* space.share({ + audience: alice, + expiration: time + 60 * 60 * 24, + }) + + const db = DB.from({ + proofs: [...valid.proofs, ...expired.proofs], + }) + + const withoutExpired = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + time, + }) + + assert.deepEqual(withoutExpired, [ + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: space.did(), + proofs: valid.proofs, + }), + ]) + + const withExpired = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + time: time - 60 * 60 * 24 * 2, + }) + + assert.deepEqual(withExpired, [ + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: space.did(), + proofs: valid.proofs, + }), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: space.did(), + proofs: expired.proofs, + }), + ]) + }), + + 'does match non-expiring ucans': (assert) => + Task.spawn(function* () { + const space = yield* Space.create({ name: 'space' }) + const { proofs } = yield* space.share({ + audience: alice, + expiration: Infinity, + }) + + const db = DB.from({ + proofs, + }) + + const result = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + }) + + assert.deepEqual(result, [ + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: space.did(), + proofs, + }), + ]) + }), + + 'account view': (assert) => + Task.spawn(function* () { + const aliceAccount = yield* setupAccount({ + email: 'alice@web.mail', + agent: alice, + }) + const bobAccount = yield* setupAccount({ + email: 'bob@web3.storage', + agent: bob, + }) + + const db = DB.from({ + proofs: [...aliceAccount.proofs, ...bobAccount.proofs], + }) + + const time = Date.now() / 1000 + const ucan = DB.link() + const audience = DB.string() + const account = DB.string() + + const accounts = DB.query(db.index, { + select: { + ucan, + account, + }, + where: [ + Account.match(ucan, { + time, + audience, + account, + }), + ], + }) + + assert.deepEqual( + accounts, + [ + { + ucan: aliceAccount.login.cid, + account: 'did:mailto:web.mail:alice', + }, + { + ucan: bobAccount.login.cid, + account: 'did:mailto:web3.storage:bob', + }, + ], + 'found both accounts' + ) + + DB.query(db.index, { + select: { + ucan, + account, + }, + where: [ + Account.match(ucan, { + time, + audience, + account, + }), + ], + }) + }), + + 'find account spaces': (assert) => + Task.spawn(function* () { + const aliceLogin = yield* setupAccount({ + name: 'Alice', + email: 'alice@web.mail', + agent: alice, + }) + const bobLogin = yield* setupAccount({ + name: 'Bob', + email: 'bob@web3.storage', + agent: bob, + }) + const aliLogin = yield* setupAccount({ + name: 'Ali', + email: 'alice@web.mail', + agent: alice, + }) + + const space = yield* Space.create({ name: 'space' }) + const { proofs } = yield* space.share({ + audience: alice, + expiration: Infinity, + }) + + const db = DB.from({ + proofs: [ + ...aliceLogin.proofs, + ...bobLogin.proofs, + ...aliLogin.proofs, + ...proofs, + ], + }) + + const time = Date.now() / 1000 + const ucan = DB.link() + const audience = DB.string() + const account = DB.string() + + // const space = DB.string() + // const proof = DB.link() + // const proofCap = DB.link() + + // const result = DB.query( + // db.index, + // Spaces.indirect({ + // audience: alice.did(), + // can: { 'store/*': [] }, + // }) + // ) + + // assert.deepEqual(result, [ + // { + // subject: aliceLogin.space.did(), + // audience: alice.did(), + // account: aliceLogin.account.did(), + // 'store/*': aliceLogin.login.cid, + // }, + // { + // subject: aliLogin.space.did(), + // audience: alice.did(), + // account: aliLogin.account.did(), + // 'store/*': aliLogin.login.cid, + // }, + // ]) + + // assert.deepEqual( + // DB.query( + // db.index, + // Spaces.indirect({ audience: bob.did(), can: { '*': [] } }) + // ), + // [ + // { + // subject: bobLogin.space.did(), + // audience: bob.did(), + // account: bobLogin.account.did(), + // '*': bobLogin.login.cid, + // }, + // ], + // 'finds account spaces delegated to bob' + // ) + + // assert.deepEqual( + // DB.query( + // db.index, + // Spaces.indirect({ audience: bob.did(), can: { '*': [] } }) + // ), + // [ + // { + // subject: bobLogin.space.did(), + // audience: bob.did(), + // account: bobLogin.account.did(), + // '*': bobLogin.login.cid, + // }, + // ] + // ) + + // assert.deepEqual( + // DB.query( + // db.index, + // Spaces.direct({ + // subject: { glob: 'did:key:*' }, + // audience: alice.did(), + // can: { 'store/*': [] }, + // }) + // ), + // [ + // { + // audience: alice.did(), + // subject: space.did(), + // 'store/*': proofs[0].cid, + // }, + // ], + // 'finds spaces delegated to agent directly' + // ) + + // assert.deepEqual( + // DB.query( + // db.index, + // Spaces.indirect({ + // account: aliceLogin.account.did(), + // }) + // ), + // [ + // { + // subject: aliceLogin.space.did(), + // audience: alice.did(), + // account: aliceLogin.account.did(), + // '*': aliceLogin.login.cid, + // }, + // { + // subject: aliLogin.space.did(), + // audience: alice.did(), + // account: aliLogin.account.did(), + // '*': aliLogin.login.cid, + // }, + // ] + // ) + }), + + 'account authority from login': (assert) => + Task.spawn(function* () { + const account = Absentee.from({ id: fromEmail('alice@web.mail') }) + const proof = yield* Task.wait( + delegate({ + issuer: account, + audience: alice, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + proofs: [], + }) + ) + + const attestation = yield* Task.wait( + Capability.UCAN.attest.delegate({ + issuer: w3up, + audience: alice, + with: w3up.did(), + nb: { proof: proof.cid }, + expiration: Infinity, + }) + ) + + const db = DB.from({ + proofs: [proof, attestation], + }) + + const result = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + subject: account.did(), + }) + + assert.deepEqual( + result, + [ + Authorization.from({ + authority: alice.did(), + subject: account.did(), + can: { 'store/add': [] }, + proofs: [proof, attestation], + }), + ], + 'requires attestation' + ) + }), + + 'account authority from authorization': (assert) => + Task.spawn(function* () { + const account = Absentee.from({ id: fromEmail('alice@web.mail') }) + const proof = yield* Task.wait( + delegate({ + issuer: account, + audience: alice, + capabilities: [ + { + with: 'ucan:*', + can: 'store/*', + }, + ], + proofs: [], + }) + ) + + const db = DB.from({ + proofs: [proof], + }) + + assert.deepEqual( + Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + subject: account.did(), + }), + [], + 'can not find without attestation' + ) + + const attestation = yield* Task.wait( + Capability.UCAN.attest.delegate({ + issuer: w3up, + audience: alice, + with: w3up.did(), + nb: { proof: proof.cid }, + expiration: Infinity, + }) + ) + + // save attestation + yield* DB.transact(db, [{ assert: { proof: attestation } }]) + + assert.deepEqual( + Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + subject: account.did(), + }), + [ + Authorization.from({ + authority: alice.did(), + subject: account.did(), + can: { 'store/add': [] }, + proofs: [proof, attestation], + }), + ], + 'found when attestation was added' + ) + }), + + 'find whatever capabilities match': (assert) => + Task.spawn(function* () { + const space = yield* Space.create({ name: 'beet-box' }) + + const { proofs } = yield* space.share({ audience: alice }) + const db = DB.from({ proofs }) + + const result = Authorization.find(db, { + audience: alice.did(), + }) + + assert.deepEqual( + result, + proofs[0].capabilities.map(({ can }) => + Authorization.from({ + authority: alice.did(), + can: { [can]: [] }, + subject: space.did(), + proofs, + }) + ) + ) + }), + + 'find capabilities grouped by spaces': (assert) => + Task.spawn(function* () { + const beetBox = yield* Space.create({ name: 'beet-box' }) + const yumBox = yield* Space.create({ name: 'yum-box' }) + const aliceLogin = yield* setupAccount({ + name: 'Alice', + email: 'alice@web.mail', + agent: alice, + }) + + const db = DB.from({ + proofs: [ + yield* Task.wait( + Capability.Space.space.delegate({ + issuer: bob, + audience: alice, + with: bob.did(), + }) + ), + + ...(yield* beetBox.share({ audience: alice })).proofs, + ...(yield* beetBox.share({ audience: alice, can: { 'debug/*': [] } })) + .proofs, + ...(yield* yumBox.share({ audience: alice, can: { 'store/*': [] } })) + .proofs, + ...(yield* yumBox.share({ audience: alice, can: { 'upload/*': [] } })) + .proofs, + ...(yield* yumBox.share({ audience: alice, can: { 'space/*': [] } })) + .proofs, + ...(yield* yumBox.share({ audience: alice, can: { 'access/*': [] } })) + .proofs, + aliceLogin.login, + aliceLogin.attestation, + ], + }) + + const space = DB.string() + const proof = DB.link() + const name = DB.string() + + const explicit = DB.query(db.index, { + select: { + space, + name, + }, + where: [ + Spaces.explicit(proof, { + audience: alice.did(), + name, + space, + }), + ], + }) + + assert.deepEqual( + Object.fromEntries(explicit.map(({ space, name }) => [space, name])), + { + [beetBox.did()]: 'beet-box', + [yumBox.did()]: 'yum-box', + [bob.did()]: undefined, + } + ) + + const implicit = DB.query(db.index, { + select: { + space, + name, + }, + where: [ + Spaces.implicit(proof, { + audience: alice.did(), + name, + space, + }), + ], + }) + + assert.deepEqual( + Object.fromEntries(implicit.map(({ space, name }) => [space, name])), + { + [aliceLogin.space.did()]: 'Alice', + } + ) + + const all = DB.query(db.index, { + select: { + space, + name, + }, + where: [ + Spaces.match(proof, { + audience: alice.did(), + name, + space, + }), + ], + }) + + assert.deepEqual( + Object.fromEntries(all.map(({ space, name }) => [space, name])), + { + [beetBox.did()]: 'beet-box', + [yumBox.did()]: 'yum-box', + [aliceLogin.space.did()]: 'Alice', + [bob.did()]: undefined, + } + ) + }), + + 'find re-delegated account capabilities': (assert) => + Task.spawn(function* () { + const { account, space: subject, login, proofs } = yield* setupAccount() + + const delegation = yield* Task.wait( + delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: account.did(), + can: 'provider/*', + }, + { + with: subject.did(), + can: 'store/*', + }, + ], + proofs, + }) + ) + + const db = DB.from({ proofs: [delegation, ...proofs] }) + + const space = DB.string() + const proof = DB.link() + + const spaces = DB.query(db.index, { + select: { + space, + proof, + }, + where: [ + Spaces.match(proof, { + audience: bob.did(), + space, + }), + ], + }) + + assert.deepEqual(spaces, [ + { space: subject.did(), proof: delegation.cid }, + ]) + + const accountVar = DB.string() + const bobAccounts = DB.query(db.index, { + select: { + account: accountVar, + proof, + }, + where: [ + Account.match(proof, { + account: accountVar, + audience: bob.did(), + }), + ], + }) + + assert.deepEqual(bobAccounts, [ + { account: account.did(), proof: delegation.cid }, + ]) + + const aliceAccounts = DB.query(db.index, { + select: { + account: accountVar, + proof, + }, + where: [ + Account.match(proof, { + account: accountVar, + audience: alice.did(), + }), + ], + }) + assert.deepEqual(aliceAccounts, [ + { account: account.did(), proof: login.cid }, + ]) + }), +} + +/** + * + * @param {API.Delegation} delegation + * @returns {Task.Task} + */ + +function* attest(delegation) { + return yield* Task.wait( + Capability.UCAN.attest.delegate({ + issuer: w3up, + audience: delegation.audience, + with: w3up.did(), + nb: { proof: delegation.cid }, + expiration: Infinity, + }) + ) +} + +function* setupAccount({ + email = /** @type {`${string}@${string}`} */ ('alice@web.mail'), + name = 'stuff', + agent = alice, +} = {}) { + const space = yield* Space.create({ name }) + + const account = Absentee.from({ id: fromEmail(email) }) + + const recovery = yield* space.createRecovery({ audience: account }) + const login = yield* Task.wait( + delegate({ + issuer: account, + audience: agent, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + proofs: recovery.proofs, + }) + ) + + const attestation = yield* attest(login) + + return { + space, + account, + recovery, + login, + attestation, + proofs: [login, attestation], + } +} + +Test.basic({ DB: testDB }) diff --git a/packages/w3up-client/test/fixtures/principals.js b/packages/w3up-client/test/fixtures/principals.js new file mode 100644 index 000000000..2f046dc0a --- /dev/null +++ b/packages/w3up-client/test/fixtures/principals.js @@ -0,0 +1,21 @@ +import { Signer } from '@ucanto/principal/ed25519' + +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ +export const alice = Signer.parse( + 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' +) +/** did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob */ +export const bob = Signer.parse( + 'MgCYbj5AJfVvdrjkjNCxB3iAUwx7RQHVQ7H1sKyHy46Iose0BEevXgL1V73PD9snOCIoONgb+yQ9sycYchQC8kygR4qY=' +) +/** did:key:z6MktafZTREjJkvV5mfJxcLpNBoVPwDLhTuMg9ng7dY4zMAL */ +export const mallory = Signer.parse( + 'MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkO0B0elFYkl3Ulf3Q3A/EvcVY0utb4etiSE8e6pi4H0FEmU=' +) + +/** did:key:z6MkrZ1r5XBFZjBU34qyD8fueMbMRkKw17BZaq2ivKFjnz2z */ +export const service = Signer.parse( + 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' +) + +export const w3up = service.withDID('did:web:web3.storage') diff --git a/packages/w3up-client/test/index.browser.test.js b/packages/w3up-client/test/index.browser.test.js index fff724a41..bbad6d964 100644 --- a/packages/w3up-client/test/index.browser.test.js +++ b/packages/w3up-client/test/index.browser.test.js @@ -1,11 +1,14 @@ import assert from 'assert' import { RS256 } from '@ipld/dag-ucan/signature' -import { create } from '../src/index.js' +import * as W3Up from '@web3-storage/w3up-client' describe('create', () => { it('should create RSA key', async () => { - const client = await create() - const signer = client.agent.issuer + const client = await W3Up.open({ + store: W3Up.Store.open({ name: 'w3up-client-test' }), + }) + + const signer = client.agent.signer assert.equal(signer.signatureAlgorithm, 'RS256') assert.equal(signer.signatureCode, RS256) }) diff --git a/packages/w3up-client/test/index.node.test.js b/packages/w3up-client/test/index.node.test.js index f9f263c2b..696f541d6 100644 --- a/packages/w3up-client/test/index.node.test.js +++ b/packages/w3up-client/test/index.node.test.js @@ -1,47 +1,49 @@ import assert from 'assert' import { Signer } from '@ucanto/principal/ed25519' import { EdDSA } from '@ipld/dag-ucan/signature' -import { StoreConf } from '@web3-storage/access/stores/store-conf' -import { create } from '../src/index.node.js' +import * as W3Up from '@web3-storage/w3up-client' -describe('create', () => { +describe('open', () => { it('should create Ed25519 key', async () => { - const client = await create() - const signer = client.agent.issuer + const client = await W3Up.open({ + store: W3Up.Store.open({ name: 'w3up-client-test' }), + }) + + const signer = client.agent.signer assert.equal(signer.signatureAlgorithm, 'EdDSA') assert.equal(signer.signatureCode, EdDSA) }) it('should load from existing store', async () => { - const store = new StoreConf({ profile: 'w3up-client-test' }) + const store = W3Up.Store.open({ name: 'w3up-client-test' }) await store.reset() - const client0 = await create({ store }) - const client1 = await create({ store }) + const client0 = await W3Up.open({ store }) + const client1 = await W3Up.open({ store }) assert.equal(client0.agent.did(), client1.agent.did()) }) it('should allow BYO principal', async () => { - const store = new StoreConf({ profile: 'w3up-client-test' }) + const store = W3Up.Store.open({ name: 'w3up-client-test' }) await store.reset() const principal = await Signer.generate() - const client = await create({ principal, store }) + const client = await W3Up.open({ as: principal, store }) assert.equal(client.agent.did(), principal.did()) }) - it('should throw for mismatched BYO principal', async () => { - const store = new StoreConf({ profile: 'w3up-client-test' }) + it('can override stored principal', async () => { + const store = W3Up.Store.open({ name: 'w3up-client-test' }) await store.reset() - const principal0 = await Signer.generate() - await create({ principal: principal0, store }) + const basic = await W3Up.create({ store }) - const principal1 = await Signer.generate() - await assert.rejects(create({ principal: principal1, store }), { - message: `store cannot be used with ${principal1.did()}, stored principal and passed principal must match`, - }) + const principal = await Signer.generate() + const advanced = await W3Up.open({ store, as: principal }) + + assert.notDeepEqual(basic.agent.did(), advanced.agent.did()) + assert.deepEqual(advanced.agent.did(), principal.did()) }) }) diff --git a/packages/w3up-client/test/result.test.js b/packages/w3up-client/test/result.test.js index c568945b8..88a3aa073 100644 --- a/packages/w3up-client/test/result.test.js +++ b/packages/w3up-client/test/result.test.js @@ -3,10 +3,10 @@ import assert from 'assert' describe('Result', () => { it('expect throws on error', async () => { - assert.throws(() => Result.try({ error: new Error('Boom') }), /Boom/) + assert.throws(() => Result.unwrap({ error: new Error('Boom') }), /Boom/) }) it('expect returns ok value if not an error', () => { - assert.equal(Result.try({ ok: 'ok' }), 'ok') + assert.equal(Result.unwrap({ ok: 'ok' }), 'ok') }) }) diff --git a/packages/w3up-client/test/session.test.js b/packages/w3up-client/test/session.test.js new file mode 100644 index 000000000..a43aa6563 --- /dev/null +++ b/packages/w3up-client/test/session.test.js @@ -0,0 +1,72 @@ +import * as Test from './test.js' +import { alice, bob, mallory, service } from './fixtures/principals.js' +import * as Agent from '../src/agent.js' +import * as Result from '../src/result.js' +import * as Session from '../src/session.js' +import * as Task from '../src/task.js' +import { Console } from '@web3-storage/capabilities' + +/** + * @type {Test.Suite} + */ +export const testSession = { + 'test execute': async (assert, { session, service }) => { + session.connection + const task = Console.log.invoke({ + issuer: service, + audience: service, + with: service.did(), + nb: { value: 'Hello, World!' }, + }) + + const output = await Session.execute(session, task) + assert.deepEqual(output, 'Hello, World!') + }, + 'test execute receipt': async (assert, { session, service }) => { + session.connection + const task = Console.log.invoke({ + issuer: service, + audience: service, + with: service.did(), + nb: { value: { x: 1 } }, + }) + + const receipt = await Session.execute(session, task).receipt() + assert.deepEqual(receipt.out, { ok: { x: 1 } }) + assert.deepEqual(await receipt.verifySignature(service.verifier), { + ok: {}, + }) + }, + 'test from task': async (assert, { session, service }) => { + const invocation = Task.spawn(function* () { + const task = Console.log.invoke({ + issuer: service, + audience: service, + with: service.did(), + nb: { value: { x: 1 } }, + }) + + return yield* Session.execute(session, task) + }) + + assert.deepEqual(await invocation, { x: 1 }) + }, + 'test from task receipt': async (assert, { session, service }) => { + const invocation = Task.spawn(function* () { + const task = Console.log.invoke({ + issuer: service, + audience: service, + with: service.did(), + nb: { value: { x: 1 } }, + }) + + return yield* Session.execute(session, task).receipt() + }) + + const receipt = await invocation + + assert.deepEqual(receipt.out, { ok: { x: 1 } }) + }, +} + +Test.test({ Session: testSession }) diff --git a/packages/w3up-client/test/space.test.js b/packages/w3up-client/test/space.test.js index de9bf395e..4e40d279e 100644 --- a/packages/w3up-client/test/space.test.js +++ b/packages/w3up-client/test/space.test.js @@ -1,56 +1,145 @@ import * as Signer from '@ucanto/principal/ed25519' -import * as StoreCapabilities from '@web3-storage/capabilities/store' import * as Test from './test.js' -import { Space } from '../src/space.js' +import * as Space from '../src/space.js' import * as Account from '../src/account.js' import * as Result from '../src/result.js' import { randomCAR } from './helpers/random.js' +import { parseLink } from '@ucanto/core' +import * as Task from '../src/task.js' +import * as API from '../src/types.js' /** * @type {Test.Suite} */ export const testSpace = { - 'should get meta': async (assert, { client }) => { - const signer = await Signer.generate() - const name = `space-${Date.now()}` - const space = new Space({ - id: signer.did(), - meta: { name }, - agent: client.agent, - }) - assert.equal(space.did(), signer.did()) - assert.equal(space.name, name) - assert.equal(space.meta()?.name, name) - }, - - 'should get usage': async (assert, { client, grantAccess, mail }) => { - const space = await client.createSpace('test') - - const email = 'alice@web.mail' - const login = Account.login(client, email) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const account = Result.try(await login) - - Result.try(await account.provision(space.did())) - await space.save() - - const size = 1138 - const archive = await randomCAR(size) - await client.agent.invokeAndExecute(StoreCapabilities.add, { - nb: { - link: archive.cid, - size, - }, - }) - - const found = client.spaces().find((s) => s.did() === space.did()) - if (!found) return assert.fail('space not found') - - const usage = Result.unwrap(await found.usage.get()) - assert.equal(usage, BigInt(size)) - }, + 'create a new space': (assert, { session, provisionsStorage }) => + Task.spawn(function* () { + const spaces = Space.view(session) + const none = spaces.list() + assert.deepEqual(none, {}) + + const space = yield* spaces.create({ name: 'my-space' }) + assert.equal(space.name, 'my-space') + + // Provision space so the API can be used. + yield* Task.wait( + provisionsStorage.put({ + provider: /** @type {API.ProviderDID} */ ( + session.connection.id.did() + ), + customer: 'did:mailto:web.mail:alice', + consumer: space.did(), + cause: parseLink('bafkqaaa'), + }) + ) + + const info = yield* space.info() + + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + + assert.deepEqual(spaces.list(), {}, 'space was not saved') + + const sharedSpace = yield* space.share({ audience: session.agent.signer }) + + yield* spaces.add(sharedSpace) + + assert.deepEqual( + spaces.list(), + { + [space.did()]: sharedSpace, + }, + 'space was saved' + ) + + const saved = spaces.list()[space.did()] + const status = yield* saved.info() + + assert.deepEqual(status, { + did: space.did(), + providers: [session.connection.id.did()], + }) + }), + 'should get usage': async ( + assert, + { session, grantAccess, mail, plansStorage } + ) => + Task.spawn(function* () { + const product = 'did:web:test.web3.storage' + const space = yield* session.spaces.create({ name: 'test' }) + + const email = 'alice@web.mail' + const login = session.accounts.login({ email }) + + const message = yield* Task.wait(mail.take()) + assert.deepEqual(message.to, email) + yield* Task.wait(grantAccess(message)) + + const account = yield* login + + // setup billing plan + yield* Task.ok.wait(plansStorage.set(account.did(), product)) + + const plans = yield* account.plans.list() + const [plan] = Object.values(plans) + + yield* plan.subscriptions.add({ consumer: space.did() }) + + const shared = yield* space.share({ audience: session.agent.signer }) + yield* session.spaces.add(shared) + + const [saved] = session.spaces + assert.deepEqual(saved.did(), space.did()) + + // const size = 1138 + // const archive = await randomCAR(size) + // await client.agent.invokeAndExecute(StoreCapabilities.add, { + // nb: { + // link: archive.cid, + // size, + // }, + // }) + // const found = client.spaces().find((s) => s.did() === space.did()) + // if (!found) return assert.fail('space not found') + // const usage = Result.unwrap(await found.usage.get()) + // assert.equal(usage, BigInt(size)) + }), + + 'get space info': async ( + assert, + { session, mail, plansStorage, grantAccess } + ) => + Task.spawn(function* () { + const product = 'did:web:test.web3.storage' + const email = 'alice@web.mail' + yield* Task.ok.wait( + plansStorage.set(Account.DIDMailto.fromEmail(email), product) + ) + + const login = Account.login(session, { email }) + const message = yield* Task.wait(mail.take()) + + yield* Task.wait(grantAccess(message)) + const alice = yield* login + + const plans = yield* alice.plans.list() + const [plan] = Object.values(plans) + + assert.equal(plan.customer, alice.did()) + assert.equal(plan.provider, session.connection.id.did()) + + const space = yield* Space.create({ name: 'test-space' }) + + yield* plan.subscriptions.add({ consumer: space.did() }) + + const info = yield* space.connect(session.connection).info() + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + }), } Test.test({ Space: testSpace }) diff --git a/packages/w3up-client/test/store/conf.node.test.js b/packages/w3up-client/test/store/conf.node.test.js new file mode 100644 index 000000000..defa6631c --- /dev/null +++ b/packages/w3up-client/test/store/conf.node.test.js @@ -0,0 +1,97 @@ +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 { StoreConf } from '../../src/stores/store-conf.js' + +describe('Conf store', () => { + it('should not fail on to store undefined value', async () => { + const driver = new ConfDriver({ profile: 'w3protocol-access-client-test' }) + await driver.reset() + await driver.save({ foo: undefined, bar: 1 }) + const data = await driver.load() + assert(data) + assert.strictEqual(data.foo, undefined) + assert.strictEqual(data.bar, 1) + }) + + it('should store a Buffer', async () => { + const driver = new ConfDriver({ profile: 'w3protocol-access-client-test' }) + await driver.reset() + await driver.save({ buf: Buffer.from('⁂', 'utf8') }) + const actual = await driver.load() + assert(actual) + assert.deepEqual(actual.buf, new TextEncoder().encode('⁂')) + }) + + it('should create and load data', async () => { + const data = await AgentData.create({ + principal: await RSASigner.generate({ extractable: false }), + }) + + const store = new StoreConf({ profile: 'test-access-db-' + Date.now() }) + await store.open() + await store.save(data.export()) + + const exportData = await store.load() + assert(exportData) + + // no accounts or delegations yet + assert.equal(exportData.spaces.size, 0) + assert.equal(exportData.delegations.size, 0) + + // default meta + assert.equal(exportData.meta.name, 'agent') + assert.equal(exportData.meta.type, 'device') + }) + + it('should round trip delegations', async () => { + const store = new StoreConf({ profile: 'test-access-db-' + Date.now() }) + await store.open() + + const data0 = await AgentData.create() + const signer = await EdSigner.generate() + const del0 = await top.delegate({ + issuer: signer, + audience: data0.principal, + with: signer.did(), + expiration: Infinity, + }) + + await data0.addDelegation(del0, { + audience: { name: 'test', type: 'device' }, + }) + await store.save(data0.export()) + + const exportData1 = await store.load() + assert(exportData1) + + const data1 = AgentData.fromExport(exportData1) + + const { delegation: del1 } = + data1.delegations.get(del0.cid.toString()) ?? {} + assert(del1) + assert.equal(del1.cid.toString(), del0.cid.toString()) + assert.equal(del1.issuer.did(), del0.issuer.did()) + assert.equal(del1.audience.did(), del0.audience.did()) + assert.equal(del1.capabilities[0].can, del0.capabilities[0].can) + assert.equal(del1.capabilities[0].with, del0.capabilities[0].with) + }) + + it('should be resettable', async () => { + const principal = await RSASigner.generate({ extractable: false }) + const data = await AgentData.create({ principal }) + + const store = new StoreConf({ profile: 'test-access-db-' + Date.now() }) + await store.open() + await store.save(data.export()) + + const exportData = await store.load() + assert.equal(exportData?.principal.id, principal.did()) + + await store.reset() + const resetExportData = await store.load() + assert.equal(resetExportData?.principal.id, undefined) + }) +}) diff --git a/packages/w3up-client/test/store/indexeddb.browser.test.js b/packages/w3up-client/test/store/indexeddb.browser.test.js new file mode 100644 index 000000000..10887781f --- /dev/null +++ b/packages/w3up-client/test/store/indexeddb.browser.test.js @@ -0,0 +1,121 @@ +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 * as Store from '../../src/store/indexed-db.js' +import * as DB from '../../src/agent/db.js' +import * as Agent from '../../src/agent.js' + +describe('IndexedDB store', () => { + it('should create and load data', async () => { + const store = Store.open({ name: 'test-w3up-db-' + Date.now() }) + const agent = await Agent.open({ + store, + }) + + await store.connect() + await DB.save(agent.db) + + const exportData = await store.load() + assert.ok(exportData) + + // principal private key is not extractable + const archive = exportData.principal + assert(!(archive instanceof Uint8Array)) + // eslint-disable-next-line no-unused-vars + const [[_, key], ...keys] = [...Object.entries(archive.keys)] + assert.deepEqual(keys, []) + assert(key instanceof CryptoKey) + assert.equal(key.extractable, false) + + // no accounts or delegations yet + assert.equal(exportData.delegations.size, 0) + + // default meta + assert.equal(exportData.meta.name, 'agent') + assert.equal(exportData.meta.type, 'device') + }) + + it('should allow custom store name', async () => { + const store = Store.open({ + name: 'test-w3up-db-' + Date.now(), + storeName: `store-${Date.now()}`, + }) + await store.connect() + + const agent = await Agent.open({ + store, + }) + await DB.save(agent.db) + + await store.close() + await store.connect() + + const archive = await DB.toArchive(agent.db) + assert.ok(archive) + + const db = DB.fromArchive(archive) + + assert.equal(db.signer?.id, agent.did()) + }) + + it('should close and disallow usage', async () => { + const store = Store.open({ + name: 'test-w3up-db-' + Date.now(), + autoOpen: false, + }) + + await store.connect() + await store.load() + await store.close() + + // 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 = Store.open({ + name: 'test-w3up-db-' + Date.now(), + }) + await store.connect() + + const agent = await Agent.open({ store }) + + const signer = await EdSigner.generate() + const proof = await top.delegate({ + issuer: signer, + audience: agent, + with: signer.did(), + expiration: Infinity, + }) + + await DB.transact(agent.db, [DB.assert({ proof })]) + + await DB.save(agent.db) + + const archive = await store.load() + assert.ok(archive) + + const db = DB.from({ archive }) + + const { delegation } = db.proofs.get(`${proof.cid}`) ?? {} + + assert.deepEqual(delegation, proof) + }) + + it('should be resettable', async () => { + const store = Store.open({ name: 'test-w3up-db-' + Date.now() }) + const agent = await Agent.open({ store }) + + await store.connect() + await DB.save(agent.db) + + const exportData = await store.load() + assert.equal(exportData?.principal.id, agent.did()) + + await store.reset() + const resetExportData = await store.load() + assert.equal(resetExportData?.principal.id, undefined) + }) +}) diff --git a/packages/w3up-client/test/task.test.js b/packages/w3up-client/test/task.test.js new file mode 100644 index 000000000..171ec1751 --- /dev/null +++ b/packages/w3up-client/test/task.test.js @@ -0,0 +1,56 @@ +import * as Test from './test.js' +import * as Task from '../src/task.js' + +/** + * @type {Test.BasicSuite} + + */ +export const taskTests = { + 'task sleep can be aborted': async (assert) => { + const task = Task.perform(Task.sleep(10)) + + task.abort('cancel') + + const result = await task.result() + assert.deepEqual(result.error?.reason, 'cancel') + assert.deepEqual(result.error?.name, 'AbortError') + }, + + 'sleep awake': async (assert) => { + const task = Task.perform(Task.sleep(10)) + const result = await task.result() + assert.deepEqual(result, { ok: undefined }) + }, + + 'task cancels joined task': async (assert) => { + let done = false + function* worker() { + yield* Task.sleep(10) + done = true + } + + function* main() { + return yield* Task.spawn(worker) + } + + const task = Task.perform(main()) + task.abort('cancel') + const result = await task.result() + assert.deepEqual(result.error?.reason, 'cancel') + + await new Promise((resolve) => setTimeout(resolve, 20)) + + assert.deepEqual(done, false) + }, + + 'test wait': async (assert) => { + const task = Task.spawn(function* () { + const value = yield* Task.wait(Promise.resolve(4)) + return value + }) + + assert.deepEqual(await task.result(), { ok: 4 }) + }, +} + +Test.basic({ Task: taskTests }) diff --git a/packages/w3up-client/test/test.js b/packages/w3up-client/test/test.js index 7bf1f9105..94bec13a0 100644 --- a/packages/w3up-client/test/test.js +++ b/packages/w3up-client/test/test.js @@ -1,7 +1,10 @@ -import { StoreMemory } from '@web3-storage/access/stores/store-memory' +// import { StoreMemory } from '@web3-storage/access/stores/store-memory' import * as Context from '@web3-storage/upload-api/test/context' -import * as Client from '@web3-storage/w3up-client' +// import * as Client from '@web3-storage/w3up-client' +import { open } from '../src/store/memory.js' +import * as W3Up from '../src/w3up.js' import * as assert from 'assert' +import * as API from '../src/types.js' /** * @typedef {Omit & {ok(value:unknown, message?:string):void}} Assert @@ -31,20 +34,56 @@ export const test = (suite) => { } } -export const setup = async () => { - const context = await Context.createContext({ - assert, +const setupContext = async () => { + const context = await Context.createContext({ assert }) + return Object.assign(context, { + connection: Object.assign(context.connection, { + address: { + id: context.connection.id, + url: context.url, + }, + }), }) +} - const connect = () => - Client.create({ - store: new StoreMemory(), - serviceConf: { - access: context.connection, - upload: context.connection, - filecoin: context.connection, - }, - }) +/** + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} connection + */ +export const connect = (connection) => + W3Up.open({ + store: open(), + connection, + }) + +export const setup = async () => { + const context = await setupContext() + + const session = /** @type {API.W3UpSession} */ ( + await connect(context.connection) + ) + + return { ...context, session } +} + +/** + * @typedef {Record unknown>} BasicSuite + * @param {BasicSuite|Record} suite + */ +export const basic = (suite) => { + for (const [name, member] of Object.entries(suite)) { + if (typeof member === 'function') { + const define = name.startsWith('only ') + ? it.only + : name.startsWith('skip ') + ? it.skip + : it - return { ...context, connect, client: await connect() } + define(name, async () => { + await member(assert) + }) + } else { + describe(name, () => test(member)) + } + } } diff --git a/packages/w3up-client/test/usage.test.js b/packages/w3up-client/test/usage.test.js new file mode 100644 index 000000000..e6159a164 --- /dev/null +++ b/packages/w3up-client/test/usage.test.js @@ -0,0 +1,53 @@ +import * as Test from './test.js' +import * as Task from '../src/task.js' +import * as Result from '../src/result.js' + +/** + * @type {Test.Suite} + */ +export const testUsage = { + 'space.usage.report()': ( + assert, + { mail, session, grantAccess, plansStorage } + ) => + Task.spawn(function* () { + // First we login to the workshop account + const login = session.accounts.login({ email: 'alice@web.mail' }) + const message = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(message)) + const account = yield* login + // Result.unwrap(await session.accounts.add(account)) + + // Then we setup a billing for this account + yield* Task.wait( + plansStorage.set(account.did(), 'did:web:test.web3.storage') + ) + + const space = yield* account.spaces.create({ name: 'home' }) + + const [plan] = yield* account.plans.list() + + yield* plan.subscriptions.add({ consumer: space.did() }) + + const period = { from: new Date(0), to: new Date(1709769229000) } + + const report = yield* space.usage.report(period) + assert.deepEqual(report, { + 'did:web:test.web3.storage': { + size: { final: 0, initial: 0 }, + space: space.did(), + events: [], + period: { + from: period.from.toISOString(), + to: period.to.toISOString(), + }, + provider: 'did:web:test.web3.storage', + }, + }) + + const usage = yield* space.usage.get() + assert.deepEqual(usage, 0n) + }), +} + +Test.test({ Access: testUsage }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7810ce1fc..701fa0aca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -20,10 +20,10 @@ importers: version: 0.12.2 '@docusaurus/core': specifier: ^3.0.0 - version: 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + version: 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/preset-classic': specifier: ^3.0.0 - version: 3.0.0(@algolia/client-search@4.20.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.2.2) + version: 3.0.0(@algolia/client-search@4.20.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.4.2) docusaurus-plugin-typedoc: specifier: ^0.21.0 version: 0.21.0(typedoc-plugin-markdown@3.17.1)(typedoc@0.25.3) @@ -38,13 +38,13 @@ importers: version: 18.2.0 typedoc: specifier: ^0.25.3 - version: 0.25.3(typescript@5.2.2) + version: 0.25.3(typescript@5.4.2) typedoc-plugin-markdown: specifier: ^3.17.0 version: 3.17.1(typedoc@0.25.3) typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages/access-client: dependencies: @@ -143,8 +143,8 @@ importers: specifier: ^15.0.3 version: 15.2.0 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 watch: specifier: ^1.0.2 version: 1.0.2 @@ -195,8 +195,8 @@ importers: specifier: ^3.3.0 version: 3.13.1 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 watch: specifier: ^1.0.2 version: 1.0.2 @@ -216,17 +216,17 @@ importers: specifier: ^10.2.0 version: 10.2.0 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages/eslint-config-w3up: dependencies: '@typescript-eslint/eslint-plugin': specifier: ^6.9.1 - version: 6.11.0(@typescript-eslint/parser@6.11.0)(eslint@8.54.0)(typescript@5.2.2) + version: 6.11.0(@typescript-eslint/parser@6.11.0)(eslint@8.54.0)(typescript@5.4.2) '@typescript-eslint/parser': specifier: ^6.9.1 - version: 6.11.0(eslint@8.54.0)(typescript@5.2.2) + version: 6.11.0(eslint@8.54.0)(typescript@5.4.2) eslint: specifier: '>= 8' version: 8.54.0 @@ -295,8 +295,8 @@ importers: specifier: ^5.0.2 version: 5.0.2 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages/filecoin-client: dependencies: @@ -365,8 +365,8 @@ importers: specifier: ^12.3.4 version: 12.6.1 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages/upload-api: dependencies: @@ -438,8 +438,8 @@ importers: specifier: git://github.com/web3-storage/one-webcrypto version: github.com/web3-storage/one-webcrypto/5148cd14d5489a8ac4cd38223870e02db15a2382 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages/upload-client: dependencies: @@ -529,14 +529,17 @@ importers: specifier: ^12.3.4 version: 12.6.1 typescript: - specifier: 5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages/w3up-client: dependencies: '@ipld/dag-ucan': specifier: ^3.4.0 version: 3.4.0 + '@scure/bip39': + specifier: ^1.2.1 + version: 1.2.1 '@ucanto/client': specifier: ^9.0.0 version: 9.0.0 @@ -567,6 +570,15 @@ importers: '@web3-storage/upload-client': specifier: workspace:^ version: link:../upload-client + conf: + specifier: 11.0.2 + version: 11.0.2 + datalogia: + specifier: ^0.4.0 + version: 0.4.0 + uint8arrays: + specifier: ^4.0.9 + version: 4.0.9 devDependencies: '@ipld/car': specifier: ^5.1.1 @@ -615,10 +627,10 @@ importers: version: 12.6.1 typedoc: specifier: ^0.25.3 - version: 0.25.3(typescript@5.2.2) + version: 0.25.3(typescript@5.4.2) typescript: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.4.2 + version: 5.4.2 packages: @@ -800,7 +812,7 @@ packages: fetch-ponyfill: 7.1.0 fflate: 0.7.4 semver: 7.5.4 - typescript: 5.2.2 + typescript: 5.4.2 validate-npm-package-name: 5.0.0 transitivePeerDependencies: - encoding @@ -2153,7 +2165,7 @@ packages: - '@algolia/client-search' dev: true - /@docusaurus/core@3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2): + /@docusaurus/core@3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-bHWtY55tJTkd6pZhHrWz1MpWuwN4edZe0/UWgFF7PW/oJeDZvLSXKqwny3L91X1/LGGoypBGkeZn8EOuKeL4yQ==} engines: {node: '>=18.0'} hasBin: true @@ -2213,10 +2225,10 @@ packages: lodash: 4.17.21 mini-css-extract-plugin: 2.7.6(webpack@5.89.0) postcss: 8.4.31 - postcss-loader: 7.3.3(postcss@8.4.31)(typescript@5.2.2)(webpack@5.89.0) + postcss-loader: 7.3.3(postcss@8.4.31)(typescript@5.4.2)(webpack@5.89.0) prompts: 2.4.2 react: 18.2.0 - react-dev-utils: 12.0.1(typescript@5.2.2)(webpack@5.89.0) + react-dev-utils: 12.0.1(typescript@5.4.2)(webpack@5.89.0) react-helmet-async: 1.3.0(react@18.2.0) react-loadable: /@docusaurus/react-loadable@5.5.2(react@18.2.0) react-loadable-ssr-addon-v5-slorber: 1.0.1(@docusaurus/react-loadable@5.5.2)(webpack@5.89.0) @@ -2349,7 +2361,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-content-blog@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-content-blog@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-iA8Wc3tIzVnROJxrbIsU/iSfixHW16YeW9RWsBw7hgEk4dyGsip9AsvEDXobnRq3lVv4mfdgoS545iGWf1Ip9w==} engines: {node: '>=18.0'} peerDependencies: @@ -2361,7 +2373,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/logger': 3.0.0 '@docusaurus/mdx-loader': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0) '@docusaurus/types': 3.0.0(react@18.2.0) @@ -2397,7 +2409,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-content-docs@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-content-docs@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-MFZsOSwmeJ6rvoZMLieXxPuJsA9M9vn7/mUZmfUzSUTeHAeq+fEqvLltFOxcj4DVVDTYlQhgWYd+PISIWgamKw==} engines: {node: '>=18.0'} peerDependencies: @@ -2409,7 +2421,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/logger': 3.0.0 '@docusaurus/mdx-loader': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0) '@docusaurus/module-type-aliases': 3.0.0(react@18.2.0) @@ -2443,7 +2455,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-content-pages@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-content-pages@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-EXYHXK2Ea1B5BUmM0DgSwaOYt8EMSzWtYUToNo62Q/EoWxYOQFdWglYnw3n7ZEGyw5Kog4LHaRwlazAdmDomvQ==} engines: {node: '>=18.0'} peerDependencies: @@ -2455,7 +2467,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/mdx-loader': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0) '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils': 3.0.0(@docusaurus/types@3.0.0) @@ -2482,7 +2494,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-debug@3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-debug@3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-gSV07HfQgnUboVEb3lucuVyv5pEoy33E7QXzzn++3kSc/NLEimkjXh3sSnTGOishkxCqlFV9BHfY/VMm5Lko5g==} engines: {node: '>=18.0'} peerDependencies: @@ -2494,7 +2506,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils': 3.0.0(@docusaurus/types@3.0.0) '@microlink/react-json-view': 1.23.0(@types/react@18.2.37)(react@18.2.0) @@ -2521,7 +2533,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-google-analytics@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-google-analytics@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-0zcLK8w+ohmSm1fjUQCqeRsjmQc0gflvXnaVA/QVVCtm2yCiBtkrSGQXqt4MdpD7Xq8mwo3qVd5nhIcvrcebqw==} engines: {node: '>=18.0'} peerDependencies: @@ -2533,7 +2545,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils-validation': 3.0.0(@docusaurus/types@3.0.0) react: 18.2.0 @@ -2556,7 +2568,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-google-gtag@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-google-gtag@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-asEKavw8fczUqvXu/s9kG2m1epLnHJ19W6CCCRZEmpnkZUZKiM8rlkDiEmxApwIc2JDDbIMk+Y2TMkJI8mInbQ==} engines: {node: '>=18.0'} peerDependencies: @@ -2568,7 +2580,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils-validation': 3.0.0(@docusaurus/types@3.0.0) '@types/gtag.js': 0.0.12 @@ -2592,7 +2604,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-google-tag-manager@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-google-tag-manager@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-lytgu2eyn+7p4WklJkpMGRhwC29ezj4IjPPmVJ8vGzcSl6JkR1sADTHLG5xWOMuci420xZl9dGEiLTQ8FjCRyA==} engines: {node: '>=18.0'} peerDependencies: @@ -2604,7 +2616,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils-validation': 3.0.0(@docusaurus/types@3.0.0) react: 18.2.0 @@ -2627,7 +2639,7 @@ packages: - webpack-cli dev: true - /@docusaurus/plugin-sitemap@3.0.0(react@18.2.0)(typescript@5.2.2): + /@docusaurus/plugin-sitemap@3.0.0(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-cfcONdWku56Oi7Hdus2uvUw/RKRRlIGMViiHLjvQ21CEsEqnQ297MRoIgjU28kL7/CXD/+OiANSq3T1ezAiMhA==} engines: {node: '>=18.0'} peerDependencies: @@ -2639,7 +2651,7 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/logger': 3.0.0 '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils': 3.0.0(@docusaurus/types@3.0.0) @@ -2667,7 +2679,7 @@ packages: - webpack-cli dev: true - /@docusaurus/preset-classic@3.0.0(@algolia/client-search@4.20.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.2.2): + /@docusaurus/preset-classic@3.0.0(@algolia/client-search@4.20.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.4.2): resolution: {integrity: sha512-90aOKZGZdi0+GVQV+wt8xx4M4GiDrBRke8NO8nWwytMEXNrxrBxsQYFRD1YlISLJSCiHikKf3Z/MovMnQpnZyg==} engines: {node: '>=18.0'} peerDependencies: @@ -2679,18 +2691,18 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-blog': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-pages': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-debug': 3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-google-analytics': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-google-gtag': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-google-tag-manager': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-sitemap': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/theme-classic': 3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.2.2) - '@docusaurus/theme-common': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) - '@docusaurus/theme-search-algolia': 3.0.0(@algolia/client-search@4.20.0)(@docusaurus/types@3.0.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-blog': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-pages': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-debug': 3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-google-analytics': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-google-gtag': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-google-tag-manager': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-sitemap': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/theme-classic': 3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.4.2) + '@docusaurus/theme-common': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) + '@docusaurus/theme-search-algolia': 3.0.0(@algolia/client-search@4.20.0)(@docusaurus/types@3.0.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.4.2) '@docusaurus/types': 3.0.0(react@18.2.0) react: 18.2.0 transitivePeerDependencies: @@ -2728,7 +2740,7 @@ packages: react: 18.2.0 dev: true - /@docusaurus/theme-classic@3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.2.2): + /@docusaurus/theme-classic@3.0.0(@types/react@18.2.37)(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-wWOHSrKMn7L4jTtXBsb5iEJ3xvTddBye5PjYBnWiCkTAlhle2yMdc4/qRXW35Ot+OV/VXu6YFG8XVUJEl99z0A==} engines: {node: '>=18.0'} peerDependencies: @@ -2740,13 +2752,13 @@ packages: react-dom: optional: true dependencies: - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/mdx-loader': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0) '@docusaurus/module-type-aliases': 3.0.0(react@18.2.0) - '@docusaurus/plugin-content-blog': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-pages': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/theme-common': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/plugin-content-blog': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-pages': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/theme-common': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/theme-translations': 3.0.0 '@docusaurus/types': 3.0.0(react@18.2.0) '@docusaurus/utils': 3.0.0(@docusaurus/types@3.0.0) @@ -2785,7 +2797,7 @@ packages: - webpack-cli dev: true - /@docusaurus/theme-common@3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2): + /@docusaurus/theme-common@3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2): resolution: {integrity: sha512-PahRpCLRK5owCMEqcNtUeTMOkTUCzrJlKA+HLu7f+8osYOni617YurXvHASCsSTxurjXaLz/RqZMnASnqATxIA==} engines: {node: '>=18.0'} peerDependencies: @@ -2799,9 +2811,9 @@ packages: dependencies: '@docusaurus/mdx-loader': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0) '@docusaurus/module-type-aliases': 3.0.0(react@18.2.0) - '@docusaurus/plugin-content-blog': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/plugin-content-pages': 3.0.0(react@18.2.0)(typescript@5.2.2) + '@docusaurus/plugin-content-blog': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/plugin-content-pages': 3.0.0(react@18.2.0)(typescript@5.4.2) '@docusaurus/utils': 3.0.0(@docusaurus/types@3.0.0) '@docusaurus/utils-common': 3.0.0(@docusaurus/types@3.0.0) '@types/history': 4.7.11 @@ -2832,7 +2844,7 @@ packages: - webpack-cli dev: true - /@docusaurus/theme-search-algolia@3.0.0(@algolia/client-search@4.20.0)(@docusaurus/types@3.0.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.2.2): + /@docusaurus/theme-search-algolia@3.0.0(@algolia/client-search@4.20.0)(@docusaurus/types@3.0.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0)(typescript@5.4.2): resolution: {integrity: sha512-PyMUNIS9yu0dx7XffB13ti4TG47pJq3G2KE/INvOFb6M0kWh+wwCnucPg4WAOysHOPh+SD9fjlXILoLQstgEIA==} engines: {node: '>=18.0'} peerDependencies: @@ -2845,10 +2857,10 @@ packages: optional: true dependencies: '@docsearch/react': 3.5.2(@algolia/client-search@4.20.0)(@types/react@18.2.37)(react@18.2.0)(search-insights@2.11.0) - '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/core': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/logger': 3.0.0 - '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.2.2) - '@docusaurus/theme-common': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.2.2) + '@docusaurus/plugin-content-docs': 3.0.0(react@18.2.0)(typescript@5.4.2) + '@docusaurus/theme-common': 3.0.0(@docusaurus/types@3.0.0)(react@18.2.0)(typescript@5.4.2) '@docusaurus/theme-translations': 3.0.0 '@docusaurus/utils': 3.0.0(@docusaurus/types@3.0.0) '@docusaurus/utils-validation': 3.0.0(@docusaurus/types@3.0.0) @@ -3274,6 +3286,14 @@ packages: cborg: 4.0.5 multiformats: 12.1.3 + /@ipld/dag-cbor@9.1.0: + resolution: {integrity: sha512-7pMKjBaapEh+1Nk/1umPPhQGT6znb5E71lke2ekxlcuVZLLrPPdDSy0UAMwWgj3a28cjir/ZJ6CQH2DEs3DUOQ==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + dependencies: + cborg: 4.0.5 + multiformats: 13.0.1 + dev: false + /@ipld/dag-json@10.1.5: resolution: {integrity: sha512-AIIDRGPgIqVG2K1O42dPDzNOfP0YWV/suGApzpF+YWZLwkwdGVsxjmXcJ/+rwOhRGdjpuq/xQBKPCu1Ao6rdOQ==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} @@ -3451,6 +3471,10 @@ packages: resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} + /@noble/hashes@1.3.3: + resolution: {integrity: sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==} + engines: {node: '>= 16'} + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4126,7 +4150,7 @@ packages: '@types/yargs-parser': 21.0.3 dev: true - /@typescript-eslint/eslint-plugin@6.11.0(@typescript-eslint/parser@6.11.0)(eslint@8.54.0)(typescript@5.2.2): + /@typescript-eslint/eslint-plugin@6.11.0(@typescript-eslint/parser@6.11.0)(eslint@8.54.0)(typescript@5.4.2): resolution: {integrity: sha512-uXnpZDc4VRjY4iuypDBKzW1rz9T5YBBK0snMn8MaTSNd2kMlj50LnLBABELjJiOL5YHk7ZD8hbSpI9ubzqYI0w==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -4138,10 +4162,10 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.11.0(eslint@8.54.0)(typescript@5.2.2) + '@typescript-eslint/parser': 6.11.0(eslint@8.54.0)(typescript@5.4.2) '@typescript-eslint/scope-manager': 6.11.0 - '@typescript-eslint/type-utils': 6.11.0(eslint@8.54.0)(typescript@5.2.2) - '@typescript-eslint/utils': 6.11.0(eslint@8.54.0)(typescript@5.2.2) + '@typescript-eslint/type-utils': 6.11.0(eslint@8.54.0)(typescript@5.4.2) + '@typescript-eslint/utils': 6.11.0(eslint@8.54.0)(typescript@5.4.2) '@typescript-eslint/visitor-keys': 6.11.0 debug: 4.3.4(supports-color@8.1.1) eslint: 8.54.0 @@ -4149,13 +4173,13 @@ packages: ignore: 5.3.0 natural-compare: 1.4.0 semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.3(typescript@5.4.2) + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: false - /@typescript-eslint/parser@6.11.0(eslint@8.54.0)(typescript@5.2.2): + /@typescript-eslint/parser@6.11.0(eslint@8.54.0)(typescript@5.4.2): resolution: {integrity: sha512-+whEdjk+d5do5nxfxx73oanLL9ghKO3EwM9kBCkUtWMRwWuPaFv9ScuqlYfQ6pAD6ZiJhky7TZ2ZYhrMsfMxVQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -4167,11 +4191,11 @@ packages: dependencies: '@typescript-eslint/scope-manager': 6.11.0 '@typescript-eslint/types': 6.11.0 - '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.4.2) '@typescript-eslint/visitor-keys': 6.11.0 debug: 4.3.4(supports-color@8.1.1) eslint: 8.54.0 - typescript: 5.2.2 + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: false @@ -4184,7 +4208,7 @@ packages: '@typescript-eslint/visitor-keys': 6.11.0 dev: false - /@typescript-eslint/type-utils@6.11.0(eslint@8.54.0)(typescript@5.2.2): + /@typescript-eslint/type-utils@6.11.0(eslint@8.54.0)(typescript@5.4.2): resolution: {integrity: sha512-nA4IOXwZtqBjIoYrJcYxLRO+F9ri+leVGoJcMW1uqr4r1Hq7vW5cyWrA43lFbpRvQ9XgNrnfLpIkO3i1emDBIA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -4194,12 +4218,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.2.2) - '@typescript-eslint/utils': 6.11.0(eslint@8.54.0)(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.4.2) + '@typescript-eslint/utils': 6.11.0(eslint@8.54.0)(typescript@5.4.2) debug: 4.3.4(supports-color@8.1.1) eslint: 8.54.0 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.3(typescript@5.4.2) + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: false @@ -4209,7 +4233,7 @@ packages: engines: {node: ^16.0.0 || >=18.0.0} dev: false - /@typescript-eslint/typescript-estree@6.11.0(typescript@5.2.2): + /@typescript-eslint/typescript-estree@6.11.0(typescript@5.4.2): resolution: {integrity: sha512-Aezzv1o2tWJwvZhedzvD5Yv7+Lpu1by/U1LZ5gLc4tCx8jUmuSCMioPFRjliN/6SJIvY6HpTtJIWubKuYYYesQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -4224,13 +4248,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.3(typescript@5.4.2) + typescript: 5.4.2 transitivePeerDependencies: - supports-color dev: false - /@typescript-eslint/utils@6.11.0(eslint@8.54.0)(typescript@5.2.2): + /@typescript-eslint/utils@6.11.0(eslint@8.54.0)(typescript@5.4.2): resolution: {integrity: sha512-p23ibf68fxoZy605dc0dQAEoUsoiNoP3MD9WQGiHLDuTSOuqoTsa4oAy+h3KDkTcxbbfOtUjb9h3Ta0gT4ug2g==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -4241,7 +4265,7 @@ packages: '@types/semver': 7.5.5 '@typescript-eslint/scope-manager': 6.11.0 '@typescript-eslint/types': 6.11.0 - '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.4.2) eslint: 8.54.0 semver: 7.5.4 transitivePeerDependencies: @@ -5654,7 +5678,7 @@ packages: path-type: 4.0.0 yaml: 1.10.2 - /cosmiconfig@8.3.6(typescript@5.2.2): + /cosmiconfig@8.3.6(typescript@5.4.2): resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} peerDependencies: @@ -5667,7 +5691,7 @@ packages: js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 - typescript: 5.2.2 + typescript: 5.4.2 dev: true /cpy@11.0.0: @@ -5901,6 +5925,14 @@ packages: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true + /datalogia@0.4.0: + resolution: {integrity: sha512-ScBAPsoSNEVmSr4V98o32a/fs/CNWJGah/Rxu8lWFN7xBOo1UpDcVq5r1vJERK6dZTDslfHbRfBjH2HSFaezeA==} + dependencies: + '@ipld/dag-cbor': 9.1.0 + '@noble/hashes': 1.3.3 + multiformats: 13.0.1 + dev: false + /debounce-fn@5.1.2: resolution: {integrity: sha512-Sr4SdOZ4vw6eQDvPYNxHogvrxmCIld/VenC5JbNrFwMiwd7lY/Z18ZFfo+EWNG4DD9nFlAujWAo/wGuOPHmy5A==} engines: {node: '>=12'} @@ -6153,7 +6185,7 @@ packages: typedoc: '>=0.24.0' typedoc-plugin-markdown: '>=3.15.0' dependencies: - typedoc: 0.25.3(typescript@5.2.2) + typedoc: 0.25.3(typescript@5.4.2) typedoc-plugin-markdown: 3.17.1(typedoc@0.25.3) dev: true @@ -7008,7 +7040,7 @@ packages: signal-exit: 3.0.7 dev: true - /fork-ts-checker-webpack-plugin@6.5.3(typescript@5.2.2)(webpack@5.89.0): + /fork-ts-checker-webpack-plugin@6.5.3(typescript@5.4.2)(webpack@5.89.0): resolution: {integrity: sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==} engines: {node: '>=10', yarn: '>=1.0.0'} peerDependencies: @@ -7035,7 +7067,7 @@ packages: schema-utils: 2.7.0 semver: 7.5.4 tapable: 1.1.3 - typescript: 5.2.2 + typescript: 5.4.2 webpack: 5.89.0 dev: true @@ -7364,7 +7396,7 @@ packages: engines: {node: '>=16.0.0', npm: '>=7.0.0'} dependencies: sparse-array: 1.3.2 - uint8arrays: 4.0.6 + uint8arrays: 4.0.9 dev: true /handle-thing@2.0.1: @@ -9623,6 +9655,10 @@ packages: resolution: {integrity: sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} + /multiformats@13.0.1: + resolution: {integrity: sha512-bt3R5iXe2O8xpp3wkmQhC73b/lC4S2ihU8Dndwcsysqbydqb8N+bpP116qMcClZ17g58iSIwtXUTcg2zT4sniA==} + dev: false + /multimatch@5.0.0: resolution: {integrity: sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==} engines: {node: '>=10'} @@ -10414,14 +10450,14 @@ packages: postcss-selector-parser: 6.0.13 dev: true - /postcss-loader@7.3.3(postcss@8.4.31)(typescript@5.2.2)(webpack@5.89.0): + /postcss-loader@7.3.3(postcss@8.4.31)(typescript@5.4.2)(webpack@5.89.0): resolution: {integrity: sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==} engines: {node: '>= 14.15.0'} peerDependencies: postcss: ^7.0.0 || ^8.0.1 webpack: ^5.0.0 dependencies: - cosmiconfig: 8.3.6(typescript@5.2.2) + cosmiconfig: 8.3.6(typescript@5.4.2) jiti: 1.21.0 postcss: 8.4.31 semver: 7.5.4 @@ -10948,7 +10984,7 @@ packages: pure-color: 1.3.0 dev: true - /react-dev-utils@12.0.1(typescript@5.2.2)(webpack@5.89.0): + /react-dev-utils@12.0.1(typescript@5.4.2)(webpack@5.89.0): resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} engines: {node: '>=14'} peerDependencies: @@ -10967,7 +11003,7 @@ packages: escape-string-regexp: 4.0.0 filesize: 8.0.7 find-up: 5.0.0 - fork-ts-checker-webpack-plugin: 6.5.3(typescript@5.2.2)(webpack@5.89.0) + fork-ts-checker-webpack-plugin: 6.5.3(typescript@5.4.2)(webpack@5.89.0) global-modules: 2.0.0 globby: 11.1.0 gzip-size: 6.0.0 @@ -10982,7 +11018,7 @@ packages: shell-quote: 1.8.1 strip-ansi: 6.0.1 text-table: 0.2.0 - typescript: 5.2.2 + typescript: 5.4.2 webpack: 5.89.0 transitivePeerDependencies: - eslint @@ -12165,7 +12201,7 @@ packages: /sync-multihash-sha2@1.0.0: resolution: {integrity: sha512-A5gVpmtKF0ov+/XID0M0QRJqF2QxAsj3x/LlDC8yivzgoYCoWkV+XaZPfVu7Vj1T/hYzYS1tfjwboSbXjqocug==} dependencies: - '@noble/hashes': 1.3.2 + '@noble/hashes': 1.3.3 /tapable@1.1.3: resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} @@ -12289,13 +12325,13 @@ packages: matchit: 1.1.0 dev: true - /ts-api-utils@1.0.3(typescript@5.2.2): + /ts-api-utils@1.0.3(typescript@5.4.2): resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==} engines: {node: '>=16.13.0'} peerDependencies: typescript: '>=4.2.0' dependencies: - typescript: 5.2.2 + typescript: 5.4.2 dev: false /tslib@2.6.2: @@ -12390,7 +12426,7 @@ packages: typedoc: '>=0.24.0' dependencies: handlebars: 4.7.8 - typedoc: 0.25.3(typescript@5.2.2) + typedoc: 0.25.3(typescript@5.4.2) dev: true /typedoc-plugin-missing-exports@2.1.0(typedoc@0.25.3): @@ -12398,10 +12434,10 @@ packages: peerDependencies: typedoc: 0.24.x || 0.25.x dependencies: - typedoc: 0.25.3(typescript@5.2.2) + typedoc: 0.25.3(typescript@5.4.2) dev: false - /typedoc@0.25.3(typescript@5.2.2): + /typedoc@0.25.3(typescript@5.4.2): resolution: {integrity: sha512-Ow8Bo7uY1Lwy7GTmphRIMEo6IOZ+yYUyrc8n5KXIZg1svpqhZSWgni2ZrDhe+wLosFS8yswowUzljTAV/3jmWw==} engines: {node: '>= 16'} hasBin: true @@ -12412,10 +12448,10 @@ packages: marked: 4.3.0 minimatch: 9.0.3 shiki: 0.14.5 - typescript: 5.2.2 + typescript: 5.4.2 - /typescript@5.2.2: - resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + /typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} engines: {node: '>=14.17'} hasBin: true @@ -12436,6 +12472,11 @@ packages: dependencies: multiformats: 12.1.3 + /uint8arrays@4.0.9: + resolution: {integrity: sha512-iHU8XJJnfeijILZWzV7RgILdPHqe0mjJvyzY4mO8aUUtHsDbPa2Gc8/02Kc4zeokp2W6Qq8z9Ap1xkQ1HfbKwg==} + dependencies: + multiformats: 12.1.3 + /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: