From 12d532a182b3c784085bf95543490eb05b62141a Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 30 Jan 2024 09:15:11 -0800 Subject: [PATCH 01/16] stash: all the changes --- packages/access-client/src/provider.js | 3 +- .../access-client/test/helpers/fixtures.js | 4 +- packages/capabilities/src/index.js | 3 + .../capabilities/test/helpers/fixtures.js | 6 +- packages/w3up-client/package.json | 4 +- packages/w3up-client/src/agent.js | 712 ++++++++++++++++++ packages/w3up-client/src/agent/data.js | 206 +++++ packages/w3up-client/src/agent/db.js | 538 +++++++++++++ packages/w3up-client/src/agent/db2.js | 539 +++++++++++++ packages/w3up-client/src/agent/delegations.js | 109 +++ packages/w3up-client/src/agent/encoding.js | 155 ++++ packages/w3up-client/src/agent/store.js | 220 ++++++ packages/w3up-client/src/agent/use-cases.js | 347 +++++++++ packages/w3up-client/src/base.js | 61 -- packages/w3up-client/src/capability/access.js | 366 +++++++-- packages/w3up-client/src/capability/plan.js | 2 +- .../w3up-client/src/capability/provider.js | 44 ++ packages/w3up-client/src/capability/space.js | 351 ++++++++- .../src/capability/subscription.js | 49 +- packages/w3up-client/src/capability/ucan.js | 58 ++ packages/w3up-client/src/capability/upload.js | 56 -- packages/w3up-client/src/capability/usage.js | 31 +- packages/w3up-client/src/client.js | 217 +++--- packages/w3up-client/src/client/access.js | 106 +++ packages/w3up-client/src/client/client.js | 50 ++ .../src/{capability => client}/filecoin.js | 20 +- packages/w3up-client/src/client/space.js | 23 + .../src/{capability => client}/store.js | 31 +- .../w3up-client/src/client/subscription.js | 20 + packages/w3up-client/src/client/upload.js | 129 ++++ packages/w3up-client/src/client/usage.js | 27 + packages/w3up-client/src/coupon.js | 17 +- packages/w3up-client/src/index.js | 2 +- packages/w3up-client/src/index.node.js | 2 +- packages/w3up-client/src/service.js | 3 +- packages/w3up-client/src/space.js | 6 +- packages/w3up-client/src/types.ts | 352 ++++++++- .../w3up-client/src/{ => view}/account.js | 56 +- packages/w3up-client/src/view/space.js | 332 ++++++++ packages/w3up-client/test/account.test.js | 2 +- packages/w3up-client/test/agent/db.test.js | 175 +++++ packages/w3up-client/test/agent/store.test.js | 85 +++ .../w3up-client/test/client-accounts.test.js | 2 +- .../w3up-client/test/fixtures/movie-facts.js | 238 ++++++ .../w3up-client/test/fixtures/principals.js | 19 + packages/w3up-client/test/space.test.js | 2 +- packages/w3up-client/test/test.js | 22 + pnpm-lock.yaml | 15 +- 48 files changed, 5374 insertions(+), 443 deletions(-) create mode 100644 packages/w3up-client/src/agent.js create mode 100644 packages/w3up-client/src/agent/data.js create mode 100644 packages/w3up-client/src/agent/db.js create mode 100644 packages/w3up-client/src/agent/db2.js create mode 100644 packages/w3up-client/src/agent/delegations.js create mode 100644 packages/w3up-client/src/agent/encoding.js create mode 100644 packages/w3up-client/src/agent/store.js create mode 100644 packages/w3up-client/src/agent/use-cases.js delete mode 100644 packages/w3up-client/src/base.js create mode 100644 packages/w3up-client/src/capability/provider.js create mode 100644 packages/w3up-client/src/capability/ucan.js delete mode 100644 packages/w3up-client/src/capability/upload.js create mode 100644 packages/w3up-client/src/client/access.js create mode 100644 packages/w3up-client/src/client/client.js rename packages/w3up-client/src/{capability => client}/filecoin.js (62%) create mode 100644 packages/w3up-client/src/client/space.js rename packages/w3up-client/src/{capability => client}/store.js (70%) create mode 100644 packages/w3up-client/src/client/subscription.js create mode 100644 packages/w3up-client/src/client/upload.js create mode 100644 packages/w3up-client/src/client/usage.js rename packages/w3up-client/src/{ => view}/account.js (81%) create mode 100644 packages/w3up-client/src/view/space.js create mode 100644 packages/w3up-client/test/agent/db.test.js create mode 100644 packages/w3up-client/test/agent/store.test.js create mode 100644 packages/w3up-client/test/fixtures/movie-facts.js create mode 100644 packages/w3up-client/test/fixtures/principals.js 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/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/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/w3up-client/package.json b/packages/w3up-client/package.json index 5dfbcffd6..14f419043 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -104,7 +104,9 @@ "@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", diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js new file mode 100644 index 000000000..c7a446ebb --- /dev/null +++ b/packages/w3up-client/src/agent.js @@ -0,0 +1,712 @@ +import * as Client from '@ucanto/client' +import * as CAR from '@ucanto/transport/car' +import * as HTTP from '@ucanto/transport/http' +import * as ucanto from '@ucanto/core' +import { UCAN, capability } from '@web3-storage/capabilities' +import * as Access from './capability/access.js' +import * as Space from './capability/space.js' + +import { invoke, DID, Delegation, Schema, isDelegation } from '@ucanto/core' +import { + isExpired, + isTooEarly, + canDelegateCapability, + isValid, +} from './agent/delegations.js' +import { AgentData, getAttestations } from './agent/data.js' +import * as Config from './service.js' +import * as API from './types.js' + +export * from './types.js' +export { + isExpired, + isTooEarly, + canDelegateCapability, +} from './agent/delegations.js' +export { AgentData, Access, Space, Delegation, Schema } + +export * from './agent/use-cases.js' + +const HOST = 'https://up.web3.storage' +const PRINCIPAL = DID.parse('did:web:web3.storage') + +/** + * Keeps track of AgentData for all Agents constructed. + * Used by addSpacesFromDelegations - so it can only accept Agent as param, but + * still mutate corresponding AgentData + * + * @deprecated - remove this when deprecated addSpacesFromDelegations is removed + */ +/** @type {WeakMap>, AgentData>} */ +const agentToData = new WeakMap() + +/** + * @typedef {API.Service} Service + * @typedef {API.Receipt} Receipt + */ + +/** + * Creates a Ucanto connection for the w3access API + * + * Usage: + * + * ```js + * import { connection } from '@web3-storage/access/agent' + * ``` + * + * @template {API.DID} T - DID method + * @template {Record} [S=Service] + * @param {object} [options] + * @param {API.Principal} [options.principal] - w3access API Principal + * @param {URL} [options.url] - w3access API URL + * @param {API.Transport.Channel} [options.channel] - Ucanto channel to use + * @param {typeof fetch} [options.fetch] - Fetch implementation to use + * @returns {API.ConnectionView} + */ +export function connection(options = {}) { + return Client.connect({ + id: options.principal ?? PRINCIPAL, + codec: CAR.outbound, + channel: + options.channel ?? + HTTP.open({ + url: options.url ?? new URL(HOST), + method: 'POST', + fetch: options.fetch ?? globalThis.fetch.bind(globalThis), + }), + }) +} + +/** + * @template {Record} [Service={}] + * @typedef {object} AgentModel + * @property {AgentData} data + * @property {API.ConnectionView} connection + */ + +/** + * @template {Record} Protocol + * @param {object} source + * @param {AgentData} source.data + * @param {API.ConnectionView} source.connection + * @param {URL} [source.receiptsEndpoint] + * @returns {Agent} + */ +export const from = (source) => new Agent(source.data, source) + +/** + * Agent + * + * Usage: + * + * ```js + * import { Agent } from '@web3-storage/w3up-client' + * ``` + * + * @template {Record} [S=Record] - Service + */ +export class Agent { + /** @type {AgentData} */ + #data + + /** + * @param {AgentData} data - Agent data + * @param {API.AgentOptions} [options] + */ + constructor(data, options = {}) { + /** @type { Client.Channel & { url?: URL } | undefined } */ + const channel = options.connection?.channel + this.url = options.url ?? channel?.url ?? new URL(HOST) + this.connection = + options.connection ?? + connection({ + principal: options.servicePrincipal, + url: this.url, + }) + this.receiptsEndpoint = options.receiptsEndpoint ?? Config.receiptsEndpoint + this.#data = data + agentToData.set(this, this.#data) + } + + get data() { + return this.#data + } + + /** + * Create a new Agent instance, optionally with the passed initialization data. + * + * @template {Record} [R=Service] + * @param {Partial} [init] + * @param {API.AgentOptions & API.AgentDataOptions} [options] + */ + static async create(init, options = {}) { + const data = await AgentData.create(init, options) + return new Agent(data, options) + } + + /** + * Instantiate an Agent from pre-exported agent data. + * + * @template {Record} [R=Service] + * @param {API.AgentDataExport} raw + * @param {API.AgentOptions & API.AgentDataOptions} [options] + */ + static from(raw, options = {}) { + const data = AgentData.fromExport(raw, options) + return new Agent(data, options) + } + + get issuer() { + return this.#data.principal + } + + get meta() { + return this.#data.meta + } + + get spaces() { + return this.#data.spaces + } + + did() { + return this.#data.principal.did() + } + + /** + * Import a space from a delegation. + * + * @param {API.Delegation} delegation + * @param {object} options + * @param {string} [options.name] + */ + async importSpaceFromDelegation(delegation, { name = '' } = {}) { + const space = + name === '' + ? Space.fromDelegation(delegation) + : Space.fromDelegation(delegation).withName(name) + + this.#data.spaces.set(space.did(), { ...space.meta, name: space.name }) + + await addProofs(this.#data, space.proofs) + + // if we do not have a current space, make this one current + if (!this.#data.currentSpace) { + await this.#data.setCurrentSpace(space.did()) + } + + return space + } + + /** + * Execute invocations on the agent's connection + * + * @example + * ```js + * const i1 = await agent.invoke(Space.info, {}) + * const i2 = await agent.invoke(Space.recover, { + * nb: { + * identity: 'mailto:hello@web3.storage', + * }, + * }) + * + * const results = await agent.execute2(i1, i2) + * + * ``` + * @template {API.Capability} C + * @template {API.Tuple>} I + * @param {I} invocations + */ + execute(...invocations) { + return this.connection.execute(...invocations) + } +} + +/** + * Given a list of delegations, add to agent data spaces list. + * + * @deprecated - trying to remove explicit space tracking from Agent/AgentData + * in favor of functions that derive the space set from access.delegations + * + * @template {Record} [S=Service] + * @param {Agent} agent + * @param {API.Delegation[]} delegations + */ +export async function addSpacesFromDelegations(agent, delegations) { + const data = agentToData.get(agent) + if (!data) { + throw Object.assign(new Error(`cannot determine AgentData for Agent`), { + agent: agent, + }) + } + + // spaces we find along the way. + const spaces = new Map() + // only consider ucans with this agent as the audience + const ours = delegations.filter((x) => x.audience.did() === agent.did()) + // space names are stored as facts in proofs in the special `ucan:*` delegation from email to agent. + const ucanStars = ours.filter( + (x) => x.capabilities[0].can === '*' && x.capabilities[0].with === 'ucan:*' + ) + for (const delegation of ucanStars) { + for (const proof of delegation.proofs) { + if ( + !isDelegation(proof) || + !proof.capabilities[0].with.startsWith('did:key') + ) { + continue + } + const space = Space.fromDelegation(proof) + spaces.set(space.did(), space.meta) + } + } + + // Find any other spaces the user may have access to + for (const delegation of ours) { + // TODO: we need a more robust way to determine which spaces a user has access to + // it may or may not involve look at delegations + const allows = ucanto.Delegation.allows(delegation) + for (const [resource, value] of Object.entries(allows)) { + // If we discovered a delegation to any DID, we add it to the spaces list. + if (resource.startsWith('did:key') && Object.keys(value).length > 0) { + if (!spaces.has(resource)) { + spaces.set(resource, {}) + } + } + } + } + + for (const [did, meta] of spaces) { + await data.addSpace(did, meta) + } +} + +/** + * Stores given delegations in the agent's data store and adds discovered spaces + * to the agent's space list. + * + * @param {Agent<{}>} agent + * @param {object} authorization + * @param {API.Delegation[]} authorization.proofs + * @returns {Promise>} + */ +export const importAuthorization = async (agent, { proofs }) => { + try { + await addProofs(agent.data, proofs) + await addSpacesFromDelegations(agent, proofs) + return { ok: {} } + } catch (error) { + return /** @type {{error:Error}} */ ({ error }) + } +} + +/** + * Get all the proofs matching the capabilities. + * + * Proofs are delegations with an audience matching agent DID, or with an + * audience matching the session DID. + * + * Attestations will also be included in the returned proofs require them. + * + * @param {object} agent + * @param {AgentData} agent.data + * @param {API.Principal} agent.issuer + * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the proofs. + * @param {object} [options] + * @param {API.UTCUnixTimestamp} [options.time] - Time when the capability should be valid + * @param {API.DID} [options.sessionProofIssuer] - only include session proofs for this issuer + */ +export const selectAuthorization = ({ data, issuer }, caps, options) => { + const authorizations = [] + for (const { delegation } of selectProofs(data, caps, options)) { + if (delegation.audience.did() === issuer.did()) { + authorizations.push(delegation) + } + } + + // now let's add any session proofs that refer to those authorizations + const sessions = getAttestations(data, options) + for (const proof of authorizations) { + const proofsByIssuer = sessions[proof.asCID.toString()] ?? {} + const sessionProofs = options?.sessionProofIssuer + ? proofsByIssuer[options.sessionProofIssuer] ?? [] + : Object.values(proofsByIssuer).flat() + if (sessionProofs.length) { + authorizations.push(...sessionProofs) + } + } + + return authorizations +} + +/** + * Query the delegations store for all the delegations matching the capabilities provided. + * + * @param {AgentData} store + * @param {API.CapabilityQuery[]} caps + * @param {object} [options] + * @param {API.UTCUnixTimestamp} [options.time] + */ +export const selectProofs = (store, caps, { time } = {}) => { + const _caps = new Set(caps) + /** @type {Array<{ delegation: API.Delegation, meta: API.DelegationMeta }>} */ + const values = [] + for (const [, value] of store.delegations) { + // check expiration + if (!time || isValid(value.delegation, time)) { + // check if we need to filter for caps + if (Array.isArray(caps) && caps.length > 0) { + for (const cap of _caps) { + if (canDelegateCapability(value.delegation, cap)) { + values.push(value) + } + } + } else { + values.push(value) + } + } + } + + return values +} + +/** + * Get delegations created by the agent for others and their metadata. + * + * @param {AgentData} data + * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. + */ +export const selectIssuedDelegationsWithMeta = (data, caps) => { + const arr = [] + + for (const value of selectProofs(data, caps)) { + const { delegation } = value + const isAttestation = delegation.capabilities.some( + (c) => c.can === attest.can + ) + + if (!isAttestation && delegation.audience.did() !== data.principal.did()) { + arr.push(value) + } + } + + return arr +} + +/** + * Get delegations created by the agent for others. + * + * @param {AgentData} data + * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. + */ +export const selectIssuedDelegations = (data, caps) => { + const arr = [] + + for (const { delegation } of selectIssuedDelegationsWithMeta(data, caps)) { + arr.push(delegation) + } + + return arr +} + +/** + * Add a proof to the agent store. + * + * @param {AgentData} data + * @param {API.Delegation} delegation + */ +export const addProof = async (data, delegation) => { + return await addProofs(data, [delegation]) +} + +/** + * Adds set of proofs to the agent store. + * + * @param {AgentData} data + * @param {Iterable} delegations + */ +export const addProofs = async (data, delegations) => { + for (const proof of delegations) { + await data.addDelegation(proof, { audience: data.meta }) + } + + await removeExpiredDelegations(data, { time: Date.now() / 1000 }) + + return {} +} + +/** + * Clean up any expired delegations. + * + * @param {AgentData} data + * @param {object} options + * @param {API.UTCUnixTimestamp} options.time + */ +export const removeExpiredDelegations = async (data, options) => { + for (const [, value] of data.delegations) { + if (isExpired(value.delegation, options.time)) { + await data.removeDelegation(value.delegation.cid) + } + } +} + +/** + * Get current space DID, proofs and abilities + * + * @param {object} agent + * @param {AgentData} agent.data + * @param {API.Principal} agent.issuer + */ +export const currentSpaceWithMeta = ({ data, issuer }) => { + const space = data.currentSpace + if (!space) { + return + } + + const proofs = selectAuthorization({ data, issuer }, [ + { + can: 'space/info', + with: space, + }, + ]) + + const abilities = new Set() + for (const { capabilities } of proofs) { + for (const { can } of capabilities) { + abilities.add(can) + } + } + + return { + did: data.currentSpace, + proofs: proofs, + capabilities: [...abilities], + meta: data.spaces.get(space), + } +} + +/** + * @param {object} agent + * @param {AgentData} agent.data + * @param {API.Signer} agent.issuer + * @param {API.DelegateOptions} options + */ +export const issueDelegation = async ({ data, issuer }, options) => { + const time = Date.now() / 1000 + const space = currentSpaceWithMeta({ data, issuer }) + if (!space) { + throw new Error('no space selected.') + } + + const capabilities = /** @type {API.Capabilities} */ ( + options.abilities.map((can) => { + return { + with: space.did, + can, + } + }) + ) + + // Verify agent can provide proofs for each requested capability + for (const capability of capabilities) { + if (!selectAuthorization({ data, issuer }, [capability], { time }).length) { + throw new Error( + `cannot delegate capability ${capability.can} with ${capability.with}` + ) + } + } + + const delegation = await Delegation.delegate({ + issuer, + capabilities, + proofs: selectAuthorization({ data, issuer }, capabilities, { time }), + facts: [{ space: space.meta ?? {} }], + ...options, + }) + + await data.addDelegation(delegation, { + audience: options.audienceMeta, + }) + await removeExpiredDelegations(data, { time }) + + return delegation +} + +/** + * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. + * + * @example + * ```js + * const spaceList = await Agent.issueInvocation(agent, Store.list, { + * nb: { + * size: 10, + * }, + * }) + * + * await spaceList.execute(agent.connection) + * ``` + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.TheCapabilityParser>} CAP + * @template {API.Caveats} [C={}] + * + * @param {object} agent + * @param {AgentData} agent.data + * @param {API.Signer} agent.issuer + * @param {{id: API.Principal}} agent.connection + * @param {CAP} cap + * @param {API.InvokeOptions} options + */ +export const issueInvocation = async ( + { connection, issuer, data }, + cap, + options +) => { + const audience = options.audience || connection.id + const time = Date.now() / 1000 + + const space = options.with || data.currentSpace + if (!space) { + throw new Error('No space or resource selected, you need pass a resource.') + } + + const proofs = [ + ...(options.proofs || []), + ...selectAuthorization( + { data, issuer }, + [ + { + with: space, + can: cap.can, + }, + ], + { sessionProofIssuer: audience.did(), time } + ), + ] + + if (proofs.length === 0 && options.with !== issuer.did()) { + throw new Error( + `no proofs available for resource ${space} and ability ${cap.can}` + ) + } + + const inv = invoke({ + ...options, + issuer, + audience, + // @ts-ignore + capability: cap.create({ + with: space, + nb: options.nb, + }), + proofs: [...proofs], + }) + + return /** @type {API.IssuedInvocationView>} */ ( + inv + ) +} + +/** + * Returns iterable of all the proofs that contain capabilities matching + * passed query. + * + * @param {AgentData} store + * @param {object} query + * @param {API.CapabilityParser} [query.capability] - Capability to match + * @param {API.UTCUnixTimestamp} [query.time] - Time when the capability should be valid + * @param {API.DID} [query.audience] - Audience of the capability + */ +export const selectAccess = function* (store, { capability, audience, time }) { + for (const [, { delegation }] of store.delegations) { + // Skip if delegated to a different audience + if (audience && delegation.audience.did() !== audience) { + continue + } + + // Skip if not valid at the given time + if (time && !isValid(delegation, time)) { + continue + } + + // If proof matches the capability, yield it otherwise skip + const proof = capability ? matchProof(delegation, capability) : delegation + if (proof) { + yield proof + } + } +} + +/** + * Select all attestations matching the given query. + * + * @param {AgentData} store + * @param {object} query + * @param {API.UCANLink[]} query.proofs + * @param {API.DID} [query.audience] - Audience of the capability + */ +const selectAttestations = (store, { proofs, audience }) => { + const proof = proofs + .map((proof) => Schema.link(proof)) + .reduce((left, right) => Schema.or(left, right)) + + const selector = capability({ + can: UCAN.attest.can, + with: Schema.did(), + nb: Schema.struct({ proof }), + }) + + return selectAccess(store, { capability: selector, audience }) +} + +/** + * + * @param {API.Delegation} delegation + * @param {API.CapabilityParser} query + */ + +const matchProof = (delegation, query) => { + for (const capability of delegation.capabilities) { + const result = query.match( + /** @type {API.Source} */ ({ capability, delegation }) + ) + if (result.ok) { + return delegation + } + } +} +/** + * Invoke and execute the given capability on the Access service connection + * + * ```js + * + * await Agent.invokeAndExecute(agent, Store.list, { + * nb: { + * size: 10, + * }, + * }) + * ``` + * + * @deprecated - use following instead + * ```js + * const task = await Agent.issueInvocation(agent, cap, opts) + * await task.execute(connection) instead + * ``` + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.Caveats} C + * @template {Record} Protocol + * @param {object} agent + * @param {AgentData} agent.data + * @param {API.Signer} agent.issuer + * @param {API.ConnectionView} agent.connection + * @param {API.TheCapabilityParser>} cap + * @param {API.InvokeOptions>>} options + * @returns {Promise, Protocol>>} + */ +export const invokeAndExecute = async (agent, cap, options) => { + const invocation = await issueInvocation(agent, cap, options) + const out = invocation.execute(/** @type {*} */ (agent.connection)) + return /** @type {*} */ (out) +} diff --git a/packages/w3up-client/src/agent/data.js b/packages/w3up-client/src/agent/data.js new file mode 100644 index 000000000..1e901f0f4 --- /dev/null +++ b/packages/w3up-client/src/agent/data.js @@ -0,0 +1,206 @@ +import { Signer } from '@ucanto/principal' +import { Signer as EdSigner } from '@ucanto/principal/ed25519' +import { importDAG } from '@ucanto/core/delegation' +import * as Ucanto from '@ucanto/interface' +import { CID } from 'multiformats' +import { UCAN } from '@web3-storage/capabilities' +import { isExpired, isValid } from './delegations.js' +import * as API from '../types.js' + +/** @implements {API.AgentDataModel} */ +export class AgentData { + /** @type {(data: API.AgentDataExport) => Promise | void} */ + #save + + /** + * @param {API.AgentDataModel} data + * @param {API.AgentDataOptions} [options] + */ + constructor(data, options = {}) { + this.meta = data.meta + this.principal = data.principal + this.spaces = data.spaces + this.delegations = data.delegations + this.currentSpace = data.currentSpace + this.#save = (data) => + options.store ? options.store.save(data) : undefined + } + + /** + * Create a new AgentData instance from the passed initialization data. + * + * @param {Partial} [init] + * @param {API.AgentDataOptions} [options] + */ + static async create(init = {}, options = {}) { + const agentData = new AgentData( + { + meta: { name: 'agent', type: 'device', ...init.meta }, + principal: init.principal ?? (await EdSigner.generate()), + spaces: init.spaces ?? new Map(), + delegations: init.delegations ?? new Map(), + currentSpace: init.currentSpace, + }, + options + ) + if (options.store) { + await options.store.save(agentData.export()) + } + return agentData + } + + /** + * Instantiate AgentData from previously exported data. + * + * @param {API.AgentDataExport} raw + * @param {API.AgentDataOptions} [options] + */ + static fromExport(raw, options) { + /** @type {API.AgentDataModel['delegations']} */ + const dels = new Map() + + for (const [key, value] of raw.delegations) { + dels.set(key, { + delegation: importDAG( + value.delegation.map((d) => ({ + cid: CID.parse(d.cid).toV1(), + bytes: d.bytes, + })) + ), + meta: value.meta, + }) + } + + return new AgentData( + { + meta: raw.meta, + // @ts-expect-error for some reason TS thinks this is a EdSigner + principal: Signer.from(raw.principal), + currentSpace: raw.currentSpace, + spaces: raw.spaces, + delegations: dels, + }, + options + ) + } + + /** + * Export data in a format safe to pass to `structuredClone()`. + */ + export() { + /** @type {API.AgentDataExport} */ + const raw = { + meta: this.meta, + principal: this.principal.toArchive(), + currentSpace: this.currentSpace, + spaces: this.spaces, + delegations: new Map(), + } + for (const [key, value] of this.delegations) { + raw.delegations.set(key, { + meta: value.meta, + delegation: [...value.delegation.export()].map((b) => ({ + cid: `${b.cid}`, + bytes: b.bytes, + })), + }) + } + return raw + } + + /** + * @deprecated + * @param {API.DID} did + * @param {API.SpaceMeta} meta + * @param {API.Delegation} [proof] + */ + async addSpace(did, meta, proof) { + this.spaces.set(did, meta) + await (proof ? this.addDelegation(proof) : this.#save(this.export())) + } + + /** + * @deprecated + * @param {API.DIDKey} did + */ + async setCurrentSpace(did) { + if (!this.spaces.has(did)) { + throw new RangeError(`Agent has no proofs for ${did}.`) + } + this.currentSpace = did + await this.#save(this.export()) + } + + /** + * @param {API.Delegation} delegation + * @param {API.DelegationMeta} [meta] + */ + async addDelegation(delegation, meta) { + this.delegations.set(`${delegation.cid}`, { + delegation, + meta: meta ?? {}, + }) + await this.#save(this.export()) + } + + /** + * @param {API.UCANLink} cid + */ + async removeDelegation(cid) { + this.delegations.delete(`${cid}`) + await this.#save(this.export()) + } +} + +/** + * Is the given capability a session attestation? + * + * @param {Ucanto.Capability} cap + * @returns {boolean} + */ +const isAttestationCapability = (cap) => cap.can === UCAN.attest.can + +/** + * Is the given delegation an attestation ? + * + * @param {Ucanto.Delegation} delegation + * @returns {delegation is Ucanto.Delegation<[API.UCANAttest]>} + */ +export const isAttestation = (delegation) => + delegation.capabilities.some((cap) => isAttestationCapability(cap)) + +/** + * @typedef {string} SessionProofAuthorizationCid - the nb.proof CID of the ucan/attest in the session proof + * @typedef {Ucanto.DID} SessionProofIssuer - issuer of ucan/attest session proof + * @typedef {Record>} SessionProofIndexedByAuthorizationAndIssuer + */ + +/** + * Get a map from CIDs to their corresponding attestations. + * + * @param {AgentData} data + * @param {object} [options] + * @param {API.UTCUnixTimestamp} [options.time] + * @returns {SessionProofIndexedByAuthorizationAndIssuer} + */ +export function getAttestations(data, { time } = {}) { + /** @type {SessionProofIndexedByAuthorizationAndIssuer} */ + const proofs = {} + for (const { delegation } of data.delegations.values()) { + if (isAttestation(delegation)) { + const cap = delegation.capabilities[0] + if (cap && (!time || isValid(delegation, time))) { + const proof = cap.nb.proof + if (proof) { + const proofCid = proof.toString() + const issuerDid = delegation.issuer.did() + proofs[proofCid] = proofs[proofCid] ?? {} + proofs[proofCid][issuerDid] = proofs[proofCid][issuerDid] ?? [] + proofs[proofCid][issuerDid].push(delegation) + } + } + } + } + + return proofs +} diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js new file mode 100644 index 000000000..612f9d573 --- /dev/null +++ b/packages/w3up-client/src/agent/db.js @@ -0,0 +1,538 @@ +import { todo } from 'node:test' +import db from '../../test/fixtures/movie-facts.js' + +/** + * @typedef {string|number|boolean} Value + * @typedef {[Var|Value, Var|Value, Var|Value]} Pattern + + * @typedef {readonly [Value, Value, Value]} Datom + * @typedef {readonly Datom[]} Database + * @typedef {Record} Attributes + * + */ + +const ENTITY = 0 +const ATTRIBUTE = 1 +const VALUE = 2 + +/** + * @template {Selector} Selection + + * @param {Pattern} pattern + * @param {Datom} source + * @param {InferState} context + * @returns {InferState|null} + */ +export const matchPattern = (pattern, source, context) => { + const entity = matchPart(pattern[ENTITY], source[ENTITY], context) + const attribute = entity + ? matchPart(pattern[ATTRIBUTE], source[ATTRIBUTE], entity) + : entity + const value = attribute + ? matchPart(pattern[VALUE], source[VALUE], attribute) + : attribute + + return /** @type {InferState|null} */ (value) +} + +/** + * @template {Selector} Selection + * + * @param {Var|Value} input + * @param {Value} source + * @param {InferState} context + */ +const matchPart = (input, source, context) => + isVariable(input) + ? matchVariable(input, source, context) + : matchValue(input, source, context) + +/** + * @template {{}} Context + * @param {Value} expect + * @param {Value} actual + * @param {Context} context + * @returns {Context|null} + */ +const matchValue = (expect, actual, context) => + expect === actual ? context : null + +/** + * @typedef {Record} Context + */ + +/** + * @template {Selector} Selection + * + * @param {Var} variable + * @param {Value} source + * @param {InferState} context + * @returns {InferState|null} + */ +const matchVariable = (variable, source, context) => { + if (variable.name in context) { + const bound = context[variable.name] + return matchPart(bound, source, context) + } + + return { ...context, [variable.name]: source } +} + +/** + * @template {Value} T + * @template {string} Name + * @param {unknown|Var} x + * @returns {x is Var} + */ +const isVariable = (x) => { + return x instanceof Var +} + +/** + * @template {Selector} Selection + * @param {Pattern} pattern + * @param {Database} db + * @param {InferState} context + */ +const querySingle = (pattern, db, context) => { + const matches = [] + for (const triple of db) { + const match = matchPattern(pattern, triple, context) + if (match) { + matches.push(match) + } + } + return matches +} + +/** + * @template {Selector} Selection + * @param {Database} db + * @param {object} query + * @param {InferVariables} query.find + * @param {Pattern[]} query.where + */ +export function queryWhere(db, { where }) { + return where.reduce( + /** + * + * @param {InferState[]} contexts + * @param {Pattern} pattern + * @returns + */ + (contexts, pattern) => + contexts.flatMap((context) => querySingle(pattern, db, context)), + [/** @type {InferState} */ ({})] + ) +} + +// /** +// * @template {Bindings} Selector +// * +// * @param {Database} db +// * @param {object} selector +// * @param {Selector} selector.find +// * @param {Pattern[]} selector.where +// * @returns +// */ +// export const query = (db, { find, where }) => { +// const contexts = queryWhere(db, where) +// return contexts.map((context) => actualize(find, context)) +// } + +/** + * @typedef {Record|Value>} Selector + */ +/** + * @template {Selector} Selection + * @typedef {{[Key in keyof Selection]: Selection[Key] extends Reader ? Var : Selection[Key]}} InferVariables + */ +/** + * @template {Selector} Selection + * @typedef {{[Key in keyof Selection]: Selection[Key] extends Reader ? T : Selection[Key]}} InferMatch + */ +/** + * @template {Selector} Selection + * @typedef {{[Key in keyof Selection]: Selection[Key] extends Reader ? (Var | T) : Selection[Key] & Value}} InferState + */ + +/** + * Takes a selector which is set of variables that will be used in the query + * conditions. Returns a query builder that has `.where` method for specifying + * the query conditions. + * + * ```example + * const moviesAndTheirDirectorsThatShotArnold = select({ + * directorName: Schema.string(), + * movieTitle: Schema.string(), + * arnold: Schema.number(), + * movie: Schema.number(), + * director: Schema.number() + * }).where(({ directorName, movieTitle, arnold }) => [ + * [arnold, "person/name", "Arnold Schwarzenegger"], + * [movie, "movie/cast", arnoldId], + * [movie, "movie/title", movieTitle], + * [movie, "movie/director", director], + * [director, "person/name", directorName] + * ]) + * ``` + * + * @template {Selector} Selection + * @param {Selection} selector + */ +export const select = (selector) => new Select({ selector }) + +/** + * A query builder API which is designed to enable type inference of the query + * and the results it will produce. + * + * @template {Selector} Selection + */ +class Select { + /** + * @param {object} source + * @param {Selection} source.selector + */ + constructor({ selector }) { + /** @type {InferVariables} */ + this.bindings = Object.fromEntries( + Object.entries(selector).map(([name, schema]) => + schema instanceof Schema + ? [name, new Var({ name, schema })] + : [name, schema] + ) + ) + } + /** + * @param {(variables: InferVariables) => Iterable} where + * @returns {Query} + */ + where(where) { + return new Query({ + find: this.bindings, + where: where(this.bindings), + }) + } +} + +/** + * @template {Selector} Selection + */ +class Query { + /** + * @param {object} source + * @param {InferVariables} source.find + * @param {Iterable} source.where + */ + constructor({ find, where }) { + this.find = find + this.where = [...where] + } + + /** + * + * @param {Database} db + * @returns {InferMatch[]} + */ + from(db) { + const contexts = queryWhere(db, this) + return contexts.map((context) => actualize(this.find, context)) + } +} + +/** + * @template {Selector} Selection + * @param {InferVariables} selector + * @param {InferState} context + * @returns {InferMatch} + */ +const actualize = (selector, context) => + /** @type {InferMatch} */ + ( + Object.fromEntries( + Object.entries(selector).map(([name, binding]) => [ + name, + isVariable(binding) ? context[name] : binding, + ]) + ) + ) + +const moviedId = Symbol('movieId') +const another = Symbol('test') + +/** + * @template {Value} T + */ +class Reader { + /** + * @param {unknown} value + * @returns {{ok: T, error?: undefined}|{ok?: undefined, error: Error}} + */ + read(value) { + return { error: new Error('Value does not match') } + } +} + +/** + * @template {Value} T + * @extends {Reader} + */ +class Schema extends Reader { + /** + * @param {(value: unknown) => value is T} is + */ + constructor(is) { + super() + this.is = is + } + /** + * @param {unknown} value + * @returns {{ok: T, error?: undefined}|{ok?: undefined, error: Error}} + */ + read(value) { + return this.is(value) + ? { ok: value } + : { error: new TypeError(`Unknown value type ${typeof value}`) } + } + + static string() { + return new Schema( + /** + * @param {unknown} value + * @returns {value is string} + */ + (value) => typeof value === 'string' + ) + } + static number() { + return new Schema( + /** + * @param {unknown} value + * @returns {value is number} + */ + (value) => typeof value === 'number' + ) + } + static boolean() { + return new Schema( + /** + * @param {unknown} value + * @returns {value is boolean} + */ + (value) => typeof value === 'boolean' + ) + } + + /** + * @template {string} Name + * @param {Name} name + */ + variable(name) { + return new Var({ name, schema: this }) + } +} + +/** + * @template {Value} [T=Value] + * @template {string} [Name=string] + * @extends {Reader} + */ +class Var extends Reader { + /** + * @param {object} source + * @param {Name} source.name + * @param {Schema} source.schema + */ + constructor({ name, schema }) { + super() + this.name = name + this.schema = schema + } + + /** + * @param {unknown} value + * @returns {{ok: T, error?: undefined}|{ok?: undefined, error: Error}} + */ + read(value) { + return this.schema.read(value) + } +} + +const output = select({ + uploadLink: Schema.string(), + storeLink: Schema.string(), + space: Schema.string(), + uploadID: Schema.string(), + storeID: Schema.string(), +}) + .where(({ uploadLink, storeLink, space, uploadID, storeID }) => [ + [uploadLink, 'capabilities', uploadID], + [uploadID, 'can', 'upload/add'], + [uploadID, 'with', space], + [storeLink, 'capabilities', storeID], + [storeID, 'can', 'store/add'], + [storeID, 'with', space], + ]) + .from([ + ['bafy...upload', 'issuer', 'did:key:zAlice'], + ['bafy...upload', 'audience', 'did:key:zBob'], + ['bafy...upload', 'expiration', 1702413523], + ['bafy...upload', 'capabilities', 'bafy...upload/capabilities/0'], + ['bafy...upload/capabilities/0', 'can', 'upload/add'], + ['bafy...upload/capabilities/0', 'with', 'did:key:zAlice'], + + ['bafy...store', 'issuer', 'did:key:zAlice'], + ['bafy...store', 'audience', 'did:key:zBob'], + ['bafy...store', 'expiration', 1702413523], + ['bafy...store', 'capabilities', 'bafy...store/capabilities/0'], + ['bafy...store/capabilities/0', 'can', 'store/add'], + ['bafy...store/capabilities/0', 'with', 'did:key:zAlice'], + + ['bafy...store', 'capabilities', 'bafy...store/capabilities/1'], + ['bafy...store/capabilities/1', 'can', 'store/list'], + ['bafy...store/capabilities/1', 'with', 'did:key:zAlice'], + ]) + +const result = { ...output[0] } + +// const legacy = () => { +// transact([ +// tx.todos[workoutId].update({ title: 'Go on a run' }), +// tx.todos[proteinId].update({ title: 'Drink protein' }), +// tx.todos[sleepId].update({ title: 'Go to bed early' }), +// tx.todos[standupId].update({ title: 'Do standup' }), +// tx.todos[reviewPRsId].update({ title: 'Review PRs' }), +// tx.todos[focusId].update({ title: 'Code a bunch' }), +// tx.goals[healthId] +// .update({ title: 'Get fit!' }) +// .link({ todos: workoutId }) +// .link({ todos: proteinId }) +// .link({ todos: sleepId }), +// tx.goals[workId] +// .update({ title: 'Get promoted!' }) +// .link({ todos: standupId }) +// .link({ todos: reviewPRsId }) +// .link({ todos: focusId }), +// ]) + +class DB { + /** + * @template {string} EntityType + * @param {EntityType} type + */ + static collection(type) { + return new Collection(type) + } + + constructor() { + return new DB() + } + + /** + * @param {(Assertion|Retraction)[]} assertions + */ + transact(assertions) {} +} + +/** + * @template {string} EntityType + */ +class Collection { + /** + * @param {EntityType} type + */ + constructor(type) { + this.type = type + } + /** + * @template {Attributes} Model + * @param {Model} model + */ + create(model) { + return new Entity(this.type, model) + } + + /** + * @template {Attributes} Assertion + * @param {Assertion} model + */ + assert(model) { + return new Assertion(this.type, model) + } + /** + * @template {Attributes} Assertion + * @param {Assertion} model + */ + retract(model) { + return new Retraction(this.type, model) + } +} + +/** + * @template {string} EntityType + * @template {Attributes} Assertion + */ +class Assertion { + /** + * + * @param {EntityType} type + * @param {Assertion} attributes + */ + constructor(type, attributes) { + this.type = type + this.attributes = attributes + } +} + +/** + * @template {string} EntityType + * @template {Attributes} Assertion + */ +class Retraction { + /** + * + * @param {EntityType} type + * @param {Assertion} attributes + */ + constructor(type, attributes) { + this.type = type + this.attributes = attributes + } +} + +/** + * @template {string} EntityType + * @template {Record} Model + */ +class Entity { + /** + * + * @param {EntityType} type + * @param {Model} model + */ + constructor(type, model) { + this.type = type + this.model = model + } + /** + * @template {Record>} Extension + * @param {Extension} extension + * @returns {Entity} + */ + link(extension) { + return new Entity(this.type, { ...this.model, ...extension }) + } +} + +const Todo = DB.collection('todo') +const Goal = DB.collection('goal') + +const demo = () => { + const workout = Todo.create({ title: 'Go on a run' }) + const protein = Todo.create({ title: 'Drink protein' }) + const sleep = Todo.create({ title: 'Go to bed early' }) + + const health = Goal.create({ title: 'Get fit!' }) + + workout.link({ todo: workout }).link({ todo: protein }).link({ todo: sleep }) +} diff --git a/packages/w3up-client/src/agent/db2.js b/packages/w3up-client/src/agent/db2.js new file mode 100644 index 000000000..6bf72da2f --- /dev/null +++ b/packages/w3up-client/src/agent/db2.js @@ -0,0 +1,539 @@ +import * as API from '@ucanto/interface' + +/** + * @typedef {number} Integer + * @typedef {number} Float + * @typedef {Readonly} Bytes + * @typedef {string} UTF8 + * + * @typedef {string|Float|Integer} Entity + * @typedef {Integer|Float|Bytes|UTF8} Attribute + * @typedef {boolean|UTF8|Integer|Float|Bytes} Data + */ + +/** + * Database is represented as a collection of facts. + * @typedef {object} Database + * @property {readonly Fact[]} facts + */ + +/** + * An atomic fact in the database, associating an `entity` , `attribute` , + * `value`. + * + * - `entity` - The first component is `entity` that specifies who or what the fact is about. + * - `attribute` - Something that can be said about an `entity` . An attribute has a name, + * e.g. "firstName" and a value type, e.g. string, and a cardinality. + * - `value` - Something that does not change e.g. 42, "John", true. Fact relates + * an `entity` to a particular `value` through an `attribute`.ich + * + * @typedef {readonly [entity: Entity, attribute: Attribute, value: Data]} Fact + */ + +/** + * Creates an assertion. + * + * @template {Entity} E + * @template {Attribute} A + * @template {Data} V + * + * @param {E} entity + * @param {A} attribute + * @param {V} value + * @returns {readonly [entity: E, attribute:A, value:V]} + */ +export const assert = (entity, attribute, value) => [entity, attribute, value] + +/** + * Variable is placeholder for a value that will be matched against by the + * query engine. It is represented as an abstract `Reader` that will attempt + * to read arbitrary {@type Data} and return result with either `ok` of the + * `Type` or an `error`. + * + * Variables will be assigned unique `bindingKey` by a query engine that will + * be used as unique identifier for the variable. + * + * @template {Data} [Type=Data] + * @typedef {API.Reader & {propertyKey?: PropertyKey}} Variable + */ + +/** + * Term is either a constant or a {@link Variable}. Terms are used to describe + * predicates of the query. + * + * @typedef {Data|Variable} Term + */ + +/** + * Describes association between `entity`, `attribute`, `value` of the + * {@link Fact}. Each component of the {@link Relation} is a {@link Term} + * that is either a constant or a {@link Variable}. + * + * Query engine during execution will attempt to match {@link Relation} against + * all facts in the database and unify {@link Variable}s across them to identify + * all possible solutions. + * + * @typedef {[entity: Term, attribute: Term, value: Term]} Relation + */ + +/** + * @typedef {{where: Relation[]}} Predicate + */ + +/** + * Selection describes set of (named) variables that query engine will attempt + * to find values for that satisfy the query. + * + * @typedef {Record} Selector + */ + +/** + * @template {Selector} Selection + * @typedef {{[Key in keyof Selection]: Selection[Key] extends Variable ? T : never}} InferMatch + */ + +/** + * @template {Selector} Selection + * @typedef {{[Key in keyof Selection]: Selection[Key] extends Variable ? (Variable | T) : never}} InferState + */ + +const ENTITY = 0 +const ATTRIBUTE = 1 +const VALUE = 2 + +/** + * @template {Selector} Selection + + * @param {Relation} relation + * @param {Fact} fact + * @param {InferState} context + * @returns {InferState|null} + */ +export const matchRelation = (relation, fact, context) => { + let state = context + for (const id of [ENTITY, ATTRIBUTE, VALUE]) { + const match = matchTerm(relation[id], fact[id], state) + if (match) { + state = match + } else { + return null + } + } + + return state +} + +/** + * @template {Selector} Selection + * + * @param {Term} term + * @param {Data} data + * @param {InferState} context + */ +export const matchTerm = (term, data, context) => + // If term is a variable then we attempt to match a data against it + // otherwise we compare data against the constant term. + isVariable(term) + ? matchVariable(term, data, context) + : isBlank(term) + ? context + : matchConstant(term, data, context) + +/** + * @template Context + * + * @param {Data} constant + * @param {Data} data + * @param {Context} context + * @returns {Context|null} + */ +export const matchConstant = (constant, data, context) => + constant === data ? context : null + +/** + * @typedef {Record} Context + */ + +/** + * @template {Selector} Selection + * + * @param {Variable} variable + * @param {Data} data + * @param {InferState} context + * @returns {InferState|null} + */ +export const matchVariable = (variable, data, context) => { + // Get key this variable is bound to in the context + const key = SelectedVariable.getPropertyKey(variable) + // If context already contains binding for we attempt o unify it with the + // new data otherwise we bind the data to the variable. + return key in context + ? matchTerm(context[key], data, context) + : { ...context, [key]: data } +} + +/** + * @template {Data} T + * @param {unknown|Variable} x + * @returns {x is Variable} + */ +const isVariable = (x) => { + return ( + typeof x === 'object' && + x !== null && + 'read' in x && + typeof x.read === 'function' + ) +} + +/** + * + * @param {unknown} x + * @returns {x is Schema._} + */ +const isBlank = (x) => x === Schema._ + +/** + * @template {Selector} Selection + * @param {Relation} relation + * @param {Database} db + * @param {InferState} context + * @returns {InferState[]} + */ +const queryRelation = (relation, { facts }, context) => { + const matches = [] + for (const triple of facts) { + const match = matchRelation(relation, triple, context) + if (match) { + matches.push(match) + } + } + + return matches +} + +/** + * @template {Selector} Selection + * @param {Database} db + * @param {Relation[]} relations + * @param {InferState} context + * @returns {InferState[]} + */ +export const queryRelations = (db, relations, context) => + relations.reduce( + /** + * @param {InferState[]} contexts + * @param {Relation} relation + * @returns + */ + (contexts, relation) => + contexts.flatMap((context) => queryRelation(relation, db, context)), + [context] + ) + +/** + * Takes a selector which is set of variables that will be used in the query + * conditions. Returns a query builder that has `.where` method for specifying + * the query conditions. + * + * @example + * ```ts + * const moviesAndTheirDirectorsThatShotArnold = select({ + * directorName: Schema.string(), + * movieTitle: Schema.string(), + * }).where(({ directorName, movieTitle }) => { + * const arnoldId = Schema.number() + * const movie = Schema.number() + * const director = Schema.number() + * + * return [ + * [arnold, "person/name", "Arnold Schwarzenegger"], + * [movie, "movie/cast", arnoldId], + * [movie, "movie/title", movieTitle], + * [movie, "movie/director", director], + * [director, "person/name", directorName] + * ] + * }) + * ``` + * + * @template {Selector} Selection + * @param {Selection} selector + * @returns {QueryBuilder} + */ +export const select = (selector) => new QueryBuilder({ select: selector }) + +/** + * @template {Selector} Selection + * @param {Database} db + * @param {object} source + * @param {Selection} source.select + * @param {Iterable} source.where + * @returns {InferMatch[]} + */ +export const query = (db, { select, where }) => { + /** @type {Relation[]} */ + const relations = [] + for (const relation of where) { + if (Array.isArray(relation)) { + relations.push(relation) + } else { + relations.push(...relation.where) + } + } + + const contexts = queryRelations( + db, + relations, + /** @type {InferState} */ ({}) + ) + return contexts.map((context) => materialize(select, context)) +} +/** + * A query builder API which is designed to enable type inference of the query + * and the results it will produce. + * + * @template {Selector} Select + */ +class QueryBuilder { + /** + * @param {object} source + * @param {Select} source.select + */ + constructor({ select }) { + this.select = select + } + /** + * @param {(variables: Select) => Iterable} conditions + * @returns {Query} - */ - where(conditions) { - return new Query({ - select: this.select, - where: [...conditions(this.select)], - }) - } -} - -/** - * @template {Record} Object - * @param {Object} object - * @returns {{[Key in keyof Object]: [Key, Object[Key]]}[keyof Object][]} - */ -const entries = (object) => /** @type {any} */ (Object.entries(object)) - -/** - * @template {Selector} Selection - */ -class Query { - /** - * @param {object} model - * @param {Selection} model.select - * @param {(Relation|Predicate)[]} model.where - */ - constructor(model) { - this.model = model - } - - /** - * - * @param {Database} db - * @returns {InferMatch[]} - */ - execute(db) { - return query(db, this.model) - } -} - -/** - * @template {Selector} Selection - * @param {Selection} select - * @param {InferState} context - * @returns {InferMatch} - */ -const materialize = (select, context) => - /** @type {InferMatch} */ - ( - Object.fromEntries( - entries(select).map(([name, variable]) => [ - name, - isVariable(variable) - ? context[SelectedVariable.getPropertyKey(variable)] - : variable, - ]) - ) - ) - -const IS = Symbol.for('is') - -/** - * @template {Data} T - * @implements {API.Reader} - */ -export class Schema { - /** - * @param {(value: unknown) => value is T} is - */ - constructor(is) { - this[IS] = is - } - /** - * @param {unknown} value - * @returns {{ok: T, error?: undefined}|{ok?: undefined, error: Error}} - */ - read(value) { - return this[IS](value) - ? { ok: value } - : { error: new TypeError(`Unknown value type ${typeof value}`) } - } - - static string() { - return new Schema( - /** - * @param {unknown} value - * @returns {value is string} - */ - (value) => typeof value === 'string' - ) - } - static number() { - return new Schema( - /** - * @param {unknown} value - * @returns {value is number} - */ - (value) => typeof value === 'number' - ) - } - static boolean() { - return new Schema( - /** - * @param {unknown} value - * @returns {value is boolean} - */ - (value) => typeof value === 'boolean' - ) - } - - static _ = Object.assign( - new Schema( - /** - * @param {unknown} _ - * @returns {_ is any} - */ - (_) => true - ), - { propertyKey: '_' } - ) -} - -/** - * @template {Data} [T=Data] - * @template {PropertyKey} [Key=PropertyKey] - * @extends {Variable} - */ -class SelectedVariable { - static lastKey = 0 - - /** - * @param {Variable} variable - * @returns {PropertyKey} - */ - static getPropertyKey(variable) { - const { propertyKey } = variable - if (propertyKey) { - return propertyKey - } else { - const bindingKey = `${++this.lastKey}` - variable.propertyKey = bindingKey - return bindingKey - } - } - - /** - * @param {object} source - * @param {Key} source.key - * @param {Variable} source.schema - */ - constructor({ key, schema }) { - this.propertyKey = key - this.schema = schema - } - - /** - * @param {Data} value - */ - read(value) { - return this.schema.read(value) - } -} - -/** - * @template {Record} Attributes - * @param {Attributes} attributes - * @returns {EntityView & Attributes} - */ -export const entity = (attributes) => - Object.assign(new EntityView(attributes), attributes) - -/** - * @template {Record} Attributes - * @extends {Schema} - */ -class EntityView extends Schema { - /** - * - * @param {unknown} value - * @returns {value is Entity} - */ - static isEntity(value) { - switch (typeof value) { - case 'string': - case 'number': - return true - default: - return false - } - } - /** - * @param {Attributes} attributes - */ - constructor(attributes) { - super(EntityView.isEntity) - Object.assign(this, attributes) - } - /** - * @param {Partial<{[Key in keyof Attributes]: Term}>} pattern - * @returns {{where: Relation[]}} - */ - match(pattern = {}) { - const where = [] - const attributes = /** @type {Attributes} */ (this.valueOf()) - - for (const [key, variable] of entries(attributes)) { - const term = pattern[key] ?? variable - // If there is a reference to an entity we include into relation, this - // ensures that values for all entity attributes are aggregated. - if (term instanceof EntityView) { - where.push(...term.match().where) - } - - where.push(/** @type {Relation} */ ([this, key, term])) - } - - return { where } - } - - /** - * @param {Partial<{[Key in keyof Attributes]: Data}>} model - * @returns {Iterable} - */ - *assert(model) { - const attributes = /** @type {Attributes} */ (this.valueOf()) - for (const key of Object.keys(attributes)) { - const value = model[key] - if (value) { - yield assert(0, key, value) - } - } - } -} diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js new file mode 100644 index 000000000..7832d6dc9 --- /dev/null +++ b/packages/w3up-client/src/agent/delegation.js @@ -0,0 +1,80 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Block from './block.js' +import { importDAG, allows, isDelegation } from '@ucanto/core/delegation' +import * as Association from './db/association.js' + +/** + * Derives set of facts about the given delegation. + * + * @param {API.Delegation} delegation + * @returns {Iterable} + */ +export const assert = 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 [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) { + yield* Association.assert(fact, { entity, path: ['ucan', 'fact'] }) + } + + // for (const proof of delegation.proofs) { + // if (isDelegation(proof)) { + // yield* assert(proof) + // yield [ + // entity, + // 'ucan/proof', + // /** @type {API.Link & DB.Entity} */ (proof.cid), + // ] + // } + // } +} + +/** + * 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 index 43c536687..a3eae8b99 100644 --- a/packages/w3up-client/src/agent/delegations.js +++ b/packages/w3up-client/src/agent/delegations.js @@ -1,7 +1,6 @@ import * as ucanto from '@ucanto/core' import * as API from '../types.js' import { canDelegateAbility } from '@web3-storage/capabilities/utils' -import { ok } from 'assert' /** * diff --git a/packages/w3up-client/src/agent/encoding.js b/packages/w3up-client/src/agent/encoding.js index ef31a3b1c..6e3ee507b 100644 --- a/packages/w3up-client/src/agent/encoding.js +++ b/packages/w3up-client/src/agent/encoding.js @@ -107,7 +107,7 @@ export function delegationsToString(delegations, encoding = 'base64url') { } /** - * Encode one {@link Types.Delegation Delegation} into a string + * Encode one {@link API.Delegation Delegation} into a string * * @param {API.Delegation} delegation * @param {import('uint8arrays/to-string').SupportedEncodings} [encoding] @@ -117,7 +117,7 @@ export function delegationToString(delegation, encoding) { } /** - * Decode string into {@link Types.Delegation Delegation} + * Decode string into {@link API.Delegation Delegation} * * @template {API.Capabilities} [T=API.Capabilities] * @param {API.EncodedDelegation} raw diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 0cfc143d6..3fc6f0fd0 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -23,6 +23,7 @@ import type { Match, CapabilityParser, InferInvokedCapability, + Variant, } from '@ucanto/interface' import type { @@ -458,3 +459,23 @@ export type Access = Record, Unit> & { ['*']?: Unit } + +export type LikePattern = string + +/** + * 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 Clause = 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, Clause[]> & { + ['*']?: Clause[] + } diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index 0f772681f..5ccbcaa29 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -1,175 +1,212 @@ -import * as DB from '../../src/agent/db2.js' +import * as DB from '../../src/agent/db.js' import * as Test from '../test.js' -import * as movieDB from '../fixtures/movie-facts.js' - -const proofsDB = /** @type {const} */ ({ - facts: [ - ['bafy...upload', 'issuer', 'did:key:zAlice'], - ['bafy...upload', 'audience', 'did:key:zBob'], - ['bafy...upload', 'expiration', 1702413523], - ['bafy...upload', 'capabilities', 'bafy...upload/capabilities/0'], - ['bafy...upload/capabilities/0', 'can', 'upload/add'], - ['bafy...upload/capabilities/0', 'with', 'did:key:zAlice'], - - ['bafy...store', 'issuer', 'did:key:zAlice'], - ['bafy...store', 'audience', 'did:key:zBob'], - ['bafy...store', 'expiration', 1702413523], - ['bafy...store', 'capabilities', 'bafy...store/capabilities/0'], - ['bafy...store/capabilities/0', 'can', 'store/add'], - ['bafy...store/capabilities/0', 'with', 'did:key:zAlice'], - - ['bafy...store', 'capabilities', 'bafy...store/capabilities/1'], - ['bafy...store/capabilities/1', 'can', 'store/list'], - ['bafy...store/capabilities/1', 'with', 'did:key:zAlice'], - ], -}) +import * as Space from '../../src/capability/space.js' +import * as Account from '../../src/view/account.js' +import { delegate } from '@ucanto/core' +import { Absentee, Verifier } from '@ucanto/principal' +import * as Capability from '@web3-storage/capabilities' +import { alice, bob, mallory, service } from '../fixtures/principals.js' +import { assert } from 'console' /** * @type {Test.BasicSuite} */ export const testDB = { - 'test capabilities across ucans': async (assert) => { - const uploadLink = DB.Schema.string() - const storeLink = DB.Schema.string() + 'test find space': async (assert) => { + const space = await Space.generate({ + name: 'beet-box', + }) + const auth = await space.createAuthorization(alice) + const db = DB.from({ proofs: [auth] }) - const space = DB.Schema.string() - const uploadID = DB.Schema.string() - const storeID = DB.Schema.string() + const result = DB.find(db, { + can: { 'store/add': [] }, + audience: alice, + }) - const result = DB.query(proofsDB, { - select: { - uploadLink, - storeLink, - space, + assert.deepEqual(result, [ + { + audience: alice, + subject: space.did(), + proofs: [auth], }, - where: [ - [uploadLink, 'capabilities', uploadID], - [uploadID, 'can', 'upload/add'], - [uploadID, 'with', space], - [storeLink, 'capabilities', storeID], - [storeID, 'can', 'store/add'], - [storeID, 'with', space], - ], + ]) + }, + + 'test find several spaces': async (assert) => { + const beetBox = await Space.generate({ + name: 'beet-box', + }) + const beetBoxAuth = await beetBox.createAuthorization(alice) + + const plumBox = await Space.generate({ + name: 'plum-box', + }) + const plumBoxAuth = await plumBox.createAuthorization(alice) + + const db = DB.from({ proofs: [beetBoxAuth, plumBoxAuth] }) + + const result = DB.find(db, { + can: { 'store/add': [], 'store/remove': [] }, + audience: alice, }) assert.deepEqual(result, [ { - uploadLink: 'bafy...upload', - storeLink: 'bafy...store', - space: 'did:key:zAlice', + audience: alice, + subject: beetBox.did(), + proofs: [beetBoxAuth], + }, + { + audience: alice, + subject: plumBox.did(), + proofs: [plumBoxAuth], }, ]) }, - 'test query builder': async (assert) => { - const query = DB.select({ - uploadLink: DB.Schema.string(), - storeLink: DB.Schema.string(), - }).where(({ uploadLink, storeLink }) => { - const space = DB.Schema.string() - const uploadID = DB.Schema.string() - const storeID = DB.Schema.string() - - return [ - [uploadLink, 'capabilities', uploadID], - [uploadID, 'can', 'upload/add'], - [uploadID, 'with', space], - [storeLink, 'capabilities', storeID], - [storeID, 'can', 'store/add'], - [storeID, 'with', space], - ] + + 'test finds authorization across multiple ucans': async (assert) => { + const spaceInfo = await Capability.Space.info.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + }) + + const uploadList = await Capability.Upload.list.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + }) + + const db = DB.from({ proofs: [spaceInfo, uploadList] }) + + const result = DB.find(db, { + can: { 'space/info': [], 'upload/list': [] }, + audience: bob, }) - assert.deepEqual(query.execute(proofsDB), [ + assert.deepEqual(result, [ { - uploadLink: 'bafy...upload', - storeLink: 'bafy...store', + audience: bob, + subject: alice.did(), + proofs: [spaceInfo, uploadList], }, ]) }, - 'test baisc': async (assert) => { - const facts = [ - DB.assert('sally', 'age', 21), - DB.assert('fred', 'age', 42), - DB.assert('ethel', 'age', 42), - DB.assert('fred', 'likes', 'pizza'), - DB.assert('sally', 'likes', 'opera'), - DB.assert('ethel', 'likes', 'sushi'), - ] - - const e = DB.Schema.number() - - assert.deepEqual( - DB.query( - { facts }, - { - select: { e }, - where: [[e, 'age', 42]], - } - ), - [{ e: 'fred' }, { e: 'ethel' }] - ) - - const x = DB.Schema.number() - assert.deepEqual( - DB.query( - { facts }, - { - select: { x }, - where: [[DB.Schema._, 'likes', x]], - } - ), - [{ x: 'pizza' }, { x: 'opera' }, { x: 'sushi' }] - ) - }, + 'test find accounts ignoring spaces': async (assert) => { + const localSpace = await Space.generate({ + name: 'local-box', + }) + const localAuth = await localSpace.createAuthorization(alice) - 'sketch pull pattern': (assert) => { - const director = DB.entity({ - 'person/name': DB.Schema.string(), + const { + login, + attestation, + account, + space: remoteSpace, + } = await setupAccount() + + const db = DB.from({ + proofs: [login, attestation, localAuth], }) - const actor = DB.entity({ - 'person/name': DB.Schema.string(), + const result = DB.find(db, { + subject: 'did:mailto:%', + can: { '*': [] }, + audience: alice, }) - const movie = DB.entity({ - 'movie/title': DB.Schema.string(), - 'movie/director': director, - 'movie/cast': actor, + assert.deepEqual(result, [ + { + subject: account.did(), + audience: alice, + proofs: [login], + }, + ]) + + const spaces = DB.find(db, { + subject: 'did:key:%', + can: { 'store/add': [] }, + audience: alice, + }) + + assert.deepEqual(spaces, [ + { + subject: remoteSpace.did(), + audience: alice, + proofs: [login], + }, + { + subject: localSpace.did(), + audience: alice, + proofs: [localAuth], + }, + ]) + }, + + 'only test find accounts and attestations': async (assert) => { + const { login, attestation, account } = await 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', account.did()]), + + 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( - DB.query( - { - facts: [...actor.assert({ 'person/name': 'Arnold Schwarzenegger' })], - }, - { - select: { - director: director['person/name'], - movie: movie['movie/title'], - }, - where: [ - actor.match({ 'person/name': 'Arnold Schwarzenegger' }), - movie.match({ - 'movie/cast': actor, - 'movie/director': director, - }), - ], - } - ), - [ - , - { director: 'James Cameron', movie: 'The Terminator' }, - { director: 'John McTiernan', movie: 'Predator' }, - { director: 'Mark L. Lester', movie: 'Commando' }, - { director: 'James Cameron', movie: 'Terminator 2: Judgment Day' }, - { - director: 'Jonathan Mostow', - movie: 'Terminator 3: Rise of the Machines', - }, - ] - ) + assert.deepEqual(result, [ + { + loginProof: login.cid, + attestProof: attestation.cid, + }, + ]) }, } +const setupAccount = async () => { + const space = await Space.generate({ name: 'stuff' }) + const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + + const recovery = await space.createRecovery(account.did()) + const login = await delegate({ + issuer: account, + audience: alice, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + proofs: [recovery], + }) + + const attestation = await Capability.UCAN.attest.delegate({ + issuer: service, + audience: alice, + with: service.did(), + nb: { proof: login.cid }, + expiration: Infinity, + }) + + return { space, account, recovery, login, attestation } +} + Test.basic({ DB: testDB }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8397773b0..4a4463109 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,6 +570,9 @@ importers: '@web3-storage/upload-client': specifier: workspace:^ version: link:../upload-client + datalogia: + specifier: ^0.2.0 + version: 0.2.0 uint8arrays: specifier: ^4.0.9 version: 4.0.9 @@ -3280,6 +3283,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'} @@ -3457,6 +3468,11 @@ 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'} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5907,6 +5923,14 @@ packages: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true + /datalogia@0.2.0: + resolution: {integrity: sha512-E2iPJAQWRf1V91V7ZktzJ1c7RMW/D8avzTOBHXzD9qkEq8FT4Itm41OEcZJ/OxMS5XSSkMJUgeXogabG09Nclw==} + 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'} @@ -9629,6 +9653,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'} From f900b481ab399315bda604edd1c4d10982cabae3 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 31 Jan 2024 14:57:05 -0800 Subject: [PATCH 03/16] feat: take advantage of glob / like constraints --- packages/w3up-client/package.json | 2 +- packages/w3up-client/src/agent/db.js | 102 ++++++++++++------ packages/w3up-client/src/agent/db/like.js | 12 --- packages/w3up-client/src/agent/delegation.js | 2 +- packages/w3up-client/src/types.ts | 9 ++ packages/w3up-client/test/agent/db.test.js | 104 ++++++++++++++++--- pnpm-lock.yaml | 11 +- 7 files changed, 175 insertions(+), 67 deletions(-) delete mode 100644 packages/w3up-client/src/agent/db/like.js diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index 651d37220..ebad0e670 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -100,7 +100,7 @@ "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", - "datalogia": "^0.2.0", + "datalogia": "^0.3.0", "@web3-storage/access": "workspace:^", "@web3-storage/capabilities": "workspace:^", "@web3-storage/did-mailto": "workspace:^", diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js index 7d5adee78..51e38d300 100644 --- a/packages/w3up-client/src/agent/db.js +++ b/packages/w3up-client/src/agent/db.js @@ -1,7 +1,6 @@ import * as Datalogia from 'datalogia' import * as API from '../types.js' import * as Delegation from './delegation.js' -import { like } from './db/like.js' export * from 'datalogia' @@ -115,58 +114,57 @@ export const facts = function* (proofs) { * @typedef {object} Authorization * @property {API.SpaceDID} subject * @property {API.Delegation[]} proofs - * @property {API.Audience} audience + * @property {API.DID} audience * * @param {DB} db * @param {object} query - * @param {API.Audience} query.audience - * @param {API.LikePattern} [query.subject] + * @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 = '%', audience, time = Date.now() / 1000, can = {} } + { subject = { like: '%' }, audience, time = Date.now() / 1000, can = {} } ) => { const space = Datalogia.string() const abilities = Object.keys(can) - const proofs = - abilities.length > 0 - ? Object.fromEntries(abilities.map((can) => [can, Datalogia.link()])) - : { '%': Datalogia.link() } + const principal = Datalogia.string() + const proofs = Object.fromEntries( + abilities.map((can) => [can, Datalogia.link()]) + ) const matches = Datalogia.query(db.index, { select: { ...proofs, space, + principal, }, where: [ ...Object.entries(proofs).flatMap(([need, proof]) => { const capability = Datalogia.link() - const expiration = Datalogia.integer() - const can = Datalogia.string() return [ Datalogia.match([capability, 'capability/with', space]), - Datalogia.match([capability, 'capability/can', can]), - can.confirm((can) => matchAbility(need, can)), + providesAbility({ capability, ability: need }), Datalogia.match([proof, 'ucan/capability', capability]), - Datalogia.match([proof, 'ucan/audience', audience.did()]), - Datalogia.match([proof, 'ucan/expiration', expiration]), - expiration.confirm((value) => value > time), + Datalogia.match([proof, 'ucan/audience', principal]), + matchText(principal, audience), + Datalogia.not(isExpired({ ucan: proof, time })), + Datalogia.not(isTooEarly({ ucan: proof, time })), ] }), - space.confirm((did) => like`${subject}`.test(did)), + matchText(space, subject), ], }) - return matches.map(({ space: did, ...proofs }) => { + return matches.map(({ space: did, principal, ...proofs }) => { // query engine will provide proof for each requested capability, so we may // have duplicates here, which we prune. const keys = [...new Set(Object.values(proofs).map(String))] return { - audience, + audience: /** @type {API.DID} */ (principal), subject: /** @type {API.SpaceDID} */ (did), // Dereference proofs from the store. proofs: keys.map( @@ -177,17 +175,59 @@ export const find = ( } /** - * Returns true if requested `need` ability is satisfied by the given `can` - * ability. * - * @param {string} can - * @param {string} need + * @param {Datalogia.Term} source + * @param {API.TextConstraint} constraint */ -const matchAbility = (need, can) => - can === '*' - ? true - : need === '%' - ? true - : can.endsWith('/*') - ? need.startsWith(can.slice(0, -1)) - : can === need +const matchText = (source, constraint) => + constraint.glob != null + ? Datalogia.glob(source, constraint.glob) + : constraint.like != null + ? Datalogia.like(source, constraint.like) + : Datalogia.Constraint.is(source, constraint) + +/** + * Composes the clause that matches given `query.ucan` only if it has expired, + * that is it has `exp` field set and is less than given `query.time`. + * + * @param {object} query + * @param {Datalogia.Term} query.ucan + * @param {Datalogia.API.Term} query.time + * @returns {Datalogia.Clause} + */ +const isExpired = ({ ucan, time }) => { + const expiration = Datalogia.integer() + return Datalogia.match([ucan, 'ucan/expiration', expiration]).and( + Datalogia.Constraint.greater(time, expiration) + ) +} + +/** + * Composes the clause that will match a `query.ucan` only if is not active yet, + * that is it's `nbf` field is set and greater than given `query.time`. + * + * @param {object} query + * @param {Datalogia.Term} query.ucan + * @param {Datalogia.API.Term} query.time + * @returns {Datalogia.Clause} + */ +const isTooEarly = ({ ucan, time }) => { + const notBefore = Datalogia.integer() + return Datalogia.match([ucan, 'ucan/notBefore', notBefore]).and( + Datalogia.Constraint.less(time, notBefore) + ) +} + +/** + * + * @param {object} query + * @param {Datalogia.Term} query.capability + * @param {string} query.ability + */ +const providesAbility = ({ capability, ability }) => { + const can = Datalogia.string() + return Datalogia.match([capability, 'capability/can', can]).and( + // can is a glob pattern that we try to match against + Datalogia.glob(ability, can) + ) +} diff --git a/packages/w3up-client/src/agent/db/like.js b/packages/w3up-client/src/agent/db/like.js deleted file mode 100644 index f8799d42b..000000000 --- a/packages/w3up-client/src/agent/db/like.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Compiles DB like pattern into regular expression. - * - * @param {{raw: ArrayLike}} template - * @param {...unknown} substitutions - */ -export const like = (template, ...substitutions) => - new RegExp( - String.raw(template, ...substitutions) - .replaceAll('%', '[\\s\\s]*') - .replaceAll('_', '[\\s\\s]{1}') - ) diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js index 7832d6dc9..36087b8f8 100644 --- a/packages/w3up-client/src/agent/delegation.js +++ b/packages/w3up-client/src/agent/delegation.js @@ -1,7 +1,7 @@ import * as API from '../types.js' import * as DB from 'datalogia' import * as Block from './block.js' -import { importDAG, allows, isDelegation } from '@ucanto/core/delegation' +import { importDAG, allows } from '@ucanto/core/delegation' import * as Association from './db/association.js' /** diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 3fc6f0fd0..4825a5fe6 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -462,6 +462,15 @@ export type Access = export type LikePattern = string +export type GlobPattern = string + +export type TextConstraint = + | Variant<{ + like: LikePattern + glob: GlobPattern + }> + | (string & { like?: undefined; glob?: undefined }) + /** * In the future, we want to implement AccessRequestSchema per spec, but for * now we do not support passing any clauses. diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index 5ccbcaa29..b727de343 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -6,7 +6,6 @@ import { delegate } from '@ucanto/core' import { Absentee, Verifier } from '@ucanto/principal' import * as Capability from '@web3-storage/capabilities' import { alice, bob, mallory, service } from '../fixtures/principals.js' -import { assert } from 'console' /** * @type {Test.BasicSuite} @@ -21,12 +20,12 @@ export const testDB = { const result = DB.find(db, { can: { 'store/add': [] }, - audience: alice, + audience: alice.did(), }) assert.deepEqual(result, [ { - audience: alice, + audience: alice.did(), subject: space.did(), proofs: [auth], }, @@ -48,17 +47,17 @@ export const testDB = { const result = DB.find(db, { can: { 'store/add': [], 'store/remove': [] }, - audience: alice, + audience: alice.did(), }) assert.deepEqual(result, [ { - audience: alice, + audience: alice.did(), subject: beetBox.did(), proofs: [beetBoxAuth], }, { - audience: alice, + audience: alice.did(), subject: plumBox.did(), proofs: [plumBoxAuth], }, @@ -82,12 +81,12 @@ export const testDB = { const result = DB.find(db, { can: { 'space/info': [], 'upload/list': [] }, - audience: bob, + audience: bob.did(), }) assert.deepEqual(result, [ { - audience: bob, + audience: bob.did(), subject: alice.did(), proofs: [spaceInfo, uploadList], }, @@ -112,40 +111,40 @@ export const testDB = { }) const result = DB.find(db, { - subject: 'did:mailto:%', + subject: { like: 'did:mailto:%' }, can: { '*': [] }, - audience: alice, + audience: alice.did(), }) assert.deepEqual(result, [ { subject: account.did(), - audience: alice, + audience: alice.did(), proofs: [login], }, ]) const spaces = DB.find(db, { - subject: 'did:key:%', + subject: { like: 'did:key:%' }, can: { 'store/add': [] }, - audience: alice, + audience: alice.did(), }) assert.deepEqual(spaces, [ { subject: remoteSpace.did(), - audience: alice, + audience: alice.did(), proofs: [login], }, { subject: localSpace.did(), - audience: alice, + audience: alice.did(), proofs: [localAuth], }, ]) }, - 'only test find accounts and attestations': async (assert) => { + 'test find accounts and attestations': async (assert) => { const { login, attestation, account } = await setupAccount() const db = DB.from({ proofs: [login, attestation] }) @@ -179,6 +178,79 @@ export const testDB = { }, ]) }, + + 'does not match expired ucans': async (assert) => { + const space = await Space.generate({ name: 'space' }) + const time = (Date.now() / 1000) | 0 + const expired = await space.createAuthorization(alice, { + expiration: time - 60 * 60 * 24, + }) + + const valid = await space.createAuthorization(alice, { + expiration: time + 60 * 60 * 24, + }) + + const db = DB.from({ + proofs: [valid, expired], + }) + + const withoutExpired = DB.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + time, + }) + + assert.deepEqual(withoutExpired, [ + { + audience: alice.did(), + subject: space.did(), + proofs: [valid], + }, + ]) + + const withExpired = DB.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + time: time - 60 * 60 * 24 * 2, + }) + + assert.deepEqual(withExpired, [ + { + audience: alice.did(), + subject: space.did(), + proofs: [valid], + }, + { + audience: alice.did(), + subject: space.did(), + proofs: [expired], + }, + ]) + }, + + 'does match non-expiring ucans': async (assert) => { + const space = await Space.generate({ name: 'space' }) + const proof = await space.createAuthorization(alice, { + expiration: Infinity, + }) + + const db = DB.from({ + proofs: [proof], + }) + + const result = DB.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + }) + + assert.deepEqual(result, [ + { + audience: alice.did(), + subject: space.did(), + proofs: [proof], + }, + ]) + }, } const setupAccount = async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a4463109..37b0d5088 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,8 +571,8 @@ importers: specifier: workspace:^ version: link:../upload-client datalogia: - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.0 + version: 0.3.0 uint8arrays: specifier: ^4.0.9 version: 4.0.9 @@ -3471,7 +3471,6 @@ packages: /@noble/hashes@1.3.3: resolution: {integrity: sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==} engines: {node: '>= 16'} - dev: false /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -5923,8 +5922,8 @@ packages: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true - /datalogia@0.2.0: - resolution: {integrity: sha512-E2iPJAQWRf1V91V7ZktzJ1c7RMW/D8avzTOBHXzD9qkEq8FT4Itm41OEcZJ/OxMS5XSSkMJUgeXogabG09Nclw==} + /datalogia@0.3.0: + resolution: {integrity: sha512-n5cFthKC1mvfUSee03aN76NO5bt6TR7jkcmN28iq1hTns5Qwpf7/8XSlxdmue+I5fnwhTynqaqQDAMISaZ6frA==} dependencies: '@ipld/dag-cbor': 9.1.0 '@noble/hashes': 1.3.3 @@ -12199,7 +12198,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==} From 74cace3689c52ceb5ba1712822d89f21146c29ff Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 5 Feb 2024 11:38:04 -0800 Subject: [PATCH 04/16] feat: save all hte code --- packages/w3up-client/src/agent.js | 1446 +++++++++-------- .../w3up-client/src/agent/authorization.js | 49 + packages/w3up-client/src/agent/capability.js | 33 + packages/w3up-client/src/agent/connection.js | 40 + .../src/agent/connection/address.js | 22 + packages/w3up-client/src/agent/db.js | 307 ++-- packages/w3up-client/src/agent/db/text.js | 18 + packages/w3up-client/src/agent/delegation.js | 46 +- packages/w3up-client/src/agent/delegations.js | 109 +- packages/w3up-client/src/agent/ex-store.js | 220 +++ packages/w3up-client/src/agent/store.js | 281 ++-- packages/w3up-client/src/agent/use-cases.js | 2 +- packages/w3up-client/src/capability/access.js | 12 +- packages/w3up-client/src/capability/plan.js | 2 +- .../w3up-client/src/capability/provider.js | 2 +- packages/w3up-client/src/capability/space.js | 16 +- .../src/capability/subscription.js | 2 +- packages/w3up-client/src/capability/usage.js | 2 +- packages/w3up-client/src/client/access.js | 6 +- packages/w3up-client/src/client/client.js | 8 +- packages/w3up-client/src/coupon.js | 4 +- packages/w3up-client/src/session.js | 26 + packages/w3up-client/src/space.js | 2 +- packages/w3up-client/src/types.ts | 239 ++- packages/w3up-client/src/view/account.js | 10 +- packages/w3up-client/src/view/space.js | 14 +- packages/w3up-client/test/agent.test.js | 90 + packages/w3up-client/test/test.js | 22 +- 28 files changed, 1892 insertions(+), 1138 deletions(-) create mode 100644 packages/w3up-client/src/agent/authorization.js create mode 100644 packages/w3up-client/src/agent/capability.js create mode 100644 packages/w3up-client/src/agent/connection.js create mode 100644 packages/w3up-client/src/agent/connection/address.js create mode 100644 packages/w3up-client/src/agent/db/text.js create mode 100644 packages/w3up-client/src/agent/ex-store.js create mode 100644 packages/w3up-client/src/session.js create mode 100644 packages/w3up-client/test/agent.test.js diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js index c7a446ebb..0d65f2eb5 100644 --- a/packages/w3up-client/src/agent.js +++ b/packages/w3up-client/src/agent.js @@ -1,712 +1,868 @@ import * as Client from '@ucanto/client' import * as CAR from '@ucanto/transport/car' import * as HTTP from '@ucanto/transport/http' -import * as ucanto from '@ucanto/core' -import { UCAN, capability } from '@web3-storage/capabilities' -import * as Access from './capability/access.js' -import * as Space from './capability/space.js' - -import { invoke, DID, Delegation, Schema, isDelegation } from '@ucanto/core' -import { - isExpired, - isTooEarly, - canDelegateCapability, - isValid, -} from './agent/delegations.js' -import { AgentData, getAttestations } from './agent/data.js' -import * as Config from './service.js' +import * as DB from './agent/db.js' +import { Signer, ed25519 } from '@ucanto/principal' +import { DID } from '@ucanto/core' + import * as API from './types.js' +import * as Session from './session.js' +import * as Connection from './agent/connection.js' export * from './types.js' -export { - isExpired, - isTooEarly, - canDelegateCapability, -} from './agent/delegations.js' -export { AgentData, Access, Space, Delegation, Schema } - -export * from './agent/use-cases.js' - -const HOST = 'https://up.web3.storage' -const PRINCIPAL = DID.parse('did:web:web3.storage') -/** - * Keeps track of AgentData for all Agents constructed. - * Used by addSpacesFromDelegations - so it can only accept Agent as param, but - * still mutate corresponding AgentData - * - * @deprecated - remove this when deprecated addSpacesFromDelegations is removed - */ -/** @type {WeakMap>, AgentData>} */ -const agentToData = new WeakMap() +export { DB, Connection, DID } /** - * @typedef {API.Service} Service - * @typedef {API.Receipt} Receipt + * @type {API.DataStore} */ +export const ephemeral = { + open: async () => {}, + close: async () => {}, + save: async () => {}, + /** + * + * @returns {Promise} + */ + load: async () => { + return undefined + }, + reset: async () => {}, +} /** - * Creates a Ucanto connection for the w3access API - * - * Usage: - * - * ```js - * import { connection } from '@web3-storage/access/agent' - * ``` - * - * @template {API.DID} T - DID method - * @template {Record} [S=Service] - * @param {object} [options] - * @param {API.Principal} [options.principal] - w3access API Principal - * @param {URL} [options.url] - w3access API URL - * @param {API.Transport.Channel} [options.channel] - Ucanto channel to use - * @param {typeof fetch} [options.fetch] - Fetch implementation to use - * @returns {API.ConnectionView} + * @param {API.W3UpOpen} source + * @returns {AgentPromise} */ -export function connection(options = {}) { - return Client.connect({ - id: options.principal ?? PRINCIPAL, - codec: CAR.outbound, - channel: - options.channel ?? - HTTP.open({ - url: options.url ?? new URL(HOST), - method: 'POST', - fetch: options.fetch ?? globalThis.fetch.bind(globalThis), - }), - }) -} +export const open = (source) => AgentPromise.from(Agent.open(source)) /** - * @template {Record} [Service={}] - * @typedef {object} AgentModel - * @property {AgentData} data - * @property {API.ConnectionView} connection + * @param {API.W3Load} source */ +export const load = (source) => AgentPromise.from(Agent.load(source)) /** - * @template {Record} Protocol - * @param {object} source - * @param {AgentData} source.data - * @param {API.ConnectionView} source.connection - * @param {URL} [source.receiptsEndpoint] - * @returns {Agent} + * @param {API.W3Create} source */ -export const from = (source) => new Agent(source.data, source) +export const create = (source) => AgentPromise.from(Agent.create(source)) /** - * Agent - * - * Usage: - * - * ```js - * import { Agent } from '@web3-storage/w3up-client' - * ``` - * - * @template {Record} [S=Record] - Service + * @extends {Promise>} */ -export class Agent { - /** @type {AgentData} */ - #data - +class AgentPromise extends Promise { /** - * @param {AgentData} data - Agent data - * @param {API.AgentOptions} [options] + * + * @param {Promise>} promise + * @returns {AgentPromise} */ - constructor(data, options = {}) { - /** @type { Client.Channel & { url?: URL } | undefined } */ - const channel = options.connection?.channel - this.url = options.url ?? channel?.url ?? new URL(HOST) - this.connection = - options.connection ?? - connection({ - principal: options.servicePrincipal, - url: this.url, - }) - this.receiptsEndpoint = options.receiptsEndpoint ?? Config.receiptsEndpoint - this.#data = data - agentToData.set(this, this.#data) - } - - get data() { - return this.#data + static from(promise) { + return new AgentPromise((resolve, reject) => promise.then(resolve, reject)) } - /** - * Create a new Agent instance, optionally with the passed initialization data. - * - * @template {Record} [R=Service] - * @param {Partial} [init] - * @param {API.AgentOptions & API.AgentDataOptions} [options] + * @template {API.UnknownProtocol} Protocol + * @param {API.ConnectionView} [connection] + * @returns {Promise, API.ConnectError>>} */ - static async create(init, options = {}) { - const data = await AgentData.create(init, options) - return new Agent(data, options) + async connect(connection) { + const result = await this + if (result.ok) { + return result.ok.connect(connection) + } else { + return result + } } +} +/** + * @implements {API.AgentView} + */ +class Agent { /** - * Instantiate an Agent from pre-exported agent data. - * - * @template {Record} [R=Service] - * @param {API.AgentDataExport} raw - * @param {API.AgentOptions & API.AgentDataOptions} [options] + * @param {object} source + * @param {API.Signer} source.signer + * @param {API.Database} source.db */ - static from(raw, options = {}) { - const data = AgentData.fromExport(raw, options) - return new Agent(data, options) - } - - get issuer() { - return this.#data.principal - } - - get meta() { - return this.#data.meta + constructor(source) { + this.model = source } - get spaces() { - return this.#data.spaces + did() { + return this.model.signer.did() } - did() { - return this.#data.principal.did() + get db() { + return this.model.db } /** - * Import a space from a delegation. - * - * @param {API.Delegation} delegation - * @param {object} options - * @param {string} [options.name] + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} [connection] + * @returns {Promise, API.SignerLoadError>>} */ - async importSpaceFromDelegation(delegation, { name = '' } = {}) { - const space = - name === '' - ? Space.fromDelegation(delegation) - : Space.fromDelegation(delegation).withName(name) - - this.#data.spaces.set(space.did(), { ...space.meta, name: space.name }) - - await addProofs(this.#data, space.proofs) - - // if we do not have a current space, make this one current - if (!this.#data.currentSpace) { - await this.#data.setCurrentSpace(space.did()) + async connect(connection = Connection.open()) { + return { + ok: Session.create({ agent: this, connection }), } - - return space } - /** - * Execute invocations on the agent's connection - * - * @example - * ```js - * const i1 = await agent.invoke(Space.info, {}) - * const i2 = await agent.invoke(Space.recover, { - * nb: { - * identity: 'mailto:hello@web3.storage', - * }, - * }) - * - * const results = await agent.execute2(i1, i2) - * - * ``` - * @template {API.Capability} C - * @template {API.Tuple>} I - * @param {I} invocations + * @param {API.W3Create} source */ - execute(...invocations) { - return this.connection.execute(...invocations) - } -} - -/** - * Given a list of delegations, add to agent data spaces list. - * - * @deprecated - trying to remove explicit space tracking from Agent/AgentData - * in favor of functions that derive the space set from access.delegations - * - * @template {Record} [S=Service] - * @param {Agent} agent - * @param {API.Delegation[]} delegations - */ -export async function addSpacesFromDelegations(agent, delegations) { - const data = agentToData.get(agent) - if (!data) { - throw Object.assign(new Error(`cannot determine AgentData for Agent`), { - agent: agent, - }) - } - - // spaces we find along the way. - const spaces = new Map() - // only consider ucans with this agent as the audience - const ours = delegations.filter((x) => x.audience.did() === agent.did()) - // space names are stored as facts in proofs in the special `ucan:*` delegation from email to agent. - const ucanStars = ours.filter( - (x) => x.capabilities[0].can === '*' && x.capabilities[0].with === 'ucan:*' - ) - for (const delegation of ucanStars) { - for (const proof of delegation.proofs) { - if ( - !isDelegation(proof) || - !proof.capabilities[0].with.startsWith('did:key') - ) { - continue - } - const space = Space.fromDelegation(proof) - spaces.set(space.did(), space.meta) + static async create({ store, as }) { + const { ok: db, error } = await DB.open({ store }) + if (error) { + return { error } } - } - - // Find any other spaces the user may have access to - for (const delegation of ours) { - // TODO: we need a more robust way to determine which spaces a user has access to - // it may or may not involve look at delegations - const allows = ucanto.Delegation.allows(delegation) - for (const [resource, value] of Object.entries(allows)) { - // If we discovered a delegation to any DID, we add it to the spaces list. - if (resource.startsWith('did:key') && Object.keys(value).length > 0) { - if (!spaces.has(resource)) { - spaces.set(resource, {}) - } - } + const signer = as ?? (await ed25519.generate()) + const archive = signer.toArchive() + const result = await DB.transact(db, [{ signer: archive }]) + if (result.error) { + return result } - } - - for (const [did, meta] of spaces) { - await data.addSpace(did, meta) - } -} - -/** - * Stores given delegations in the agent's data store and adds discovered spaces - * to the agent's space list. - * - * @param {Agent<{}>} agent - * @param {object} authorization - * @param {API.Delegation[]} authorization.proofs - * @returns {Promise>} - */ -export const importAuthorization = async (agent, { proofs }) => { - try { - await addProofs(agent.data, proofs) - await addSpacesFromDelegations(agent, proofs) - return { ok: {} } - } catch (error) { - return /** @type {{error:Error}} */ ({ error }) - } -} -/** - * Get all the proofs matching the capabilities. - * - * Proofs are delegations with an audience matching agent DID, or with an - * audience matching the session DID. - * - * Attestations will also be included in the returned proofs require them. - * - * @param {object} agent - * @param {AgentData} agent.data - * @param {API.Principal} agent.issuer - * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the proofs. - * @param {object} [options] - * @param {API.UTCUnixTimestamp} [options.time] - Time when the capability should be valid - * @param {API.DID} [options.sessionProofIssuer] - only include session proofs for this issuer - */ -export const selectAuthorization = ({ data, issuer }, caps, options) => { - const authorizations = [] - for (const { delegation } of selectProofs(data, caps, options)) { - if (delegation.audience.did() === issuer.did()) { - authorizations.push(delegation) - } + return { ok: new Agent({ db, signer }) } } - - // now let's add any session proofs that refer to those authorizations - const sessions = getAttestations(data, options) - for (const proof of authorizations) { - const proofsByIssuer = sessions[proof.asCID.toString()] ?? {} - const sessionProofs = options?.sessionProofIssuer - ? proofsByIssuer[options.sessionProofIssuer] ?? [] - : Object.values(proofsByIssuer).flat() - if (sessionProofs.length) { - authorizations.push(...sessionProofs) + /** + * @param {API.W3Load} source + * @returns {Promise>} + */ + static async load({ store, as }) { + const { ok: db, error } = await DB.open({ store }) + if (error) { + return { error } } - } - return authorizations -} + if (as != null) { + return { ok: new Agent({ db, signer: as }) } + } else if (db?.signer != null) { + const signer = Signer.from( + /** @type {API.SignerArchive} */ (db.signer) + ) -/** - * Query the delegations store for all the delegations matching the capabilities provided. - * - * @param {AgentData} store - * @param {API.CapabilityQuery[]} caps - * @param {object} [options] - * @param {API.UTCUnixTimestamp} [options.time] - */ -export const selectProofs = (store, caps, { time } = {}) => { - const _caps = new Set(caps) - /** @type {Array<{ delegation: API.Delegation, meta: API.DelegationMeta }>} */ - const values = [] - for (const [, value] of store.delegations) { - // check expiration - if (!time || isValid(value.delegation, time)) { - // check if we need to filter for caps - if (Array.isArray(caps) && caps.length > 0) { - for (const cap of _caps) { - if (canDelegateCapability(value.delegation, cap)) { - values.push(value) - } - } - } else { - values.push(value) + return { ok: new Agent({ db, signer }) } + } else { + return { + error: new SignerLoadError( + 'Signer key material is not stored in storage' + ), } } } - - return values -} - -/** - * Get delegations created by the agent for others and their metadata. - * - * @param {AgentData} data - * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. - */ -export const selectIssuedDelegationsWithMeta = (data, caps) => { - const arr = [] - - for (const value of selectProofs(data, caps)) { - const { delegation } = value - const isAttestation = delegation.capabilities.some( - (c) => c.can === attest.can - ) - - if (!isAttestation && delegation.audience.did() !== data.principal.did()) { - arr.push(value) - } - } - - return arr -} - -/** - * Get delegations created by the agent for others. - * - * @param {AgentData} data - * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. - */ -export const selectIssuedDelegations = (data, caps) => { - const arr = [] - - for (const { delegation } of selectIssuedDelegationsWithMeta(data, caps)) { - arr.push(delegation) - } - - return arr -} - -/** - * Add a proof to the agent store. - * - * @param {AgentData} data - * @param {API.Delegation} delegation - */ -export const addProof = async (data, delegation) => { - return await addProofs(data, [delegation]) -} - -/** - * Adds set of proofs to the agent store. - * - * @param {AgentData} data - * @param {Iterable} delegations - */ -export const addProofs = async (data, delegations) => { - for (const proof of delegations) { - await data.addDelegation(proof, { audience: data.meta }) - } - - await removeExpiredDelegations(data, { time: Date.now() / 1000 }) - - return {} -} - -/** - * Clean up any expired delegations. - * - * @param {AgentData} data - * @param {object} options - * @param {API.UTCUnixTimestamp} options.time - */ -export const removeExpiredDelegations = async (data, options) => { - for (const [, value] of data.delegations) { - if (isExpired(value.delegation, options.time)) { - await data.removeDelegation(value.delegation.cid) - } - } -} - -/** - * Get current space DID, proofs and abilities - * - * @param {object} agent - * @param {AgentData} agent.data - * @param {API.Principal} agent.issuer - */ -export const currentSpaceWithMeta = ({ data, issuer }) => { - const space = data.currentSpace - if (!space) { - return - } - - const proofs = selectAuthorization({ data, issuer }, [ - { - can: 'space/info', - with: space, - }, - ]) - - const abilities = new Set() - for (const { capabilities } of proofs) { - for (const { can } of capabilities) { - abilities.add(can) + /** + * @param {API.W3UpOpen} source + * @returns {Promise>} + */ + static async open({ store, as }) { + const { ok: db, error } = await DB.open({ store }) + if (error) { + return { error } } - } - - return { - did: data.currentSpace, - proofs: proofs, - capabilities: [...abilities], - meta: data.spaces.get(space), - } -} -/** - * @param {object} agent - * @param {AgentData} agent.data - * @param {API.Signer} agent.issuer - * @param {API.DelegateOptions} options - */ -export const issueDelegation = async ({ data, issuer }, options) => { - const time = Date.now() / 1000 - const space = currentSpaceWithMeta({ data, issuer }) - if (!space) { - throw new Error('no space selected.') - } - - const capabilities = /** @type {API.Capabilities} */ ( - options.abilities.map((can) => { + if (as) { + return { ok: new Agent({ db, signer: as }) } + } else if (db.signer) { return { - with: space.did, - can, + ok: new Agent({ + signer: Signer.from(db.signer), + db, + }), } - }) - ) - - // Verify agent can provide proofs for each requested capability - for (const capability of capabilities) { - if (!selectAuthorization({ data, issuer }, [capability], { time }).length) { - throw new Error( - `cannot delegate capability ${capability.can} with ${capability.with}` - ) - } - } - - const delegation = await Delegation.delegate({ - issuer, - capabilities, - proofs: selectAuthorization({ data, issuer }, capabilities, { time }), - facts: [{ space: space.meta ?? {} }], - ...options, - }) - - await data.addDelegation(delegation, { - audience: options.audienceMeta, - }) - await removeExpiredDelegations(data, { time }) - - return delegation -} - -/** - * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. - * - * @example - * ```js - * const spaceList = await Agent.issueInvocation(agent, Store.list, { - * nb: { - * size: 10, - * }, - * }) - * - * await spaceList.execute(agent.connection) - * ``` - * - * @template {API.Ability} A - * @template {API.URI} R - * @template {API.TheCapabilityParser>} CAP - * @template {API.Caveats} [C={}] - * - * @param {object} agent - * @param {AgentData} agent.data - * @param {API.Signer} agent.issuer - * @param {{id: API.Principal}} agent.connection - * @param {CAP} cap - * @param {API.InvokeOptions} options - */ -export const issueInvocation = async ( - { connection, issuer, data }, - cap, - options -) => { - const audience = options.audience || connection.id - const time = Date.now() / 1000 - - const space = options.with || data.currentSpace - if (!space) { - throw new Error('No space or resource selected, you need pass a resource.') - } - - const proofs = [ - ...(options.proofs || []), - ...selectAuthorization( - { data, issuer }, - [ - { - with: space, - can: cap.can, - }, - ], - { sessionProofIssuer: audience.did(), time } - ), - ] - - if (proofs.length === 0 && options.with !== issuer.did()) { - throw new Error( - `no proofs available for resource ${space} and ability ${cap.can}` - ) - } - - const inv = invoke({ - ...options, - issuer, - audience, - // @ts-ignore - capability: cap.create({ - with: space, - nb: options.nb, - }), - proofs: [...proofs], - }) - - return /** @type {API.IssuedInvocationView>} */ ( - inv - ) -} - -/** - * Returns iterable of all the proofs that contain capabilities matching - * passed query. - * - * @param {AgentData} store - * @param {object} query - * @param {API.CapabilityParser} [query.capability] - Capability to match - * @param {API.UTCUnixTimestamp} [query.time] - Time when the capability should be valid - * @param {API.DID} [query.audience] - Audience of the capability - */ -export const selectAccess = function* (store, { capability, audience, time }) { - for (const [, { delegation }] of store.delegations) { - // Skip if delegated to a different audience - if (audience && delegation.audience.did() !== audience) { - continue - } - - // Skip if not valid at the given time - if (time && !isValid(delegation, time)) { - continue - } + } else { + const signer = await ed25519.generate() - // If proof matches the capability, yield it otherwise skip - const proof = capability ? matchProof(delegation, capability) : delegation - if (proof) { - yield proof + const { error } = await DB.transact(db, [{ signer: signer.toArchive() }]) + if (error) { + return { error } + } else { + return { + ok: new Agent({ db, signer }), + } + } } } } -/** - * Select all attestations matching the given query. - * - * @param {AgentData} store - * @param {object} query - * @param {API.UCANLink[]} query.proofs - * @param {API.DID} [query.audience] - Audience of the capability - */ -const selectAttestations = (store, { proofs, audience }) => { - const proof = proofs - .map((proof) => Schema.link(proof)) - .reduce((left, right) => Schema.or(left, right)) - - const selector = capability({ - can: UCAN.attest.can, - with: Schema.did(), - nb: Schema.struct({ proof }), - }) - - return selectAccess(store, { capability: selector, audience }) +class SignerLoadError extends Error { + name = /** @type {const} */ ('SignerLoadError') } -/** - * - * @param {API.Delegation} delegation - * @param {API.CapabilityParser} query - */ +// /** +// * Keeps track of AgentData for all Agents constructed. +// * Used by addSpacesFromDelegations - so it can only accept Agent as param, but +// * still mutate corresponding AgentData +// * +// * @deprecated - remove this when deprecated addSpacesFromDelegations is removed +// */ +// /** @type {WeakMap>, AgentData>} */ +// const agentToData = new WeakMap() -const matchProof = (delegation, query) => { - for (const capability of delegation.capabilities) { - const result = query.match( - /** @type {API.Source} */ ({ capability, delegation }) - ) - if (result.ok) { - return delegation - } - } -} /** - * Invoke and execute the given capability on the Access service connection - * - * ```js - * - * await Agent.invokeAndExecute(agent, Store.list, { - * nb: { - * size: 10, - * }, - * }) - * ``` - * - * @deprecated - use following instead - * ```js - * const task = await Agent.issueInvocation(agent, cap, opts) - * await task.execute(connection) instead - * ``` - * - * @template {API.Ability} A - * @template {API.URI} R - * @template {API.Caveats} C - * @template {Record} Protocol - * @param {object} agent - * @param {AgentData} agent.data - * @param {API.Signer} agent.issuer - * @param {API.ConnectionView} agent.connection - * @param {API.TheCapabilityParser>} cap - * @param {API.InvokeOptions>>} options - * @returns {Promise, Protocol>>} + * @typedef {API.Service} Service + * @typedef {API.Receipt} Receipt */ -export const invokeAndExecute = async (agent, cap, options) => { - const invocation = await issueInvocation(agent, cap, options) - const out = invocation.execute(/** @type {*} */ (agent.connection)) - return /** @type {*} */ (out) -} + +// /** +// * @template {Record} [Protocol={}] +// * @typedef {object} Model +// * @property {API.AgentMeta} meta +// * @property {Connection.Model} connection +// * @property {API.Signer} principal +// * @property {API.Storage} store +// * @property {DB.DB} db +// * @property {URL} [receiptsEndpoint] +// */ + +// /** +// * @typedef {object} Archive +// * @property {Connection.Archive} connection +// * @property {API.AgentMeta} meta +// * @property {API.SignerArchive} principal +// * @property {API.DatabaseArchive} delegations +// * @property {API.DIDKey} [currentSpace] +// */ + +// /** +// * @param {Model} model +// * @returns {Archive} +// */ +// export const toArchive = (model) => { +// return { +// connection: Connection.toArchive(model.connection), +// meta: model.meta, +// principal: model.principal.toArchive(), +// delegations: DB.toArchive(model.db), +// } +// } + +// /** +// * @template {Record} [Service={}] +// * @typedef {object} AgentModel +// * @property {AgentData} data +// * @property {API.ConnectionView} connection +// */ + +// /** +// * @template {Record} Protocol +// * @param {object} source +// * @param {API.DataStore} source.store +// * @param {API.ConnectionView} source.connection +// * @param {URL} [source.receiptsEndpoint] +// * @returns {Agent} +// */ +// export const from = (source) => new Agent(source.data, source) + +// /** +// * Agent +// * +// * Usage: +// * +// * ```js +// * import { Agent } from '@web3-storage/w3up-client' +// * ``` +// * +// * @template {Record} [S=Record] - Service +// */ +// export class ExAgent { +// /** @type {AgentData} */ +// #data + +// /** +// * @param {AgentData} data - Agent data +// * @param {API.AgentOptions} [options] +// */ +// constructor(data, options = {}) { +// /** @type { Client.Channel & { url?: URL } | undefined } */ +// const channel = options.connection?.channel +// this.url = options.url ?? channel?.url ?? new URL(HOST) +// this.connection = +// options.connection ?? +// connection({ +// principal: options.servicePrincipal, +// url: this.url, +// }) +// this.receiptsEndpoint = options.receiptsEndpoint ?? Config.receiptsEndpoint +// this.#data = data +// agentToData.set(this, this.#data) +// } + +// get data() { +// return this.#data +// } + +// /** +// * Create a new Agent instance, optionally with the passed initialization data. +// * +// * @template {Record} [R=Service] +// * @param {Partial} [init] +// * @param {API.AgentOptions & API.AgentDataOptions} [options] +// */ +// static async create(init, options = {}) { +// const data = await AgentData.create(init, options) +// return new Agent(data, options) +// } + +// /** +// * Instantiate an Agent from pre-exported agent data. +// * +// * @template {Record} [R=Service] +// * @param {API.AgentDataExport} raw +// * @param {API.AgentOptions & API.AgentDataOptions} [options] +// */ +// static from(raw, options = {}) { +// const data = AgentData.fromExport(raw, options) +// return new Agent(data, options) +// } + +// get issuer() { +// return this.#data.principal +// } + +// get meta() { +// return this.#data.meta +// } + +// get spaces() { +// return this.#data.spaces +// } + +// did() { +// return this.#data.principal.did() +// } + +// /** +// * Import a space from a delegation. +// * +// * @param {API.Delegation} delegation +// * @param {object} options +// * @param {string} [options.name] +// */ +// async importSpaceFromDelegation(delegation, { name = '' } = {}) { +// const space = +// name === '' +// ? Space.fromDelegation(delegation) +// : Space.fromDelegation(delegation).withName(name) + +// this.#data.spaces.set(space.did(), { ...space.meta, name: space.name }) + +// await addProofs(this.#data, space.proofs) + +// // if we do not have a current space, make this one current +// if (!this.#data.currentSpace) { +// await this.#data.setCurrentSpace(space.did()) +// } + +// return space +// } + +// /** +// * Execute invocations on the agent's connection +// * +// * @example +// * ```js +// * const i1 = await agent.invoke(Space.info, {}) +// * const i2 = await agent.invoke(Space.recover, { +// * nb: { +// * identity: 'mailto:hello@web3.storage', +// * }, +// * }) +// * +// * const results = await agent.execute2(i1, i2) +// * +// * ``` +// * @template {API.Capability} C +// * @template {API.Tuple>} I +// * @param {I} invocations +// */ +// execute(...invocations) { +// return this.connection.execute(...invocations) +// } +// } + +// /** +// * Given a list of delegations, add to agent data spaces list. +// * +// * @deprecated - trying to remove explicit space tracking from Agent/AgentData +// * in favor of functions that derive the space set from access.delegations +// * +// * @template {Record} [S=Service] +// * @param {Agent} agent +// * @param {API.Delegation[]} delegations +// */ +// export async function addSpacesFromDelegations(agent, delegations) { +// const data = agentToData.get(agent) +// if (!data) { +// throw Object.assign(new Error(`cannot determine AgentData for Agent`), { +// agent: agent, +// }) +// } + +// // spaces we find along the way. +// const spaces = new Map() +// // only consider ucans with this agent as the audience +// const ours = delegations.filter((x) => x.audience.did() === agent.did()) +// // space names are stored as facts in proofs in the special `ucan:*` delegation from email to agent. +// const ucanStars = ours.filter( +// (x) => x.capabilities[0].can === '*' && x.capabilities[0].with === 'ucan:*' +// ) +// for (const delegation of ucanStars) { +// for (const proof of delegation.proofs) { +// if ( +// !isDelegation(proof) || +// !proof.capabilities[0].with.startsWith('did:key') +// ) { +// continue +// } +// const space = Space.fromDelegation(proof) +// spaces.set(space.did(), space.meta) +// } +// } + +// // Find any other spaces the user may have access to +// for (const delegation of ours) { +// // TODO: we need a more robust way to determine which spaces a user has access to +// // it may or may not involve look at delegations +// const allows = ucanto.Delegation.allows(delegation) +// for (const [resource, value] of Object.entries(allows)) { +// // If we discovered a delegation to any DID, we add it to the spaces list. +// if (resource.startsWith('did:key') && Object.keys(value).length > 0) { +// if (!spaces.has(resource)) { +// spaces.set(resource, {}) +// } +// } +// } +// } + +// for (const [did, meta] of spaces) { +// await data.addSpace(did, meta) +// } +// } + +// /** +// * Stores given delegations in the agent's data store and adds discovered spaces +// * to the agent's space list. +// * +// * @param {Agent<{}>} agent +// * @param {object} authorization +// * @param {API.Delegation[]} authorization.proofs +// * @returns {Promise>} +// */ +// export const importAuthorization = async (agent, { proofs }) => { +// try { +// await addProofs(agent.data, proofs) +// await addSpacesFromDelegations(agent, proofs) +// return { ok: {} } +// } catch (error) { +// return /** @type {{error:Error}} */ ({ error }) +// } +// } + +// /** +// * Get all the proofs matching the capabilities. +// * +// * Proofs are delegations with an audience matching agent DID, or with an +// * audience matching the session DID. +// * +// * Attestations will also be included in the returned proofs require them. +// * +// * @param {object} agent +// * @param {AgentData} agent.data +// * @param {API.Principal} agent.issuer +// * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the proofs. +// * @param {object} [options] +// * @param {API.UTCUnixTimestamp} [options.time] - Time when the capability should be valid +// * @param {API.DID} [options.sessionProofIssuer] - only include session proofs for this issuer +// */ +// export const selectAuthorization = ({ data, issuer }, caps, options) => { +// const authorizations = [] +// for (const { delegation } of selectProofs(data, caps, options)) { +// if (delegation.audience.did() === issuer.did()) { +// authorizations.push(delegation) +// } +// } + +// // now let's add any session proofs that refer to those authorizations +// const sessions = getAttestations(data, options) +// for (const proof of authorizations) { +// const proofsByIssuer = sessions[proof.asCID.toString()] ?? {} +// const sessionProofs = options?.sessionProofIssuer +// ? proofsByIssuer[options.sessionProofIssuer] ?? [] +// : Object.values(proofsByIssuer).flat() +// if (sessionProofs.length) { +// authorizations.push(...sessionProofs) +// } +// } + +// return authorizations +// } + +// /** +// * Query the delegations store for all the delegations matching the capabilities provided. +// * +// * @param {AgentData} store +// * @param {API.CapabilityQuery[]} caps +// * @param {object} [options] +// * @param {API.UTCUnixTimestamp} [options.time] +// */ +// export const selectProofs = (store, caps, { time } = {}) => { +// const _caps = new Set(caps) +// /** @type {Array<{ delegation: API.Delegation, meta: API.DelegationMeta }>} */ +// const values = [] +// for (const [, value] of store.delegations) { +// // check expiration +// if (!time || isValid(value.delegation, time)) { +// // check if we need to filter for caps +// if (Array.isArray(caps) && caps.length > 0) { +// for (const cap of _caps) { +// if (canDelegateCapability(value.delegation, cap)) { +// values.push(value) +// } +// } +// } else { +// values.push(value) +// } +// } +// } + +// return values +// } + +// /** +// * Get delegations created by the agent for others and their metadata. +// * +// * @param {AgentData} data +// * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. +// */ +// export const selectIssuedDelegationsWithMeta = (data, caps) => { +// const arr = [] + +// for (const value of selectProofs(data, caps)) { +// const { delegation } = value +// const isAttestation = delegation.capabilities.some( +// (c) => c.can === attest.can +// ) + +// if (!isAttestation && delegation.audience.did() !== data.principal.did()) { +// arr.push(value) +// } +// } + +// return arr +// } + +// /** +// * Get delegations created by the agent for others. +// * +// * @param {AgentData} data +// * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. +// */ +// export const selectIssuedDelegations = (data, caps) => { +// const arr = [] + +// for (const { delegation } of selectIssuedDelegationsWithMeta(data, caps)) { +// arr.push(delegation) +// } + +// return arr +// } + +// /** +// * Add a proof to the agent store. +// * +// * @param {AgentData} data +// * @param {API.Delegation} delegation +// */ +// export const addProof = async (data, delegation) => { +// return await addProofs(data, [delegation]) +// } + +// /** +// * Adds set of proofs to the agent store. +// * +// * @param {AgentData} data +// * @param {Iterable} delegations +// */ +// export const addProofs = async (data, delegations) => { +// for (const proof of delegations) { +// await data.addDelegation(proof, { audience: data.meta }) +// } + +// await removeExpiredDelegations(data, { time: Date.now() / 1000 }) + +// return {} +// } + +// /** +// * Clean up any expired delegations. +// * +// * @param {AgentData} data +// * @param {object} options +// * @param {API.UTCUnixTimestamp} options.time +// */ +// export const removeExpiredDelegations = async (data, options) => { +// for (const [, value] of data.delegations) { +// if (isExpired(value.delegation, options.time)) { +// await data.removeDelegation(value.delegation.cid) +// } +// } +// } + +// /** +// * Get current space DID, proofs and abilities +// * +// * @param {object} agent +// * @param {AgentData} agent.data +// * @param {API.Principal} agent.issuer +// */ +// export const currentSpaceWithMeta = ({ data, issuer }) => { +// const space = data.currentSpace +// if (!space) { +// return +// } + +// const proofs = selectAuthorization({ data, issuer }, [ +// { +// can: 'space/info', +// with: space, +// }, +// ]) + +// const abilities = new Set() +// for (const { capabilities } of proofs) { +// for (const { can } of capabilities) { +// abilities.add(can) +// } +// } + +// return { +// did: data.currentSpace, +// proofs: proofs, +// capabilities: [...abilities], +// meta: data.spaces.get(space), +// } +// } + +// /** +// * @param {object} agent +// * @param {AgentData} agent.data +// * @param {API.Signer} agent.issuer +// * @param {API.DelegateOptions} options +// */ +// export const issueDelegation = async ({ data, issuer }, options) => { +// const time = Date.now() / 1000 +// const space = currentSpaceWithMeta({ data, issuer }) +// if (!space) { +// throw new Error('no space selected.') +// } + +// const capabilities = /** @type {API.Capabilities} */ ( +// options.abilities.map((can) => { +// return { +// with: space.did, +// can, +// } +// }) +// ) + +// // Verify agent can provide proofs for each requested capability +// for (const capability of capabilities) { +// if (!selectAuthorization({ data, issuer }, [capability], { time }).length) { +// throw new Error( +// `cannot delegate capability ${capability.can} with ${capability.with}` +// ) +// } +// } + +// const delegation = await Delegation.delegate({ +// issuer, +// capabilities, +// proofs: selectAuthorization({ data, issuer }, capabilities, { time }), +// facts: [{ space: space.meta ?? {} }], +// ...options, +// }) + +// await data.addDelegation(delegation, { +// audience: options.audienceMeta, +// }) +// await removeExpiredDelegations(data, { time }) + +// return delegation +// } + +// /** +// * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. +// * +// * @example +// * ```js +// * const spaceList = await Agent.issueInvocation(agent, Store.list, { +// * nb: { +// * size: 10, +// * }, +// * }) +// * +// * await spaceList.execute(agent.connection) +// * ``` +// * +// * @template {API.Ability} A +// * @template {API.URI} R +// * @template {API.TheCapabilityParser>} CAP +// * @template {API.Caveats} [C={}] +// * +// * @param {object} agent +// * @param {AgentData} agent.data +// * @param {API.Signer} agent.issuer +// * @param {{id: API.Principal}} agent.connection +// * @param {CAP} cap +// * @param {API.InvokeOptions} options +// */ +// export const issueInvocation = async ( +// { connection, issuer, data }, +// cap, +// options +// ) => { +// const audience = options.audience || connection.id +// const time = Date.now() / 1000 + +// const space = options.with || data.currentSpace +// if (!space) { +// throw new Error('No space or resource selected, you need pass a resource.') +// } + +// const proofs = [ +// ...(options.proofs || []), +// ...selectAuthorization( +// { data, issuer }, +// [ +// { +// with: space, +// can: cap.can, +// }, +// ], +// { sessionProofIssuer: audience.did(), time } +// ), +// ] + +// if (proofs.length === 0 && options.with !== issuer.did()) { +// throw new Error( +// `no proofs available for resource ${space} and ability ${cap.can}` +// ) +// } + +// const inv = invoke({ +// ...options, +// issuer, +// audience, +// // @ts-ignore +// capability: cap.create({ +// with: space, +// nb: options.nb, +// }), +// proofs: [...proofs], +// }) + +// return /** @type {API.IssuedInvocationView>} */ ( +// inv +// ) +// } + +// /** +// * Returns iterable of all the proofs that contain capabilities matching +// * passed query. +// * +// * @param {AgentData} store +// * @param {object} query +// * @param {API.CapabilityParser} [query.capability] - Capability to match +// * @param {API.UTCUnixTimestamp} [query.time] - Time when the capability should be valid +// * @param {API.DID} [query.audience] - Audience of the capability +// */ +// export const selectAccess = function* (store, { capability, audience, time }) { +// for (const [, { delegation }] of store.delegations) { +// // Skip if delegated to a different audience +// if (audience && delegation.audience.did() !== audience) { +// continue +// } + +// // Skip if not valid at the given time +// if (time && !isValid(delegation, time)) { +// continue +// } + +// // If proof matches the capability, yield it otherwise skip +// const proof = capability ? matchProof(delegation, capability) : delegation +// if (proof) { +// yield proof +// } +// } +// } + +// /** +// * Select all attestations matching the given query. +// * +// * @param {AgentData} store +// * @param {object} query +// * @param {API.UCANLink[]} query.proofs +// * @param {API.DID} [query.audience] - Audience of the capability +// */ +// const selectAttestations = (store, { proofs, audience }) => { +// const proof = proofs +// .map((proof) => Schema.link(proof)) +// .reduce((left, right) => Schema.or(left, right)) + +// const selector = capability({ +// can: UCAN.attest.can, +// with: Schema.did(), +// nb: Schema.struct({ proof }), +// }) + +// return selectAccess(store, { capability: selector, audience }) +// } + +// /** +// * +// * @param {API.Delegation} delegation +// * @param {API.CapabilityParser} query +// */ + +// const matchProof = (delegation, query) => { +// for (const capability of delegation.capabilities) { +// const result = query.match( +// /** @type {API.Source} */ ({ capability, delegation }) +// ) +// if (result.ok) { +// return delegation +// } +// } +// } +// /** +// * Invoke and execute the given capability on the Access service connection +// * +// * ```js +// * +// * await Agent.invokeAndExecute(agent, Store.list, { +// * nb: { +// * size: 10, +// * }, +// * }) +// * ``` +// * +// * @deprecated - use following instead +// * ```js +// * const task = await Agent.issueInvocation(agent, cap, opts) +// * await task.execute(connection) instead +// * ``` +// * +// * @template {API.Ability} A +// * @template {API.URI} R +// * @template {API.Caveats} C +// * @template {Record} Protocol +// * @param {object} agent +// * @param {AgentData} agent.data +// * @param {API.Signer} agent.issuer +// * @param {API.ConnectionView} agent.connection +// * @param {API.TheCapabilityParser>} cap +// * @param {API.InvokeOptions>>} options +// * @returns {Promise, Protocol>>} +// */ +// export const invokeAndExecute = async (agent, cap, options) => { +// const invocation = await issueInvocation(agent, cap, options) +// const out = invocation.execute(/** @type {*} */ (agent.connection)) +// return /** @type {*} */ (out) +// } diff --git a/packages/w3up-client/src/agent/authorization.js b/packages/w3up-client/src/agent/authorization.js new file mode 100644 index 000000000..d4a63aa28 --- /dev/null +++ b/packages/w3up-client/src/agent/authorization.js @@ -0,0 +1,49 @@ +import * as API from '../types.js' +import * as DB from 'datalogia' +import * as Capability from './capability.js' +import * as Delegation from './delegation.js' +import * as Text from './db/text.js' + +/** + * 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.TextConstraint} selector.subject + * @param {API.Can} selector.can + * @param {API.UTCUnixTimestamp} selector.time + */ +export const query = (selector) => { + const subject = DB.string() + const audience = DB.string() + const abilities = Object.keys(selector.can) + const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) + + return { + select: { + ...proofs, + subject, + audience, + }, + where: [ + ...Object.entries(proofs).flatMap(([can, proof]) => { + const capability = DB.link() + return [ + Capability.match(capability, { + subject, + can, + }), + Delegation.match(proof, { + capability, + audience, + time: selector.time, + }), + ] + }), + Text.match(audience, selector.audience), + Text.match(subject, selector.subject), + ], + } +} diff --git a/packages/w3up-client/src/agent/capability.js b/packages/w3up-client/src/agent/capability.js new file mode 100644 index 000000000..52e6fdccc --- /dev/null +++ b/packages/w3up-client/src/agent/capability.js @@ -0,0 +1,33 @@ +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 hasAbility = (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) + ) +} + +/** + * 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 {string} constraints.can + */ +export const match = (capability, { subject, can }) => + DB.match([capability, 'capability/with', subject]).and( + hasAbility(capability, 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..f3f4943cf --- /dev/null +++ b/packages/w3up-client/src/agent/connection.js @@ -0,0 +1,40 @@ +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' + +/** + * 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.Service] + * @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, + } + ) 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 index 51e38d300..362961787 100644 --- a/packages/w3up-client/src/agent/db.js +++ b/packages/w3up-client/src/agent/db.js @@ -1,110 +1,171 @@ import * as Datalogia from 'datalogia' import * as API from '../types.js' import * as Delegation from './delegation.js' - +import * as Authorization from './authorization.js' +import * as Delegations from './delegations.js' export * from 'datalogia' /** - * {@link Proofs} formatted for storage, making it compatible with - * `structuredClone()` used by `indexedDB`. - * - * @typedef {API.AgentDataExport['delegations']} Archive + * @param {API.Variant<{ + * proofs: Iterable, + * archive: API.DatabaseArchive + * }>} source + * @returns {API.Database} */ +export const from = (source) => + source.proofs ? fromProofs(source.proofs) : fromArchive(source.archive) /** - * Set of delegations available to the agent. For legacy reasons, they are boxed - * and have optional `meta` field. - * - * @typedef {API.AgentData['delegations']} Proofs + * @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), + }) + } -/** - * 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. - * - * @typedef {object} DB - * @property {Proofs} proofs - * @property {Datalogia.Querier & Datalogia.Transactor} index - */ + return { principal: db.signer, meta: db.meta, delegations } +} /** - * Takes {@link Archive} and returns {@link DB} which can be used to restore - * persisted session. - * - * @param {Archive} archive - * @returns {DB} + * @param {Partial} archive + * @returns {API.Database} */ -export const fromArchive = (archive) => { - const delegations = [] +export const fromArchive = ({ + principal, + meta = { name: 'agent', type: 'device' }, + delegations = new Map(), +}) => { const proofs = new Map() - for (const { meta, delegation } of archive.values()) { + for (const { meta, delegation } of delegations.values()) { const proof = Delegation.fromArchive(delegation) - delegations.push(proof) proofs.set(`${proof.cid}`, { delegation, meta }) } + const db = Datalogia.Memory.create(Delegations.facts(proofs.values())) + return { - index: Datalogia.Memory.create(facts(delegations)), + meta, + signer: principal, proofs, + index: db, + transactor: db, } } -/** - * Formats {@link DB} into {@link Archive} so it can be stored in the database. - * This is used to persist the state of the agent across sessions. - * - * @param {DB} db - * @returns {Archive} - */ -export const toArchive = (db) => { - const archive = new Map() - for (const [key, { meta, delegation }] of db.proofs) { - archive.set(key, { - meta, - delegation: Delegation.toArchive(delegation), - }) - } - - return archive -} - /** * Builds a database from the given set of proofs. * - * @param {Iterable} proofs - * @returns {DB} + * @param {Iterable} source + * @returns {API.Database} */ -export const fromProofs = (proofs) => ({ - proofs: new Map( - [...proofs].map((proof) => [ +export const fromProofs = (source) => { + const proofs = new Map( + [...source].map((proof) => [ `${proof.cid}`, { meta: {}, delegation: proof, }, ]) - ), - index: Datalogia.Memory.create(facts(proofs)), -}) + ) + + const db = Datalogia.Memory.create(Delegations.facts(proofs.values())) + return { + meta: { name: 'agent', type: 'device' }, + proofs, + signer: undefined, + index: db, + transactor: db, + } +} /** - * @param {API.Variant<{proofs: Iterable, archive: Archive }>} source - * @returns {DB} + * @param {object} source + * @param {API.DataStore} [source.store] + * @returns {Promise>} */ -export const from = (source) => - source.proofs ? fromProofs(source.proofs) : fromArchive(source.archive) +export const open = async ({ store }) => { + try { + const archive = store ? await store.load() : null + const db = fromArchive(archive ?? {}) + return { ok: { ...db, store } } + } catch (cause) { + return { + error: new DataStoreOpenError('Failed to open a datastore', { + cause, + }), + } + } +} + +/** + * @param {API.Database} db + * @returns {Promise>} + */ +export const save = async (db) => { + const archive = toArchive(db) + if (db.store) { + try { + await db.store.save(archive) + } catch (cause) { + return { + error: new DataStoreSaveError('Failed to store data', { cause }), + } + } + } + + return { ok: {} } +} /** + * Rebuilds proofs index from the proofs the proofs. * - * @param {Iterable} proofs - * @returns {Iterable} + * @param {API.Database} db */ -export const facts = function* (proofs) { - for (const proof of proofs) { - yield* Delegation.assert(proof) +export const reindex = (db) => { + db.index = Datalogia.Memory.create(Delegations.facts(db.proofs.values())) + + return db +} + +/** + * @typedef {API.Variant<{ proof: API.Delegation, signer: API.SignerArchive }>} Instruction + * @param {API.Database} db + * @param {Iterable} transaction + * @returns {Promise>} + */ +export const transact = async (db, transaction) => { + const instructions = [] + for (const each of transaction) { + if (each.proof) { + const { proof } = each + db.proofs.set(`${proof.cid}`, { meta: {}, delegation: proof }) + for (const fact of Delegation.facts(proof)) { + instructions.push({ Associate: fact }) + } + } else if (each.signer) { + db.signer = each.signer + } } + const result = await db.transactor.transact(instructions) + if (result.error) { + return { + error: new DatabaseTransactionError(transaction, { cause: result.error }), + } + } + + const { error } = await save(db) + if (error) { + return { error } + } + + return { ok: db } } /** @@ -116,7 +177,7 @@ export const facts = function* (proofs) { * @property {API.Delegation[]} proofs * @property {API.DID} audience * - * @param {DB} db + * @param {API.Database} db * @param {object} query * @param {API.TextConstraint} query.audience * @param {API.TextConstraint} [query.subject] @@ -127,107 +188,49 @@ export const facts = function* (proofs) { export const find = ( db, { subject = { like: '%' }, audience, time = Date.now() / 1000, can = {} } -) => { - const space = Datalogia.string() - const abilities = Object.keys(can) - const principal = Datalogia.string() - const proofs = Object.fromEntries( - abilities.map((can) => [can, Datalogia.link()]) - ) - - const matches = Datalogia.query(db.index, { - select: { - ...proofs, - space, - principal, - }, - where: [ - ...Object.entries(proofs).flatMap(([need, proof]) => { - const capability = Datalogia.link() - return [ - Datalogia.match([capability, 'capability/with', space]), - providesAbility({ capability, ability: need }), - Datalogia.match([proof, 'ucan/capability', capability]), - Datalogia.match([proof, 'ucan/audience', principal]), - matchText(principal, audience), - Datalogia.not(isExpired({ ucan: proof, time })), - Datalogia.not(isTooEarly({ ucan: proof, time })), - ] - }), - matchText(space, subject), - ], - }) - - return matches.map(({ space: did, principal, ...proofs }) => { +) => + Datalogia.query( + db.index, + Authorization.query({ + can, + subject, + audience, + time, + }) + ).map(({ subject, audience, ...proofs }) => { // query engine will provide proof for each requested capability, so we may // have duplicates here, which we prune. const keys = [...new Set(Object.values(proofs).map(String))] return { - audience: /** @type {API.DID} */ (principal), - subject: /** @type {API.SpaceDID} */ (did), + audience: /** @type {API.DID} */ (audience), + subject: /** @type {API.SpaceDID} */ (subject), // Dereference proofs from the store. proofs: keys.map( ($) => /** @type {API.Delegation} */ (db.proofs.get($)?.delegation) ), } }) -} - -/** - * - * @param {Datalogia.Term} source - * @param {API.TextConstraint} constraint - */ -const matchText = (source, constraint) => - constraint.glob != null - ? Datalogia.glob(source, constraint.glob) - : constraint.like != null - ? Datalogia.like(source, constraint.like) - : Datalogia.Constraint.is(source, constraint) -/** - * Composes the clause that matches given `query.ucan` only if it has expired, - * that is it has `exp` field set and is less than given `query.time`. - * - * @param {object} query - * @param {Datalogia.Term} query.ucan - * @param {Datalogia.API.Term} query.time - * @returns {Datalogia.Clause} - */ -const isExpired = ({ ucan, time }) => { - const expiration = Datalogia.integer() - return Datalogia.match([ucan, 'ucan/expiration', expiration]).and( - Datalogia.Constraint.greater(time, expiration) - ) +class DataStoreOpenError extends Error { + name = /** @type {const} */ ('DataStoreOpenError') } -/** - * Composes the clause that will match a `query.ucan` only if is not active yet, - * that is it's `nbf` field is set and greater than given `query.time`. - * - * @param {object} query - * @param {Datalogia.Term} query.ucan - * @param {Datalogia.API.Term} query.time - * @returns {Datalogia.Clause} - */ -const isTooEarly = ({ ucan, time }) => { - const notBefore = Datalogia.integer() - return Datalogia.match([ucan, 'ucan/notBefore', notBefore]).and( - Datalogia.Constraint.less(time, notBefore) - ) +class DataStoreSaveError extends Error { + name = /** @type {const} */ ('DataStoreSaveError') } -/** - * - * @param {object} query - * @param {Datalogia.Term} query.capability - * @param {string} query.ability - */ -const providesAbility = ({ capability, ability }) => { - const can = Datalogia.string() - return Datalogia.match([capability, 'capability/can', can]).and( - // can is a glob pattern that we try to match against - Datalogia.glob(ability, can) - ) +class DatabaseTransactionError extends Error { + name = /** @type {const} */ ('DatabaseTransactionError') + /** + * + * @param {Iterable} transaction + * @param {object} options + * @param {Error} options.cause + */ + constructor(transaction, { cause }) { + super('Failed to transact') + this.transaction = transaction + this.cause = cause + } } 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 index 36087b8f8..5c558e972 100644 --- a/packages/w3up-client/src/agent/delegation.js +++ b/packages/w3up-client/src/agent/delegation.js @@ -4,13 +4,57 @@ import * as Block from './block.js' import { importDAG, allows } from '@ucanto/core/delegation' import * as Association from './db/association.js' +/** + * Composes the clause that matches given `query.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) + ) +} + +/** + * 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 + */ +export const match = (ucan, { capability, audience, time }) => + DB.match([ucan, 'ucan/capability', capability]) + .and(DB.match([ucan, 'ucan/audience', audience])) + .and(DB.not(isExpired(ucan, time))) + .and(DB.not(isTooEarly(ucan, time))) + /** * Derives set of facts about the given delegation. * * @param {API.Delegation} delegation * @returns {Iterable} */ -export const assert = function* (delegation) { +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()] diff --git a/packages/w3up-client/src/agent/delegations.js b/packages/w3up-client/src/agent/delegations.js index a3eae8b99..e4b7f255d 100644 --- a/packages/w3up-client/src/agent/delegations.js +++ b/packages/w3up-client/src/agent/delegations.js @@ -1,108 +1,13 @@ -import * as ucanto from '@ucanto/core' import * as API from '../types.js' -import { canDelegateAbility } from '@web3-storage/capabilities/utils' +import * as Delegation from './delegation.js' +import * as Datalogia from 'datalogia' /** - * - * @param {API.Delegation} delegation - * @param {API.UTCUnixTimestamp} time + * @param {Iterable} proofs + * @returns {Iterable} */ -export function isExpired(delegation, time) { - if (delegation.expiration === undefined || delegation.expiration <= time) { - return true - } - return false -} - -/** - * @param {API.Delegation} delegation - * @param {API.UTCUnixTimestamp} time - */ -export function isTooEarly(delegation, time) { - if (!delegation.notBefore) { - return false - } - return delegation.notBefore > time -} - -/** - * @param {API.Delegation} delegation - * @param {API.UTCUnixTimestamp} time - */ -export const isValid = (delegation, time) => - !isExpired(delegation, time) && !isTooEarly(delegation, time) - -/** - * - * @param {API.Delegation} delegation - * @param {object} [options] - * @param {API.UTCUnixTimestamp} [options.time] - * @param {API.Principal} [options.audience] - */ -export function validate(delegation, { audience, time } = {}) { - if (audience && delegation.audience.did() !== audience.did()) { - throw new Error( - `Delegation audience ${delegation.audience.did()} does not match required DID ${audience.did()}` - ) - } - - if (time && isExpired(delegation, time)) { - throw new Error(`Delegation expired.`) - } - - if (time && isTooEarly(delegation, time)) { - throw new Error(`Delegation is not active yet (too early).`) - } -} - -/** - * Returns true if the delegation includes capability been queried. - * - * @param {API.Delegation} delegation - * @param {API.CapabilityQuery} query - */ -export function canDelegateCapability(delegation, query) { - const allowsCapabilities = ucanto.Delegation.allows(delegation) - for (const [uri, abilities] of Object.entries(allowsCapabilities)) { - if (matchResource(/** @type {API.Resource} */ (uri), query.with)) { - const { can } = query - const cans = /** @type {API.Ability[]} */ (Object.keys(abilities)) - - if (can == null) { - return true - } else if (cans.some((ability) => matchAbility(ability, can))) { - return true - } - } - } - return false -} - -/** - * @param {API.Ability} ability - * @param {API.AbilityQuery} query - */ -const matchAbility = (ability, query) => { - if (typeof query === 'string') { - return canDelegateAbility(ability, query) - } else { - return query.test(ability) - } -} - -/** - * Returns true if given `resource` matches the resource query per UCAN - * specification. - * - * @param {API.Resource} resource - * @param {API.ResourceQuery} query - */ -export const matchResource = (resource, query) => { - if (query === 'ucan:*') { - return true - } else if (typeof query === 'string') { - return resource === query - } else { - return query.test(resource) +export const facts = function* (proofs) { + for (const { delegation } of proofs) { + yield* Delegation.facts(delegation) } } 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/store.js b/packages/w3up-client/src/agent/store.js index d6569b5b9..1c2b451f3 100644 --- a/packages/w3up-client/src/agent/store.js +++ b/packages/w3up-client/src/agent/store.js @@ -1,220 +1,133 @@ 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 } - } +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', } -/** - * @param {API.Ability} ability - */ -export const ability = (ability) => new AbilitySchema(ability) - /** * @typedef {object} Model - * @property {Map} proofs + * @property {Connection.Address} address + * @property {API.Signer} principal + * @property {DB.DB} delegations + * @property {API.AgentMeta} meta + * @property {API.DIDKey} [currentSpace] */ /** - * @param {object} source - * @param {Iterable} source.proofs - * @returns {Model} + * @typedef {object} Store + * @property {Model} state + * @property {API.Storage} storage */ -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 {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, +}) /** - * @param {Model} model - * @param {Query} selector - * @returns {IterableIterator} + * @typedef {object} Archive + * @property {Connection.Archive} [connection] + * @property {API.AgentMeta} meta + * @property {API.SignerArchive} principal + * @property {DB.Archive} delegations + * @property {API.DIDKey} [currentSpace] */ -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} + * @param {API.Storage} storage + * @param {object} options + * @param {API.Signer} [options.principal] + * @param {API.Delegation[]} [options.proofs] */ -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 - } +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), + } - for (const [ability, constraint] of Object.entries(abilities)) { - if (can && !can.read(ability).ok) { - continue + if (options.proofs) { + await assert({ storage, state }, { delegations: options.proofs }) } - if ( - nb && - /** @type {API.Caveats[]} */ (constraint).every( - (caveats) => nb && !nb.read(caveats).ok - ) - ) { - continue + 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), } - // If we got this far we found a capability in the current proof that - // meets the query criteria. - return ok(proof) + return { ok: { storage, state } } } + } catch (error) { + return { error: new Error('Failed to load agent data from storage') } } - - return error(new RangeError('No matching capability found.')) } -/** - * @template {Record>} Selector - * @param {Selector} selector - */ -export const select = (selector) => new Select(selector) +export const load = async (storage) => { + const result = await open(storage, options) + if (result.ok) { + return result.ok + } else { + throw result.error + } +} /** - * @template {Record>} Selector - * @param {Selector} selector + * @param {Store} store + * @param {API.Variant<{ + * meta: API.AgentMeta + * delegations: API.Delegation[] + * currentSpace: API.DIDKey + * }>} fact */ -class Select { - /** - * - * @param {Selector} selector - */ - constructor(selector) { - this.selector = selector +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) + } } - /** - * @param {Selector} variables - */ - where(variables) {} + await storage.save(toArchive({ state })) } /** - * 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"] - * + * @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 index 66ee9d85a..8f695bcff 100644 --- a/packages/w3up-client/src/agent/use-cases.js +++ b/packages/w3up-client/src/agent/use-cases.js @@ -1,4 +1,4 @@ -import { addSpacesFromDelegations, Agent as AccessAgent } from '../agent.js' +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' diff --git a/packages/w3up-client/src/capability/access.js b/packages/w3up-client/src/capability/access.js index 90650436b..dab8fe8e2 100644 --- a/packages/w3up-client/src/capability/access.js +++ b/packages/w3up-client/src/capability/access.js @@ -16,7 +16,7 @@ import { bytesToDelegations } from '../agent/encoding.js' * Returns error result if agent has no current space and no space was provided. * Also returns error result if invocation fails. * - * @param {API.Agent} agent - Agent connected to the w3up service. + * @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. @@ -54,7 +54,7 @@ export const delegate = async ( * `PendingAccessRequest` object that can be used to poll for the requested * delegation through `access/claim` capability. * - * @param {API.Agent} agent + * @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. @@ -100,7 +100,7 @@ export const request = async ( * Claims access that has been delegated to the given audience, which by * default is the agent's DID. * - * @param {API.Agent} agent + * @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. @@ -138,7 +138,7 @@ export const claim = async ( class PendingAccessRequest { /** * @typedef {object} PendingAccessRequestModel - * @property {API.Agent} agent - Agent handling interaction. + * @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. @@ -265,7 +265,7 @@ class RequestExpired extends Failure { export class GrantedAccess { /** * @typedef {object} GrantedAccessModel - * @property {API.Agent} agent - Agent that processed the request. + * @property {API.AgentView} agent - Agent that processed the request. * @property {API.Tuple} proofs - Delegations that grant access. * * @param {GrantedAccessModel} model @@ -282,7 +282,7 @@ export class GrantedAccess { * between sessions. * * @param {object} input - * @param {API.Agent<{}>} [input.agent] + * @param {API.AgentView<{}>} [input.agent] */ save({ agent = this.model.agent } = {}) { return importAuthorization(agent, this) diff --git a/packages/w3up-client/src/capability/plan.js b/packages/w3up-client/src/capability/plan.js index 61afe91bc..47060f3d5 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 index bab1ab6e7..27047c102 100644 --- a/packages/w3up-client/src/capability/provider.js +++ b/packages/w3up-client/src/capability/provider.js @@ -8,7 +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. * - * @param {API.Agent} agent + * @param {API.AgentView} 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/w3up-client/src/capability/space.js b/packages/w3up-client/src/capability/space.js index 7fc9932ab..5badd7f5f 100644 --- a/packages/w3up-client/src/capability/space.js +++ b/packages/w3up-client/src/capability/space.js @@ -13,7 +13,7 @@ import { Result } from '../index.js' * * Get Space information from Access service * - * @param {API.Agent} agent + * @param {API.AgentView} agent * @param {API.SpaceDID} space * @returns */ @@ -32,7 +32,7 @@ export const info = async (agent, space) => { * @typedef {object} Model * @property {ED25519.EdSigner} signer * @property {string} name - * @property {API.Agent} [agent] + * @property {API.AgentView} [agent] */ /** @@ -40,7 +40,7 @@ export const info = async (agent, space) => { * * @param {object} options * @param {string} options.name - * @param {API.Agent} [options.agent] + * @param {API.AgentView} [options.agent] */ export const generate = async ({ name, agent }) => { const { signer } = await ED25519.generate() @@ -54,7 +54,7 @@ export const generate = async ({ name, agent }) => { * @param {string} mnemonic * @param {object} options * @param {string} options.name - Name to give to the recovered space. - * @param {API.Agent} [options.agent] + * @param {API.AgentView} [options.agent] */ export const fromMnemonic = async (mnemonic, { name, agent }) => { const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) @@ -186,7 +186,7 @@ class OwnedSpace { * Saves account in the agent store so it can be accessed across sessions. * * @param {object} input - * @param {API.Agent<{}>} [input.agent] + * @param {API.AgentView<{}>} [input.agent] * @returns {Promise>} */ async save({ agent = this.model.agent } = {}) { @@ -204,7 +204,7 @@ class OwnedSpace { /** * @param {Authorization} authorization * @param {object} options - * @param {API.Agent} [options.agent] + * @param {API.AgentView} [options.agent] */ provision({ proofs }, { agent = this.model.agent } = {}) { if (!agent) { @@ -287,7 +287,7 @@ export const fromDelegation = (delegation) => { * @param {Space} space * @param {object} options * @param {API.Delegation[]} options.proofs - * @param {API.Agent} options.agent + * @param {API.AgentView} options.agent */ export const provision = async (space, { proofs, agent }) => { const [capability] = proofs[0].capabilities @@ -316,7 +316,7 @@ class SharedSpace { * @property {API.SpaceDID} id * @property {API.Tuple} proofs * @property {{name?:string}} meta - * @property {API.Agent} [agent] + * @property {API.AgentView} [agent] * * @param {SharedSpaceModel} model */ diff --git a/packages/w3up-client/src/capability/subscription.js b/packages/w3up-client/src/capability/subscription.js index 0e124c9eb..209954861 100644 --- a/packages/w3up-client/src/capability/subscription.js +++ b/packages/w3up-client/src/capability/subscription.js @@ -8,7 +8,7 @@ export { Subscription } /** * Gets subscriptions associated with the account. * - * @param {API.Agent} agent + * @param {API.AgentView} agent * @param {object} options * @param {API.AccountDID} options.account * @param {API.Delegation[]} [options.proofs] diff --git a/packages/w3up-client/src/capability/usage.js b/packages/w3up-client/src/capability/usage.js index 8f591bce7..6102eecd0 100644 --- a/packages/w3up-client/src/capability/usage.js +++ b/packages/w3up-client/src/capability/usage.js @@ -6,7 +6,7 @@ export { Usage } /** * Get a usage report for the period. * - * @param {API.Agent} agent + * @param {API.AgentView} agent * @param {object} options * @param {API.SpaceDID} options.space * @param {{ from: Date, to: Date }} options.period diff --git a/packages/w3up-client/src/client/access.js b/packages/w3up-client/src/client/access.js index 43867d159..d26a738e8 100644 --- a/packages/w3up-client/src/client/access.js +++ b/packages/w3up-client/src/client/access.js @@ -73,7 +73,7 @@ export class AccessClient extends Client { } /** - * @param {{agent: API.Agent}} client + * @param {{agent: API.AgentView}} client * @param {object} [input] * @param {API.DID} [input.audience] */ @@ -84,7 +84,7 @@ export const claim = async ({ agent }, input) => Access.claim(agent, input) * `access/authorize` capability and keep polling `access/claim` capability * until access is granted or request is aborted. * - * @param {{agent: API.Agent}} agent + * @param {{agent: API.AgentView}} agent * @param {object} input * @param {API.AccountDID} input.account * @param {API.Access} [input.access] @@ -94,7 +94,7 @@ export const request = async ({ agent }, input) => Access.request(agent, input) /** * - * @param {{agent: API.Agent}} agent + * @param {{agent: API.AgentView}} agent * @param {object} input * @param {API.Delegation[]} input.delegations * @param {API.SpaceDID} [input.space] diff --git a/packages/w3up-client/src/client/client.js b/packages/w3up-client/src/client/client.js index 31baaab75..a2b14e585 100644 --- a/packages/w3up-client/src/client/client.js +++ b/packages/w3up-client/src/client/client.js @@ -1,4 +1,4 @@ -import { Agent } from '../agent.js' +import { AgentView } from '../agent.js' import * as API from '../types.js' /** @@ -6,13 +6,13 @@ import * as API from '../types.js' */ export class Client { /** - * @type {Agent} + * @type {AgentView} * @protected */ _agent /** - * @param {API.Agent} agent + * @param {API.AgentView} agent */ constructor(agent) { this._agent = agent @@ -21,7 +21,7 @@ export class Client { /** * The current user agent (this device). * - * @type {Agent} + * @type {AgentView} */ get agent() { return this._agent diff --git a/packages/w3up-client/src/coupon.js b/packages/w3up-client/src/coupon.js index 81ae57478..de1c27508 100644 --- a/packages/w3up-client/src/coupon.js +++ b/packages/w3up-client/src/coupon.js @@ -85,7 +85,7 @@ export const issue = async ({ password = '', ...options }) => { * * @param {Model} coupon * @param {object} options - * @param {API.Agent} options.agent + * @param {API.AgentView} options.agent * @param {string} [options.password] * @returns {Promise>} */ @@ -137,7 +137,7 @@ export class Coupon { /** * - * @param {API.Agent} agent + * @param {API.AgentView} agent * @param {object} [options] * @param {string} [options.password] */ diff --git a/packages/w3up-client/src/session.js b/packages/w3up-client/src/session.js new file mode 100644 index 000000000..98d6d463d --- /dev/null +++ b/packages/w3up-client/src/session.js @@ -0,0 +1,26 @@ +import * as API from './types.js' + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @param {API.Session} model + */ +export const create = (model) => new Session(model) + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @implements {API.Session} + */ +class Session { + /** + * @param {API.Session} model + */ + constructor(model) { + this.model = model + } + get connection() { + return this.model.connection + } + get agent() { + return this.model.agent + } +} diff --git a/packages/w3up-client/src/space.js b/packages/w3up-client/src/space.js index b37dfd65b..20b2b4aa4 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -6,7 +6,7 @@ export * from './capability/space.js' * @typedef {object} Model * @property {API.SpaceDID} id * @property {{name?:string}} [meta] - * @property {API.Agent} agent + * @property {API.AgentView} agent */ export class Space { diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 4825a5fe6..5b8234d46 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -1,5 +1,8 @@ import { type Driver } from '@web3-storage/access/drivers/types' import { type Service as UploadService } from '@web3-storage/upload-client/types' +import { Querier, Transactor } from 'datalogia' + +export type { Querier, Transactor } import type { ConnectionView, Signer, @@ -24,6 +27,10 @@ import type { CapabilityParser, InferInvokedCapability, Variant, + Result, + IPLDBlock, + DIDKey, + Protocol, } from '@ucanto/interface' import type { @@ -56,6 +63,9 @@ import type { } from '@web3-storage/capabilities' import { type Client } from './client.js' import { StorefrontService } from '@web3-storage/filecoin-client/storefront' +import exp from 'constants' +import { CID } from 'multiformats' +import { Block } from '@ipld/car/buffer-reader' export * from '@ipld/dag-ucan' export * from '@ucanto/interface' @@ -74,7 +84,7 @@ export type { } from '@ucanto/interface' export type { UCAN } from '@web3-storage/capabilities' -export { Agent, AgentData, type AgentModel } from './agent.js' +export type { Driver as Storage } export type ProofQuery = Record> @@ -468,8 +478,9 @@ export type TextConstraint = | Variant<{ like: LikePattern glob: GlobPattern + '=': string }> - | (string & { like?: undefined; glob?: undefined }) + | (string & { like?: undefined; glob?: undefined; ['=']?: undefined }) /** * In the future, we want to implement AccessRequestSchema per spec, but for @@ -488,3 +499,227 @@ export type Can = Record, Clause[]> & { ['*']?: Clause[] } + +export type { Driver } + +export interface DataStore extends Driver {} + +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 API.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 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 W3UpOpen { + as?: Signer + store: DataStore +} + +export interface W3Load { + as?: Signer + store: DataStore +} + +export interface W3Create { + as?: Signer + store: DataStore +} + +export type W3From = Variant<{ + load: W3Load + open: W3UpOpen + create: W3Create +}> + +/** + * 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: W3Load): 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: W3Create): AgentView + + /** + * General function that does `load`, `create` or `open` based on input. + */ + from(source: W3From): AgentView +} + +export interface Agent { + signer: Signer + 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 { + /** + * Store used to persist agent delegations and signing authority. + */ + db: Database + + did(): DID + + /** + * Connects to a service provider and returns a session that can be used to + * invoke capabilities provided by the service. + */ + connect( + connection?: ConnectionView + ): Promise< + Result< + Session, + SignerLoadError | DataStoreOpenError | DataStoreSaveError + > + > +} + +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: AgentView + connection: Connection +} + +export interface Connection + extends ConnectionView { + address: Address +} diff --git a/packages/w3up-client/src/view/account.js b/packages/w3up-client/src/view/account.js index 1e22f7ae7..7f422c8a1 100644 --- a/packages/w3up-client/src/view/account.js +++ b/packages/w3up-client/src/view/account.js @@ -13,7 +13,7 @@ export { fromEmail } * List all accounts that agent has stored access to. Returns a dictionary * of accounts keyed by their `did:mailto` identifier. * - * @param {API.Agent} agent + * @param {API.AgentView} agent * @param {object} query * @param {API.DID<'mailto'>} [query.account] */ @@ -72,7 +72,7 @@ export const list = (agent, { account } = {}) => { * authorization session time bounds (currently 15 minutes), the promise will * resolve to an error. * - * @param {API.Agent} agent + * @param {API.AgentView} agent * @param {API.EmailAddress} email * @param {object} [options] * @param {AbortSignal} [options.signal] @@ -119,7 +119,7 @@ export const login = async (agent, email, options = {}) => { /** * @typedef {object} Model * @property {API.DidMailto} id - * @property {API.Agent} agent + * @property {API.AgentView} agent * @property {API.Delegation[]} proofs */ @@ -169,7 +169,7 @@ export class Account { * @param {API.SpaceDID} space * @param {object} input * @param {API.ProviderDID} [input.provider] - * @param {API.Agent} [input.agent] + * @param {API.AgentView} [input.agent] */ provision(space, input = {}) { return provision(this.agent, { @@ -184,7 +184,7 @@ export class Account { * Saves account in the agent store so it can be accessed across sessions. * * @param {object} input - * @param {API.Agent<{}>} [input.agent] + * @param {API.AgentView<{}>} [input.agent] */ async save({ agent = this.agent } = {}) { return await importAuthorization(agent, this) diff --git a/packages/w3up-client/src/view/space.js b/packages/w3up-client/src/view/space.js index 7b6a1295c..9a9aa3283 100644 --- a/packages/w3up-client/src/view/space.js +++ b/packages/w3up-client/src/view/space.js @@ -12,7 +12,7 @@ import * as Provider from '../capability/provider.js' * @typedef {object} Model * @property {ED25519.EdSigner} signer * @property {string} name - * @property {API.Agent} [agent] + * @property {API.AgentView} [agent] */ /** @@ -20,7 +20,7 @@ import * as Provider from '../capability/provider.js' * * @param {object} options * @param {string} options.name - * @param {API.Agent} [options.agent] + * @param {API.AgentView} [options.agent] */ export const generate = async ({ name, agent }) => { const { signer } = await ED25519.generate() @@ -34,7 +34,7 @@ export const generate = async ({ name, agent }) => { * @param {string} mnemonic * @param {object} options * @param {string} options.name - Name to give to the recovered space. - * @param {API.Agent} [options.agent] + * @param {API.AgentView} [options.agent] */ export const fromMnemonic = async (mnemonic, { name, agent }) => { const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) @@ -166,7 +166,7 @@ class OwnedSpace { * Saves account in the agent store so it can be accessed across sessions. * * @param {object} input - * @param {API.Agent} [input.agent] + * @param {API.AgentView} [input.agent] * @returns {Promise>} */ async save({ agent = this.model.agent } = {}) { @@ -184,7 +184,7 @@ class OwnedSpace { /** * @param {Authorization} authorization * @param {object} options - * @param {API.Agent} [options.agent] + * @param {API.AgentView} [options.agent] */ provision({ proofs }, { agent = this.model.agent } = {}) { if (!agent) { @@ -267,7 +267,7 @@ export const fromDelegation = (delegation) => { * @param {Space} space * @param {object} options * @param {API.Delegation[]} options.proofs - * @param {API.Agent} options.agent + * @param {API.AgentView} options.agent */ export const provision = async (space, { proofs, agent }) => { const [capability] = proofs[0].capabilities @@ -296,7 +296,7 @@ class SharedSpace { * @property {API.SpaceDID} id * @property {API.Delegation} delegation * @property {{name?:string}} meta - * @property {API.Agent} [agent] + * @property {API.AgentView} [agent] * * @param {SharedSpaceModel} model */ diff --git a/packages/w3up-client/test/agent.test.js b/packages/w3up-client/test/agent.test.js new file mode 100644 index 000000000..a88263566 --- /dev/null +++ b/packages/w3up-client/test/agent.test.js @@ -0,0 +1,90 @@ +import * as Test from './test.js' +import { alice, bob, mallory, service } from './fixtures/principals.js' +import * as API from '../src/types.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 result = await Agent.open({ + as: alice, + store: Agent.ephemeral, + }).connect() + + assert.ok(result.ok) + const session = Result.unwrap(result) + 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 }) + + assert.equal(result?.error?.name, 'SignerLoadError') + }, + + 'agent loads from store': async (assert) => { + const result = await Agent.load({ + store: { + ...Agent.ephemeral, + async load() { + return { + principal: alice.toArchive(), + delegations: new Map(), + currentSpace: undefined, + address: {}, + } + }, + }, + }) + + assert.ok(result?.ok) + assert.deepEqual(result?.ok?.did(), alice.did()) + }, + 'load from store but use different signer': async (assert) => { + const result = await Agent.load({ + as: bob, + store: { + ...Agent.ephemeral, + async load() { + return { + principal: alice.toArchive(), + delegations: new Map(), + currentSpace: undefined, + address: {}, + } + }, + }, + }) + + assert.ok(result?.ok) + const agent = Result.unwrap(result) + 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, [ + { + signer: bob.toArchive(), + }, + ]) + + assert.ok(tr.ok) + assert.deepEqual(agent.db.signer?.id, bob.did(), 'signer was updated') + }, +} + +Test.basic({ Agent: testAgent }) diff --git a/packages/w3up-client/test/test.js b/packages/w3up-client/test/test.js index 034e9e83f..cc9d175e6 100644 --- a/packages/w3up-client/test/test.js +++ b/packages/w3up-client/test/test.js @@ -1,6 +1,6 @@ -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 * as assert from 'assert' /** @@ -36,15 +36,15 @@ export const setup = async () => { assert, }) - const connect = () => - Client.create({ - store: new StoreMemory(), - serviceConf: { - access: context.connection, - upload: context.connection, - filecoin: context.connection, - }, - }) + const connect = () => {} + // Client.create({ + // store: new StoreMemory(), + // serviceConf: { + // access: context.connection, + // upload: context.connection, + // filecoin: context.connection, + // }, + // }) return { ...context, connect, client: await connect() } } From 17db24510cf6a4bd7b2a0f3c627b7e104b2b943a Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 6 Feb 2024 22:05:23 -0800 Subject: [PATCH 05/16] feat: implement access and account apis --- packages/capabilities/src/access.js | 2 +- packages/capabilities/src/types.ts | 3 + packages/w3up-client/package.json | 2 +- packages/w3up-client/src/access.js | 394 ++++++++++++++++ packages/w3up-client/src/account.js | 210 +++++++++ packages/w3up-client/src/agent.js | 246 +++++----- packages/w3up-client/src/agent/account.js | 33 ++ packages/w3up-client/src/agent/attestation.js | 36 ++ .../w3up-client/src/agent/authorization.js | 242 +++++++++- packages/w3up-client/src/agent/capability.js | 2 +- packages/w3up-client/src/agent/db.js | 142 +++--- packages/w3up-client/src/agent/delegation.js | 77 ++-- packages/w3up-client/src/agent/space.js | 126 ++++++ packages/w3up-client/src/capability/space.js | 2 +- packages/w3up-client/src/store/memory.js | 54 +++ packages/w3up-client/src/types.ts | 61 ++- packages/w3up-client/src/view/account.js | 49 +- packages/w3up-client/test/access.test.js | 42 +- packages/w3up-client/test/agent/db.test.js | 427 +++++++++++++++--- packages/w3up-client/test/agent/store.test.js | 2 +- packages/w3up-client/test/test.js | 43 +- pnpm-lock.yaml | 8 +- 22 files changed, 1842 insertions(+), 361 deletions(-) create mode 100644 packages/w3up-client/src/access.js create mode 100644 packages/w3up-client/src/account.js create mode 100644 packages/w3up-client/src/agent/account.js create mode 100644 packages/w3up-client/src/agent/attestation.js create mode 100644 packages/w3up-client/src/agent/space.js create mode 100644 packages/w3up-client/src/store/memory.js 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/types.ts b/packages/capabilities/src/types.ts index 2c17f5d11..59b4a2e46 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -80,6 +80,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 { diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index ebad0e670..2984b83aa 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -100,7 +100,7 @@ "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", - "datalogia": "^0.3.0", + "datalogia": "^0.3.1", "@web3-storage/access": "workspace:^", "@web3-storage/capabilities": "workspace:^", "@web3-storage/did-mailto": "workspace:^", diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js new file mode 100644 index 000000000..ad7f0f6a2 --- /dev/null +++ b/packages/w3up-client/src/access.js @@ -0,0 +1,394 @@ +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, fail, DID } from '@ucanto/core' +import { importAuthorization } from './agent.js' +import { bytesToDelegations } from './agent/encoding.js' +import * as Authorization from './agent/authorization.js' +import * as DB from './agent/db.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. + * @param {API.Delegation[]} [input.proofs] - Optional set of proofs to be + * included in the invocation. + */ +export const delegate = async ( + session, + { delegations, proofs = [], subject } +) => { + if (!subject) { + return fail('Space must be specified') + } + + const entries = Object.values(delegations).map((proof) => [ + proof.cid.toString(), + proof.cid, + ]) + + const auth = Authorization.get(session.agent.db, { + authority: session.agent.did(), + subject, + can: { 'access/delegate': [] }, + }) + + if (auth.error) { + return auth + } + + const { out } = await 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: [...auth.ok.proofs, ...delegations], + }) + .execute(session.connection) + + 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.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. + * @returns {Promise>} + */ +export const request = async ( + session, + { + account, + authority = session.agent.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 auth = Authorization.get(session.agent.db, { + authority: session.agent.did(), + subject: authority, + can: { 'access/authorize': [] }, + }) + + if (auth.error) { + return auth + } + + // Build an invocation and execute it. + const { out: result } = await 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: auth.ok.proofs, + }) + .execute(session.connection) + + return result.error + ? result + : { + ok: new PendingAccessRequest({ + ...result.ok, + 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. + * @returns {Promise>} + */ +export const claim = async ( + session, + { + provider = /** @type {API.ProviderDID} */ (session.connection.id.did()), + authority = session.agent.did(), + } = {} +) => { + const auth = Authorization.get(session.agent.db, { + authority: session.agent.did(), + subject: authority, + can: { 'access/claim': [] }, + }) + + if (auth.error) { + return auth + } + + const { out: result } = await Access.claim + .invoke({ + issuer: session.agent.signer, + audience: DID.parse(provider), + with: authority, + proofs: auth.ok.proofs, + }) + .execute(session.connection) + + 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({ session, proofs }) } + } +} + +/** + * Represents a pending access request. It can be used to poll for the requested + * delegation. + */ +class PendingAccessRequest { + /** + * @typedef {object} PendingAccessRequestModel + * @property {API.Session} session - Session with a service. + * @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. + * + * @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 + } + + /** + * 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 {Promise>} + */ + async poll() { + const { session, provider, expiration, authority } = this.model + const timeout = expiration * 1000 - Date.now() + if (timeout <= 0) { + return { error: new RequestExpired(this.model) } + } else { + const result = await claim(session, { authority, 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 {object} options + * @param {number} [options.interval] + * @param {AbortSignal} [options.signal] + * @returns {Promise>} + */ + 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({ + session: this.session, + proofs: /** @type {API.Tuple} */ (result.ok), + }), + } + } + + await new Promise((resolve) => setTimeout(resolve, interval)) + } + + return { + error: Object.assign(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.Session} session - Agent that processed the request. + * @property {API.Tuple} proofs - Delegations that grant access. + * + * @param {GrantedAccessModel} model + */ + constructor(model) { + this.model = model + } + 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.session.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 new file mode 100644 index 000000000..c13172f68 --- /dev/null +++ b/packages/w3up-client/src/account.js @@ -0,0 +1,210 @@ +import * as API from './types.js' +import * as Account from './agent/account.js' +import * as DB from './agent/db.js' +import { fromEmail, toEmail } from '@web3-storage/did-mailto' + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @typedef {object} Model + * @property {API.Session} session + */ + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + */ +export class AccountsView { + /** + * @param {Model} model + */ + constructor(model) { + this.model = model + } + get session() { + return this.model.session + } + + [Symbol.iterator]() { + return list(this) + } + + /** + * @param {object} source + * @param {API.EmailAddress} source.email + * @param {AbortSignal} [source.signal] + */ + login(source) { + return login(this, source) + } + + /** + * Returns iterable of all the accounts saved in the agent's database. + */ + list() { + return list(this) + } + + /** + * 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 {API.EmailAddress} email + */ + get(email) { + return get(this, email) + } + + /** + * Removes all the delegations corresponding to the account logins for the + * given email address. + * + * @param {object} account + * @param {API.EmailAddress} account.email + */ + remove({ email }) { + return remove(this, email) + } +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @param {Model} param0 + * @param {object} source + * @param {API.EmailAddress} source.email + * @param {AbortSignal} [source.signal] + */ +export const login = async ({ session }, { email, signal }) => { + const login = get({ session }, email) + if (login) { + return { ok: login } + } + + const result = await Access.request(session, { + account: id, + access: Access.accountAccess, + }) +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @param {Model} model + * @returns {Iterable>} + */ + +export const list = ({ session }) => { + const proof = DB.link() + const account = DB.string() + const results = DB.query(session.agent.db.index, { + select: { + id: account, + proof, + }, + where: [ + Account.match(proof, { + audience: session.agent.did(), + time: Date.now() / 1000, + account, + }), + ], + }) + + const map = new Map() + for (const { id } of results) { + if (!map.has(id)) { + const account = new AccountView({ + session, + id: /** @type {API.DidMailto} */ (id), + }) + map.set(id, account) + } + } + + return map.values() +} + +/** + * Gets the account view for the login with a given email address. Returns + * `undefined` if no matching login is found. + * + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @param {Model} model + * @param {API.EmailAddress} email + */ +export const get = ({ session }, email) => { + const id = fromEmail(email) + const proof = DB.link() + const result = DB.query(session.agent.db.index, { + select: { + proof, + }, + where: [ + Account.match(proof, { + audience: session.agent.did(), + time: Date.now() / 1000, + account: id, + }), + ], + }) + + return result.length ? new AccountView({ session, id }) : undefined +} + +/** + * Removes all the delegations corresponding to the account logins for the given + * email address. + * + * @template {API.UnknownProtocol} [Protocol=API.Service] + * @param {Model} model + * @param {API.EmailAddress} email + */ +export const remove = async ({ session }, email) => { + const id = fromEmail(email) + const proof = DB.link() + // Find all the delegations that are logins for the account with this a given + // email address. + const matches = DB.query(session.agent.db.index, { + select: { + proof, + }, + where: [ + Account.match(proof, { + audience: session.agent.did(), + time: Date.now() / 1000, + account: id, + }), + ], + }) + + // Build a transaction that retracts all matching delegations. + const transaction = [] + for (const { proof } of matches) { + const record = session.agent.db.proofs.get(proof.toString()) + if (record) { + transaction.push(DB.retract({ proof: record.delegation })) + } + } + + // Execute the transaction. + const { error } = await DB.transact(session.agent.db, transaction) + return error ? { error } : { ok: {} } +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.Service] + */ +class AccountView { + /** + * @param {object} source + * @param {API.Session} source.session + * @param {API.DidMailto} source.id + */ + constructor(source) { + this.model = source + } + + /** + * @type {API.EmailAddress} + */ + get email() { + return toEmail(this.model.id) + } +} diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js index 0d65f2eb5..bdb3a59a0 100644 --- a/packages/w3up-client/src/agent.js +++ b/packages/w3up-client/src/agent.js @@ -1,6 +1,6 @@ -import * as Client from '@ucanto/client' -import * as CAR from '@ucanto/transport/car' -import * as HTTP from '@ucanto/transport/http' +// import * as Client from '@ucanto/client' +// import * as CAR from '@ucanto/transport/car' +// import * as HTTP from '@ucanto/transport/http' import * as DB from './agent/db.js' import { Signer, ed25519 } from '@ucanto/principal' import { DID } from '@ucanto/core' @@ -90,6 +90,10 @@ class Agent { return this.model.signer.did() } + get signer() { + return this.model.signer + } + get db() { return this.model.db } @@ -436,24 +440,24 @@ class SignerLoadError extends Error { // } // } -// /** -// * Stores given delegations in the agent's data store and adds discovered spaces -// * to the agent's space list. -// * -// * @param {Agent<{}>} agent -// * @param {object} authorization -// * @param {API.Delegation[]} authorization.proofs -// * @returns {Promise>} -// */ -// export const importAuthorization = async (agent, { proofs }) => { -// try { -// await addProofs(agent.data, proofs) -// await addSpacesFromDelegations(agent, proofs) -// return { ok: {} } -// } catch (error) { -// return /** @type {{error:Error}} */ ({ error }) -// } -// } +/** + * Stores given delegations in the agent's data store and adds discovered spaces + * to the agent's space list. + * + * @param {Agent<{}>} agent + * @param {object} authorization + * @param {API.Delegation[]} authorization.proofs + * @returns {Promise>} + */ +export const importAuthorization = async (agent, { proofs }) => { + try { + await addProofs(agent.data, proofs) + await addSpacesFromDelegations(agent, proofs) + return { ok: {} } + } catch (error) { + return /** @type {{error:Error}} */ ({ error }) + } +} // /** // * Get all the proofs matching the capabilities. @@ -574,36 +578,36 @@ class SignerLoadError extends Error { // return await addProofs(data, [delegation]) // } -// /** -// * Adds set of proofs to the agent store. -// * -// * @param {AgentData} data -// * @param {Iterable} delegations -// */ -// export const addProofs = async (data, delegations) => { -// for (const proof of delegations) { -// await data.addDelegation(proof, { audience: data.meta }) -// } +/** + * Adds set of proofs to the agent store. + * + * @param {AgentData} data + * @param {Iterable} delegations + */ +export const addProofs = async (data, delegations) => { + for (const proof of delegations) { + await data.addDelegation(proof, { audience: data.meta }) + } -// await removeExpiredDelegations(data, { time: Date.now() / 1000 }) + await removeExpiredDelegations(data, { time: Date.now() / 1000 }) -// return {} -// } + return {} +} -// /** -// * Clean up any expired delegations. -// * -// * @param {AgentData} data -// * @param {object} options -// * @param {API.UTCUnixTimestamp} options.time -// */ -// export const removeExpiredDelegations = async (data, options) => { -// for (const [, value] of data.delegations) { -// if (isExpired(value.delegation, options.time)) { -// await data.removeDelegation(value.delegation.cid) -// } -// } -// } +/** + * Clean up any expired delegations. + * + * @param {AgentData} data + * @param {object} options + * @param {API.UTCUnixTimestamp} options.time + */ +export const removeExpiredDelegations = async (data, options) => { + for (const [, value] of data.delegations) { + if (isExpired(value.delegation, options.time)) { + await data.removeDelegation(value.delegation.cid) + } + } +} // /** // * Get current space DID, proofs and abilities @@ -687,81 +691,81 @@ class SignerLoadError extends Error { // return delegation // } -// /** -// * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. -// * -// * @example -// * ```js -// * const spaceList = await Agent.issueInvocation(agent, Store.list, { -// * nb: { -// * size: 10, -// * }, -// * }) -// * -// * await spaceList.execute(agent.connection) -// * ``` -// * -// * @template {API.Ability} A -// * @template {API.URI} R -// * @template {API.TheCapabilityParser>} CAP -// * @template {API.Caveats} [C={}] -// * -// * @param {object} agent -// * @param {AgentData} agent.data -// * @param {API.Signer} agent.issuer -// * @param {{id: API.Principal}} agent.connection -// * @param {CAP} cap -// * @param {API.InvokeOptions} options -// */ -// export const issueInvocation = async ( -// { connection, issuer, data }, -// cap, -// options -// ) => { -// const audience = options.audience || connection.id -// const time = Date.now() / 1000 - -// const space = options.with || data.currentSpace -// if (!space) { -// throw new Error('No space or resource selected, you need pass a resource.') -// } - -// const proofs = [ -// ...(options.proofs || []), -// ...selectAuthorization( -// { data, issuer }, -// [ -// { -// with: space, -// can: cap.can, -// }, -// ], -// { sessionProofIssuer: audience.did(), time } -// ), -// ] - -// if (proofs.length === 0 && options.with !== issuer.did()) { -// throw new Error( -// `no proofs available for resource ${space} and ability ${cap.can}` -// ) -// } +/** + * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. + * + * @example + * ```js + * const spaceList = await Agent.issueInvocation(agent, Store.list, { + * nb: { + * size: 10, + * }, + * }) + * + * await spaceList.execute(agent.connection) + * ``` + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.TheCapabilityParser>} CAP + * @template {API.Caveats} [C={}] + * + * @param {object} agent + * @param {AgentData} agent.data + * @param {API.Signer} agent.issuer + * @param {{id: API.Principal}} agent.connection + * @param {CAP} cap + * @param {API.InvokeOptions} options + */ +export const issueInvocation = async ( + { connection, issuer, data }, + cap, + options +) => { + const audience = options.audience || connection.id + const time = Date.now() / 1000 + + const space = options.with || data.currentSpace + if (!space) { + throw new Error('No space or resource selected, you need pass a resource.') + } -// const inv = invoke({ -// ...options, -// issuer, -// audience, -// // @ts-ignore -// capability: cap.create({ -// with: space, -// nb: options.nb, -// }), -// proofs: [...proofs], -// }) + const proofs = [ + ...(options.proofs || []), + ...selectAuthorization( + { data, issuer }, + [ + { + with: space, + can: cap.can, + }, + ], + { sessionProofIssuer: audience.did(), time } + ), + ] + + if (proofs.length === 0 && options.with !== issuer.did()) { + throw new Error( + `no proofs available for resource ${space} and ability ${cap.can}` + ) + } -// return /** @type {API.IssuedInvocationView>} */ ( -// inv -// ) -// } + const inv = invoke({ + ...options, + issuer, + audience, + // @ts-ignore + capability: cap.create({ + with: space, + nb: options.nb, + }), + proofs: [...proofs], + }) + + return /** @type {API.IssuedInvocationView>} */ ( + inv + ) +} // /** // * Returns iterable of all the proofs that contain capabilities matching diff --git a/packages/w3up-client/src/agent/account.js b/packages/w3up-client/src/agent/account.js new file mode 100644 index 000000000..8c1c8f2cf --- /dev/null +++ b/packages/w3up-client/src/agent/account.js @@ -0,0 +1,33 @@ +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 } + +/** + * 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.audience + * @param {DB.Term} constraints.time + * @returns {DB.Clause} + */ +export const match = (ucan, { account = DB.string(), audience, time }) => { + const capability = DB.link() + return Delegation.match(ucan, { + capability: capability, + audience, + 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:*' })) +} diff --git a/packages/w3up-client/src/agent/attestation.js b/packages/w3up-client/src/agent/attestation.js new file mode 100644 index 000000000..c22da8714 --- /dev/null +++ b/packages/w3up-client/src/agent/attestation.js @@ -0,0 +1,36 @@ +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.capability + * @param {DB.Term} constraints.authority + * @param {DB.Term} constraints.time + * @param {DB.Term} constraints.audience + * @param {DB.Term} constraints.proof + */ +export const match = ( + ucan, + { capability, authority, audience, proof, time } +) => { + Capability.match(capability, { + subject: authority, + 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/authorization.js b/packages/w3up-client/src/agent/authorization.js index d4a63aa28..2e01c3b23 100644 --- a/packages/w3up-client/src/agent/authorization.js +++ b/packages/w3up-client/src/agent/authorization.js @@ -4,20 +4,111 @@ import * as Capability from './capability.js' import * as Delegation from './delegation.js' import * as Text from './db/text.js' +/** + * @param {API.Authorization} model + */ +export const from = (model) => new Authorization(model) + +/** + * @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: new Authorization({ + authority, + can: { + '*': [], + }, + subject, + proofs: [], + }), + } + } else { + const result = find(db, { + 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}` + ), + } + } + } +} + +class AccessDenied extends Error { + name = /** @type {const} */ ('AccessDenied') +} + +/** + * 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.authority + * @param {API.TextConstraint} [query.subject] + * @param {API.Can} query.can + * @param {API.UTCUnixTimestamp} [query.time] + * @returns {Authorization[]} + */ +export const find = ( + db, + { subject = { glob: '*' }, authority, time = Date.now() / 1000, can } +) => + DB.query( + db.index, + query({ + can, + subject, + authority, + time, + }) + ).map(({ subject, authority, ...proofs }) => { + // query engine will provide proof for each requested capability, so we may + // have duplicates here, which we prune. + const keys = [...new Set(Object.values(proofs).map(String))] + + return new Authorization({ + authority: /** @type {API.DID} */ (authority), + subject: /** @type {API.SpaceDID} */ (subject), + can, + // Dereference proofs from the store. + proofs: keys.map( + ($) => /** @type {API.Delegation} */ (db.proofs.get($)?.delegation) + ), + }) + }) + /** * 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.TextConstraint} selector.subject + * @param {API.TextConstraint} selector.authority * @param {API.Can} selector.can - * @param {API.UTCUnixTimestamp} selector.time + * @param {API.TextConstraint} [selector.subject] + * @param {API.UTCUnixTimestamp} [selector.time] */ -export const query = (selector) => { +export const query = ({ time = Date.now() / 1000, ...selector }) => { const subject = DB.string() - const audience = DB.string() + const authority = DB.string() const abilities = Object.keys(selector.can) const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) @@ -25,25 +116,132 @@ export const query = (selector) => { select: { ...proofs, subject, - audience, + authority, }, where: [ - ...Object.entries(proofs).flatMap(([can, proof]) => { - const capability = DB.link() - return [ - Capability.match(capability, { - subject, - can, - }), - Delegation.match(proof, { - capability, - audience, - time: selector.time, - }), - ] - }), - Text.match(audience, selector.audience), - Text.match(subject, selector.subject), + ...Object.entries(proofs).map(([can, delegation]) => + match(delegation, { + subject, + can, + authority, + time, + }) + ), + Text.match(subject, selector.subject ?? { glob: '*' }), + Text.match(authority, selector.authority), ], } } + +/** + * @param {DB.Term} delegation + * @param {object} selector + * @param {string} selector.can + * @param {API.UTCUnixTimestamp} selector.time + * @param {DB.Term} [selector.subject] + * @param {DB.Term} [selector.authority] + */ +export const match = ( + delegation, + { authority = DB.string(), subject = DB.string(), can, time } +) => { + const capability = DB.link() + + // simple case where capability is directly delegated to the audience + const direct = Capability.match(capability, { + can, + subject, + }).and( + Delegation.match(delegation, { + capability, + audience: authority, + time, + }) + ) + + const everything = DB.link() + const proof = DB.link() + const account = DB.string() + // Complicated case when all owned and delegated resources are delegated + // resources + const indirect = Capability.match(everything, { + subject: 'ucan:*', + can, + }) + .and( + Delegation.match(delegation, { + capability: everything, + audience: authority, + time, + }) + ) + // ucan:* resource implies both own and delegated resources + .and( + // Issuer owns their DID resource and since `ucan:*` implies all + // resources, it also implies the issuer DID. Which is why if the + // subject matches the issuer we have a match. + Delegation.issuedBy(delegation, subject) + // Otherwise we need to match subject with one of the resources + // in the proofs as those are re-delegated by `ucan:*` resource. + .or( + DB.match([delegation, 'ucan/proof', proof]) + .and( + Capability.match(capability, { + subject, + can, + }) + ) + .and( + Delegation.match(proof, { + capability, + // In this instance account must be the audience of the + // proof, as account is re-delegating it. + audience: account, + time, + }) + ) + ) + ) + + return direct.or(indirect) +} + +/** + * @typedef {object} Model + * @property {API.SpaceDID} subject + * @property {API.Delegation[]} proofs + * @property {API.DID} authority + */ +class Authorization { + /** + * + * @param {API.Authorization} model + */ + constructor(model) { + this.model = model + } + get can() { + return this.model.can + } + get proofs() { + return this.model.proofs + } + [Symbol.iterator]() { + return this.model.proofs + } + 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/agent/capability.js b/packages/w3up-client/src/agent/capability.js index 52e6fdccc..06331c500 100644 --- a/packages/w3up-client/src/agent/capability.js +++ b/packages/w3up-client/src/agent/capability.js @@ -24,7 +24,7 @@ export const hasAbility = (capability, can) => { * * @param {DB.Term} capability * @param {object} constraints - * @param {DB.Term} constraints.subject + * @param {DB.Term} constraints.subject * @param {string} constraints.can */ export const match = (capability, { subject, can }) => diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js index 362961787..a859705a1 100644 --- a/packages/w3up-client/src/agent/db.js +++ b/packages/w3up-client/src/agent/db.js @@ -1,9 +1,9 @@ import * as Datalogia from 'datalogia' import * as API from '../types.js' import * as Delegation from './delegation.js' -import * as Authorization from './authorization.js' import * as Delegations from './delegations.js' export * from 'datalogia' +export * as Text from './db/text.js' /** * @param {API.Variant<{ @@ -124,42 +124,74 @@ export const save = async (db) => { } /** - * Rebuilds proofs index from the proofs the proofs. - * * @param {API.Database} db - */ -export const reindex = (db) => { - db.index = Datalogia.Memory.create(Delegations.facts(db.proofs.values())) - - return db -} - -/** - * @typedef {API.Variant<{ proof: API.Delegation, signer: API.SignerArchive }>} Instruction - * @param {API.Database} db - * @param {Iterable} transaction + * @param {API.DBTransaction} transaction * @returns {Promise>} */ export const transact = async (db, transaction) => { - const instructions = [] - for (const each of transaction) { - if (each.proof) { - const { proof } = each - db.proofs.set(`${proof.cid}`, { meta: {}, delegation: proof }) - for (const fact of Delegation.facts(proof)) { - instructions.push({ Associate: fact }) + 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 { + error: 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 { + error: new DatabaseTransactionError( + `Transaction contains unknown retraction`, + { cause: retract, transaction } + ), + } } - } else if (each.signer) { - db.signer = each.signer } } - const result = await db.transactor.transact(instructions) - if (result.error) { + + const commit = await db.transactor.transact(assertions) + if (commit.error) { return { - error: new DatabaseTransactionError(transaction, { cause: result.error }), + error: 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. const { error } = await save(db) if (error) { return { error } @@ -169,48 +201,20 @@ export const transact = async (db, transaction) => { } /** - * Returns authorizations that match the given query, that is they provide - * abilities to the given audience. + * Creates a retraction instruction. * - * @typedef {object} Authorization - * @property {API.SpaceDID} subject - * @property {API.Delegation[]} proofs - * @property {API.DID} 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[]} + * @param {API.DBAssertion} assertion + * @returns {API.DBInstruction} */ -export const find = ( - db, - { subject = { like: '%' }, audience, time = Date.now() / 1000, can = {} } -) => - Datalogia.query( - db.index, - Authorization.query({ - can, - subject, - audience, - time, - }) - ).map(({ subject, audience, ...proofs }) => { - // query engine will provide proof for each requested capability, so we may - // have duplicates here, which we prune. - const keys = [...new Set(Object.values(proofs).map(String))] +export const retract = (assertion) => ({ retract: assertion }) - return { - audience: /** @type {API.DID} */ (audience), - subject: /** @type {API.SpaceDID} */ (subject), - // Dereference proofs from the store. - proofs: keys.map( - ($) => /** @type {API.Delegation} */ (db.proofs.get($)?.delegation) - ), - } - }) +/** + * 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') @@ -223,13 +227,13 @@ class DataStoreSaveError extends Error { class DatabaseTransactionError extends Error { name = /** @type {const} */ ('DatabaseTransactionError') /** - * - * @param {Iterable} transaction + * @param {string} message * @param {object} options + * @param {API.DBTransaction} options.transaction * @param {Error} options.cause */ - constructor(transaction, { cause }) { - super('Failed to transact') + constructor(message, { transaction, cause }) { + super(message) this.transaction = transaction this.cause = cause } diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js index 5c558e972..7f4c12735 100644 --- a/packages/w3up-client/src/agent/delegation.js +++ b/packages/w3up-client/src/agent/delegation.js @@ -1,11 +1,19 @@ import * as API from '../types.js' import * as DB from 'datalogia' import * as Block from './block.js' -import { importDAG, allows } from '@ucanto/core/delegation' +import { importDAG, allows, isDelegation } from '@ucanto/core/delegation' import * as Association from './db/association.js' /** - * Composes the clause that matches given `query.ucan` only if it has expired, + * @param {DB.Term} ucan + * @param {DB.Term} issuer + * @returns + */ +export const issuedBy = (ucan, issuer) => + DB.match([ucan, 'ucan/issuer', issuer]) + +/** + * 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 @@ -35,7 +43,6 @@ export const isTooEarly = (ucan, time) => { } /** - * * @param {DB.Term} ucan * @param {object} constraints * @param {DB.Term} constraints.capability @@ -62,40 +69,50 @@ export const facts = function* (delegation) { yield [entity, 'ucan/expiration', delegation.expiration] } - 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) + 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] + } - yield* Association.assert(capability, { - entity: id, - path: ['capability'], - }) + // 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 [entity, 'ucan/capability', id] - } - } - } + // yield* Association.assert(capability, { + // entity: id, + // path: ['capability'], + // }) + + // yield [entity, 'ucan/capability', id] + // } + // } + // } for (const fact of delegation.facts) { yield* Association.assert(fact, { entity, path: ['ucan', 'fact'] }) } - // for (const proof of delegation.proofs) { - // if (isDelegation(proof)) { - // yield* assert(proof) - // yield [ - // entity, - // 'ucan/proof', - // /** @type {API.Link & DB.Entity} */ (proof.cid), - // ] - // } - // } + 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)] + } + } } /** diff --git a/packages/w3up-client/src/agent/space.js b/packages/w3up-client/src/agent/space.js new file mode 100644 index 000000000..571db4211 --- /dev/null +++ b/packages/w3up-client/src/agent/space.js @@ -0,0 +1,126 @@ +import * as API from '../types.js' +import * as Delegation from './delegation.js' +import * as Capability from './capability.js' +import * as Text from './db/text.js' +import * as DB from 'datalogia' + +/** + * Creates a query that selects delegations to the `selector.audience` that + * delegate `selector.can` access on the `selector.subject` space. It only + * includes direct delegations and not the ones that have been re-delegated. + * + * @param {object} selector + * @param {API.TextConstraint} selector.audience + * @param {API.TextConstraint} [selector.subject] + * @param {API.Can} [selector.can] + * @param {API.UTCUnixTimestamp} [selector.time] + */ +export const direct = ({ + subject = { glob: '*' }, + audience, + time = Date.now() / 1000, + can = {}, +}) => { + const abilities = Object.keys(can) + const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) + const space = DB.string() + + const delegate = DB.string() + + return { + select: { + ...proofs, + subject: space, + audience: delegate, + }, + where: [ + ...Object.entries(proofs).flatMap(([can, delegation]) => { + const capability = DB.link() + return [ + Capability.match(capability, { + subject: space, + can, + }).and( + Delegation.match(delegation, { + capability, + audience: delegate, + time: time, + }) + ), + ] + }), + Text.match(delegate, audience), + Text.match(space, subject), + ], + } +} + +/** + * @param {object} selector + * @param {API.TextConstraint} [selector.audience] + * @param {API.TextConstraint} [selector.subject] + * @param {API.Can} [selector.can] + * @param {API.TextConstraint} [selector.account] + * @param {API.UTCUnixTimestamp} [selector.time] + */ +export const indirect = ({ + subject = { like: '%' }, + audience = { like: '%' }, + time = Date.now() / 1000, + account = { glob: 'did:mailto:*' }, + can = { '*': [] }, +}) => { + const abilities = Object.keys(can) + const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) + const space = DB.string() + + const delegate = DB.string() + const accountPrincipal = DB.string() + + return { + select: { + ...proofs, + subject: space, + audience: delegate, + account: accountPrincipal, + }, + where: [ + ...Object.entries(proofs).flatMap(([can, delegation]) => { + const login = DB.link() + const proof = DB.link() + const capability = DB.link() + return [ + Capability.match(login, { + subject: 'ucan:*', + can: '*', + }) + .and( + Delegation.match(delegation, { + capability: login, + audience: delegate, + time: time, + }) + ) + .and(Delegation.issuedBy(delegation, accountPrincipal)) + .and(DB.match([delegation, 'ucan/proof', proof])) + .and( + Delegation.match(proof, { + capability, + audience: accountPrincipal, + time: time, + }) + ) + .and( + Capability.match(capability, { + subject: space, + can, + }) + ), + ] + }), + Text.match(delegate, audience), + Text.match(space, subject), + Text.match(accountPrincipal, account), + ], + } +} diff --git a/packages/w3up-client/src/capability/space.js b/packages/w3up-client/src/capability/space.js index 5badd7f5f..744e75fc1 100644 --- a/packages/w3up-client/src/capability/space.js +++ b/packages/w3up-client/src/capability/space.js @@ -7,7 +7,7 @@ import * as Access from './access.js' import * as Provider from './provider.js' import * as Space from '@web3-storage/capabilities/space' import { issueInvocation } from '../agent.js' -import { Result } from '../index.js' +import * as Result from '../result.js' /** * diff --git a/packages/w3up-client/src/store/memory.js b/packages/w3up-client/src/store/memory.js new file mode 100644 index 000000000..d9a900663 --- /dev/null +++ b/packages/w3up-client/src/store/memory.js @@ -0,0 +1,54 @@ +import * as API from '../types.js' + +/** + * @template T + * @typedef {API.Driver} Driver + */ + +/** + * Driver implementation that stores data in memory." + * + * Usage: + * + * ```js + * import { MemoryDriver } from '@web3-storage/access/drivers/memory' + * ``` + * + * @template {Record} T + * @implements {Driver} + */ +class MemoryDriver { + /** + * @type {T|undefined} + */ + #data + + constructor() { + this.#data = undefined + } + + async open() {} + + async close() {} + + async reset() { + this.#data = undefined + } + + /** @param {T} 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 + } +} + +/** + * @returns {API.DataStore} + */ +export const memory = () => new MemoryDriver() diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 5b8234d46..b3c2d4a1c 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -520,7 +520,7 @@ export interface BlockArchive { } /** - * A {@link API.Delegation} formatted for storage, making it compatible with + * A {@link Delegation} formatted for storage, making it compatible with * `structuredClone()` used by `indexedDB`. */ export interface DelegationArchive extends Array {} @@ -562,6 +562,31 @@ export interface Database { store?: DataStore } +/** + * 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 @@ -653,6 +678,10 @@ export interface W3Up { export interface Agent { signer: Signer + + /** + * DB used to persist agent delegations and signing authority. + */ db: Database } @@ -663,13 +692,8 @@ export interface Agent { * create sessions with service providers that can be used to invoke * provided capabilities on behalf of the signing authority. */ -export interface AgentView { - /** - * Store used to persist agent delegations and signing authority. - */ - db: Database - - did(): DID +export interface AgentView extends Agent { + did(): DIDKey /** * Connects to a service provider and returns a session that can be used to @@ -723,3 +747,24 @@ export interface Connection extends ConnectionView { address: Address } + +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[] +} diff --git a/packages/w3up-client/src/view/account.js b/packages/w3up-client/src/view/account.js index 7f422c8a1..700f9a13a 100644 --- a/packages/w3up-client/src/view/account.js +++ b/packages/w3up-client/src/view/account.js @@ -9,15 +9,30 @@ 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.AgentView} agent + * @param {API.Session} session * @param {object} query * @param {API.DID<'mailto'>} [query.account] */ -export const list = (agent, { account } = {}) => { +export const list = (session, { account } = {}) => { const query = /** @type {API.CapabilityQuery} */ ({ with: account ?? /did:mailto:.*/, can: '*', @@ -72,13 +87,13 @@ export const list = (agent, { account } = {}) => { * authorization session time bounds (currently 15 minutes), the promise will * resolve to an error. * - * @param {API.AgentView} agent + * @param {API.Session} session * @param {API.EmailAddress} email * @param {object} [options] * @param {AbortSignal} [options.signal] * @returns {Promise>} */ -export const login = async (agent, email, options = {}) => { +export const login = async (session, email, options = {}) => { const account = fromEmail(email) // If we already have a session for this account we @@ -91,12 +106,12 @@ export const login = async (agent, email, options = {}) => { // 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 login = list(session, { account })[account] + if (login) { + return { ok: login } } - const result = await Access.request(agent, { + const result = await Access.request(session, { account, access: Access.accountAccess, }) @@ -111,16 +126,15 @@ export const login = async (agent, email, options = {}) => { if (error) { return { error } } else { - return { ok: new Account({ id: account, proofs: ok.proofs, agent }) } + return { ok: new Account({ proofs: ok.proofs, session }) } } } } /** * @typedef {object} Model - * @property {API.DidMailto} id - * @property {API.AgentView} agent - * @property {API.Delegation[]} proofs + * @property {API.Session} session + * @property {API.Tuple} proofs */ export class Account { @@ -131,15 +145,18 @@ export class Account { this.model = model this.plan = new AccountPlan(model) } + get session() { + return this.model.session + } get agent() { - return this.model.agent + return this.model.session.agent } get proofs() { return this.model.proofs } did() { - return this.model.id + return /** @type {API.DidMailto} */ (this.model.proofs[0].issuer.did()) } toEmail() { @@ -184,9 +201,9 @@ export class Account { * Saves account in the agent store so it can be accessed across sessions. * * @param {object} input - * @param {API.AgentView<{}>} [input.agent] + * @param {API.Agent} [input.agent] */ - async save({ agent = this.agent } = {}) { + async save({ agent = this.session.agent } = {}) { return await importAuthorization(agent, this) } } diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index 9cdf83f91..a9548c969 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -1,6 +1,7 @@ import * as Test from './test.js' -import * as Access from '../src/capability/access.js' +import * as Access from '../src/access.js' import * as Result from '../src/result.js' +import * as Authorization from '../src/agent/authorization.js' /** * @type {Test.Suite} @@ -8,29 +9,50 @@ import * as Result from '../src/result.js' export const testAccess = { 'capability.access.request': async ( assert, - { client, mail, grantAccess } + { session, mail, grantAccess } ) => { const email = 'alice@web.mail' const account = Access.DIDMailto.fromEmail(email) - const request = Result.try( - await client.capability.access.request({ account }) - ) + const request = Result.unwrap(await Access.request(session, { account })) const message = await mail.take() assert.deepEqual(message.to, email) await grantAccess(message) - assert.deepEqual(request.audience, client.did()) + assert.deepEqual(request.authority, session.agent.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) + const results = Authorization.find(session.agent.db, { + authority: session.agent.did(), + can: { 'store/add': [] }, + }) - await access.save() - assert.ok(client.proofs().length > 0) + assert.deepEqual(results, []) + + Result.unwrap(await access.save()) + const [login] = Authorization.find(session.agent.db, { + authority: 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': [] }, + authority: 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) }, } diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index b727de343..4e630de45 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -1,11 +1,17 @@ import * as DB from '../../src/agent/db.js' import * as Test from '../test.js' import * as Space from '../../src/capability/space.js' -import * as Account from '../../src/view/account.js' -import { delegate } from '@ucanto/core' +import * as Account from '../../src/agent/account.js' +import * as Delegation from '../../src/agent/delegation.js' +import * as Spaces from '../../src/agent/space.js' +import { createLegacyLink, delegate } from '@ucanto/core' import { Absentee, Verifier } from '@ucanto/principal' import * as Capability from '@web3-storage/capabilities' +import * as Cap from '../../src/agent/capability.js' +import { fromEmail, toEmail } from '@web3-storage/did-mailto' + import { alice, bob, mallory, service } from '../fixtures/principals.js' +import * as Authorization from '../../src/agent/authorization.js' /** * @type {Test.BasicSuite} @@ -15,20 +21,21 @@ export const testDB = { const space = await Space.generate({ name: 'beet-box', }) - const auth = await space.createAuthorization(alice) - const db = DB.from({ proofs: [auth] }) + const proof = await space.createAuthorization(alice) + const db = DB.from({ proofs: [proof] }) - const result = DB.find(db, { + const result = Authorization.find(db, { can: { 'store/add': [] }, - audience: alice.did(), + authority: alice.did(), }) assert.deepEqual(result, [ - { - audience: alice.did(), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, subject: space.did(), - proofs: [auth], - }, + proofs: [proof], + }), ]) }, @@ -45,22 +52,24 @@ export const testDB = { const db = DB.from({ proofs: [beetBoxAuth, plumBoxAuth] }) - const result = DB.find(db, { + const result = Authorization.find(db, { can: { 'store/add': [], 'store/remove': [] }, - audience: alice.did(), + authority: alice.did(), }) assert.deepEqual(result, [ - { - audience: alice.did(), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [], 'store/remove': [] }, subject: beetBox.did(), proofs: [beetBoxAuth], - }, - { - audience: alice.did(), + }), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [], 'store/remove': [] }, subject: plumBox.did(), proofs: [plumBoxAuth], - }, + }), ]) }, @@ -79,17 +88,18 @@ export const testDB = { const db = DB.from({ proofs: [spaceInfo, uploadList] }) - const result = DB.find(db, { + const result = Authorization.find(db, { can: { 'space/info': [], 'upload/list': [] }, - audience: bob.did(), + authority: bob.did(), }) assert.deepEqual(result, [ - { - audience: bob.did(), + Authorization.from({ + authority: bob.did(), + can: { 'space/info': [], 'upload/list': [] }, subject: alice.did(), proofs: [spaceInfo, uploadList], - }, + }), ]) }, @@ -110,37 +120,40 @@ export const testDB = { proofs: [login, attestation, localAuth], }) - const result = DB.find(db, { - subject: { like: 'did:mailto:%' }, + const result = Authorization.find(db, { + subject: { glob: 'did:mailto:*' }, can: { '*': [] }, - audience: alice.did(), + authority: alice.did(), }) assert.deepEqual(result, [ - { + Authorization.from({ subject: account.did(), - audience: alice.did(), + authority: alice.did(), + can: { '*': [] }, proofs: [login], - }, + }), ]) - const spaces = DB.find(db, { + const spaces = Authorization.find(db, { subject: { like: 'did:key:%' }, can: { 'store/add': [] }, - audience: alice.did(), + authority: alice.did(), }) assert.deepEqual(spaces, [ - { - subject: remoteSpace.did(), - audience: alice.did(), - proofs: [login], - }, - { + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, subject: localSpace.did(), - audience: alice.did(), proofs: [localAuth], - }, + }), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: remoteSpace.did(), + proofs: [login], + }), ]) }, @@ -162,7 +175,7 @@ export const testDB = { where: [ DB.match([loginProof, 'ucan/audience', alice.did()]), DB.match([loginProof, 'ucan/capability', loginCan]), - DB.match([loginCan, 'capability/with', account.did()]), + DB.match([loginCan, 'capability/with', 'ucan:*']), DB.match([attestProof, 'ucan/audience', alice.did()]), DB.match([attestProof, 'ucan/capability', attestCan]), @@ -194,37 +207,40 @@ export const testDB = { proofs: [valid, expired], }) - const withoutExpired = DB.find(db, { + const withoutExpired = Authorization.find(db, { can: { 'store/add': [] }, - audience: alice.did(), + authority: alice.did(), time, }) assert.deepEqual(withoutExpired, [ - { - audience: alice.did(), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, subject: space.did(), proofs: [valid], - }, + }), ]) - const withExpired = DB.find(db, { + const withExpired = Authorization.find(db, { can: { 'store/add': [] }, - audience: alice.did(), + authority: alice.did(), time: time - 60 * 60 * 24 * 2, }) assert.deepEqual(withExpired, [ - { - audience: alice.did(), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, subject: space.did(), proofs: [valid], - }, - { - audience: alice.did(), + }), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, subject: space.did(), proofs: [expired], - }, + }), ]) }, @@ -238,29 +254,307 @@ export const testDB = { proofs: [proof], }) - const result = DB.find(db, { + const result = Authorization.find(db, { can: { 'store/add': [] }, - audience: alice.did(), + authority: alice.did(), }) assert.deepEqual(result, [ - { - audience: alice.did(), + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, subject: space.did(), proofs: [proof], + }), + ]) + }, + + 'account view': async (assert) => { + const aliceAccount = await setupAccount({ + email: 'alice@web.mail', + agent: alice, + }) + const bobAccount = await 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': async (assert) => { + const aliceLogin = await setupAccount({ + name: 'Alice', + email: 'alice@web.mail', + agent: alice, + }) + const bobLogin = await setupAccount({ + name: 'Bob', + email: 'bob@web3.storage', + agent: bob, + }) + const aliLogin = await setupAccount({ + name: 'Ali', + email: 'alice@web.mail', + agent: alice, + }) + + const space = await Space.generate({ name: 'space' }) + const proof = await space.createAuthorization(alice, { + expiration: Infinity, + }) + + const db = DB.from({ + proofs: [ + ...aliceLogin.proofs, + ...bobLogin.proofs, + ...aliLogin.proofs, + proof, + ], + }) + + 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() + + assert.deepEqual( + DB.query( + db.index, + Spaces.indirect({ + audience: alice.did(), + can: { 'store/*': [] }, + }) + ), + [ + { + 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/*': proof.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': async (assert) => { + const account = Absentee.from({ id: fromEmail('alice@web.mail') }) + const proof = await delegate({ + issuer: account, + audience: alice, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + proofs: [], + }) + + const db = DB.from({ + proofs: [proof], + }) + + const result = Authorization.find(db, { + can: { 'store/add': [] }, + authority: alice.did(), + subject: account.did(), + }) + + assert.deepEqual(result, [ + { + model: { + authority: alice.did(), + subject: account.did(), + can: { 'store/add': [] }, + proofs: [proof], + }, + }, + ]) + }, + + 'account authority from authorization': async (assert) => { + const account = Absentee.from({ id: fromEmail('alice@web.mail') }) + const proof = await delegate({ + issuer: account, + audience: alice, + capabilities: [ + { + with: 'ucan:*', + can: 'store/*', + }, + ], + proofs: [], + }) + + const db = DB.from({ + proofs: [proof], + }) + + const result = Authorization.find(db, { + can: { 'store/add': [] }, + authority: alice.did(), + subject: account.did(), + }) + + assert.deepEqual(result, [ + { + model: { + authority: alice.did(), + subject: account.did(), + can: { 'store/add': [] }, + proofs: [proof], + }, }, ]) }, } -const setupAccount = async () => { - const space = await Space.generate({ name: 'stuff' }) - const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) +const setupAccount = async ({ + email = /** @type {`${string}@${string}`} */ ('alice@web.mail'), + name = 'stuff', + agent = alice, +} = {}) => { + const space = await Space.generate({ name }) + const account = Absentee.from({ id: fromEmail(email) }) const recovery = await space.createRecovery(account.did()) const login = await delegate({ issuer: account, - audience: alice, + audience: agent, capabilities: [ { with: 'ucan:*', @@ -272,13 +566,20 @@ const setupAccount = async () => { const attestation = await Capability.UCAN.attest.delegate({ issuer: service, - audience: alice, + audience: agent, with: service.did(), nb: { proof: login.cid }, expiration: Infinity, }) - return { space, account, recovery, login, attestation } + return { + space, + account, + recovery, + login, + attestation, + proofs: [login, attestation], + } } Test.basic({ DB: testDB }) diff --git a/packages/w3up-client/test/agent/store.test.js b/packages/w3up-client/test/agent/store.test.js index d32b499c7..6b1baab33 100644 --- a/packages/w3up-client/test/agent/store.test.js +++ b/packages/w3up-client/test/agent/store.test.js @@ -1,6 +1,6 @@ import * as Test from '../test.js' import { alice, bob, mallory } from '../fixtures/principals.js' -import * as Store from '../../src/agent/store.js' +import * as Store from '../../src/agent/ex-store.js' /** * @type {Test.Suite} diff --git a/packages/w3up-client/test/test.js b/packages/w3up-client/test/test.js index cc9d175e6..89b366af6 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 * as Context from '@web3-storage/upload-api/test/context' // import * as Client from '@web3-storage/w3up-client' +import { memory } from '../src/store/memory.js' +import * as Agent from '../src/agent.js' import * as assert from 'assert' +import * as API from '../src/types.js' /** * @typedef {Omit & {ok(value:unknown, message?:string):void}} Assert @@ -31,22 +34,36 @@ 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, + }, + }), }) +} + +/** + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} connection + */ +export const connect = (connection) => + Agent.open({ + store: memory(), + }).connect(connection) + +export const setup = async () => { + const context = await setupContext() - const connect = () => {} - // Client.create({ - // store: new StoreMemory(), - // serviceConf: { - // access: context.connection, - // upload: context.connection, - // filecoin: context.connection, - // }, - // }) + const { error, ok: session } = await connect(context.connection) + if (error) { + throw error + } - return { ...context, connect, client: await connect() } + return { ...context, session } } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37b0d5088..f54e78623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,8 +571,8 @@ importers: specifier: workspace:^ version: link:../upload-client datalogia: - specifier: ^0.3.0 - version: 0.3.0 + specifier: ^0.3.1 + version: 0.3.1 uint8arrays: specifier: ^4.0.9 version: 4.0.9 @@ -5922,8 +5922,8 @@ packages: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true - /datalogia@0.3.0: - resolution: {integrity: sha512-n5cFthKC1mvfUSee03aN76NO5bt6TR7jkcmN28iq1hTns5Qwpf7/8XSlxdmue+I5fnwhTynqaqQDAMISaZ6frA==} + /datalogia@0.3.1: + resolution: {integrity: sha512-2DEWy9yVvHsDV+6o/STKlb+Hdhg4Y5Q4JmbFLLmDkVIqWarA7udolK+tpwoZg21xVZ4UWY9jGdIyEfvWVaIhuA==} dependencies: '@ipld/dag-cbor': 9.1.0 '@noble/hashes': 1.3.3 From bb5a6ba95a2e156ed684a8c3049e81b3224aae44 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 7 Feb 2024 02:02:51 -0800 Subject: [PATCH 06/16] feat: land access api tests --- packages/w3up-client/package.json | 2 +- packages/w3up-client/src/access.js | 1 - .../w3up-client/src/agent/authorization.js | 47 ++++++++----- packages/w3up-client/src/agent/capability.js | 27 +++++-- packages/w3up-client/src/agent/space.js | 5 +- packages/w3up-client/test/access.test.js | 70 +++++++++++++++++-- packages/w3up-client/test/agent/db.test.js | 67 ++++++++++++------ pnpm-lock.yaml | 8 +-- 8 files changed, 169 insertions(+), 58 deletions(-) diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index 2984b83aa..2f81629f6 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -100,7 +100,7 @@ "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", - "datalogia": "^0.3.1", + "datalogia": "^0.4.0", "@web3-storage/access": "workspace:^", "@web3-storage/capabilities": "workspace:^", "@web3-storage/did-mailto": "workspace:^", diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js index ad7f0f6a2..8de2c72a6 100644 --- a/packages/w3up-client/src/access.js +++ b/packages/w3up-client/src/access.js @@ -6,7 +6,6 @@ export { DIDMailto } import * as Access from '@web3-storage/capabilities/access' import { Failure, fail, DID } from '@ucanto/core' -import { importAuthorization } from './agent.js' import { bytesToDelegations } from './agent/encoding.js' import * as Authorization from './agent/authorization.js' import * as DB from './agent/db.js' diff --git a/packages/w3up-client/src/agent/authorization.js b/packages/w3up-client/src/agent/authorization.js index 2e01c3b23..7d6d4dfd3 100644 --- a/packages/w3up-client/src/agent/authorization.js +++ b/packages/w3up-client/src/agent/authorization.js @@ -4,6 +4,8 @@ import * as Capability from './capability.js' import * as Delegation from './delegation.js' import * as Text from './db/text.js' +export { Capability, Delegation, Text } + /** * @param {API.Authorization} model */ @@ -63,7 +65,7 @@ class AccessDenied extends Error { * @param {object} query * @param {API.TextConstraint} query.authority * @param {API.TextConstraint} [query.subject] - * @param {API.Can} query.can + * @param {API.Can} [query.can] * @param {API.UTCUnixTimestamp} [query.time] * @returns {Authorization[]} */ @@ -79,15 +81,15 @@ export const find = ( authority, time, }) - ).map(({ subject, authority, ...proofs }) => { + ).map(({ subject, authority, proofs }) => { // query engine will provide proof for each requested capability, so we may // have duplicates here, which we prune. - const keys = [...new Set(Object.values(proofs).map(String))] + const keys = [...new Set(proofs.map(({ proof }) => String(proof)))] return new Authorization({ authority: /** @type {API.DID} */ (authority), subject: /** @type {API.SpaceDID} */ (subject), - can, + can: can ?? Object.fromEntries(proofs.map(({ can }) => [can, []])), // Dereference proofs from the store. proofs: keys.map( ($) => /** @type {API.Delegation} */ (db.proofs.get($)?.delegation) @@ -102,31 +104,38 @@ export const find = ( * * @param {object} selector * @param {API.TextConstraint} selector.authority - * @param {API.Can} selector.can + * @param {API.Can} [selector.can] * @param {API.TextConstraint} [selector.subject] * @param {API.UTCUnixTimestamp} [selector.time] */ -export const query = ({ time = Date.now() / 1000, ...selector }) => { +export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { const subject = DB.string() const authority = DB.string() - const abilities = Object.keys(selector.can) - const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) + const need = Object.keys(can) + /** @type {{proof: DB.Term, can: DB.Term, need?: string }[]} */ + const proofs = need.length + ? need.map((need) => ({ proof: DB.link(), need, can: DB.string() })) + : [{ proof: DB.link(), can: DB.string() }] + + const where = proofs.map(({ proof, need, can }) => { + const clause = match(proof, { + subject, + can, + authority, + time, + }) + + return need ? clause.and(DB.glob(need, can)) : clause + }) return { select: { - ...proofs, + proofs, subject, authority, }, where: [ - ...Object.entries(proofs).map(([can, delegation]) => - match(delegation, { - subject, - can, - authority, - time, - }) - ), + ...where, Text.match(subject, selector.subject ?? { glob: '*' }), Text.match(authority, selector.authority), ], @@ -136,14 +145,14 @@ export const query = ({ time = Date.now() / 1000, ...selector }) => { /** * @param {DB.Term} delegation * @param {object} selector - * @param {string} selector.can * @param {API.UTCUnixTimestamp} selector.time + * @param {DB.Term} [selector.can] * @param {DB.Term} [selector.subject] * @param {DB.Term} [selector.authority] */ export const match = ( delegation, - { authority = DB.string(), subject = DB.string(), can, time } + { authority = DB.string(), subject = DB.string(), can = DB.string(), time } ) => { const capability = DB.link() diff --git a/packages/w3up-client/src/agent/capability.js b/packages/w3up-client/src/agent/capability.js index 06331c500..a597b33e8 100644 --- a/packages/w3up-client/src/agent/capability.js +++ b/packages/w3up-client/src/agent/capability.js @@ -8,7 +8,7 @@ import * as API from '../types.js' * @param {DB.Term} capability * @param {string} can */ -export const hasAbility = (capability, 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 @@ -18,6 +18,25 @@ export const hasAbility = (capability, can) => { ) } +/** + * 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. @@ -25,9 +44,7 @@ export const hasAbility = (capability, can) => { * @param {DB.Term} capability * @param {object} constraints * @param {DB.Term} constraints.subject - * @param {string} constraints.can + * @param {DB.Term} constraints.can */ export const match = (capability, { subject, can }) => - DB.match([capability, 'capability/with', subject]).and( - hasAbility(capability, can) - ) + hasSubject(capability, subject).and(hasAbility(capability, can)) diff --git a/packages/w3up-client/src/agent/space.js b/packages/w3up-client/src/agent/space.js index 571db4211..3387476c0 100644 --- a/packages/w3up-client/src/agent/space.js +++ b/packages/w3up-client/src/agent/space.js @@ -85,7 +85,8 @@ export const indirect = ({ account: accountPrincipal, }, where: [ - ...Object.entries(proofs).flatMap(([can, delegation]) => { + ...Object.entries(proofs).flatMap(([need, delegation]) => { + const can = DB.string() const login = DB.link() const proof = DB.link() const capability = DB.link() @@ -114,7 +115,7 @@ export const indirect = ({ Capability.match(capability, { subject: space, can, - }) + }).and(DB.glob(need, can)) ), ] }), diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index a9548c969..752b84b11 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -2,15 +2,15 @@ import * as Test from './test.js' import * as Access from '../src/access.js' import * as Result from '../src/result.js' import * as Authorization from '../src/agent/authorization.js' +import * as Space from '../src/space.js' +import * as API from '../src/types.js' +import * as DB from '../src/agent/db.js' /** * @type {Test.Suite} */ export const testAccess = { - 'capability.access.request': async ( - assert, - { session, mail, grantAccess } - ) => { + 'access.request': async (assert, { session, mail, grantAccess }) => { const email = 'alice@web.mail' const account = Access.DIDMailto.fromEmail(email) @@ -54,6 +54,68 @@ export const testAccess = { assert.equal(auth.subject, account) assert.ok(auth.proofs.length > 0) }, + + 'access delegate and claim': async ( + assert, + { session, provisionsStorage } + ) => { + const space = await Space.generate({ name: 'main' }) + Result.unwrap( + await DB.transact(session.agent.db, [ + DB.assert({ proof: await space.createAuthorization(session.agent) }), + ]) + ) + + Result.unwrap( + await 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 = await Space.generate({ name: 'shared' }) + const delegation = await shared.createAuthorization(session.agent) + + const result = await Access.delegate(session, { + delegations: [delegation], + subject: space.did(), + }) + + assert.ok(result.ok) + + const claim = Result.unwrap(await Access.claim(session)) + assert.deepEqual(claim.proofs, [delegation]) + + const none = Authorization.find(session.agent.db, { + authority: session.agent.did(), + subject: shared.did(), + can: { 'store/add': [] }, + }) + + assert.deepEqual(none, [], 'claimed access has not been added to an agent') + + Result.unwrap(await claim.save()) + + const [auth] = Authorization.find(session.agent.db, { + authority: 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: [delegation], + }), + 'claimed access has been added to an agent' + ) + }, } Test.test({ Access: testAccess }) diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index 4e630de45..1a92f2720 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -372,30 +372,29 @@ export const testDB = { // const proof = DB.link() // const proofCap = DB.link() - assert.deepEqual( - DB.query( - db.index, - Spaces.indirect({ - audience: alice.did(), - can: { 'store/*': [] }, - }) - ), - [ - { - 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, - }, - ] + 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, @@ -541,6 +540,30 @@ export const testDB = { }, ]) }, + + 'find whatever capabilities match': async (assert) => { + const space = await Space.generate({ + name: 'beet-box', + }) + const proof = await space.createAuthorization(alice) + const db = DB.from({ proofs: [proof] }) + + const result = Authorization.find(db, { + authority: alice.did(), + }) + + assert.deepEqual( + result, + proof.capabilities.map(({ can }) => + Authorization.from({ + authority: alice.did(), + can: { [can]: [] }, + subject: space.did(), + proofs: [proof], + }) + ) + ) + }, } const setupAccount = async ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f54e78623..36d44b321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,8 +571,8 @@ importers: specifier: workspace:^ version: link:../upload-client datalogia: - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.4.0 + version: 0.4.0 uint8arrays: specifier: ^4.0.9 version: 4.0.9 @@ -5922,8 +5922,8 @@ packages: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true - /datalogia@0.3.1: - resolution: {integrity: sha512-2DEWy9yVvHsDV+6o/STKlb+Hdhg4Y5Q4JmbFLLmDkVIqWarA7udolK+tpwoZg21xVZ4UWY9jGdIyEfvWVaIhuA==} + /datalogia@0.4.0: + resolution: {integrity: sha512-ScBAPsoSNEVmSr4V98o32a/fs/CNWJGah/Rxu8lWFN7xBOo1UpDcVq5r1vJERK6dZTDslfHbRfBjH2HSFaezeA==} dependencies: '@ipld/dag-cbor': 9.1.0 '@noble/hashes': 1.3.3 From ac6598e3e7ef692abf97a437aa2f6d2505bf1062 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 9 Feb 2024 09:04:53 -0800 Subject: [PATCH 07/16] feat: save current changes --- packages/w3up-client/src/access.js | 44 +-- packages/w3up-client/src/account.js | 264 +++++++++--------- packages/w3up-client/src/account/plan.js | 80 ++++++ .../w3up-client/src/account/subscription.js | 58 ++++ packages/w3up-client/src/agent.js | 34 ++- packages/w3up-client/src/agent/account.js | 33 --- packages/w3up-client/src/agent/attestation.js | 7 +- .../w3up-client/src/agent/authorization.js | 177 +++++++----- packages/w3up-client/src/agent/capability.js | 19 +- packages/w3up-client/src/agent/connection.js | 2 +- packages/w3up-client/src/agent/data.js | 206 -------------- packages/w3up-client/src/agent/db.js | 7 + packages/w3up-client/src/agent/delegation.js | 60 +++- packages/w3up-client/src/agent/login.js | 141 ++++++++++ packages/w3up-client/src/agent/meta.js | 14 + packages/w3up-client/src/agent/space.js | 130 +++++++++ packages/w3up-client/src/capability/access.js | 10 +- packages/w3up-client/src/capability/plan.js | 2 +- .../w3up-client/src/capability/provider.js | 2 +- packages/w3up-client/src/capability/space.js | 93 ++++-- .../src/capability/subscription.js | 2 +- packages/w3up-client/src/client/access.js | 8 +- packages/w3up-client/src/client/space.js | 2 +- .../w3up-client/src/client/subscription.js | 2 +- packages/w3up-client/src/coupon.js | 4 +- packages/w3up-client/src/plan.js | 34 +++ packages/w3up-client/src/session.js | 17 +- packages/w3up-client/src/session/space.js | 75 +++++ packages/w3up-client/src/space.js | 42 +-- packages/w3up-client/src/task.js | 86 ++++++ packages/w3up-client/src/types.ts | 201 ++++++++++--- packages/w3up-client/src/view/account.js | 10 +- packages/w3up-client/src/view/space.js | 12 +- packages/w3up-client/test/account.test.js | 228 ++++++++------- packages/w3up-client/test/agent/db.test.js | 113 +++++++- .../w3up-client/test/fixtures/principals.js | 2 + packages/w3up-client/test/plan.test.js | 59 ++++ .../w3up-client/test/subscription.test.js | 50 ++++ 38 files changed, 1631 insertions(+), 699 deletions(-) create mode 100644 packages/w3up-client/src/account/plan.js create mode 100644 packages/w3up-client/src/account/subscription.js delete mode 100644 packages/w3up-client/src/agent/account.js delete mode 100644 packages/w3up-client/src/agent/data.js create mode 100644 packages/w3up-client/src/agent/login.js create mode 100644 packages/w3up-client/src/agent/meta.js create mode 100644 packages/w3up-client/src/plan.js create mode 100644 packages/w3up-client/src/session/space.js create mode 100644 packages/w3up-client/src/task.js create mode 100644 packages/w3up-client/test/plan.test.js create mode 100644 packages/w3up-client/test/subscription.test.js diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js index 8de2c72a6..7938d1ad8 100644 --- a/packages/w3up-client/src/access.js +++ b/packages/w3up-client/src/access.js @@ -17,7 +17,7 @@ import * as DB from './agent/db.js' * 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 {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. @@ -37,8 +37,7 @@ export const delegate = async ( proof.cid, ]) - const auth = Authorization.get(session.agent.db, { - authority: session.agent.did(), + const auth = session.agent.authorize({ subject, can: { 'access/delegate': [] }, }) @@ -69,13 +68,14 @@ export const delegate = async ( * `PendingAccessRequest` object that can be used to poll for the requested * delegation through `access/claim` capability. * - * @param {API.Session} session + * @template {API.AccessRequestProvider} Protocol + * @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. - * @returns {Promise>} + * @returns {Promise, API.AccessAuthorizeFailure|API.AccessDenied|API.InvocationError>>} */ export const request = async ( session, @@ -88,8 +88,7 @@ export const request = async ( ) => { // Find proofs that allows this agent to invoke `access/authorize` capability // on behalf of the principal requesting access. - const auth = Authorization.get(session.agent.db, { - authority: session.agent.did(), + const auth = session.agent.authorize({ subject: authority, can: { 'access/authorize': [] }, }) @@ -113,7 +112,11 @@ export const request = async ( }, proofs: auth.ok.proofs, }) - .execute(session.connection) + .execute( + /** @type {API.Connection} */ ( + session.connection + ) + ) return result.error ? result @@ -131,11 +134,12 @@ export const request = async ( * Claims access that has been delegated to the given `authority`, which by * default is the agent's DID. * - * @param {API.Session} session + * @template {API.AccessClaimProvider} Protocol + * @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. - * @returns {Promise>} + * @returns {Promise, API.AccessClaimFailure|API.InvocationError|API.AccessDenied>>} */ export const claim = async ( session, @@ -144,8 +148,7 @@ export const claim = async ( authority = session.agent.did(), } = {} ) => { - const auth = Authorization.get(session.agent.db, { - authority: session.agent.did(), + const auth = session.agent.authorize({ subject: authority, can: { 'access/claim': [] }, }) @@ -161,10 +164,14 @@ export const claim = async ( with: authority, proofs: auth.ok.proofs, }) - .execute(session.connection) + .execute( + /** @type {API.Connection} */ ( + session.connection + ) + ) if (result.error) { - return result + return { error: result.error } } else { const delegations = Object.values(result.ok.delegations) @@ -179,11 +186,13 @@ export const claim = async ( /** * Represents a pending access request. It can be used to poll for the requested * delegation. + * + * @template {API.AccessClaimProvider} Protocol */ class PendingAccessRequest { /** * @typedef {object} PendingAccessRequestModel - * @property {API.Session} session - Session with a service. + * @property {API.Session} session - Session with a service. * @property {API.ProviderDID} provider - Provider handling request. * @property {API.UTCUnixTimestamp} expiration - Seconds in UTC. * @property {API.DIDKey|API.DidMailto} authority - Principal requesting an access. @@ -249,7 +258,7 @@ class PendingAccessRequest { * @param {object} options * @param {number} [options.interval] * @param {AbortSignal} [options.signal] - * @returns {Promise>} + * @returns {Promise, Error>>} */ async claim({ signal, interval = 250 } = {}) { while (signal?.aborted !== true) { @@ -306,12 +315,13 @@ class RequestExpired extends Failure { } /** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * View over the UCAN Delegations that grant access to a specific principal. */ export class GrantedAccess { /** * @typedef {object} GrantedAccessModel - * @property {API.Session} session - Agent that processed the request. + * @property {API.Session} session - Agent that processed the request. * @property {API.Tuple} proofs - Delegations that grant access. * * @param {GrantedAccessModel} model diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index c13172f68..c246748e6 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -1,30 +1,24 @@ import * as API from './types.js' -import * as Account from './agent/account.js' +import * as Login from './agent/login.js' +import * as Access from './access.js' import * as DB from './agent/db.js' -import { fromEmail, toEmail } from '@web3-storage/did-mailto' +import * as DIDMailto from '@web3-storage/did-mailto' +import * as Plan from './account/plan.js' +export { DIDMailto } /** - * @template {API.UnknownProtocol} [Protocol=API.Service] - * @typedef {object} Model - * @property {API.Session} session - */ - -/** - * @template {API.UnknownProtocol} [Protocol=API.Service] + * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] */ export class AccountsView { /** - * @param {Model} model + * @param {API.Session} session */ - constructor(model) { - this.model = model - } - get session() { - return this.model.session + constructor(session) { + this.session = session } [Symbol.iterator]() { - return list(this) + return list(this.session) } /** @@ -33,14 +27,14 @@ export class AccountsView { * @param {AbortSignal} [source.signal] */ login(source) { - return login(this, source) + return login(this.session, source) } /** * Returns iterable of all the accounts saved in the agent's database. */ list() { - return list(this) + return list(this.session) } /** @@ -50,161 +44,165 @@ export class AccountsView { * @param {API.EmailAddress} email */ get(email) { - return get(this, email) - } - - /** - * Removes all the delegations corresponding to the account logins for the - * given email address. - * - * @param {object} account - * @param {API.EmailAddress} account.email - */ - remove({ email }) { - return remove(this, email) + return get(this.session, email) } } /** - * @template {API.UnknownProtocol} [Protocol=API.Service] - * @param {Model} param0 + * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session * @param {object} source * @param {API.EmailAddress} source.email * @param {AbortSignal} [source.signal] + * @returns {Promise, API.AccessDenied|API.InvocationError|API.AccessAuthorizeFailure>>} */ -export const login = async ({ session }, { email, signal }) => { - const login = get({ session }, email) - if (login) { - return { ok: login } +export const login = async (session, { email, signal }) => { + const account = get(session, email) + if (account) { + return { ok: account } } - const result = await Access.request(session, { + const id = DIDMailto.fromEmail(email) + const { ok: access, error } = await Access.request(session, { account: id, - access: Access.accountAccess, + can: Access.accountAccess, }) -} -/** - * @template {API.UnknownProtocol} [Protocol=API.Service] - * @param {Model} model - * @returns {Iterable>} - */ - -export const list = ({ session }) => { - const proof = DB.link() - const account = DB.string() - const results = DB.query(session.agent.db.index, { - select: { - id: account, - proof, - }, - where: [ - Account.match(proof, { - audience: session.agent.did(), - time: Date.now() / 1000, - account, - }), - ], - }) - - const map = new Map() - for (const { id } of results) { - if (!map.has(id)) { - const account = new AccountView({ - session, - id: /** @type {API.DidMailto} */ (id), - }) - map.set(id, account) + /* c8 ignore next 2 - don't know how to test this */ + if (error) { + return { error } + } else { + const { ok, error } = await access.claim({ signal }) + /* c8 ignore next 2 - don't know how to test this */ + if (error) { + return { error } + } else { + const login = Login.from({ account: id }) + for (const proof of ok.proofs) { + if (proof.capabilities?.[0].can === 'ucan/attest') { + login.attestations.set(`${proof.cid}`, proof) + } else { + login.proofs.set(`${proof.cid}`, proof) + } + } + return { ok: new AccountView({ session, login }) } } } - - return map.values() } /** - * Gets the account view for the login with a given email address. Returns - * `undefined` if no matching login is found. - * - * @template {API.UnknownProtocol} [Protocol=API.Service] - * @param {Model} model - * @param {API.EmailAddress} email + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @returns {Record>} */ -export const get = ({ session }, email) => { - const id = fromEmail(email) - const proof = DB.link() - const result = DB.query(session.agent.db.index, { - select: { - proof, - }, - where: [ - Account.match(proof, { - audience: session.agent.did(), - time: Date.now() / 1000, - account: id, - }), - ], - }) - return result.length ? new AccountView({ session, id }) : undefined +export const list = (session) => { + const logins = Login.select( + session.agent.db, + DB.query( + session.agent.db.index, + Login.query({ authority: session.agent.did() }) + ) + ) + + return Object.fromEntries( + [...logins].map(([account, login]) => [ + account, + new AccountView({ session, login }), + ]) + ) } /** - * Removes all the delegations corresponding to the account logins for the given - * email address. + * Gets the account view for the login with a given email address. Returns + * `undefined` if no matching login is found. * - * @template {API.UnknownProtocol} [Protocol=API.Service] - * @param {Model} model + * @template {API.PlanProtocol & API.ProviderProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session * @param {API.EmailAddress} email */ -export const remove = async ({ session }, email) => { - const id = fromEmail(email) - const proof = DB.link() - // Find all the delegations that are logins for the account with this a given - // email address. - const matches = DB.query(session.agent.db.index, { - select: { - proof, - }, - where: [ - Account.match(proof, { - audience: session.agent.did(), - time: Date.now() / 1000, - account: id, - }), - ], - }) - - // Build a transaction that retracts all matching delegations. - const transaction = [] - for (const { proof } of matches) { - const record = session.agent.db.proofs.get(proof.toString()) - if (record) { - transaction.push(DB.retract({ proof: record.delegation })) - } - } - - // Execute the transaction. - const { error } = await DB.transact(session.agent.db, transaction) - return error ? { error } : { ok: {} } +export const get = (session, email) => { + const account = DIDMailto.fromEmail(email) + const [login] = Login.select( + session.agent.db, + DB.query( + session.agent.db.index, + Login.query({ authority: session.agent.did(), account }) + ) + ).values() + + return login ? new AccountView({ session, login }) : undefined } /** - * @template {API.UnknownProtocol} [Protocol=API.Service] + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] */ class AccountView { /** * @param {object} source * @param {API.Session} source.session - * @param {API.DidMailto} source.id + * @param {object} source.login + * @param {Map} source.login.proofs + * @param {Map} source.login.attestations + * @param {API.DidMailto} source.login.id */ constructor(source) { this.model = source + + this.plans = Plan.from(this) + } + get session() { + return this.model.session + } + did() { + return this.model.login.id } /** - * @type {API.EmailAddress} + * @returns {API.EmailAddress} */ - get email() { - return toEmail(this.model.id) + toEmail() { + return DIDMailto.toEmail(this.did()) + } + + get proofs() { + return [ + ...this.model.login.proofs.values(), + ...this.model.login.attestations.values(), + ] + } + + /** + * 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.session.agent } = {}) { + return DB.transact( + agent.db, + [...this.proofs].map((proof) => DB.assert({ proof })) + ) + } + + /** + * Deletes access to this account from the agent's proofs store. + * + * @param {object} input + * @param {API.Agent} [input.agent] + */ + delete({ agent = this.model.session.agent } = {}) { + return DB.transact( + agent.db, + [...this.proofs].map((proof) => DB.retract({ proof })) + ) + } + + 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..0384ebfa8 --- /dev/null +++ b/packages/w3up-client/src/account/plan.js @@ -0,0 +1,80 @@ +import * as API from '../types.js' +import { Plan } from '@web3-storage/capabilities' +import * as Subscriptions from './subscription.js' + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.AccountView} account + */ +export const from = (account) => new AccountPlans(account) + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.AccountView} account + */ +export const list = async (account) => { + const { session } = account + const { out: result } = await Plan.get + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: account.did(), + proofs: account.proofs, + }) + .execute(/** @type {API.Session} */ (session).connection) + + if (result.ok) { + const plans = { + [result.ok.product]: new BillingPlan({ + account: account, + plan: result.ok, + }), + } + + return { ok: plans } + } else { + return result.error.name === 'PlanNotFound' ? { ok: {} } : result + } +} + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + */ +class AccountPlans { + /** + * @param {API.AccountView} account + */ + constructor(account) { + this.account = account + } + list() { + return list(this.account) + } +} + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + */ +class BillingPlan { + /** + * @param {object} source + * @param {API.AccountView} source.account + * @param {API.PlanGetSuccess} source.plan + */ + 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/subscription.js b/packages/w3up-client/src/account/subscription.js new file mode 100644 index 000000000..e209d5c75 --- /dev/null +++ b/packages/w3up-client/src/account/subscription.js @@ -0,0 +1,58 @@ +import * as API from '../types.js' +import { Provider } from '@web3-storage/capabilities' + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.BillingPlan} plan + */ +export const from = (plan) => new AccountSubscriptions(plan) + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.BillingPlan} plan + * @param {object} subscription + * @param {API.SpaceDID} subscription.consumer + * @param {API.Limit} [subscription.limit] + */ +export const add = async ({ account, provider }, { consumer }) => { + const { session } = account + const { out: result } = await Provider.add + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: account.did(), + nb: { + provider: Provider.Provider.from(provider), + consumer, + }, + }) + .execute( + /** @type {API.Session} */ (session).connection + ) + + return result +} + +/** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + */ +class AccountSubscriptions { + /** + * @param {API.BillingPlan} plan + */ + constructor(plan) { + this.plan = plan + } + get account() { + return this.plan.account + } + + /** + * @param {object} subscription + * @param {API.SpaceDID} subscription.consumer + * @param {API.Limit} [subscription.limit] + */ + add(subscription) { + return add(this.plan, subscription) + } +} diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js index bdb3a59a0..6a0070e83 100644 --- a/packages/w3up-client/src/agent.js +++ b/packages/w3up-client/src/agent.js @@ -8,6 +8,7 @@ import { DID } from '@ucanto/core' import * as API from './types.js' import * as Session from './session.js' import * as Connection from './agent/connection.js' +import * as Authorization from './agent/authorization.js' export * from './types.js' @@ -46,6 +47,19 @@ export const load = (source) => AgentPromise.from(Agent.load(source)) */ export const create = (source) => AgentPromise.from(Agent.create(source)) +/** + * @param {API.Agent} agent + * @param {object} access + * @param {API.DID} access.subject + * @param {API.Can} access.can + */ +export const authorize = (agent, { subject, can }) => + Authorization.get(agent.db, { + authority: agent.signer.did(), + subject, + can, + }) + /** * @extends {Promise>} */ @@ -73,13 +87,20 @@ class AgentPromise extends Promise { } } +/** + * @param {object} source + * @param {API.Signer} source.signer + * @param {API.Database} source.db + */ +export const view = (source) => new Agent(source) + /** * @implements {API.AgentView} */ class Agent { /** * @param {object} source - * @param {API.Signer} source.signer + * @param {API.Signer} source.signer * @param {API.Database} source.db */ constructor(source) { @@ -98,6 +119,15 @@ class Agent { return this.model.db } + /** + * @param {object} access + * @param {API.DID} access.subject + * @param {API.Can} access.can + */ + authorize(access) { + return authorize(this, access) + } + /** * @template {API.UnknownProtocol} Protocol * @param {API.Connection} [connection] @@ -200,7 +230,7 @@ class SignerLoadError extends Error { // const agentToData = new WeakMap() /** - * @typedef {API.Service} Service + * @typedef {API.W3UpProtocol} Service * @typedef {API.Receipt} Receipt */ diff --git a/packages/w3up-client/src/agent/account.js b/packages/w3up-client/src/agent/account.js deleted file mode 100644 index 8c1c8f2cf..000000000 --- a/packages/w3up-client/src/agent/account.js +++ /dev/null @@ -1,33 +0,0 @@ -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 } - -/** - * 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.audience - * @param {DB.Term} constraints.time - * @returns {DB.Clause} - */ -export const match = (ucan, { account = DB.string(), audience, time }) => { - const capability = DB.link() - return Delegation.match(ucan, { - capability: capability, - audience, - 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:*' })) -} diff --git a/packages/w3up-client/src/agent/attestation.js b/packages/w3up-client/src/agent/attestation.js index c22da8714..34b3954c3 100644 --- a/packages/w3up-client/src/agent/attestation.js +++ b/packages/w3up-client/src/agent/attestation.js @@ -11,7 +11,7 @@ import * as Delegation from './delegation.js' * * @param {DB.Term} ucan * @param {object} constraints - * @param {DB.Term} constraints.capability + * @param {DB.Term} [constraints.capability] * @param {DB.Term} constraints.authority * @param {DB.Term} constraints.time * @param {DB.Term} constraints.audience @@ -19,8 +19,8 @@ import * as Delegation from './delegation.js' */ export const match = ( ucan, - { capability, authority, audience, proof, time } -) => { + { capability = DB.link(), authority, audience, proof, time } +) => Capability.match(capability, { subject: authority, can: 'ucan/attest', @@ -33,4 +33,3 @@ export const match = ( time, }) ) -} diff --git a/packages/w3up-client/src/agent/authorization.js b/packages/w3up-client/src/agent/authorization.js index 7d6d4dfd3..c7f7977c8 100644 --- a/packages/w3up-client/src/agent/authorization.js +++ b/packages/w3up-client/src/agent/authorization.js @@ -81,21 +81,19 @@ export const find = ( authority, time, }) - ).map(({ subject, authority, proofs }) => { - // query engine will provide proof for each requested capability, so we may - // have duplicates here, which we prune. - const keys = [...new Set(proofs.map(({ proof }) => String(proof)))] + ).map((match) => select(db, match)) - return new Authorization({ - authority: /** @type {API.DID} */ (authority), - subject: /** @type {API.SpaceDID} */ (subject), - can: can ?? Object.fromEntries(proofs.map(({ can }) => [can, []])), - // Dereference proofs from the store. - proofs: keys.map( - ($) => /** @type {API.Delegation} */ (db.proofs.get($)?.delegation) - ), - }) - }) +/** + * @typedef {object} ProofSelector + * @property {DB.Term} proof + * @property {DB.Term} can + * @property {string} [need] + * + * @typedef {object} Selector + * @property {DB.Term} authority + * @property {DB.Term} subject + * @property {ProofSelector[]} proofs + */ /** * Creates query that select set of proofs that would allow the @@ -107,12 +105,13 @@ export const find = ( * @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 authority = DB.string() const need = Object.keys(can) - /** @type {{proof: DB.Term, can: DB.Term, need?: string }[]} */ + /** @type {{proof: DB.Term, can: DB.Term, need?: string }[]} */ const proofs = need.length ? need.map((need) => ({ proof: DB.link(), need, can: DB.string() })) : [{ proof: DB.link(), can: DB.string() }] @@ -143,78 +142,126 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { } /** + * @param {API.Database} db + * @param {DB.InferBindings} match + */ +export const select = (db, { authority, subject, proofs }) => { + // query engine will provide proof for each requested capability, so we may + // have duplicates here, which we prune. + const keys = [...new Set(proofs.map(({ proof }) => String(proof)))] + + return new Authorization({ + authority: /** @type {API.DID} */ (authority), + 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) + ), + }) +} + +/** + * Matches a delegation that authorizes the `selector.authority` with an ability + * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note + * that it will only match explicit authorization that is one that specifies + * `selector.subject` and will not match implicit authorizations that uses + * `ucan:*` capability. + * * @param {DB.Term} delegation * @param {object} selector - * @param {API.UTCUnixTimestamp} selector.time + * @param {DB.Term} [selector.time] * @param {DB.Term} [selector.can] * @param {DB.Term} [selector.subject] * @param {DB.Term} [selector.authority] */ -export const match = ( +export const explicit = ( delegation, - { authority = DB.string(), subject = DB.string(), can = DB.string(), time } + { + authority = DB.string(), + subject = DB.string(), + can = DB.string(), + time = DB.integer(), + } ) => { const capability = DB.link() - // simple case where capability is directly delegated to the audience - const direct = Capability.match(capability, { - can, - subject, - }).and( + return Capability.match(capability, { can, subject }).and( Delegation.match(delegation, { capability, audience: authority, time, }) ) +} - const everything = DB.link() +/** + * Matches a delegation that authorizes the `selector.authority` with an ability + * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note + * that it will only match implicit authorization that is one that has `ucan:*` + * subject and is either issued by `selector.subject` or has a proof which + * explicitly delegates `selector.can` to `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.authority] + * @returns {DB.Clause} + */ +export const implicit = ( + delegation, + { + subject = DB.string(), + can = DB.string(), + time = DB.integer(), + authority = DB.string(), + } +) => { const proof = DB.link() - const account = DB.string() - // Complicated case when all owned and delegated resources are delegated - // resources - const indirect = Capability.match(everything, { - subject: 'ucan:*', - can, - }) - .and( - Delegation.match(delegation, { - capability: everything, - audience: authority, - time, - }) - ) - // ucan:* resource implies both own and delegated resources - .and( - // Issuer owns their DID resource and since `ucan:*` implies all - // resources, it also implies the issuer DID. Which is why if the - // subject matches the issuer we have a match. - Delegation.issuedBy(delegation, subject) - // Otherwise we need to match subject with one of the resources - // in the proofs as those are re-delegated by `ucan:*` resource. - .or( - DB.match([delegation, 'ucan/proof', proof]) - .and( - Capability.match(capability, { - subject, - can, - }) - ) - .and( - Delegation.match(proof, { - capability, - // In this instance account must be the audience of the - // proof, as account is re-delegating it. - audience: account, - time, - }) - ) + return DB.and( + Delegation.forwards(delegation, { + audience: authority, + can, + time, + }), + DB.or( + Delegation.issuedBy(delegation, subject), + DB.and( + Delegation.hasProof(delegation, proof), + DB.or( + explicit(proof, { subject, can, time }) + // TODO: Add support for recursive implicit delegation + // implicit(proof, { subject, can, time, authority }) ) + ) ) - - return direct.or(indirect) + ) } +/** + * @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.authority] + */ +export const match = ( + delegation, + { + authority = DB.string(), + subject = DB.string(), + can = DB.string(), + time = DB.integer(), + } +) => + DB.or( + explicit(delegation, { authority, can, subject, time }), + implicit(delegation, { authority, can, subject, time }) + ) + /** * @typedef {object} Model * @property {API.SpaceDID} subject diff --git a/packages/w3up-client/src/agent/capability.js b/packages/w3up-client/src/agent/capability.js index a597b33e8..9388fdd12 100644 --- a/packages/w3up-client/src/agent/capability.js +++ b/packages/w3up-client/src/agent/capability.js @@ -43,8 +43,19 @@ export const hasSubject = (capability, subject) => * * @param {DB.Term} capability * @param {object} constraints - * @param {DB.Term} constraints.subject - * @param {DB.Term} constraints.can + * @param {DB.Term} [constraints.subject] + * @param {DB.Term} [constraints.can] */ -export const match = (capability, { subject, can }) => - hasSubject(capability, subject).and(hasAbility(capability, can)) +export const match = ( + capability, + { subject = DB.string(), can = DB.string() } +) => hasSubject(capability, subject).and(hasAbility(capability, can)) + +/** + * @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 index f3f4943cf..31a882c50 100644 --- a/packages/w3up-client/src/agent/connection.js +++ b/packages/w3up-client/src/agent/connection.js @@ -14,7 +14,7 @@ export * as Address from './connection/address.js' * function. In runtime where `fetch` global is not available this option MUST * be provided. * - * @template {Record} [Protocol=API.Service] + * @template {Record} [Protocol=API.W3UpProtocol] * @param {object} source * @param {API.Address} [source.address] * @param {typeof fetch} [source.fetch] - Fetch implementation to use diff --git a/packages/w3up-client/src/agent/data.js b/packages/w3up-client/src/agent/data.js deleted file mode 100644 index 1e901f0f4..000000000 --- a/packages/w3up-client/src/agent/data.js +++ /dev/null @@ -1,206 +0,0 @@ -import { Signer } from '@ucanto/principal' -import { Signer as EdSigner } from '@ucanto/principal/ed25519' -import { importDAG } from '@ucanto/core/delegation' -import * as Ucanto from '@ucanto/interface' -import { CID } from 'multiformats' -import { UCAN } from '@web3-storage/capabilities' -import { isExpired, isValid } from './delegations.js' -import * as API from '../types.js' - -/** @implements {API.AgentDataModel} */ -export class AgentData { - /** @type {(data: API.AgentDataExport) => Promise | void} */ - #save - - /** - * @param {API.AgentDataModel} data - * @param {API.AgentDataOptions} [options] - */ - constructor(data, options = {}) { - this.meta = data.meta - this.principal = data.principal - this.spaces = data.spaces - this.delegations = data.delegations - this.currentSpace = data.currentSpace - this.#save = (data) => - options.store ? options.store.save(data) : undefined - } - - /** - * Create a new AgentData instance from the passed initialization data. - * - * @param {Partial} [init] - * @param {API.AgentDataOptions} [options] - */ - static async create(init = {}, options = {}) { - const agentData = new AgentData( - { - meta: { name: 'agent', type: 'device', ...init.meta }, - principal: init.principal ?? (await EdSigner.generate()), - spaces: init.spaces ?? new Map(), - delegations: init.delegations ?? new Map(), - currentSpace: init.currentSpace, - }, - options - ) - if (options.store) { - await options.store.save(agentData.export()) - } - return agentData - } - - /** - * Instantiate AgentData from previously exported data. - * - * @param {API.AgentDataExport} raw - * @param {API.AgentDataOptions} [options] - */ - static fromExport(raw, options) { - /** @type {API.AgentDataModel['delegations']} */ - const dels = new Map() - - for (const [key, value] of raw.delegations) { - dels.set(key, { - delegation: importDAG( - value.delegation.map((d) => ({ - cid: CID.parse(d.cid).toV1(), - bytes: d.bytes, - })) - ), - meta: value.meta, - }) - } - - return new AgentData( - { - meta: raw.meta, - // @ts-expect-error for some reason TS thinks this is a EdSigner - principal: Signer.from(raw.principal), - currentSpace: raw.currentSpace, - spaces: raw.spaces, - delegations: dels, - }, - options - ) - } - - /** - * Export data in a format safe to pass to `structuredClone()`. - */ - export() { - /** @type {API.AgentDataExport} */ - const raw = { - meta: this.meta, - principal: this.principal.toArchive(), - currentSpace: this.currentSpace, - spaces: this.spaces, - delegations: new Map(), - } - for (const [key, value] of this.delegations) { - raw.delegations.set(key, { - meta: value.meta, - delegation: [...value.delegation.export()].map((b) => ({ - cid: `${b.cid}`, - bytes: b.bytes, - })), - }) - } - return raw - } - - /** - * @deprecated - * @param {API.DID} did - * @param {API.SpaceMeta} meta - * @param {API.Delegation} [proof] - */ - async addSpace(did, meta, proof) { - this.spaces.set(did, meta) - await (proof ? this.addDelegation(proof) : this.#save(this.export())) - } - - /** - * @deprecated - * @param {API.DIDKey} did - */ - async setCurrentSpace(did) { - if (!this.spaces.has(did)) { - throw new RangeError(`Agent has no proofs for ${did}.`) - } - this.currentSpace = did - await this.#save(this.export()) - } - - /** - * @param {API.Delegation} delegation - * @param {API.DelegationMeta} [meta] - */ - async addDelegation(delegation, meta) { - this.delegations.set(`${delegation.cid}`, { - delegation, - meta: meta ?? {}, - }) - await this.#save(this.export()) - } - - /** - * @param {API.UCANLink} cid - */ - async removeDelegation(cid) { - this.delegations.delete(`${cid}`) - await this.#save(this.export()) - } -} - -/** - * Is the given capability a session attestation? - * - * @param {Ucanto.Capability} cap - * @returns {boolean} - */ -const isAttestationCapability = (cap) => cap.can === UCAN.attest.can - -/** - * Is the given delegation an attestation ? - * - * @param {Ucanto.Delegation} delegation - * @returns {delegation is Ucanto.Delegation<[API.UCANAttest]>} - */ -export const isAttestation = (delegation) => - delegation.capabilities.some((cap) => isAttestationCapability(cap)) - -/** - * @typedef {string} SessionProofAuthorizationCid - the nb.proof CID of the ucan/attest in the session proof - * @typedef {Ucanto.DID} SessionProofIssuer - issuer of ucan/attest session proof - * @typedef {Record>} SessionProofIndexedByAuthorizationAndIssuer - */ - -/** - * Get a map from CIDs to their corresponding attestations. - * - * @param {AgentData} data - * @param {object} [options] - * @param {API.UTCUnixTimestamp} [options.time] - * @returns {SessionProofIndexedByAuthorizationAndIssuer} - */ -export function getAttestations(data, { time } = {}) { - /** @type {SessionProofIndexedByAuthorizationAndIssuer} */ - const proofs = {} - for (const { delegation } of data.delegations.values()) { - if (isAttestation(delegation)) { - const cap = delegation.capabilities[0] - if (cap && (!time || isValid(delegation, time))) { - const proof = cap.nb.proof - if (proof) { - const proofCid = proof.toString() - const issuerDid = delegation.issuer.did() - proofs[proofCid] = proofs[proofCid] ?? {} - proofs[proofCid][issuerDid] = proofs[proofCid][issuerDid] ?? [] - proofs[proofCid][issuerDid].push(delegation) - } - } - } - } - - return proofs -} diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js index a859705a1..2143c5f9c 100644 --- a/packages/w3up-client/src/agent/db.js +++ b/packages/w3up-client/src/agent/db.js @@ -5,6 +5,13 @@ import * as Delegations from './delegations.js' export * from 'datalogia' export * as Text from './db/text.js' +/** + * + * @param {Datalogia.Clause} clause + * @returns + */ +export const optional = (clause) => Datalogia.or(clause, Datalogia.not(clause)) + /** * @param {API.Variant<{ * proofs: Iterable, diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js index 7f4c12735..3554c9c93 100644 --- a/packages/w3up-client/src/agent/delegation.js +++ b/packages/w3up-client/src/agent/delegation.js @@ -1,8 +1,11 @@ import * as API from '../types.js' import * as DB from 'datalogia' import * as Block from './block.js' -import { importDAG, allows, isDelegation } from '@ucanto/core/delegation' +import { importDAG, isDelegation } from '@ucanto/core/delegation' import * as Association from './db/association.js' +import * as Meta from './meta.js' +import { Capability } from './authorization.js' +import { Delegation } from '@ucanto/core' /** * @param {DB.Term} ucan @@ -12,6 +15,18 @@ import * as Association from './db/association.js' 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`. @@ -27,6 +42,16 @@ export const isExpired = (ucan, time) => { ) } +/** + * + * @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`. @@ -45,16 +70,37 @@ export const isTooEarly = (ucan, time) => { /** * @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.capability] + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.audience] */ -export const match = (ucan, { capability, audience, time }) => +export const match = ( + ucan, + { capability = DB.link(), audience = DB.string(), time = DB.integer() } +) => DB.match([ucan, 'ucan/capability', capability]) .and(DB.match([ucan, 'ucan/audience', audience])) .and(DB.not(isExpired(ucan, time))) .and(DB.not(isTooEarly(ucan, time))) +/** + * @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.can] + */ +export const forwards = ( + ucan, + { audience = DB.string(), time = DB.integer(), can = DB.string() } +) => { + const capability = DB.link() + return Capability.forwards(capability, { can }).and( + match(ucan, { capability, audience, time }) + ) +} + /** * Derives set of facts about the given delegation. * @@ -97,7 +143,9 @@ export const facts = function* (delegation) { // } for (const fact of delegation.facts) { - yield* Association.assert(fact, { entity, path: ['ucan', 'fact'] }) + 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) { diff --git a/packages/w3up-client/src/agent/login.js b/packages/w3up-client/src/agent/login.js new file mode 100644 index 000000000..0ea567a61 --- /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, { + authority: 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/space.js b/packages/w3up-client/src/agent/space.js index 3387476c0..d6a800632 100644 --- a/packages/w3up-client/src/agent/space.js +++ b/packages/w3up-client/src/agent/space.js @@ -3,6 +3,136 @@ import * as Delegation from './delegation.js' import * as Capability from './capability.js' import * as Text from './db/text.js' import * as DB from 'datalogia' +import * as Authorization from './authorization.js' +import { optional } from './db.js' + +/** + * @param {object} constraints + * @param {typeof match | typeof implicit | typeof explicit} [constraints.match] + * @param {DB.Term} [constraints.time] + * @param {DB.Term} [constraints.authority] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.can] + * @param {DB.Term} [constraints.name] + * @param {boolean} [constraints.implicit] + * @returns {API.Query<{ space: DB.Term; name?: DB.Term }> + */ +export const query = (constraints) => { + const space = DB.string() + const name = DB.string() + const ucan = DB.link() + return { + select: { + space, + name, + }, + where: [(constraints.match ?? match)(ucan, 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.authority] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.can] + * @param {DB.Term} [constraints.name] + */ +export const explicit = ( + ucan, + { + time = Date.now() / 1000, + authority = DB.string(), + space = DB.string(), + can = DB.string(), + name = DB.string(), + } +) => { + return DB.and( + Authorization.explicit(ucan, { authority, 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.authority] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.can] + * @param {DB.Term} [constraints.name] + */ +export const match = ( + ucan, + { + time = Date.now() / 1000, + authority = 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.explicit(ucan, { authority, can, subject: space, time }), + named(ucan, name) + ), + // Or it could be an implicit delegation issued by the space + DB.and( + Delegation.forwards(ucan, { audience: authority, time }), + Delegation.issuedBy(ucan, space), + named(ucan, name) + ), + // or it could be an delegation that forwards explicit proof + DB.and( + Delegation.forwards(ucan, { audience: authority, time }), + Delegation.hasProof(ucan, proof), + Authorization.explicit(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.authority] + * @param {DB.Term} [constraints.account] + * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.name] + */ +export const implicit = ( + ucan, + { + time = Date.now() / 1000, + authority = DB.string(), + space = DB.string(), + name = DB.string(), + } +) => { + const proof = DB.link() + return DB.and( + Delegation.forwards(ucan, { audience: authority, time }), + Delegation.hasProof(ucan, proof), + Authorization.explicit(proof, { subject: space, time }), + Text.match(space, { glob: 'did:key:*' }), + named(proof, name) + ) +} /** * Creates a query that selects delegations to the `selector.audience` that diff --git a/packages/w3up-client/src/capability/access.js b/packages/w3up-client/src/capability/access.js index dab8fe8e2..5a6f19d00 100644 --- a/packages/w3up-client/src/capability/access.js +++ b/packages/w3up-client/src/capability/access.js @@ -16,7 +16,7 @@ import { bytesToDelegations } from '../agent/encoding.js' * 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 {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. @@ -54,7 +54,7 @@ export const delegate = async ( * `PendingAccessRequest` object that can be used to poll for the requested * delegation through `access/claim` capability. * - * @param {API.AgentView} agent + * @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. @@ -100,7 +100,7 @@ export const request = async ( * Claims access that has been delegated to the given audience, which by * default is the agent's DID. * - * @param {API.AgentView} agent + * @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. @@ -138,7 +138,7 @@ export const claim = async ( class PendingAccessRequest { /** * @typedef {object} PendingAccessRequestModel - * @property {API.AgentView} agent - Agent handling interaction. + * @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. @@ -265,7 +265,7 @@ class RequestExpired extends Failure { export class GrantedAccess { /** * @typedef {object} GrantedAccessModel - * @property {API.AgentView} agent - Agent that processed the request. + * @property {API.AgentView} agent - Agent that processed the request. * @property {API.Tuple} proofs - Delegations that grant access. * * @param {GrantedAccessModel} model diff --git a/packages/w3up-client/src/capability/plan.js b/packages/w3up-client/src/capability/plan.js index 47060f3d5..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.AgentView}} 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 index 27047c102..e77fe646f 100644 --- a/packages/w3up-client/src/capability/provider.js +++ b/packages/w3up-client/src/capability/provider.js @@ -8,7 +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. * - * @param {API.AgentView} agent + * @param {API.AgentView} 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/w3up-client/src/capability/space.js b/packages/w3up-client/src/capability/space.js index 744e75fc1..6a22415ad 100644 --- a/packages/w3up-client/src/capability/space.js +++ b/packages/w3up-client/src/capability/space.js @@ -6,24 +6,38 @@ 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 { issueInvocation } from '../agent.js' -import * as Result from '../result.js' +import * as Authorization from '../agent/authorization.js' +import * as Database from '../agent/db.js' +import * as Agent from '../agent.js' /** * * Get Space information from Access service * - * @param {API.AgentView} agent - * @param {API.SpaceDID} space - * @returns + * @param {API.Session} session + * @param {object} source + * @param {API.SpaceDID} source.id */ -export const info = async (agent, space) => { - const task = await issueInvocation(agent, Space.info, { - with: space, +export const info = async (session, { id }) => { + const auth = session.agent.authorize({ + subject: id, + can: { 'space/info': [] }, }) - const receipt = await task.execute(agent.connection) - return Result.unwrap(receipt.out) + 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 } /** @@ -32,7 +46,7 @@ export const info = async (agent, space) => { * @typedef {object} Model * @property {ED25519.EdSigner} signer * @property {string} name - * @property {API.AgentView} [agent] + * @property {API.Session} [session] */ /** @@ -40,12 +54,12 @@ export const info = async (agent, space) => { * * @param {object} options * @param {string} options.name - * @param {API.AgentView} [options.agent] + * @param {API.Session} [options.session] */ -export const generate = async ({ name, agent }) => { +export const generate = async ({ name, session }) => { const { signer } = await ED25519.generate() - return new OwnedSpace({ signer, name, agent }) + return new OwnedSpace({ signer, name, session }) } /** @@ -54,12 +68,12 @@ export const generate = async ({ name, agent }) => { * @param {string} mnemonic * @param {object} options * @param {string} options.name - Name to give to the recovered space. - * @param {API.AgentView} [options.agent] + * @param {API.Session} [options.session] */ -export const fromMnemonic = async (mnemonic, { name, agent }) => { +export const fromMnemonic = async (mnemonic, { name, session }) => { const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) const signer = await ED25519.derive(secret) - return new OwnedSpace({ signer, name, agent }) + return new OwnedSpace({ signer, name, session }) } /** @@ -186,10 +200,10 @@ class OwnedSpace { * Saves account in the agent store so it can be accessed across sessions. * * @param {object} input - * @param {API.AgentView<{}>} [input.agent] + * @param {API.AgentView} [input.agent] * @returns {Promise>} */ - async save({ agent = this.model.agent } = {}) { + async save({ agent = this.model.session?.agent } = {}) { if (!agent) { return fail('Please provide an agent to save the space into') } @@ -204,7 +218,7 @@ class OwnedSpace { /** * @param {Authorization} authorization * @param {object} options - * @param {API.AgentView} [options.agent] + * @param {API.AgentView} [options.agent] */ provision({ proofs }, { agent = this.model.agent } = {}) { if (!agent) { @@ -239,6 +253,20 @@ class OwnedSpace { 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. * @@ -287,7 +315,7 @@ export const fromDelegation = (delegation) => { * @param {Space} space * @param {object} options * @param {API.Delegation[]} options.proofs - * @param {API.AgentView} options.agent + * @param {API.AgentView} options.agent */ export const provision = async (space, { proofs, agent }) => { const [capability] = proofs[0].capabilities @@ -350,3 +378,26 @@ class SharedSpace { }) } } + +/** + * @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 209954861..db9777e98 100644 --- a/packages/w3up-client/src/capability/subscription.js +++ b/packages/w3up-client/src/capability/subscription.js @@ -8,7 +8,7 @@ export { Subscription } /** * Gets subscriptions associated with the account. * - * @param {API.AgentView} agent + * @param {API.AgentView} agent * @param {object} options * @param {API.AccountDID} options.account * @param {API.Delegation[]} [options.proofs] diff --git a/packages/w3up-client/src/client/access.js b/packages/w3up-client/src/client/access.js index d26a738e8..c08a9a913 100644 --- a/packages/w3up-client/src/client/access.js +++ b/packages/w3up-client/src/client/access.js @@ -10,7 +10,7 @@ export { DIDMailto } /** * Client for interacting with the `access/*` capabilities. * - * @extends {Client} + * @extends {Client} */ export class AccessClient extends Client { /* c8 ignore start - testing websocket code is hard */ @@ -73,7 +73,7 @@ export class AccessClient extends Client { } /** - * @param {{agent: API.AgentView}} client + * @param {{agent: API.AgentView}} client * @param {object} [input] * @param {API.DID} [input.audience] */ @@ -84,7 +84,7 @@ export const claim = async ({ agent }, input) => Access.claim(agent, input) * `access/authorize` capability and keep polling `access/claim` capability * until access is granted or request is aborted. * - * @param {{agent: API.AgentView}} agent + * @param {{agent: API.AgentView}} agent * @param {object} input * @param {API.AccountDID} input.account * @param {API.Access} [input.access] @@ -94,7 +94,7 @@ export const request = async ({ agent }, input) => Access.request(agent, input) /** * - * @param {{agent: API.AgentView}} agent + * @param {{agent: API.AgentView}} agent * @param {object} input * @param {API.Delegation[]} input.delegations * @param {API.SpaceDID} [input.space] diff --git a/packages/w3up-client/src/client/space.js b/packages/w3up-client/src/client/space.js index d0a97a3b3..3c04c261b 100644 --- a/packages/w3up-client/src/client/space.js +++ b/packages/w3up-client/src/client/space.js @@ -5,7 +5,7 @@ import * as Space from '../capability/space.js' /** * Client for interacting with the `space/*` capabilities. * - * @extends {Client} + * @extends {Client} */ export class SpaceClient extends Client { /** diff --git a/packages/w3up-client/src/client/subscription.js b/packages/w3up-client/src/client/subscription.js index 552390071..22d19e403 100644 --- a/packages/w3up-client/src/client/subscription.js +++ b/packages/w3up-client/src/client/subscription.js @@ -6,7 +6,7 @@ import * as Result from '../result.js' /** * Client for interacting with the `subscription/*` capabilities. * - * @extends {Client} + * @extends {Client} */ export class SubscriptionClient extends Client { /** diff --git a/packages/w3up-client/src/coupon.js b/packages/w3up-client/src/coupon.js index de1c27508..032fcc866 100644 --- a/packages/w3up-client/src/coupon.js +++ b/packages/w3up-client/src/coupon.js @@ -85,7 +85,7 @@ export const issue = async ({ password = '', ...options }) => { * * @param {Model} coupon * @param {object} options - * @param {API.AgentView} options.agent + * @param {API.AgentView} options.agent * @param {string} [options.password] * @returns {Promise>} */ @@ -137,7 +137,7 @@ export class Coupon { /** * - * @param {API.AgentView} agent + * @param {API.AgentView} agent * @param {object} [options] * @param {string} [options.password] */ diff --git a/packages/w3up-client/src/plan.js b/packages/w3up-client/src/plan.js new file mode 100644 index 000000000..291c3befc --- /dev/null +++ b/packages/w3up-client/src/plan.js @@ -0,0 +1,34 @@ +import * as API from './types.js' +import * as Plan from '@web3-storage/capabilities/plan' +import * as Authorization from './agent/authorization.js' + +/** + * Gets the plan currently associated with the account. + * + * @param {API.Session} session + * @param {object} options + * @param {API.AccountDID} options.account + * @param {API.Delegation[]} [options.proofs] + */ +export const get = async (session, { account, proofs = [] }) => { + const auth = Authorization.get(session.agent.db, { + can: { 'plan/get': [] }, + authority: session.agent.did(), + subject: account, + }) + + if (auth.error) { + return auth + } + + const { out: result } = await Plan.get + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: account, + proofs: auth.ok.proofs, + }) + .execute(session.connection) + + return result +} diff --git a/packages/w3up-client/src/session.js b/packages/w3up-client/src/session.js index 98d6d463d..ae84e32c5 100644 --- a/packages/w3up-client/src/session.js +++ b/packages/w3up-client/src/session.js @@ -1,13 +1,13 @@ import * as API from './types.js' /** - * @template {API.UnknownProtocol} [Protocol=API.Service] + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} model */ export const create = (model) => new Session(model) /** - * @template {API.UnknownProtocol} [Protocol=API.Service] + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * @implements {API.Session} */ class Session { @@ -24,3 +24,16 @@ class Session { return this.model.agent } } + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + */ +class SessionSpaces { + /** + * @param {API.Session} session + */ + constructor(session) { + this.session = session + } + list() {} +} diff --git a/packages/w3up-client/src/session/space.js b/packages/w3up-client/src/session/space.js new file mode 100644 index 000000000..0ef3762dc --- /dev/null +++ b/packages/w3up-client/src/session/space.js @@ -0,0 +1,75 @@ +import * as API from '../types.js' +import * as DB from '../agent/db.js' +import * as SpaceQuery from '../agent/space.js' + +/** + * @param {API.Session} session + */ +export const from = (session) => new View(session) + +class View { + /** + * @param {API.Session} session + */ + constructor(session) { + this.session = session + } + + list() { + return list(this.session) + } +} + +/** + * @param {API.Session} session + */ +export const list = async (session) => { + const results = DB.query( + session.agent.db.index, + SpaceQuery.query({ authority: session.agent.did() }) + ) + + return build(session, results) +} + +/** + * @template {API.PlanProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {{space:API.DIDKey, name?: string}[]} spaces + */ +const build = (session, spaces) => { + /** @type {Record>} */ + const result = {} + for (const { space: id, name = '' } of spaces) { + if (!result[id]) { + result[id] = new SharedSpace({ session, id, name }) + } + } + + return result +} + +/** + * @template {API.PlanProtocol} [Protocol=API.W3UpProtocol] + * @typedef {object} Model + * @property {API.DIDKey} id + * @property {API.Session} session + */ + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + */ +class SharedSpace { + /** + * @param {object} model + * @param {API.DIDKey} model.id + * @param {string} model.name + * @param {API.Session} model.session + */ + constructor(model) { + this.model = model + } + get did() { + return this.model.id + } +} diff --git a/packages/w3up-client/src/space.js b/packages/w3up-client/src/space.js index 20b2b4aa4..3c7ba4804 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -3,44 +3,20 @@ import * as API from './types.js' export * from './capability/space.js' /** - * @typedef {object} Model - * @property {API.SpaceDID} id - * @property {{name?:string}} [meta] - * @property {API.AgentView} agent + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] */ - -export class Space { - #model - +class SessionSpaces { /** - * @param {Model} model + * @param {API.Session} session */ - constructor(model) { - this.#model = model - this.usage = new StorageUsage(model) - } - - /** - * The given space name. - */ - get name() { - /* c8 ignore next */ - return String(this.#model.meta?.name ?? '') - } - - /** - * The DID of the space. - */ - did() { - return this.#model.id + constructor(session) { + this.session = session } + list() {} +} - /** - * User defined space metadata. - */ - meta() { - return this.#model.meta - } +class SpaceView { + constructor() {} } export class StorageUsage { diff --git a/packages/w3up-client/src/task.js b/packages/w3up-client/src/task.js new file mode 100644 index 000000000..c88de4aa3 --- /dev/null +++ b/packages/w3up-client/src/task.js @@ -0,0 +1,86 @@ +import * as API from '@ucanto/interface' + +/** + * @template T + * @param {unknown|PromiseLike} value + * @returns {value is PromiseLike} + */ +const isPromiseLike = (value) => + value != null && + typeof (/** @type {{then?:unknown}} */ (value).then) === 'function' + +/** + * @typedef {PromiseLike} Wait + */ + +/** + * @template T + * @param {API.Await} source + * @returns {Generator} + */ +export const wait = function* (source) { + if (isPromiseLike(source)) { + let ok + yield source.then((value) => { + ok = value + }) + return /** @type {T} */ (ok) + } else { + return source + } +} + +/** + * @template {API.Result} R + * @param {API.Await} source + * @returns {Generator} + */ +export const join = function* (source) { + const { ok, error } = yield* wait(source) + if (ok) { + return ok + } else { + throw error + } +} + +/** + * @template {API.Result} R + * @template {{}} Ok + * @template {globalThis.Error} [Error=never] + * @param {() => Generator, void>} task + * @returns {Promise>} + */ +export const execute = async (task) => { + const process = task() + let state = process.next() + try { + while (!state.done) { + if (isPromiseLike(state.value)) { + await state.value + state = process.next() + } else if (state.value.error) { + return state.value + } else { + state = process.next() + } + } + return state.value + } catch (cause) { + return { error: /** @type {Error} */ (cause) } + } +} + +/** + * @template {API.Result} R + * @template {{}} Ok + * @template {globalThis.Error} [Error=never] + * @param {() => Generator, void>} task + */ +export const perform = async (task) => { + const result = await execute(task) + if (result.ok) { + return result.ok + } + throw result.error +} diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index b3c2d4a1c..c3f7b4b1b 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -1,6 +1,37 @@ import { type Driver } from '@web3-storage/access/drivers/types' -import { type Service as UploadService } from '@web3-storage/upload-client/types' -import { Querier, Transactor } from 'datalogia' +import { Querier, Transactor, Selector, Clause } from 'datalogia' + +import { + 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, +} from '@web3-storage/capabilities/types' export type { Querier, Transactor } import type { @@ -60,9 +91,12 @@ import type { UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure, + AccountDID, + ProviderDID, + AccessDenied, } from '@web3-storage/capabilities' import { type Client } from './client.js' -import { StorefrontService } from '@web3-storage/filecoin-client/storefront' +import { StorefrontService as FilecoinProtocol } from '@web3-storage/filecoin-client/storefront' import exp from 'constants' import { CID } from 'multiformats' import { Block } from '@ipld/car/buffer-reader' @@ -109,35 +143,72 @@ export interface UCANProtocol { } } -/** - * Access api service definition type - */ -export interface AccessService extends UCANProtocol { +export interface AccessAuthorizeProvider { access: { authorize: ServiceMethod< AccessAuthorize, AccessAuthorizeSuccess, AccessAuthorizeFailure > + } +} + +export interface AccessRequestProvider { + access: AccessAuthorizeProvider['access'] & AccessClaimProvider['access'] +} + +export interface AccessClaimProvider { + access: { claim: ServiceMethod - // eslint-disable-next-line @typescript-eslint/ban-types - confirm: ServiceMethod< - AccessConfirm, - AccessConfirmSuccess, - AccessConfirmFailure - > + } +} + +export interface AccessDelegateProvider { + access: { delegate: ServiceMethod< AccessDelegate, AccessDelegateSuccess, AccessDelegateFailure > } - provider: { - add: ServiceMethod +} + +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, @@ -145,21 +216,49 @@ export interface AccessService extends UCANProtocol { SubscriptionListFailure > } +} - plan: { - get: ServiceMethod +export type { FilecoinProtocol } + +export interface StoreProtocol { + store: { + add: ServiceMethod + get: ServiceMethod + remove: ServiceMethod + list: ServiceMethod } } -export type { StorefrontService, UploadService } -export type Service = AccessService & UploadService & StorefrontService +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, + FilecoinProtocol {} + export interface ClientFactoryOptions { /** * A storage driver that persists exported agent data. @@ -168,7 +267,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 @@ -487,7 +586,7 @@ export type TextConstraint = * now we do not support passing any clauses. */ // eslint-disable-next-line @typescript-eslint/ban-types -export type Clause = Variant<{}> +export type CapabilityConstraint = Variant<{}> /** * Describes level of access to a resource. @@ -496,8 +595,8 @@ 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, Clause[]> & { - ['*']?: Clause[] + Record, CapabilityConstraint[]> & { + ['*']?: CapabilityConstraint[] } export type { Driver } @@ -562,6 +661,14 @@ export interface Database { 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. */ @@ -604,17 +711,17 @@ export interface AddressArchive< export interface UnknownProtocol extends Record {} export interface W3UpOpen { - as?: Signer + as?: Signer store: DataStore } export interface W3Load { - as?: Signer + as?: Signer store: DataStore } export interface W3Create { - as?: Signer + as?: Signer store: DataStore } @@ -699,7 +806,7 @@ export interface AgentView extends Agent { * Connects to a service provider and returns a session that can be used to * invoke capabilities provided by the service. */ - connect( + connect( connection?: ConnectionView ): Promise< Result< @@ -707,6 +814,11 @@ export interface AgentView extends Agent { SignerLoadError | DataStoreOpenError | DataStoreSaveError > > + + authorize(access: { + subject: DID + can: Can + }): Result } export type ConnectError = @@ -738,12 +850,12 @@ export interface DatabaseTransactionError extends Failure { /** * Session an agent has with a service provider. */ -export interface Session { +export interface Session { agent: AgentView connection: Connection } -export interface Connection +export interface Connection extends ConnectionView { address: Address } @@ -768,3 +880,26 @@ export interface 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 interface AccountView { + did(): AccountDID + session: Session + proofs: Delegation[] +} + +export interface BillingPlan< + Protocol extends ProviderProtocol = ProviderProtocol +> { + account: AccountView + customer: AccountDID + provider: ProviderDID +} diff --git a/packages/w3up-client/src/view/account.js b/packages/w3up-client/src/view/account.js index 700f9a13a..b858e5d57 100644 --- a/packages/w3up-client/src/view/account.js +++ b/packages/w3up-client/src/view/account.js @@ -11,7 +11,7 @@ export { fromEmail } class View { /** - * @param {API.Session} session + * @param {API.Session} session */ constructor(session) { this.session = session @@ -28,7 +28,7 @@ class View { * 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 {API.Session} session * @param {object} query * @param {API.DID<'mailto'>} [query.account] */ @@ -87,7 +87,7 @@ export const list = (session, { account } = {}) => { * authorization session time bounds (currently 15 minutes), the promise will * resolve to an error. * - * @param {API.Session} session + * @param {API.Session} session * @param {API.EmailAddress} email * @param {object} [options] * @param {AbortSignal} [options.signal] @@ -133,7 +133,7 @@ export const login = async (session, email, options = {}) => { /** * @typedef {object} Model - * @property {API.Session} session + * @property {API.Session} session * @property {API.Tuple} proofs */ @@ -186,7 +186,7 @@ export class Account { * @param {API.SpaceDID} space * @param {object} input * @param {API.ProviderDID} [input.provider] - * @param {API.AgentView} [input.agent] + * @param {API.AgentView} [input.agent] */ provision(space, input = {}) { return provision(this.agent, { diff --git a/packages/w3up-client/src/view/space.js b/packages/w3up-client/src/view/space.js index 9a9aa3283..1659c820e 100644 --- a/packages/w3up-client/src/view/space.js +++ b/packages/w3up-client/src/view/space.js @@ -12,7 +12,7 @@ import * as Provider from '../capability/provider.js' * @typedef {object} Model * @property {ED25519.EdSigner} signer * @property {string} name - * @property {API.AgentView} [agent] + * @property {API.AgentView} [agent] */ /** @@ -20,7 +20,7 @@ import * as Provider from '../capability/provider.js' * * @param {object} options * @param {string} options.name - * @param {API.AgentView} [options.agent] + * @param {API.AgentView} [options.agent] */ export const generate = async ({ name, agent }) => { const { signer } = await ED25519.generate() @@ -34,7 +34,7 @@ export const generate = async ({ name, agent }) => { * @param {string} mnemonic * @param {object} options * @param {string} options.name - Name to give to the recovered space. - * @param {API.AgentView} [options.agent] + * @param {API.AgentView} [options.agent] */ export const fromMnemonic = async (mnemonic, { name, agent }) => { const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) @@ -166,7 +166,7 @@ class OwnedSpace { * Saves account in the agent store so it can be accessed across sessions. * * @param {object} input - * @param {API.AgentView} [input.agent] + * @param {API.AgentView} [input.agent] * @returns {Promise>} */ async save({ agent = this.model.agent } = {}) { @@ -184,7 +184,7 @@ class OwnedSpace { /** * @param {Authorization} authorization * @param {object} options - * @param {API.AgentView} [options.agent] + * @param {API.AgentView} [options.agent] */ provision({ proofs }, { agent = this.model.agent } = {}) { if (!agent) { @@ -267,7 +267,7 @@ export const fromDelegation = (delegation) => { * @param {Space} space * @param {object} options * @param {API.Delegation[]} options.proofs - * @param {API.AgentView} options.agent + * @param {API.AgentView} options.agent */ export const provision = async (space, { proofs, agent }) => { const [capability] = proofs[0].capabilities diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index 93aecacf3..dbb09e808 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -1,148 +1,164 @@ import * as Test from './test.js' -import * as Account from '../src/view/account.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' +import * as DB from '../src/agent/db.js' /** * @type {Test.Suite} */ export const testAccount = { - 'list accounts': async (assert, { client, mail, grantAccess }) => { + 'only list accounts': async (assert, { session, mail, grantAccess }) => { const email = 'alice@web.mail' - assert.deepEqual(Account.list(client), {}, 'no accounts yet') + assert.deepEqual(Account.list(session), {}, 'no accounts yet') - const login = Account.login(client, email) + const login = Account.login(session, { 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) + const account = Result.unwrap(await login) + assert.equal(account.did(), Account.DIDMailto.fromEmail(email)) + assert.equal(account.toEmail(), email) + assert.equal([...account.proofs].length, 2) - assert.deepEqual(Account.list(client), {}, 'no accounts have been saved') - await session.ok?.save() - const accounts = Account.list(client) + assert.deepEqual(Account.list(session), {}, 'no accounts have been saved') + Result.unwrap(await account.save()) + const accounts = Account.list(session) assert.deepEqual(Object.values(accounts).length, 1) - assert.ok(accounts[Account.fromEmail(email)]) + assert.ok(accounts[Account.DIDMailto.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) + 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, { client, mail, grantAccess }) => { + 'only two logins': async (assert, { session, 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) + assert.deepEqual(Account.list(session), {}, 'no accounts yet') + const aliceLogin = Account.login(session, { email: aliceEmail }) await grantAccess(await mail.take()) - const alice = await aliceLogin - assert.deepEqual(alice.ok?.toEmail(), aliceEmail) + const alice = Result.unwrap(await aliceLogin) + assert.deepEqual(alice.toEmail(), aliceEmail) - assert.deepEqual(Account.list(client), {}, 'no accounts have been saved') - const saveAlice = await alice.ok?.save() - assert.equal(saveAlice?.error, undefined) + assert.deepEqual(Account.list(session), {}, 'no accounts have been saved') + Result.unwrap(await alice.save()) - const one = Account.list(client) + const one = Account.list(session) assert.deepEqual(Object.values(one).length, 1) - assert.ok(one[Account.fromEmail(aliceEmail)], 'alice in the account list') + assert.ok( + one[Account.DIDMailto.fromEmail(aliceEmail)], + 'alice in the account list' + ) - const bobLogin = Account.login(client, bobEmail) + const bobLogin = Account.login(session, { email: bobEmail }) await grantAccess(await mail.take()) - const bob = await bobLogin - assert.deepEqual(bob.ok?.toEmail(), bobEmail) - await bob.ok?.save() + const bob = Result.unwrap(await bobLogin) + assert.deepEqual(bob.toEmail(), bobEmail) + await bob.save() - const two = Account.list(client) + const two = Account.list(session) 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' + assert.ok( + two[Account.DIDMailto.fromEmail(aliceEmail)].toEmail(), + aliceEmail ) + assert.ok(two[Account.DIDMailto.fromEmail(bobEmail)].toEmail(), bobEmail) }, - '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()]) - }, + 'only login idempotence': async (assert, { session, mail, grantAccess }) => + Task.perform(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* Task.join(login) + yield* Task.join(alice.save()) + + assert.deepEqual( + Object.keys(Account.list(session)), + [alice.did()], + 'account was saved' + ) + + const retry = yield* Task.join(Account.login(session, { email })) + assert.deepEqual( + alice.toJSON(), + retry.toJSON(), + 'same account view is returned' + ) + + return { ok: {} } + }), + 'only account login': async (assert, { session, mail, grantAccess }) => + Task.perform(function* () { + const login = Account.login(session, { email: 'alice@web.mail' }) + + const message = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(message)) + + const alice = yield* Task.join(login) + assert.deepEqual(alice.toEmail(), 'alice@web.mail') + yield* Task.join(alice.save()) + + const accounts = Account.list(session) + assert.deepEqual(Object.keys(accounts), [alice.did()]) + + return { ok: {} } + }), '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()], - }) - }, + { session, mail, grantAccess } + ) => + Task.perform(function* () { + const space = yield* Task.wait(Space.generate({ name: 'test' })) + const mnemonic = space.toMnemonic() + const { signer } = yield* Task.wait( + 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(session, { email }) + const message = yield* Task.wait(mail.take()) + assert.deepEqual(message.to, email) + yield* Task.wait(grantAccess(message)) + const account = yield* Task.join(login) + + yield* Task.join(account.provision(space.did())) + + // authorize agent to use space + + const proof = yield* Task.wait( + space.createAuthorization(session.agent, { + access: { 'space/info': {} }, + expiration: Infinity, + }) + ) + + yield* Task.join(DB.transact(session.agent.db, [DB.assert({ proof })])) + + const info = yield* Task.join(Space.info(session, { id: space.did() })) + + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + + return { ok: {} } + }), 'multi device workflow': async (asserts, { connect, mail, grantAccess }) => { const laptop = await connect() diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index 1a92f2720..eaa14fd0a 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -1,7 +1,7 @@ import * as DB from '../../src/agent/db.js' import * as Test from '../test.js' import * as Space from '../../src/capability/space.js' -import * as Account from '../../src/agent/account.js' +import * as Account from '../../src/agent/login.js' import * as Delegation from '../../src/agent/delegation.js' import * as Spaces from '../../src/agent/space.js' import { createLegacyLink, delegate } from '@ucanto/core' @@ -10,7 +10,7 @@ import * as Capability from '@web3-storage/capabilities' import * as Cap from '../../src/agent/capability.js' import { fromEmail, toEmail } from '@web3-storage/did-mailto' -import { alice, bob, mallory, service } from '../fixtures/principals.js' +import { alice, bob, mallory, w3up } from '../fixtures/principals.js' import * as Authorization from '../../src/agent/authorization.js' /** @@ -296,7 +296,7 @@ export const testDB = { where: [ Account.match(ucan, { time, - audience, + authority: audience, account, }), ], @@ -325,7 +325,7 @@ export const testDB = { where: [ Account.match(ucan, { time, - audience, + authority: audience, account, }), ], @@ -564,6 +564,107 @@ export const testDB = { ) ) }, + + 'find capabilities grouped by spaces': async (assert) => { + const beetBox = await Space.generate({ name: 'beet-box' }) + const yumBox = await Space.generate({ name: 'yum-box' }) + const aliceLogin = await setupAccount({ + name: 'Alice', + email: 'alice@web.mail', + agent: alice, + }) + + const db = DB.from({ + proofs: [ + await Capability.Space.space.delegate({ + issuer: bob, + audience: alice, + with: bob.did(), + }), + + await beetBox.createAuthorization(alice), + await beetBox.createAuthorization(alice, { access: { 'debug/*': {} } }), + await yumBox.createAuthorization(alice, { access: { 'store/*': {} } }), + await yumBox.createAuthorization(alice, { access: { 'upload/*': {} } }), + await yumBox.createAuthorization(alice, { access: { 'space/*': {} } }), + await yumBox.createAuthorization(alice, { access: { 'access/*': {} } }), + 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, { + authority: 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, { + authority: alice.did(), + name, + space, + }), + ], + }) + + assert.deepEqual(implicit, [ + { + space: aliceLogin.space.did(), + name: 'Alice', + }, + ]) + + const all = DB.query(db.index, { + select: { + space, + name, + }, + where: [ + Spaces.match(proof, { + authority: 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, + } + ) + }, } const setupAccount = async ({ @@ -588,9 +689,9 @@ const setupAccount = async ({ }) const attestation = await Capability.UCAN.attest.delegate({ - issuer: service, + issuer: w3up, audience: agent, - with: service.did(), + with: w3up.did(), nb: { proof: login.cid }, expiration: Infinity, }) diff --git a/packages/w3up-client/test/fixtures/principals.js b/packages/w3up-client/test/fixtures/principals.js index c5fb5b063..2f046dc0a 100644 --- a/packages/w3up-client/test/fixtures/principals.js +++ b/packages/w3up-client/test/fixtures/principals.js @@ -17,3 +17,5 @@ export const mallory = Signer.parse( 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/plan.test.js b/packages/w3up-client/test/plan.test.js new file mode 100644 index 000000000..1ea67cca9 --- /dev/null +++ b/packages/w3up-client/test/plan.test.js @@ -0,0 +1,59 @@ +import * as Test from './test.js' +import * as Account from '../src/account.js' +import * as Task from '../src/task.js' + +/** + * @type {Test.Suite} + */ +export const testPlan = { + 'test account has no plans': async (assert, { session, mail, grantAccess }) => + Task.perform(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* Task.join(login) + yield* Task.join(alice.save()) + + assert.deepEqual( + Object.keys(Account.list(session)), + [alice.did()], + 'account was saved' + ) + + const plans = yield* Task.join(alice.plans.list()) + assert.deepEqual(plans, {}) + + return { ok: {} } + }), + 'test account with a plan': async ( + assert, + { session, mail, plansStorage, grantAccess } + ) => + Task.perform(function* () { + const product = 'did:web:test.web3.storage' + const email = 'alice@web.mail' + yield* Task.join( + 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* Task.join(login) + + const plans = yield* Task.join(alice.plans.list()) + const [plan] = Object.values(plans) + + assert.equal(plan.account, alice) + assert.equal(plan.customer, alice.did()) + assert.equal(plan.provider, session.connection.id.did()) + assert.ok(plan.subscriptions) + + return { ok: {} } + }), +} + +Test.test({ Plan: testPlan }) diff --git a/packages/w3up-client/test/subscription.test.js b/packages/w3up-client/test/subscription.test.js new file mode 100644 index 000000000..32142855a --- /dev/null +++ b/packages/w3up-client/test/subscription.test.js @@ -0,0 +1,50 @@ +import * as Test from './test.js' +import * as Account from '../src/account.js' +import * as Task from '../src/task.js' +import * as Space from '../src/space.js' + +/** + * @type {Test.Suite} + */ +export const testSubscription = { + 'provision a space': async ( + assert, + { session, mail, plansStorage, grantAccess } + ) => + Task.perform(function* () { + const product = 'did:web:test.web3.storage' + const email = 'alice@web.mail' + yield* Task.join( + 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* Task.join(login) + + const plans = yield* Task.join(alice.plans.list()) + const [plan] = Object.values(plans) + + assert.equal(plan.account, alice) + assert.equal(plan.customer, alice.did()) + assert.equal(plan.provider, session.connection.id.did()) + + const space = yield* Task.wait(Space.generate({ name: 'test-space' })) + + plan.subscriptions.add({ consumer: space.did() }) + + space.createAuthorization(session.agent) + + const info = yield* Task.join(Space.info(session, { id: space.did() })) + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + + return { ok: {} } + }), +} + +Test.test({ Subscription: testSubscription }) From 5361e2ab4628b350fa786249bde30217de6c0264 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 4 Mar 2024 11:42:08 -0800 Subject: [PATCH 08/16] chore: save changes --- packages/w3up-client/src/access.js | 22 +- packages/w3up-client/src/account.js | 142 +++++-- packages/w3up-client/src/account/plan.js | 26 +- .../w3up-client/src/account/subscription.js | 71 +++- packages/w3up-client/src/agent.js | 248 +++++------ packages/w3up-client/src/agent/attestation.js | 6 +- packages/w3up-client/src/agent/delegation.js | 2 +- packages/w3up-client/src/agent/login.js | 2 +- packages/w3up-client/src/authorization.js | 2 + .../query.js} | 169 +------- .../w3up-client/src/authorization/view.js | 172 ++++++++ packages/w3up-client/src/capability/access.js | 1 - packages/w3up-client/src/capability/space.js | 2 +- .../src/capability/subscription.js | 1 - packages/w3up-client/src/plan.js | 2 +- packages/w3up-client/src/session/space.js | 75 ---- packages/w3up-client/src/space.js | 172 +++++--- packages/w3up-client/src/space/blob.js | 1 + packages/w3up-client/src/space/delegations.js | 31 ++ packages/w3up-client/src/space/own.js | 383 +++++++++++++++++ .../src/{agent/space.js => space/query.js} | 31 +- packages/w3up-client/src/space/session.js | 43 ++ packages/w3up-client/src/space/shared.js | 121 ++++++ packages/w3up-client/src/space/usage.js | 110 +++++ packages/w3up-client/src/task.js | 14 +- packages/w3up-client/src/types.ts | 303 ++++++++++++- packages/w3up-client/src/view/account.js | 6 +- .../w3up-client/src/{session.js => w3up.js} | 21 +- packages/w3up-client/test/access.test.js | 2 +- packages/w3up-client/test/account.test.js | 400 +++++++++--------- packages/w3up-client/test/agent/db.test.js | 6 +- packages/w3up-client/test/space.test.js | 184 ++++++-- .../w3up-client/test/subscription.test.js | 50 --- 33 files changed, 2014 insertions(+), 807 deletions(-) create mode 100644 packages/w3up-client/src/authorization.js rename packages/w3up-client/src/{agent/authorization.js => authorization/query.js} (53%) create mode 100644 packages/w3up-client/src/authorization/view.js delete mode 100644 packages/w3up-client/src/session/space.js create mode 100644 packages/w3up-client/src/space/blob.js create mode 100644 packages/w3up-client/src/space/delegations.js create mode 100644 packages/w3up-client/src/space/own.js rename packages/w3up-client/src/{agent/space.js => space/query.js} (90%) create mode 100644 packages/w3up-client/src/space/session.js create mode 100644 packages/w3up-client/src/space/shared.js create mode 100644 packages/w3up-client/src/space/usage.js rename packages/w3up-client/src/{session.js => w3up.js} (58%) delete mode 100644 packages/w3up-client/test/subscription.test.js diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js index 7938d1ad8..b07d87c69 100644 --- a/packages/w3up-client/src/access.js +++ b/packages/w3up-client/src/access.js @@ -7,8 +7,9 @@ export { DIDMailto } import * as Access from '@web3-storage/capabilities/access' import { Failure, fail, DID } from '@ucanto/core' import { bytesToDelegations } from './agent/encoding.js' -import * as Authorization from './agent/authorization.js' +import * as Authorization from './authorization/query.js' import * as DB from './agent/db.js' +import * as Agent from './agent.js' /** * Takes array of delegations and propagates them to their respective audiences @@ -17,17 +18,12 @@ import * as DB from './agent/db.js' * 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 {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. - * @param {API.Delegation[]} [input.proofs] - Optional set of proofs to be - * included in the invocation. */ -export const delegate = async ( - session, - { delegations, proofs = [], subject } -) => { +export const delegate = async (session, { delegations, subject }) => { if (!subject) { return fail('Space must be specified') } @@ -37,7 +33,7 @@ export const delegate = async ( proof.cid, ]) - const auth = session.agent.authorize({ + const auth = Agent.authorize(session.agent, { subject, can: { 'access/delegate': [] }, }) @@ -81,14 +77,14 @@ export const request = async ( session, { account, - authority = session.agent.did(), + 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 auth = session.agent.authorize({ + const auth = Agent.authorize(session.agent, { subject: authority, can: { 'access/authorize': [] }, }) @@ -145,10 +141,10 @@ export const claim = async ( session, { provider = /** @type {API.ProviderDID} */ (session.connection.id.did()), - authority = session.agent.did(), + authority = /** @type {API.DIDKey} */ (session.agent.signer.did()), } = {} ) => { - const auth = session.agent.authorize({ + const auth = Agent.authorize(session.agent, { subject: authority, can: { 'access/claim': [] }, }) diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index c246748e6..5b9967224 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -4,8 +4,17 @@ 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' export { DIDMailto } + +/** + * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @returns {API.AccountsSession} + */ +export const view = (session) => new AccountsView(session) + /** * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] */ @@ -15,10 +24,11 @@ export class AccountsView { */ constructor(session) { this.session = session + this.spaces = Space.view(/** @type {API.Session} */ (this.session)) } - [Symbol.iterator]() { - return list(this.session) + *[Symbol.iterator]() { + yield* Object.values(list(this.session)) } /** @@ -46,6 +56,20 @@ export class AccountsView { get(email) { return get(this.session, email) } + + /** + * @param {API.AccountSession} account + */ + add(account) { + return add(this.session, account) + } + + /** + * @param {API.AccountSession} account + */ + remove(account) { + return remove(this.session, account) + } } /** @@ -54,7 +78,7 @@ export class AccountsView { * @param {object} source * @param {API.EmailAddress} source.email * @param {AbortSignal} [source.signal] - * @returns {Promise, API.AccessDenied|API.InvocationError|API.AccessAuthorizeFailure>>} + * @returns {Promise, API.AccessDenied|API.InvocationError|API.AccessAuthorizeFailure>>} */ export const login = async (session, { email, signal }) => { const account = get(session, email) @@ -85,7 +109,10 @@ export const login = async (session, { email, signal }) => { login.proofs.set(`${proof.cid}`, proof) } } - return { ok: new AccountView({ session, login }) } + + return { + ok: AccountSession.from({ session, login }), + } } } } @@ -93,7 +120,7 @@ export const login = async (session, { email, signal }) => { /** * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session - * @returns {Record>} + * @returns {Record>} */ export const list = (session) => { @@ -101,14 +128,14 @@ export const list = (session) => { session.agent.db, DB.query( session.agent.db.index, - Login.query({ authority: session.agent.did() }) + Login.query({ authority: session.agent.signer.did() }) ) ) return Object.fromEntries( [...logins].map(([account, login]) => [ account, - new AccountView({ session, login }), + AccountSession.from({ session, login }), ]) ) } @@ -127,35 +154,92 @@ export const get = (session, email) => { session.agent.db, DB.query( session.agent.db.index, - Login.query({ authority: session.agent.did(), account }) + Login.query({ authority: session.agent.signer.did(), account }) ) ).values() - return login ? new AccountView({ session, login }) : undefined + return login ? AccountSession.from({ 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 {object} session + * @param {API.Agent} session.agent + * @param {API.AccountSession} account + */ +export const add = async ({ agent }, account) => { + return await DB.transact( + agent.db, + [...account.proofs].map((proof) => DB.assert({ proof })) + ) +} + +/** + * Removes access to this account from the agent's database. + * + * @param {object} session + * @param {API.Agent} session.agent + * @param {API.AccountSession} account + */ +export const remove = async ({ agent }, account) => { + return DB.transact( + agent.db, + [...account.proofs].map((proof) => DB.retract({ proof })) + ) } /** * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] */ -class AccountView { +class AccountSession { /** + * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] * @param {object} source - * @param {API.Session} source.session * @param {object} source.login + * @param {API.DidMailto} source.login.id * @param {Map} source.login.proofs * @param {Map} source.login.attestations - * @param {API.DidMailto} source.login.id + * @param {API.Session} source.session + */ + static from({ login, session }) { + return new AccountSession({ + id: login.id, + session: { + agent: { + signer: session.agent.signer, + db: DB.fromProofs([ + ...login.proofs.values(), + ...login.attestations.values(), + ]), + }, + connection: session.connection, + }, + }) + } + + /** + * @param {object} source + * @param {API.DidMailto} source.id + * @param {API.Session} source.session */ constructor(source) { this.model = source + /** @type {API.AccountPlans} */ this.plans = Plan.from(this) + + this.spaces = Space.view(/** @type {API.Session} */ (this.session)) } get session() { return this.model.session } did() { - return this.model.login.id + return this.model.id } /** @@ -166,36 +250,8 @@ class AccountView { } get proofs() { - return [ - ...this.model.login.proofs.values(), - ...this.model.login.attestations.values(), - ] - } - - /** - * 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.session.agent } = {}) { - return DB.transact( - agent.db, - [...this.proofs].map((proof) => DB.assert({ proof })) - ) - } - - /** - * Deletes access to this account from the agent's proofs store. - * - * @param {object} input - * @param {API.Agent} [input.agent] - */ - delete({ agent = this.model.session.agent } = {}) { - return DB.transact( - agent.db, - [...this.proofs].map((proof) => DB.retract({ proof })) + return [...this.model.session.agent.db.proofs.values()].map( + ($) => $.delegation ) } diff --git a/packages/w3up-client/src/account/plan.js b/packages/w3up-client/src/account/plan.js index 0384ebfa8..4379b3644 100644 --- a/packages/w3up-client/src/account/plan.js +++ b/packages/w3up-client/src/account/plan.js @@ -1,25 +1,39 @@ import * as API from '../types.js' import { Plan } from '@web3-storage/capabilities' import * as Subscriptions from './subscription.js' +import * as Agent from '../agent.js' /** * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] - * @param {API.AccountView} account + * @param {API.AccountSession} account + * @returns {API.AccountPlans} */ export const from = (account) => new AccountPlans(account) /** * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] - * @param {API.AccountView} account + * @param {API.AccountSession} account + * @returns {Promise>, API.AccessDenied | API.PlanNotFound | API.InvocationError>>} */ export const list = async (account) => { const { session } = account + const auth = Agent.authorize(account.session.agent, { + subject: account.did(), + can: { + 'plan/get': [], + }, + }) + + if (auth.error) { + return auth + } + const { out: result } = await Plan.get .invoke({ issuer: session.agent.signer, audience: session.connection.id, with: account.did(), - proofs: account.proofs, + proofs: auth.ok.proofs, }) .execute(/** @type {API.Session} */ (session).connection) @@ -39,10 +53,11 @@ export const list = async (account) => { /** * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.AccountPlans} */ class AccountPlans { /** - * @param {API.AccountView} account + * @param {API.AccountSession} account */ constructor(account) { this.account = account @@ -58,11 +73,12 @@ class AccountPlans { class BillingPlan { /** * @param {object} source - * @param {API.AccountView} source.account + * @param {API.AccountSession} source.account * @param {API.PlanGetSuccess} source.plan */ constructor(source) { this.model = source + /** @type {API.AccountSubscriptions} */ this.subscriptions = Subscriptions.from(this) } get account() { diff --git a/packages/w3up-client/src/account/subscription.js b/packages/w3up-client/src/account/subscription.js index e209d5c75..2be4cc6f4 100644 --- a/packages/w3up-client/src/account/subscription.js +++ b/packages/w3up-client/src/account/subscription.js @@ -1,21 +1,21 @@ import * as API from '../types.js' -import { Provider } from '@web3-storage/capabilities' +import { Provider, Subscription } from '@web3-storage/capabilities' /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] - * @param {API.BillingPlan} plan + * @param {API.BillingPlan} plan + * @returns {API.AccountSubscriptions} */ export const from = (plan) => new AccountSubscriptions(plan) /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] - * @param {API.BillingPlan} plan + * @param {API.BillingPlan} plan * @param {object} subscription * @param {API.SpaceDID} subscription.consumer * @param {API.Limit} [subscription.limit] */ export const add = async ({ account, provider }, { consumer }) => { const { session } = account + const { out: result } = await Provider.add .invoke({ issuer: session.agent.signer, @@ -25,16 +25,62 @@ export const add = async ({ account, provider }, { consumer }) => { provider: Provider.Provider.from(provider), consumer, }, + proofs: account.proofs, }) - .execute( - /** @type {API.Session} */ (session).connection - ) + .execute(session.connection) return result } /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @param {API.BillingPlan} plan + * @returns {Promise>} + */ + +export const list = async ({ account }) => { + const { session } = account + + const customer = account.did() + const { out: result } = await Subscription.list + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: customer, + proofs: account.proofs, + nb: {}, + }) + .execute(session.connection) + + if (result.error) { + return result + } else { + /** @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 result.ok.results) { + for (const consumer of consumers) { + subscriptions[`${consumer}:${customer}@${provider}`] = { + customer, + consumer, + provider, + limit: {}, + } + } + } + + return { ok: Object.assign(new Subscriptions(), subscriptions) } + } +} + +class Subscriptions { + *[Symbol.iterator]() { + yield* Object.values(this) + } +} + +/** + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] */ class AccountSubscriptions { /** @@ -55,4 +101,11 @@ class AccountSubscriptions { add(subscription) { return add(this.plan, subscription) } + + /** + * + */ + list() { + return list(this.plan) + } } diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js index 6a0070e83..0fbac87a2 100644 --- a/packages/w3up-client/src/agent.js +++ b/packages/w3up-client/src/agent.js @@ -6,9 +6,9 @@ import { Signer, ed25519 } from '@ucanto/principal' import { DID } from '@ucanto/core' import * as API from './types.js' -import * as Session from './session.js' +import * as Session from './w3up.js' import * as Connection from './agent/connection.js' -import * as Authorization from './agent/authorization.js' +import * as Authorization from './authorization.js' export * from './types.js' @@ -75,7 +75,7 @@ class AgentPromise extends Promise { /** * @template {API.UnknownProtocol} Protocol * @param {API.ConnectionView} [connection] - * @returns {Promise, API.ConnectError>>} + * @returns {Promise>} */ async connect(connection) { const result = await this @@ -131,7 +131,7 @@ class Agent { /** * @template {API.UnknownProtocol} Protocol * @param {API.Connection} [connection] - * @returns {Promise, API.SignerLoadError>>} + * @returns {Promise>} */ async connect(connection = Connection.open()) { return { @@ -148,7 +148,7 @@ class Agent { } const signer = as ?? (await ed25519.generate()) const archive = signer.toArchive() - const result = await DB.transact(db, [{ signer: archive }]) + const result = await DB.transact(db, [DB.assert({ signer: archive })]) if (result.error) { return result } @@ -203,7 +203,9 @@ class Agent { } else { const signer = await ed25519.generate() - const { error } = await DB.transact(db, [{ signer: signer.toArchive() }]) + const { error } = await DB.transact(db, [ + DB.assert({ signer: signer.toArchive() }), + ]) if (error) { return { error } } else { @@ -470,24 +472,24 @@ class SignerLoadError extends Error { // } // } -/** - * Stores given delegations in the agent's data store and adds discovered spaces - * to the agent's space list. - * - * @param {Agent<{}>} agent - * @param {object} authorization - * @param {API.Delegation[]} authorization.proofs - * @returns {Promise>} - */ -export const importAuthorization = async (agent, { proofs }) => { - try { - await addProofs(agent.data, proofs) - await addSpacesFromDelegations(agent, proofs) - return { ok: {} } - } catch (error) { - return /** @type {{error:Error}} */ ({ error }) - } -} +// /** +// * Stores given delegations in the agent's data store and adds discovered spaces +// * to the agent's space list. +// * +// * @param {Agent<{}>} agent +// * @param {object} authorization +// * @param {API.Delegation[]} authorization.proofs +// * @returns {Promise>} +// */ +// export const importAuthorization = async (agent, { proofs }) => { +// try { +// await addProofs(agent.data, proofs) +// await addSpacesFromDelegations(agent, proofs) +// return { ok: {} } +// } catch (error) { +// return /** @type {{error:Error}} */ ({ error }) +// } +// } // /** // * Get all the proofs matching the capabilities. @@ -608,36 +610,36 @@ export const importAuthorization = async (agent, { proofs }) => { // return await addProofs(data, [delegation]) // } -/** - * Adds set of proofs to the agent store. - * - * @param {AgentData} data - * @param {Iterable} delegations - */ -export const addProofs = async (data, delegations) => { - for (const proof of delegations) { - await data.addDelegation(proof, { audience: data.meta }) - } +// /** +// * Adds set of proofs to the agent store. +// * +// * @param {AgentData} data +// * @param {Iterable} delegations +// */ +// export const addProofs = async (data, delegations) => { +// for (const proof of delegations) { +// await data.addDelegation(proof, { audience: data.meta }) +// } - await removeExpiredDelegations(data, { time: Date.now() / 1000 }) +// await removeExpiredDelegations(data, { time: Date.now() / 1000 }) - return {} -} +// return {} +// } -/** - * Clean up any expired delegations. - * - * @param {AgentData} data - * @param {object} options - * @param {API.UTCUnixTimestamp} options.time - */ -export const removeExpiredDelegations = async (data, options) => { - for (const [, value] of data.delegations) { - if (isExpired(value.delegation, options.time)) { - await data.removeDelegation(value.delegation.cid) - } - } -} +// /** +// * Clean up any expired delegations. +// * +// * @param {AgentData} data +// * @param {object} options +// * @param {API.UTCUnixTimestamp} options.time +// */ +// export const removeExpiredDelegations = async (data, options) => { +// for (const [, value] of data.delegations) { +// if (isExpired(value.delegation, options.time)) { +// await data.removeDelegation(value.delegation.cid) +// } +// } +// } // /** // * Get current space DID, proofs and abilities @@ -721,81 +723,81 @@ export const removeExpiredDelegations = async (data, options) => { // return delegation // } -/** - * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. - * - * @example - * ```js - * const spaceList = await Agent.issueInvocation(agent, Store.list, { - * nb: { - * size: 10, - * }, - * }) - * - * await spaceList.execute(agent.connection) - * ``` - * - * @template {API.Ability} A - * @template {API.URI} R - * @template {API.TheCapabilityParser>} CAP - * @template {API.Caveats} [C={}] - * - * @param {object} agent - * @param {AgentData} agent.data - * @param {API.Signer} agent.issuer - * @param {{id: API.Principal}} agent.connection - * @param {CAP} cap - * @param {API.InvokeOptions} options - */ -export const issueInvocation = async ( - { connection, issuer, data }, - cap, - options -) => { - const audience = options.audience || connection.id - const time = Date.now() / 1000 - - const space = options.with || data.currentSpace - if (!space) { - throw new Error('No space or resource selected, you need pass a resource.') - } +// /** +// * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. +// * +// * @example +// * ```js +// * const spaceList = await Agent.issueInvocation(agent, Store.list, { +// * nb: { +// * size: 10, +// * }, +// * }) +// * +// * await spaceList.execute(agent.connection) +// * ``` +// * +// * @template {API.Ability} A +// * @template {API.URI} R +// * @template {API.TheCapabilityParser>} CAP +// * @template {API.Caveats} [C={}] +// * +// * @param {object} agent +// * @param {AgentData} agent.data +// * @param {API.Signer} agent.issuer +// * @param {{id: API.Principal}} agent.connection +// * @param {CAP} cap +// * @param {API.InvokeOptions} options +// */ +// export const issueInvocation = async ( +// { connection, issuer, data }, +// cap, +// options +// ) => { +// const audience = options.audience || connection.id +// const time = Date.now() / 1000 - const proofs = [ - ...(options.proofs || []), - ...selectAuthorization( - { data, issuer }, - [ - { - with: space, - can: cap.can, - }, - ], - { sessionProofIssuer: audience.did(), time } - ), - ] - - if (proofs.length === 0 && options.with !== issuer.did()) { - throw new Error( - `no proofs available for resource ${space} and ability ${cap.can}` - ) - } +// const space = options.with || data.currentSpace +// if (!space) { +// throw new Error('No space or resource selected, you need pass a resource.') +// } - const inv = invoke({ - ...options, - issuer, - audience, - // @ts-ignore - capability: cap.create({ - with: space, - nb: options.nb, - }), - proofs: [...proofs], - }) +// const proofs = [ +// ...(options.proofs || []), +// ...selectAuthorization( +// { data, issuer }, +// [ +// { +// with: space, +// can: cap.can, +// }, +// ], +// { sessionProofIssuer: audience.did(), time } +// ), +// ] + +// if (proofs.length === 0 && options.with !== issuer.did()) { +// throw new Error( +// `no proofs available for resource ${space} and ability ${cap.can}` +// ) +// } - return /** @type {API.IssuedInvocationView>} */ ( - inv - ) -} +// const inv = invoke({ +// ...options, +// issuer, +// audience, +// // @ts-ignore +// capability: cap.create({ +// with: space, +// nb: options.nb, +// }), +// proofs: [...proofs], +// }) + +// return /** @type {API.IssuedInvocationView>} */ ( +// inv +// ) +// } // /** // * Returns iterable of all the proofs that contain capabilities matching diff --git a/packages/w3up-client/src/agent/attestation.js b/packages/w3up-client/src/agent/attestation.js index 34b3954c3..f967cd8a7 100644 --- a/packages/w3up-client/src/agent/attestation.js +++ b/packages/w3up-client/src/agent/attestation.js @@ -12,17 +12,17 @@ import * as Delegation from './delegation.js' * @param {DB.Term} ucan * @param {object} constraints * @param {DB.Term} [constraints.capability] - * @param {DB.Term} constraints.authority + * @param {DB.Term} [constraints.subject] * @param {DB.Term} constraints.time * @param {DB.Term} constraints.audience * @param {DB.Term} constraints.proof */ export const match = ( ucan, - { capability = DB.link(), authority, audience, proof, time } + { capability = DB.link(), subject = DB.string(), audience, proof, time } ) => Capability.match(capability, { - subject: authority, + subject, can: 'ucan/attest', }) .and(DB.match([capability, 'capability/nb/proof', proof])) diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js index 3554c9c93..a9b9a5e9e 100644 --- a/packages/w3up-client/src/agent/delegation.js +++ b/packages/w3up-client/src/agent/delegation.js @@ -4,7 +4,7 @@ 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.js' +import { Capability } from '../authorization/query.js' import { Delegation } from '@ucanto/core' /** diff --git a/packages/w3up-client/src/agent/login.js b/packages/w3up-client/src/agent/login.js index 0ea567a61..739e3ed05 100644 --- a/packages/w3up-client/src/agent/login.js +++ b/packages/w3up-client/src/agent/login.js @@ -62,7 +62,7 @@ export const query = ({ time = Date.now() / 1000, ...selector }) => { where: [ match(proof, { account, authority, time }), Attestation.match(attestation, { - authority: provider, + subject: provider, audience: authority, time, proof, 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/agent/authorization.js b/packages/w3up-client/src/authorization/query.js similarity index 53% rename from packages/w3up-client/src/agent/authorization.js rename to packages/w3up-client/src/authorization/query.js index c7f7977c8..f01aeaf48 100644 --- a/packages/w3up-client/src/agent/authorization.js +++ b/packages/w3up-client/src/authorization/query.js @@ -1,92 +1,17 @@ import * as API from '../types.js' import * as DB from 'datalogia' -import * as Capability from './capability.js' -import * as Delegation from './delegation.js' -import * as Text from './db/text.js' +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 } -/** - * @param {API.Authorization} model - */ -export const from = (model) => new Authorization(model) - -/** - * @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: new Authorization({ - authority, - can: { - '*': [], - }, - subject, - proofs: [], - }), - } - } else { - const result = find(db, { - 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}` - ), - } - } - } -} - -class AccessDenied extends Error { - name = /** @type {const} */ ('AccessDenied') -} - -/** - * 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.authority - * @param {API.TextConstraint} [query.subject] - * @param {API.Can} [query.can] - * @param {API.UTCUnixTimestamp} [query.time] - * @returns {Authorization[]} - */ -export const find = ( - db, - { subject = { glob: '*' }, authority, time = Date.now() / 1000, can } -) => - DB.query( - db.index, - query({ - can, - subject, - authority, - time, - }) - ).map((match) => select(db, match)) - /** * @typedef {object} ProofSelector - * @property {DB.Term} proof * @property {DB.Term} can + * @property {DB.Term} proof + * @property {DB.Term} [attestation] * @property {string} [need] * * @typedef {object} Selector @@ -111,12 +36,17 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { const subject = DB.string() const authority = DB.string() const need = Object.keys(can) - /** @type {{proof: DB.Term, can: DB.Term, need?: string }[]} */ + /** @type {{proof: DB.Term, attestation: DB.Term, can: DB.Term, need?: string }[]} */ const proofs = need.length - ? need.map((need) => ({ proof: DB.link(), need, can: DB.string() })) - : [{ proof: DB.link(), can: DB.string() }] - - const where = proofs.map(({ proof, need, can }) => { + ? need.map((need) => ({ + proof: DB.link(), + need, + can: DB.string(), + attestation: DB.link(), + })) + : [{ proof: DB.link(), can: DB.string(), attestation: DB.link() }] + + const where = proofs.map(({ proof, need, can, attestation }) => { const clause = match(proof, { subject, can, @@ -124,7 +54,12 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { time, }) - return need ? clause.and(DB.glob(need, can)) : clause + const attestations = DB.or( + DB.not(DB.Constraint.glob(subject, 'did:mailto:*')), + Attestation.match(attestation, { proof, time, audience: authority }) + ) + + return (need ? clause.and(DB.glob(need, can)) : clause).and(attestations) }) return { @@ -141,26 +76,6 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { } } -/** - * @param {API.Database} db - * @param {DB.InferBindings} match - */ -export const select = (db, { authority, subject, proofs }) => { - // query engine will provide proof for each requested capability, so we may - // have duplicates here, which we prune. - const keys = [...new Set(proofs.map(({ proof }) => String(proof)))] - - return new Authorization({ - authority: /** @type {API.DID} */ (authority), - 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) - ), - }) -} - /** * Matches a delegation that authorizes the `selector.authority` with an ability * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note @@ -261,43 +176,3 @@ export const match = ( explicit(delegation, { authority, can, subject, time }), implicit(delegation, { authority, can, subject, time }) ) - -/** - * @typedef {object} Model - * @property {API.SpaceDID} subject - * @property {API.Delegation[]} proofs - * @property {API.DID} authority - */ -class Authorization { - /** - * - * @param {API.Authorization} model - */ - constructor(model) { - this.model = model - } - get can() { - return this.model.can - } - get proofs() { - return this.model.proofs - } - [Symbol.iterator]() { - return this.model.proofs - } - 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/authorization/view.js b/packages/w3up-client/src/authorization/view.js new file mode 100644 index 000000000..030c08076 --- /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, { + 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.authority + * @param {API.TextConstraint} [query.subject] + * @param {API.Can} [query.can] + * @param {API.UTCUnixTimestamp} [query.time] + * @returns {Authorization[]} + */ +export const find = ( + db, + { subject = { glob: '*' }, authority, time = Date.now() / 1000, can } +) => + DB.query( + db.index, + Query.query({ + can, + subject, + authority, + time, + }) + ).map((match) => select(db, match)) + +/** + * @param {API.Database} db + * @param {DB.InferBindings} match + */ +export const select = (db, { authority, 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} */ (authority), + 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/capability/access.js b/packages/w3up-client/src/capability/access.js index 5a6f19d00..cad0fcd78 100644 --- a/packages/w3up-client/src/capability/access.js +++ b/packages/w3up-client/src/capability/access.js @@ -6,7 +6,6 @@ export { DIDMailto } import * as Access from '@web3-storage/capabilities/access' import { Failure, fail, DID } from '@ucanto/core' -import { importAuthorization } from '../agent.js' import { bytesToDelegations } from '../agent/encoding.js' /** diff --git a/packages/w3up-client/src/capability/space.js b/packages/w3up-client/src/capability/space.js index 6a22415ad..5db9fb9d0 100644 --- a/packages/w3up-client/src/capability/space.js +++ b/packages/w3up-client/src/capability/space.js @@ -6,7 +6,7 @@ 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 '../agent/authorization.js' +import * as Authorization from '../authorization/query.js' import * as Database from '../agent/db.js' import * as Agent from '../agent.js' diff --git a/packages/w3up-client/src/capability/subscription.js b/packages/w3up-client/src/capability/subscription.js index db9777e98..02e83dc14 100644 --- a/packages/w3up-client/src/capability/subscription.js +++ b/packages/w3up-client/src/capability/subscription.js @@ -1,7 +1,6 @@ import { Subscription } from '@web3-storage/capabilities' import * as Result from '../result.js' import * as API from '../types.js' -import { issueInvocation } from '../agent.js' export { Subscription } diff --git a/packages/w3up-client/src/plan.js b/packages/w3up-client/src/plan.js index 291c3befc..cabf099b9 100644 --- a/packages/w3up-client/src/plan.js +++ b/packages/w3up-client/src/plan.js @@ -1,6 +1,6 @@ import * as API from './types.js' import * as Plan from '@web3-storage/capabilities/plan' -import * as Authorization from './agent/authorization.js' +import * as Authorization from './authorization/query.js' /** * Gets the plan currently associated with the account. diff --git a/packages/w3up-client/src/session/space.js b/packages/w3up-client/src/session/space.js deleted file mode 100644 index 0ef3762dc..000000000 --- a/packages/w3up-client/src/session/space.js +++ /dev/null @@ -1,75 +0,0 @@ -import * as API from '../types.js' -import * as DB from '../agent/db.js' -import * as SpaceQuery from '../agent/space.js' - -/** - * @param {API.Session} session - */ -export const from = (session) => new View(session) - -class View { - /** - * @param {API.Session} session - */ - constructor(session) { - this.session = session - } - - list() { - return list(this.session) - } -} - -/** - * @param {API.Session} session - */ -export const list = async (session) => { - const results = DB.query( - session.agent.db.index, - SpaceQuery.query({ authority: session.agent.did() }) - ) - - return build(session, results) -} - -/** - * @template {API.PlanProtocol} [Protocol=API.W3UpProtocol] - * @param {API.Session} session - * @param {{space:API.DIDKey, name?: string}[]} spaces - */ -const build = (session, spaces) => { - /** @type {Record>} */ - const result = {} - for (const { space: id, name = '' } of spaces) { - if (!result[id]) { - result[id] = new SharedSpace({ session, id, name }) - } - } - - return result -} - -/** - * @template {API.PlanProtocol} [Protocol=API.W3UpProtocol] - * @typedef {object} Model - * @property {API.DIDKey} id - * @property {API.Session} session - */ - -/** - * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] - */ -class SharedSpace { - /** - * @param {object} model - * @param {API.DIDKey} model.id - * @param {string} model.name - * @param {API.Session} model.session - */ - constructor(model) { - this.model = model - } - get did() { - return this.model.id - } -} diff --git a/packages/w3up-client/src/space.js b/packages/w3up-client/src/space.js index 3c7ba4804..9087065f5 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -1,75 +1,141 @@ -import * as Usage from './capability/usage.js' import * as API from './types.js' -export * from './capability/space.js' +import * as DB from './agent/db.js' +import * as Query from './space/query.js' +import * as SharedSpace from './space/shared.js' +import * as OwnSpace from './space/own.js' /** + * @param {API.Session} session + * @returns {API.SpacesSession} + */ +export const view = (session) => new SessionSpaces(session) + +export const { create, fromMnemonic } = OwnSpace + +/** + * @param {API.Session} session + */ +export const list = (session) => { + const results = DB.query( + session.agent.db.index, + Query.query({ authority: session.agent.signer.did() }) + ) + + return build(session, results) +} + +/** + * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] + * @param {API.Session} session + * @param {API.SharedSpace} space + */ +export const add = async (session, space) => { + if (space.authority === session.agent.signer.did()) { + return await DB.transact( + session.agent.db, + space.proofs.map((proof) => DB.assert({ proof })) + ) + } else { + return { + error: new PrincipalAlignmentError( + `Space is shared with ${ + space.authority + } not ${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.SharedSpace} space + */ +export const remove = (session, space) => + DB.transact( + session.agent.db, + space.proofs.map((proof) => DB.retract({ proof })) + ) + +/** + * @template {API.PlanProtocol & API.UsageProtocol & API.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], + } + } else { + result[subject].proofs.push(delegation) + } + } + + return Object.fromEntries( + Object.entries(result).map(([k, v]) => [ + k, + SharedSpace.create(v).connect(session.connection), + ]) + ) +} + +/** + * @implements {API.SpacesSession} */ class SessionSpaces { /** - * @param {API.Session} session + * @param {API.Session} session */ constructor(session) { this.session = session } - list() {} -} - -class SpaceView { - constructor() {} -} - -export class StorageUsage { - #model - + list() { + return list(this.session) + } /** - * @param {Model} model + * @param {API.SharedSpace} space */ - constructor(model) { - this.#model = model + add(space) { + return add(this.session, space) } - /** - * Get the current usage in bytes. + * @param {API.SharedSpace} space */ - 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] + remove(space) { + return remove(this.session, space) + } - return { - /* c8 ignore next */ - ok: report?.size.final == null ? undefined : BigInt(report.size.final), - } + *[Symbol.iterator]() { + yield* Object.values(this.list()) } -} -/** @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 {object} source + * @param {string} source.name + */ + async create(source) { + return OwnSpace.create(source).connect(this.session.connection) + } } -/** @param {string|number|Date} now */ -const startOfLastMonth = (now) => { - const d = startOfMonth(now) - d.setUTCMonth(d.getUTCMonth() - 1) - return d +class PrincipalAlignmentError extends Error { + 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..d214648df --- /dev/null +++ b/packages/w3up-client/src/space/delegations.js @@ -0,0 +1,31 @@ +import * as API from '../types.js' +import * as Access from '../access.js' + +/** + * @param {API.SpaceSession} session + */ +export const view = (session) => new Delegations(session) + +/** + */ +class Delegations { + /** + * @param {API.SpaceSession} session + */ + constructor(session) { + this.session = session + } + + /** + * + * @param {API.Authorization} authorization + */ + add(authorization) { + return Access.delegate(this.session, { + delegations: authorization.proofs, + subject: this.session.did(), + }) + } + + // TODO: We really should allow deleting and listing delegations also. +} diff --git a/packages/w3up-client/src/space/own.js b/packages/w3up-client/src/space/own.js new file mode 100644 index 000000000..2f842480a --- /dev/null +++ b/packages/w3up-client/src/space/own.js @@ -0,0 +1,383 @@ +import * as API from '../types.js' +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 SharedSpace from './shared.js' +import { delegate, UCAN } from '@ucanto/core' +import * as DB from '../agent/db.js' +import * as Session from './session.js' +import * as Authorization from '../authorization.js' +import * as Delegations from './delegations.js' + +/** + * @param {object} options + * @param {string} options.name + * @returns {API.OwnSpacePromise} + */ +export const create = ({ name }) => + OwnSpacePromise.from({ name, promise: ED25519.generate() }) + +/** + * Recovers space from the saved mnemonic. + * + * @param {string} mnemonic + * @param {object} options + * @param {string} options.name - Name to give to the recovered space. + */ +export const fromMnemonic = async (mnemonic, { name }) => { + const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) + const signer = await ED25519.derive(secret) + return new OwnSpace({ signer, name }) +} + +/** + * 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) +} + +/** + * @param {API.OwnSpace} space + * @param {object} access + * @param {API.Signer} access.authority + * @param {API.Can} [access.can] + * @param {API.UTCUnixTimestamp} [access.expiration] + * @returns {Promise>} + */ +export const share = async ({ signer, name }, access) => { + const result = await authorize({ signer, name }, access) + return result.error + ? result + : { + ok: SharedSpace.create({ + signer: access.authority, + subject: signer.did(), + name, + proofs: result.ok.proofs, + }), + } +} + +// Default authorization session is valid for 1 year +export const SESSION_LIFETIME = 60 * 60 * 24 * 365 + +/** + * @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) +} + +/** + * 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.W3Protocol} + * capabilities on the space that are needed to use the space. + * + * @param {API.OwnSpace} space + * @param {object} access + * @param {API.Principal} access.authority + * @param {API.Can} [access.can] + * @param {API.UTCUnixTimestamp} [access.expiration] + * @returns {Promise>} + */ +export const authorize = async ( + { signer, name }, + { + authority, + can = Access.spaceAccess, + expiration = UCAN.now() + SESSION_LIFETIME, + } +) => { + const proof = await delegate({ + issuer: signer, + audience: authority, + capabilities: toCapabilities({ subject: signer.did(), can }), + expiration, + facts: [{ space: { name } }], + }) + + return { + ok: Authorization.from({ + authority: authority.did(), + subject: signer.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.W3Protocol} + * capabilities on the space that are needed to use the space. + * + * @param {API.OwnSpace} space + * @param {object} access + * @param {API.Principal} access.authority + * @param {API.UTCUnixTimestamp} [access.expiration] + * @returns {Promise>} + */ export const createRecovery = async ( + space, + { authority, expiration = Infinity } +) => authorize(space, { can: Access.accountAccess, authority, expiration }) + +/** + * @extends {Promise>} + * @implements {API.OwnSpacePromise} + */ +class OwnSpacePromise extends Promise { + /** + * @param {object} options + * @param {string} options.name + * @param {Promise} options.promise + * @returns {API.OwnSpacePromise} + */ + static from({ name, promise }) { + return new OwnSpacePromise((resolve, reject) => { + promise + .then((signer) => { + resolve({ ok: new OwnSpace({ name, signer }) }) + }) + .catch(reject) + }) + } + + /** + * 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 {API.SpaceProtocol & API.UsageProtocol} Protocol + * @param {API.Connection} connection + * @returns {Promise, never>>} + */ + async connect(connection) { + const result = await this + if (result.ok) { + return { ok: result.ok.connect(connection) } + } else { + return result + } + } +} + +/** + * Represents an owned space, meaning a space for which we have a private key + * and consequently have full authority over. + * + * @implements {API.OwnSpaceView} + */ +class OwnSpace { + /** + * @param {object} model + * @param {string} model.name + * @param {ED25519.EdSigner} model.signer + */ + 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 + */ + 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) + } + + /** + * 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 {API.SpaceProtocol & API.UsageProtocol} Protocol + * @param {API.Connection} connection + * @returns {API.OwnSpaceSession} + */ + connect(connection) { + return new OwnSpaceSession({ + session: { + agent: { signer: this.signer, db: DB.from({ proofs: [] }) }, + connection, + }, + name: this.name, + }) + } + + /** + * 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.Signer} authority + * @param {object} access + * @param {API.Can} access.can + * @param {API.UTCUnixTimestamp} [access.expiration] + */ + share(authority, access) { + return share(this, { ...access, authority }) + } + + /** + * @param {API.Principal} authority + * @param {object} access + * @param {API.UTCUnixTimestamp} [access.expiration] + */ + createRecovery(authority, access) { + return createRecovery(this, { ...access, authority }) + } + + /** + * @param {API.Principal} authority + * @param {API.ShareAccess} access + */ + authorize(authority, access) { + return authorize(this, { ...access, authority }) + } +} + +/** + * Represents a remote replica of the owned space. It can be used to query + * state of the replica and invoke actions on it. + * + * @template {API.SpaceProtocol & API.UsageProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.OwnSpaceSession} + */ +class OwnSpaceSession { + /** + * + * @param {object} model + * @param {string} model.name + * @param {API.Session & { agent: { signer: ED25519.EdSigner }}} model.session + */ + constructor(model) { + this.model = model + + this.delegations = Delegations.view( + /** @type {API.SpaceSession} */ (this) + ) + } + + get signer() { + return this.agent.signer + } + + get agent() { + return this.model.session.agent + } + + get connection() { + return this.model.session.connection + } + + 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 + * @returns {API.OwnSpaceSession} + */ + rename(name) { + return new OwnSpaceSession({ ...this.model, name }) + } + + /** + * Derives BIP39 mnemonic that can be used to recover the space. + * + * @returns {string} + */ + toMnemonic() { + return toMnemonic({ signer: this.agent.signer }) + } + + /** + * 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.Signer} authority + * @param {object} access + * @param {API.Can} [access.can] + */ + async share(authority, { can } = {}) { + const result = await share(this, { authority, can }) + if (result.error) { + return result + } else { + return { ok: result.ok.connect(this.connection) } + } + } + + /** + * @param {API.Principal} authority + * @param {object} [access] + * @param {API.UTCUnixTimestamp} [access.expiration] + */ + createRecovery(authority, access = {}) { + return authorize(this, { ...access, authority }) + } + + /** + * @param {API.Principal} authority + * @param {API.ShareAccess} [access] + */ + authorize(authority, access) { + return authorize(this, { ...access, authority }) + } + + info() { + return Session.info(this) + } +} diff --git a/packages/w3up-client/src/agent/space.js b/packages/w3up-client/src/space/query.js similarity index 90% rename from packages/w3up-client/src/agent/space.js rename to packages/w3up-client/src/space/query.js index d6a800632..b8b972098 100644 --- a/packages/w3up-client/src/agent/space.js +++ b/packages/w3up-client/src/space/query.js @@ -1,32 +1,37 @@ import * as API from '../types.js' -import * as Delegation from './delegation.js' -import * as Capability from './capability.js' -import * as Text from './db/text.js' +import * as Delegation from '../agent/delegation.js' +import * as Capability from '../agent/capability.js' +import * as Text from '../agent/db/text.js' import * as DB from 'datalogia' -import * as Authorization from './authorization.js' -import { optional } from './db.js' +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.authority] - * @param {DB.Term} [constraints.space] + * @param {DB.Term} [constraints.space] * @param {DB.Term} [constraints.can] * @param {DB.Term} [constraints.name] * @param {boolean} [constraints.implicit] - * @returns {API.Query<{ space: DB.Term; name?: DB.Term }> + * @returns {API.Query<{ proof: DB.Term; space: DB.Term; name?: DB.Term }>} */ -export const query = (constraints) => { - const space = DB.string() - const name = DB.string() +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, constraints)], + where: [ + (constraints.match ?? match)(ucan, { name, space, ...constraints }), + ], } } @@ -194,8 +199,8 @@ export const direct = ({ * @param {API.UTCUnixTimestamp} [selector.time] */ export const indirect = ({ - subject = { like: '%' }, - audience = { like: '%' }, + subject = { glob: '*' }, + audience = { glob: '*' }, time = Date.now() / 1000, account = { glob: 'did:mailto:*' }, can = { '*': [] }, diff --git a/packages/w3up-client/src/space/session.js b/packages/w3up-client/src/space/session.js new file mode 100644 index 000000000..5e7404d6f --- /dev/null +++ b/packages/w3up-client/src/space/session.js @@ -0,0 +1,43 @@ +import * as API from '../types.js' +import * as Agent from '../agent.js' +import * as Space from '@web3-storage/capabilities/space' + +/** + * + * Get Space information from Access service + * + * @param {API.SpaceSession} session + */ +export const info = async (session) => { + const auth = Agent.authorize(session.agent, { + subject: session.did(), + can: { 'space/info': [] }, + }) + + if (auth.error) { + return auth + } + + const { out: result } = await Space.info + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: session.did(), + proofs: auth.ok.proofs, + }) + .execute(session.connection) + + return result +} + +export class SpaceSessionView { + /** + * @param {API.SpaceSession} session + */ + constructor(session) { + this.session = session + } + info() { + return info(this.session) + } +} diff --git a/packages/w3up-client/src/space/shared.js b/packages/w3up-client/src/space/shared.js new file mode 100644 index 000000000..b83648308 --- /dev/null +++ b/packages/w3up-client/src/space/shared.js @@ -0,0 +1,121 @@ +import * as API from '../types.js' +import * as Usage from './usage.js' +import * as DB from '../agent/db.js' +import * as Session from './session.js' +import * as Delegations from './delegations.js' + +/** + * @param {object} model + * @param {API.DIDKey} model.subject + * @param {API.Signer} model.signer + * @param {string} model.name + * @param {API.Delegation[]} model.proofs + */ +export const create = ({ signer, name, subject, proofs }) => { + const agent = { signer, db: DB.from({ proofs }) } + + return new SharedSpace({ + agent, + name, + subject, + }) +} + +/** + * @implements {API.SharedSpaceView} + */ +class SharedSpace { + /** + * @param {object} model + * @param {API.Agent} model.agent + * @param {API.DIDKey} model.subject + * @param {string} model.name + */ + constructor(model) { + this.model = model + } + get authority() { + return this.model.agent.signer.did() + } + get subject() { + return this.model.subject + } + did() { + return this.model.subject + } + get name() { + return this.model.name + } + + get proofs() { + return [...this.model.agent.db.proofs.values()].map( + ({ delegation }) => delegation + ) + } + + /** + * @template {API.UsageProtocol & API.SpaceProtocol} Protocol + * @param {API.Connection} connection + * @returns {API.SharedSpaceSession} + */ + connect(connection) { + return new SharedSpaceSession({ + ...this.model, + connection, + }) + } +} + +/** + * @template {API.PlanProtocol} [Protocol=API.W3UpProtocol] + * @typedef {object} Model + * @property {API.DIDKey} id + * @property {API.Session} session + */ + +/** + * @template {API.UsageProtocol & API.SpaceProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.SharedSpaceSession} + */ +class SharedSpaceSession { + /** + * @param {object} model + * @param {API.DIDKey} model.subject + * @param {string} model.name + * @param {API.Connection} model.connection + * @param {API.Agent} model.agent + */ + constructor(model) { + this.model = model + this.usage = Usage.view(this) + + this.delegations = Delegations.view( + /** @type {API.SpaceSession} */ (this) + ) + } + get connection() { + return this.model.connection + } + get agent() { + return this.model.agent + } + get authority() { + return this.agent.signer.did() + } + did() { + return this.model.subject + } + get name() { + return this.model.name + } + + info() { + return Session.info(this) + } + + get proofs() { + return [...this.model.agent.db.proofs.values()].map( + ({ delegation }) => 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..912fe69d6 --- /dev/null +++ b/packages/w3up-client/src/space/usage.js @@ -0,0 +1,110 @@ +import { Usage } from '@web3-storage/capabilities' +import * as API from '../types.js' +import * as Task from '../task.js' +import * as Agent from '../agent.js' + +/** + * @param {API.SharedSpaceSession} session + * @returns + */ +export const view = (session) => new UsageSession(session) + +/** + * Get a usage report for the period. + * + * @param {API.SharedSpaceSession} session + * @param {object} options + * @param {API.SpaceDID} options.space + * @param {{ from: Date, to: Date }} options.period + * @param {API.Delegation[]} [options.proofs] + */ +export const report = async (session, { space, period, proofs = [] }) => + Task.execute(function* () { + const auth = yield* Task.join( + Agent.authorize(session.agent, { + subject: space, + can: { 'usage/report': [] }, + }) + ) + + const receipt = yield* Task.wait( + Usage.report + .invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: space, + proofs: auth.proofs, + nb: { + period: { + from: Math.floor(period.from.getTime() / 1000), + to: Math.ceil(period.to.getTime() / 1000), + }, + }, + }) + .execute(session.connection) + ) + + return receipt.out + }) + +class UsageSession { + /** + * @param {API.SharedSpaceSession} session + */ + constructor(session) { + this.session = session + } + /** + * @param {object} options + * @param {API.SpaceDID} options.space + * @param {{ from: Date, to: Date }} options.period + * @param {API.Delegation[]} [options.proofs] + */ + report(options) { + return report(this.session, options) + } + + async get() { + const space = /** @type {API.DIDKey} */ (this.session.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 = await report(this.session, { space, period }) + + /* c8 ignore next */ + if (result.error) return result + + const provider = /** @type {API.ProviderDID} */ ( + this.session.connection.id.did() + ) + const usage = result.ok[provider] + + return { + /* c8 ignore next */ + ok: usage?.size.final == null ? undefined : BigInt(usage.size.final), + } + } +} + +/** @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/task.js b/packages/w3up-client/src/task.js index c88de4aa3..94bdb8904 100644 --- a/packages/w3up-client/src/task.js +++ b/packages/w3up-client/src/task.js @@ -15,8 +15,8 @@ const isPromiseLike = (value) => /** * @template T - * @param {API.Await} source - * @returns {Generator} + * @param {T} source + * @returns {Generator, void>} */ export const wait = function* (source) { if (isPromiseLike(source)) { @@ -24,15 +24,15 @@ export const wait = function* (source) { yield source.then((value) => { ok = value }) - return /** @type {T} */ (ok) + return /** @type {Awaited} */ (ok) } else { - return source + return /** @type {Awaited} */ (source) } } /** * @template {API.Result} R - * @param {API.Await} source + * @param {PromiseLike|R} source * @returns {Generator} */ export const join = function* (source) { @@ -51,7 +51,7 @@ export const join = function* (source) { * @param {() => Generator, void>} task * @returns {Promise>} */ -export const execute = async (task) => { +const execute = async (task) => { const process = task() let state = process.next() try { @@ -71,6 +71,8 @@ export const execute = async (task) => { } } +export { execute as try } + /** * @template {API.Result} R * @template {{}} Ok diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index c3f7b4b1b..8336d792c 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -31,6 +31,7 @@ import { UsageReport, UsageReportSuccess, UsageReportFailure, + PlanNotFound, } from '@web3-storage/capabilities/types' export type { Querier, Transactor } @@ -62,6 +63,8 @@ import type { IPLDBlock, DIDKey, Protocol, + InvocationError, + MultihashDigest, } from '@ucanto/interface' import type { @@ -94,12 +97,17 @@ import type { AccountDID, ProviderDID, AccessDenied, + SpaceDID, } from '@web3-storage/capabilities' import { type Client } from './client.js' import { StorefrontService as FilecoinProtocol } from '@web3-storage/filecoin-client/storefront' import exp from 'constants' import { CID } from 'multiformats' import { Block } from '@ipld/car/buffer-reader' +import { SpaceInfoFailure } from '@web3-storage/upload-api' +import { EmailAddress, DidMailto } from '@web3-storage/did-mailto' +import { UTCUnixTimestamp } from '@ipld/dag-ucan' +import { extname } from 'path' export * from '@ipld/dag-ucan' export * from '@ucanto/interface' @@ -354,8 +362,6 @@ export type { ShardStoringOptions, UploadOptions, UploadDirectoryOptions, - FileLike, - BlobLike, ProgressStatus, } from '@web3-storage/upload-client/types' @@ -810,7 +816,7 @@ export interface AgentView extends Agent { connection?: ConnectionView ): Promise< Result< - Session, + W3UpSession, SignerLoadError | DataStoreOpenError | DataStoreSaveError > > @@ -851,10 +857,51 @@ export interface DatabaseTransactionError extends Failure { * Session an agent has with a service provider. */ export interface Session { - agent: AgentView + agent: Agent connection: Connection } +export interface W3UpSession extends Session { + spaces: SpacesSession + accounts: AccountsSession +} + +export interface SpacesSession extends Iterable { + create(source: { + name: string + }): Promise, never>> + list(): Record + + add(space: SharedSpace): Promise> + remove(space: SharedSpace): Promise> +} + +export interface AccountsSession< + Protocol extends AccessRequestProvider & PlanProtocol & ProviderProtocol +> extends Iterable> { + login(source: { + email: EmailAddress + signal?: AbortSignal + }): Promise< + Result< + AccountSession, + AccessDenied | InvocationError | AccessAuthorizeFailure + > + > + + list(): Record> + + get(email: EmailAddress): AccountSession | undefined + + add( + account: AccountSession + ): Promise> + + remove( + account: AccountSession + ): Promise> +} + export interface Connection extends ConnectionView { address: Address @@ -890,16 +937,258 @@ export interface Authorization { */ export interface Limit extends Record {} -export interface AccountView { +export interface AccountSession< + Protocol extends UnknownProtocol = W3UpProtocol +> { did(): AccountDID session: Session proofs: Delegation[] + + toEmail(): EmailAddress + + plans: AccountPlans + spaces: SpacesSession +} + +export interface AccountPlans< + Protocol extends ProviderProtocol & + PlanProtocol & + SubscriptionProtocol = ProviderProtocol & + PlanProtocol & + SubscriptionProtocol +> { + list(): Promise< + Result< + Record>, + AccessDenied | PlanNotFound | InvocationError + > + > } export interface BillingPlan< - Protocol extends ProviderProtocol = ProviderProtocol + Protocol extends ProviderProtocol & + PlanProtocol & + SubscriptionProtocol = ProviderProtocol & + PlanProtocol & + SubscriptionProtocol > { - account: AccountView + account: AccountSession customer: AccountDID provider: ProviderDID + + subscriptions: AccountSubscriptions +} + +export interface AccountSubscriptions { + add(subscription: { + consumer: SpaceDID + limit?: Limit + }): Promise> + + list(): Promise> +} + +export interface Subscription { + provider: ProviderDID + customer: AccountDID + consumer: SpaceDID + limit: Limit +} + +export interface Subscriptions extends Iterable { + [key: string]: Subscription +} + +export interface OwnSpace { + signer: Signer + name: string +} + +export interface SpaceSession + extends Session { + name: string + did(): DIDKey +} + +export interface SpaceSessionView + extends SpaceSession { + info(): Promise> + + delegations: SpaceDelegationsView + + blobs: SpaceBlobsView +} + +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 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 +} + +interface SpaceDelegationsView { + add( + authorization: Authorization + ): Promise> +} + +export interface ShareAccess { + can: Can + expiration?: UTCUnixTimestamp +} + +export interface OwnSpaceView extends OwnSpace { + did(): DIDKey + rename(name: string): OwnSpace + + toMnemonic(): string + + connect( + connection: Connection + ): OwnSpaceSession + + share( + authority: Signer, + access?: ShareAccess + ): Promise> + + createRecovery( + authority: Principal, + access?: { expiration?: UTCUnixTimestamp } + ): Promise> +} + +export interface OwnSpaceSession + extends SpaceSessionView { + rename(name: string): OwnSpaceSession + did(): DIDKey + rename(name: string): OwnSpaceSession + toMnemonic(): string + + share( + authority: Signer, + access?: ShareAccess + ): Promise, Unit>> + + createRecovery( + authority: Principal, + access?: ShareAccess + ): Promise> +} + +export interface OwnSpacePromise extends Promise> { + connect( + connection: Connection + ): Promise, Unit>> +} + +export interface SharedSpace { + did(): DIDKey + authority: DID + + proofs: Delegation[] +} + +export interface SharedSpaceView extends SharedSpace { + name: string + subject: DID + + connect( + connection: Connection + ): SharedSpaceSession +} + +export interface SharedSpaceSession< + Protocol extends SpaceProtocol = W3UpProtocol +> extends SharedSpace, + SpaceSessionView { + name: string } diff --git a/packages/w3up-client/src/view/account.js b/packages/w3up-client/src/view/account.js index b858e5d57..b17baf7a8 100644 --- a/packages/w3up-client/src/view/account.js +++ b/packages/w3up-client/src/view/account.js @@ -2,16 +2,16 @@ 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 '../agent.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 + * @param {API.Session} session */ constructor(session) { this.session = session @@ -28,7 +28,7 @@ class View { * 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 {API.Session} session * @param {object} query * @param {API.DID<'mailto'>} [query.account] */ diff --git a/packages/w3up-client/src/session.js b/packages/w3up-client/src/w3up.js similarity index 58% rename from packages/w3up-client/src/session.js rename to packages/w3up-client/src/w3up.js index ae84e32c5..9bb75cc5b 100644 --- a/packages/w3up-client/src/session.js +++ b/packages/w3up-client/src/w3up.js @@ -1,10 +1,14 @@ import * as API from './types.js' +import * as Space from './space.js' +import * as Account from './account.js' /** * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} model + * @returns {API.W3UpSession} */ -export const create = (model) => new Session(model) +export const create = (model) => + new Session(/** @type {API.Session} */ (model)) /** * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] @@ -16,6 +20,8 @@ class Session { */ constructor(model) { this.model = model + this.spaces = Space.view(/** @type {API.Session} */ (this.model)) + this.accounts = Account.view(/** @type {API.Session} */ (this.model)) } get connection() { return this.model.connection @@ -24,16 +30,3 @@ class Session { return this.model.agent } } - -/** - * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] - */ -class SessionSpaces { - /** - * @param {API.Session} session - */ - constructor(session) { - this.session = session - } - list() {} -} diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index 752b84b11..4c7eee00e 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -1,7 +1,7 @@ import * as Test from './test.js' import * as Access from '../src/access.js' import * as Result from '../src/result.js' -import * as Authorization from '../src/agent/authorization.js' +import * as Authorization from '../src/authorization/query.js' import * as Space from '../src/space.js' import * as API from '../src/types.js' import * as DB from '../src/agent/db.js' diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index dbb09e808..bd6dbc020 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -9,78 +9,87 @@ import * as DB from '../src/agent/db.js' * @type {Test.Suite} */ export const testAccount = { - 'only list accounts': async (assert, { session, mail, grantAccess }) => { - const email = 'alice@web.mail' - - assert.deepEqual(Account.list(session), {}, 'no accounts yet') - - const login = Account.login(session, { email }) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - const account = Result.unwrap(await 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') - Result.unwrap(await account.save()) - const accounts = Account.list(session) - - 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) - }, + 'list accounts': async (assert, { session, mail, grantAccess }) => + Task.perform(function* () { + const email = 'alice@web.mail' - 'only two logins': async (assert, { session, mail, grantAccess }) => { - const aliceEmail = 'alice@web.mail' - const bobEmail = 'bob@web.mail' - - assert.deepEqual(Account.list(session), {}, 'no accounts yet') - const aliceLogin = Account.login(session, { email: aliceEmail }) - await grantAccess(await mail.take()) - const alice = Result.unwrap(await aliceLogin) - assert.deepEqual(alice.toEmail(), aliceEmail) - - assert.deepEqual(Account.list(session), {}, 'no accounts have been saved') - Result.unwrap(await alice.save()) - - const one = Account.list(session) - assert.deepEqual(Object.values(one).length, 1) - assert.ok( - one[Account.DIDMailto.fromEmail(aliceEmail)], - 'alice in the account list' - ) - - const bobLogin = Account.login(session, { email: bobEmail }) - await grantAccess(await mail.take()) - const bob = Result.unwrap(await bobLogin) - assert.deepEqual(bob.toEmail(), bobEmail) - await bob.save() - - 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) - }, + 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* Task.join(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* Task.join(session.accounts.add(account)) + const accounts = session.accounts.list() - 'only login idempotence': async (assert, { session, mail, grantAccess }) => + 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) + + return { ok: {} } + }), + + 'two logins': async (assert, { session, mail, grantAccess }) => + Task.perform(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* Task.join(aliceLogin) + assert.deepEqual(alice.toEmail(), aliceEmail) + + assert.deepEqual( + session.accounts.list(), + {}, + 'no accounts have been saved' + ) + yield* Task.join(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* Task.join(bobLogin) + assert.deepEqual(bob.toEmail(), bobEmail) + yield* Task.join(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) + + return { ok: {} } + }), + + 'login idempotence': (assert, { session, mail, grantAccess }) => Task.perform(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* Task.join(login) - yield* Task.join(alice.save()) + + yield* Task.join(session.accounts.add(alice)) assert.deepEqual( Object.keys(Account.list(session)), @@ -97,18 +106,18 @@ export const testAccount = { return { ok: {} } }), - 'only account login': async (assert, { session, mail, grantAccess }) => + 'account login': async (assert, { session, mail, grantAccess }) => Task.perform(function* () { - const login = Account.login(session, { email: 'alice@web.mail' }) + const login = session.accounts.login({ email: 'alice@web.mail' }) const message = yield* Task.wait(mail.take()) yield* Task.wait(grantAccess(message)) const alice = yield* Task.join(login) assert.deepEqual(alice.toEmail(), 'alice@web.mail') - yield* Task.join(alice.save()) + yield* Task.join(session.accounts.add(alice)) - const accounts = Account.list(session) + const accounts = session.accounts.list() assert.deepEqual(Object.keys(accounts), [alice.did()]) return { ok: {} } @@ -116,41 +125,48 @@ export const testAccount = { 'create account and provision space': async ( assert, - { session, mail, grantAccess } + { session, mail, grantAccess, plansStorage } ) => Task.perform(function* () { - const space = yield* Task.wait(Space.generate({ name: 'test' })) + const space = yield* Task.join(session.spaces.create({ name: 'test' })) const mnemonic = space.toMnemonic() + const { signer } = yield* Task.wait( Space.fromMnemonic(mnemonic, { name: 'import' }) ) + assert.deepEqual( - space.signer.encode(), + space.agent.signer.encode(), signer.encode(), 'arrived to same signer' ) const email = 'alice@web.mail' - const login = Account.login(session, { email }) + 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* Task.join(login) - yield* Task.join(account.provision(space.did())) + yield* Task.join( + plansStorage.set(account.did(), 'did:web:free.web3.storage') + ) + const plans = yield* Task.join(account.plans.list()) + const [{ subscriptions }] = Object.values(plans) + + yield* Task.join(subscriptions.add({ consumer: space.did() })) // authorize agent to use space - const proof = yield* Task.wait( - space.createAuthorization(session.agent, { - access: { 'space/info': {} }, - expiration: Infinity, + const shared = yield* Task.join( + space.share(session.agent.signer, { + can: { 'space/info': [] }, }) ) - yield* Task.join(DB.transact(session.agent.db, [DB.assert({ proof })])) + // yield* Task.join(session.spaces.add(shared)) - const info = yield* Task.join(Space.info(session, { id: space.did() })) + const info = yield* Task.join(shared.info()) assert.deepEqual(info, { did: space.did(), @@ -160,138 +176,144 @@ export const testAccount = { return { ok: {} } }), - '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], - }) + 'multi device workflow': async ( + assert, + { connection, mail, grantAccess, plansStorage } + ) => + Task.perform(function* () { + const laptop = yield* Task.join(Test.connect(connection)) + const space = yield* Task.join(laptop.spaces.create({ name: 'main' })) - // 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(), []) + // 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* Task.join(login) - assert.deepEqual(client.spaces().length, 0, 'no spaces had been added') + // setup billing + yield* Task.join( + plansStorage.set(account.did(), 'did:web:free.web3.storage') + ) + // Authorized account can provision space + const plans = yield* Task.join(account.plans.list()) + const [{ subscriptions }] = Object.values(plans) - // 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)) + yield* Task.join(subscriptions.add({ consumer: space.did() })) - // 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) + // // Want to setup a recovery for this space ? + const recovery = yield* Task.join(space.createRecovery(account)) - Result.try(await secondAccount.save()) + // Store space delegation in the space so that account can claim it. + yield* Task.join(space.delegations.add(recovery)) - assert.deepEqual(client.spaces().length, 1, 'spaces had been added') - }, + // now connect with a second device + const phone = yield* Task.join(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* Task.join(phoneLogin) + + const [phoneSpace] = phoneAccount.spaces + assert.deepEqual(phoneSpace.did(), space.did()) + + return { ok: {} } + }), 'check account plan': async ( assert, - { client, mail, grantAccess, plansStorage } + { session, mail, grantAccess, plansStorage } ) => { - const login = Account.login(client, 'alice@web.mail') - await grantAccess(await mail.take()) - const account = Result.try(await login) - - const { error } = await account.plan.get() - assert.ok(error) + const result = Task.perform(function* () { + const login = session.accounts.login({ email: 'alice@web.mail' }) - Result.unwrap( - await plansStorage.set(account.did(), 'did:web:free.web3.storage') - ) + const message = yield* Task.wait(mail.take()) + yield* Task.wait(grantAccess(message)) - const { ok: plan } = await account.plan.get() + const account = yield* Task.join(login) - assert.ok(plan?.product, 'did:web:free.web3.storage') - }, + const plans = yield* Task.join(account.plans.list()) + assert.deepEqual(plans, {}, 'no plans yet') - '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) + yield* Task.join( + plansStorage.set(account.did(), 'did:web:free.web3.storage') + ) - Result.try(await account.provision(space.did())) + const updatePlans = yield* Task.join(account.plans.list()) + assert.deepEqual(Object.keys(updatePlans), ['did:web:free.web3.storage']) - const subs = Result.unwrap(await account.plan.subscriptions()) + return { ok: {} } + }) - 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') + try { + await result + } catch (error) { + // throw new Error(error) + console.log('error', error) + throw error + } }, - 'space.save': async (assert, { client, mail, grantAccess }) => { - const space = await client.createSpace('test') - assert.deepEqual(client.spaces(), []) + 'check account subscriptions': async ( + assert, + { session, mail, grantAccess, plansStorage } + ) => + Task.perform(function* () { + const space = yield* Task.join(session.spaces.create({ name: 'test' })) - const result = await space.save() - assert.ok(result.ok) + 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* Task.join(login) - const spaces = client.spaces() - assert.deepEqual(spaces.length, 1) - assert.deepEqual(spaces[0].did(), space.did()) + // setup billing + yield* Task.join( + plansStorage.set(account.did(), 'did:web:test.web3.storage') + ) + // Authorized account can provision space + const plans = yield* Task.join(account.plans.list()) + const [{ subscriptions }] = Object.values(plans) + + yield* Task.join(subscriptions.add({ consumer: space.did() })) + + const [...subs] = yield* Task.join(subscriptions.list()) + + assert.deepEqual(subs, [ + { + customer: account.did(), + consumer: space.did(), + provider: 'did:web:test.web3.storage', + limit: {}, + }, + ]) + + const second = yield* Task.join(session.spaces.create({ name: 'second' })) + yield* Task.join(subscriptions.add({ consumer: second.did() })) + + const [...subs2] = yield* Task.join(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: {}, + }, + ]) - assert.deepEqual(client.currentSpace()?.did(), space.did()) - }, + return { ok: {} } + }), } Test.test({ Account: testAccount }) diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index eaa14fd0a..808f242f0 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -3,7 +3,7 @@ import * as Test from '../test.js' import * as Space from '../../src/capability/space.js' import * as Account from '../../src/agent/login.js' import * as Delegation from '../../src/agent/delegation.js' -import * as Spaces from '../../src/agent/space.js' +import * as Spaces from '../../src/space/query.js' import { createLegacyLink, delegate } from '@ucanto/core' import { Absentee, Verifier } from '@ucanto/principal' import * as Capability from '@web3-storage/capabilities' @@ -11,7 +11,7 @@ import * as Cap from '../../src/agent/capability.js' import { fromEmail, toEmail } from '@web3-storage/did-mailto' import { alice, bob, mallory, w3up } from '../fixtures/principals.js' -import * as Authorization from '../../src/agent/authorization.js' +import * as Authorization from '../../src/authorization/query.js' /** * @type {Test.BasicSuite} @@ -136,7 +136,7 @@ export const testDB = { ]) const spaces = Authorization.find(db, { - subject: { like: 'did:key:%' }, + subject: { glob: 'did:key:*' }, can: { 'store/add': [] }, authority: alice.did(), }) diff --git a/packages/w3up-client/test/space.test.js b/packages/w3up-client/test/space.test.js index 39eff419d..8d871e11c 100644 --- a/packages/w3up-client/test/space.test.js +++ b/packages/w3up-client/test/space.test.js @@ -1,56 +1,152 @@ 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 Account from '../src/view/account.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.perform(function* () { + const spaces = Space.view(session) + const none = spaces.list() + assert.deepEqual(none, {}) + + const space = yield* Task.join(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* Task.join(space.info()) + + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + + assert.deepEqual(spaces.list(), {}, 'space was not saved') + + const sharedSpace = yield* Task.join(space.share(session.agent.signer)) + + yield* Task.join(spaces.add(sharedSpace)) + + assert.deepEqual( + spaces.list(), + { + [space.did()]: sharedSpace, + }, + 'space was saved' + ) + + const saved = spaces.list()[space.did()] + const status = yield* Task.join(saved.info()) + + assert.deepEqual(status, { + did: space.did(), + providers: [session.connection.id.did()], + }) + + return { ok: {} } + }), + 'should get usage': async ( + assert, + { session, grantAccess, mail, plansStorage } + ) => + Task.perform(function* () { + const product = 'did:web:test.web3.storage' + const space = yield* Task.join(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* Task.join(login) + + // setup billing plan + yield* Task.join(plansStorage.set(account.did(), product)) + + const plans = yield* Task.join(account.plans.list()) + const [plan] = Object.values(plans) + + yield* Task.join(plan.subscriptions.add({ consumer: space.did() })) + + const shared = yield* Task.join(space.share(session.agent.signer)) + yield* Task.join(session.spaces.add(shared)) + + const [saved] = session.spaces + assert.deepEqual(saved.did(), space.did()) + + return { ok: {} } + + 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.perform(function* () { + const product = 'did:web:test.web3.storage' + const email = 'alice@web.mail' + yield* Task.join( + 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* Task.join(login) + + const plans = yield* Task.join(alice.plans.list()) + const [plan] = Object.values(plans) + + assert.equal(plan.account, alice) + assert.equal(plan.customer, alice.did()) + assert.equal(plan.provider, session.connection.id.did()) + + const space = yield* Task.join(Space.create({ name: 'test-space' })) + + yield* Task.join(plan.subscriptions.add({ consumer: space.did() })) + + const info = yield* Task.join(space.connect(session.connection).info()) + assert.deepEqual(info, { + did: space.did(), + providers: [session.connection.id.did()], + }) + + return { ok: {} } + }), } Test.test({ Space: testSpace }) diff --git a/packages/w3up-client/test/subscription.test.js b/packages/w3up-client/test/subscription.test.js deleted file mode 100644 index 32142855a..000000000 --- a/packages/w3up-client/test/subscription.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import * as Test from './test.js' -import * as Account from '../src/account.js' -import * as Task from '../src/task.js' -import * as Space from '../src/space.js' - -/** - * @type {Test.Suite} - */ -export const testSubscription = { - 'provision a space': async ( - assert, - { session, mail, plansStorage, grantAccess } - ) => - Task.perform(function* () { - const product = 'did:web:test.web3.storage' - const email = 'alice@web.mail' - yield* Task.join( - 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* Task.join(login) - - const plans = yield* Task.join(alice.plans.list()) - const [plan] = Object.values(plans) - - assert.equal(plan.account, alice) - assert.equal(plan.customer, alice.did()) - assert.equal(plan.provider, session.connection.id.did()) - - const space = yield* Task.wait(Space.generate({ name: 'test-space' })) - - plan.subscriptions.add({ consumer: space.did() }) - - space.createAuthorization(session.agent) - - const info = yield* Task.join(Space.info(session, { id: space.did() })) - assert.deepEqual(info, { - did: space.did(), - providers: [session.connection.id.did()], - }) - - return { ok: {} } - }), -} - -Test.test({ Subscription: testSubscription }) From 10026a971e9d89cde09617e4263019a13c8108f5 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 4 Mar 2024 22:21:38 -0800 Subject: [PATCH 09/16] fix: misalignments --- packages/w3up-client/src/access.js | 12 +- packages/w3up-client/src/account.js | 14 +- packages/w3up-client/src/account/plan.js | 9 +- packages/w3up-client/src/agent/attestation.js | 14 +- packages/w3up-client/src/agent/capability.js | 6 + packages/w3up-client/src/agent/delegation.js | 24 +- .../w3up-client/src/authorization/query.js | 98 ++++-- .../w3up-client/src/authorization/view.js | 12 +- packages/w3up-client/src/client/space.js | 23 -- .../w3up-client/src/client/subscription.js | 20 -- packages/w3up-client/src/result.js | 5 - packages/w3up-client/src/space.js | 3 +- packages/w3up-client/src/space/delegations.js | 8 +- packages/w3up-client/src/space/own.js | 12 +- packages/w3up-client/src/space/query.js | 14 +- packages/w3up-client/src/space/shared.js | 8 +- packages/w3up-client/src/space/usage.js | 38 ++- packages/w3up-client/src/types.ts | 71 +++-- packages/w3up-client/test/access.test.js | 6 +- packages/w3up-client/test/agent.test.js | 4 +- packages/w3up-client/test/agent/db.test.js | 288 +++++++++++------- .../w3up-client/test/client-accounts.test.js | 37 --- packages/w3up-client/test/plan.test.js | 59 ---- packages/w3up-client/test/result.test.js | 4 +- 24 files changed, 406 insertions(+), 383 deletions(-) delete mode 100644 packages/w3up-client/src/client/space.js delete mode 100644 packages/w3up-client/src/client/subscription.js delete mode 100644 packages/w3up-client/test/client-accounts.test.js delete mode 100644 packages/w3up-client/test/plan.test.js diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js index b07d87c69..720d5e596 100644 --- a/packages/w3up-client/src/access.js +++ b/packages/w3up-client/src/access.js @@ -5,9 +5,8 @@ import * as API from './types.js' export { DIDMailto } import * as Access from '@web3-storage/capabilities/access' -import { Failure, fail, DID } from '@ucanto/core' +import { Failure, DID } from '@ucanto/core' import { bytesToDelegations } from './agent/encoding.js' -import * as Authorization from './authorization/query.js' import * as DB from './agent/db.js' import * as Agent from './agent.js' @@ -18,16 +17,13 @@ import * as Agent from './agent.js' * 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 {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. + * @param {API.SpaceDID} input.subject - Space to propagate through. + * @returns {Promise>} */ export const delegate = async (session, { delegations, subject }) => { - if (!subject) { - return fail('Space must be specified') - } - const entries = Object.values(delegations).map((proof) => [ proof.cid.toString(), proof.cid, diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 5b9967224..cf282bfe2 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -9,14 +9,14 @@ import * as Space from './space.js' export { DIDMailto } /** - * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session * @returns {API.AccountsSession} */ export const view = (session) => new AccountsView(session) /** - * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] */ export class AccountsView { /** @@ -73,7 +73,7 @@ export class AccountsView { } /** - * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session * @param {object} source * @param {API.EmailAddress} source.email @@ -118,7 +118,7 @@ export const login = async (session, { email, signal }) => { } /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session * @returns {Record>} */ @@ -144,7 +144,7 @@ export const list = (session) => { * Gets the account view for the login with a given email address. Returns * `undefined` if no matching login is found. * - * @template {API.PlanProtocol & API.ProviderProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session * @param {API.EmailAddress} email */ @@ -194,11 +194,11 @@ export const remove = async ({ agent }, account) => { } /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] */ class AccountSession { /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {object} source * @param {object} source.login * @param {API.DidMailto} source.login.id diff --git a/packages/w3up-client/src/account/plan.js b/packages/w3up-client/src/account/plan.js index 4379b3644..0727beeb8 100644 --- a/packages/w3up-client/src/account/plan.js +++ b/packages/w3up-client/src/account/plan.js @@ -4,14 +4,14 @@ import * as Subscriptions from './subscription.js' import * as Agent from '../agent.js' /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.AccountSession} account * @returns {API.AccountPlans} */ export const from = (account) => new AccountPlans(account) /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.AccountSession} account * @returns {Promise>, API.AccessDenied | API.PlanNotFound | API.InvocationError>>} */ @@ -52,7 +52,7 @@ export const list = async (account) => { } /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @implements {API.AccountPlans} */ class AccountPlans { @@ -68,7 +68,8 @@ class AccountPlans { } /** - * @template {API.PlanProtocol & API.ProviderProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.BillingPlan} */ class BillingPlan { /** diff --git a/packages/w3up-client/src/agent/attestation.js b/packages/w3up-client/src/agent/attestation.js index f967cd8a7..949d1c1b2 100644 --- a/packages/w3up-client/src/agent/attestation.js +++ b/packages/w3up-client/src/agent/attestation.js @@ -11,15 +11,21 @@ import * as Delegation from './delegation.js' * * @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.time - * @param {DB.Term} constraints.audience - * @param {DB.Term} constraints.proof + * @param {DB.Term} [constraints.audience] */ export const match = ( ucan, - { capability = DB.link(), subject = DB.string(), audience, proof, time } + { + capability = DB.link(), + subject = DB.string(), + audience = DB.string(), + proof, + time, + } ) => Capability.match(capability, { subject, diff --git a/packages/w3up-client/src/agent/capability.js b/packages/w3up-client/src/agent/capability.js index 9388fdd12..181b468f0 100644 --- a/packages/w3up-client/src/agent/capability.js +++ b/packages/w3up-client/src/agent/capability.js @@ -52,6 +52,12 @@ export const match = ( ) => 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] diff --git a/packages/w3up-client/src/agent/delegation.js b/packages/w3up-client/src/agent/delegation.js index a9b9a5e9e..c939f298e 100644 --- a/packages/w3up-client/src/agent/delegation.js +++ b/packages/w3up-client/src/agent/delegation.js @@ -5,7 +5,6 @@ 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' -import { Delegation } from '@ucanto/core' /** * @param {DB.Term} ucan @@ -73,31 +72,48 @@ export const isTooEarly = (ucan, time) => { * @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(), time = DB.integer() } + { + 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(), time = DB.integer(), can = DB.string() } + { + 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, audience, time }) + match(ucan, { capability, issuer, audience, time }) ) } diff --git a/packages/w3up-client/src/authorization/query.js b/packages/w3up-client/src/authorization/query.js index f01aeaf48..c15d6dc16 100644 --- a/packages/w3up-client/src/authorization/query.js +++ b/packages/w3up-client/src/authorization/query.js @@ -15,7 +15,7 @@ export { Capability, Delegation, Text } * @property {string} [need] * * @typedef {object} Selector - * @property {DB.Term} authority + * @property {DB.Term} audience * @property {DB.Term} subject * @property {ProofSelector[]} proofs */ @@ -26,7 +26,7 @@ export { Capability, Delegation, Text } * `selector.subject` when time is `selector.time`. * * @param {object} selector - * @param {API.TextConstraint} selector.authority + * @param {API.TextConstraint} selector.audience * @param {API.Can} [selector.can] * @param {API.TextConstraint} [selector.subject] * @param {API.UTCUnixTimestamp} [selector.time] @@ -34,31 +34,51 @@ export { Capability, Delegation, Text } */ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { const subject = DB.string() - const authority = 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) - /** @type {{proof: DB.Term, attestation: DB.Term, can: DB.Term, need?: string }[]} */ - const proofs = need.length - ? need.map((need) => ({ - proof: DB.link(), - need, - can: DB.string(), - attestation: DB.link(), - })) - : [{ proof: DB.link(), can: DB.string(), attestation: DB.link() }] + // 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 }), + })) - const where = proofs.map(({ proof, need, can, attestation }) => { + // Here we generate selector clause for each proof that we will try to match + const where = proofs.map(({ proof, issuer, need, can, attestation }) => { + // main clause will find a relevant proof. const clause = match(proof, { subject, can, - authority, + audience, + issuer, time, }) + // Then we try to find an attestations for the proof, however attestations + // are required only for `did:mailto:` issued proofs, 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 + // the found proof. const attestations = DB.or( - DB.not(DB.Constraint.glob(subject, 'did:mailto:*')), - Attestation.match(attestation, { proof, time, audience: authority }) + DB.not(DB.Constraint.glob(issuer, 'did:mailto:*')), + Attestation.match(attestation, { proof, time, audience }) ) + // 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. return (need ? clause.and(DB.glob(need, can)) : clause).and(attestations) }) @@ -66,18 +86,20 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { select: { proofs, subject, - authority, + audience, }, where: [ ...where, + // If subject pattern was provided we constraint matches by it. Text.match(subject, selector.subject ?? { glob: '*' }), - Text.match(authority, selector.authority), + // If audience was provided we constraint matches by it. + Text.match(audience, selector.audience), ], } } /** - * Matches a delegation that authorizes the `selector.authority` with an ability + * Matches a delegation that authorizes the `selector.audience` with an ability * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note * that it will only match explicit authorization that is one that specifies * `selector.subject` and will not match implicit authorizations that uses @@ -88,12 +110,14 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { * @param {DB.Term} [selector.time] * @param {DB.Term} [selector.can] * @param {DB.Term} [selector.subject] - * @param {DB.Term} [selector.authority] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.issuer] */ export const explicit = ( delegation, { - authority = DB.string(), + audience = DB.string(), + issuer = DB.string(), subject = DB.string(), can = DB.string(), time = DB.integer(), @@ -104,14 +128,15 @@ export const explicit = ( return Capability.match(capability, { can, subject }).and( Delegation.match(delegation, { capability, - audience: authority, + audience, + issuer, time, }) ) } /** - * Matches a delegation that authorizes the `selector.authority` with an ability + * Matches a delegation that authorizes the `selector.audience` with an ability * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note * that it will only match implicit authorization that is one that has `ucan:*` * subject and is either issued by `selector.subject` or has a proof which @@ -122,7 +147,8 @@ export const explicit = ( * @param {DB.Term} [selector.time] * @param {DB.Term} [selector.can] * @param {DB.Term} [selector.subject] - * @param {DB.Term} [selector.authority] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.issuer] * @returns {DB.Clause} */ export const implicit = ( @@ -131,13 +157,15 @@ export const implicit = ( subject = DB.string(), can = DB.string(), time = DB.integer(), - authority = DB.string(), + audience = DB.string(), + issuer = DB.string(), } ) => { const proof = DB.link() return DB.and( Delegation.forwards(delegation, { - audience: authority, + issuer, + audience, can, time, }), @@ -146,9 +174,9 @@ export const implicit = ( DB.and( Delegation.hasProof(delegation, proof), DB.or( - explicit(proof, { subject, can, time }) + explicit(proof, { audience: issuer, subject, can, time }) // TODO: Add support for recursive implicit delegation - // implicit(proof, { subject, can, time, authority }) + // implicit(proof, { subject, can, time, audience }) ) ) ) @@ -156,23 +184,29 @@ export const implicit = ( } /** + * 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} delegation * @param {object} selector * @param {DB.Term} [selector.time] * @param {DB.Term} [selector.can] * @param {DB.Term} [selector.subject] - * @param {DB.Term} [selector.authority] + * @param {DB.Term} [selector.audience] + * @param {DB.Term} [selector.issuer] */ export const match = ( delegation, { - authority = DB.string(), + audience = DB.string(), subject = DB.string(), + issuer = DB.string(), can = DB.string(), time = DB.integer(), } ) => DB.or( - explicit(delegation, { authority, can, subject, time }), - implicit(delegation, { authority, can, subject, time }) + explicit(delegation, { issuer, audience, can, subject, time }), + implicit(delegation, { issuer, audience, can, subject, time }) ) diff --git a/packages/w3up-client/src/authorization/view.js b/packages/w3up-client/src/authorization/view.js index 030c08076..8fa499f1d 100644 --- a/packages/w3up-client/src/authorization/view.js +++ b/packages/w3up-client/src/authorization/view.js @@ -40,7 +40,7 @@ export const get = (db, { authority, subject, can }) => { } } else { const result = find(db, { - authority, + audience: authority, subject, can, }) @@ -64,7 +64,7 @@ export const get = (db, { authority, subject, can }) => { * * @param {API.Database} db * @param {object} query - * @param {API.TextConstraint} query.authority + * @param {API.TextConstraint} query.audience * @param {API.TextConstraint} [query.subject] * @param {API.Can} [query.can] * @param {API.UTCUnixTimestamp} [query.time] @@ -72,14 +72,14 @@ export const get = (db, { authority, subject, can }) => { */ export const find = ( db, - { subject = { glob: '*' }, authority, time = Date.now() / 1000, can } + { subject = { glob: '*' }, audience, time = Date.now() / 1000, can } ) => DB.query( db.index, Query.query({ can, subject, - authority, + audience, time, }) ).map((match) => select(db, match)) @@ -88,7 +88,7 @@ export const find = ( * @param {API.Database} db * @param {DB.InferBindings} match */ -export const select = (db, { authority, subject, proofs }) => { +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([ @@ -98,7 +98,7 @@ export const select = (db, { authority, subject, proofs }) => { ]) return from({ - authority: /** @type {API.DID} */ (authority), + authority: /** @type {API.DID} */ (audience), subject: /** @type {API.SpaceDID} */ (subject), can: Object.fromEntries(proofs.map(({ can, need }) => [need ?? can, []])), // Dereference proofs from the store. diff --git a/packages/w3up-client/src/client/space.js b/packages/w3up-client/src/client/space.js deleted file mode 100644 index 3c04c261b..000000000 --- a/packages/w3up-client/src/client/space.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Client } from './client.js' -import * as API from '../types.js' -import * as Space from '../capability/space.js' - -/** - * Client for interacting with the `space/*` capabilities. - * - * @extends {Client} - */ -export class SpaceClient extends Client { - /** - * Get information about a space. - * - * @param {API.SpaceDID} [space] - DID of the space to retrieve info about. - */ - async info(space = this.agent.data.currentSpace) { - if (!space) { - throw new Error('No space selected, you need pass a resource.') - } - - return await Space.info(this.agent, space) - } -} diff --git a/packages/w3up-client/src/client/subscription.js b/packages/w3up-client/src/client/subscription.js deleted file mode 100644 index 22d19e403..000000000 --- a/packages/w3up-client/src/client/subscription.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as API from '../types.js' -import { Client } from './client.js' -import { list } from '../capability/subscription.js' -import * as Result from '../result.js' - -/** - * Client for interacting with the `subscription/*` capabilities. - * - * @extends {Client} - */ -export class SubscriptionClient extends Client { - /** - * List subscriptions for the passed account. - * - * @param {API.AccountDID} account - */ - async list(account) { - return Result.unwrap(await list(this.agent, { account })) - } -} 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/space.js b/packages/w3up-client/src/space.js index 9087065f5..df01bda6f 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -63,7 +63,7 @@ export const remove = (session, space) => ) /** - * @template {API.PlanProtocol & API.UsageProtocol & API.SpaceProtocol} [Protocol=API.W3UpProtocol] + * @template {API.PlanProtocol & API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session * @param {{space:API.DIDKey, name?: string, proof: DB.Link}[]} spaces */ @@ -130,6 +130,7 @@ class SessionSpaces { * * @param {object} source * @param {string} source.name + * @returns {Promise, never>>} */ async create(source) { return OwnSpace.create(source).connect(this.session.connection) diff --git a/packages/w3up-client/src/space/delegations.js b/packages/w3up-client/src/space/delegations.js index d214648df..89d830cca 100644 --- a/packages/w3up-client/src/space/delegations.js +++ b/packages/w3up-client/src/space/delegations.js @@ -2,15 +2,17 @@ import * as API from '../types.js' import * as Access from '../access.js' /** - * @param {API.SpaceSession} session + * @param {API.Session} session + * @returns {API.SpaceDelegationsView} */ export const view = (session) => new Delegations(session) /** + * @implements {API.SpaceDelegationsView} */ class Delegations { /** - * @param {API.SpaceSession} session + * @param {API.Session} session */ constructor(session) { this.session = session @@ -23,7 +25,7 @@ class Delegations { add(authorization) { return Access.delegate(this.session, { delegations: authorization.proofs, - subject: this.session.did(), + subject: /** @type {API.DIDKey} */ (this.session.agent.signer.did()), }) } diff --git a/packages/w3up-client/src/space/own.js b/packages/w3up-client/src/space/own.js index 2f842480a..2986772a7 100644 --- a/packages/w3up-client/src/space/own.js +++ b/packages/w3up-client/src/space/own.js @@ -9,6 +9,7 @@ import * as DB from '../agent/db.js' import * as Session from './session.js' import * as Authorization from '../authorization.js' import * as Delegations from './delegations.js' +import * as Usage from './usage.js' /** * @param {object} options @@ -172,7 +173,7 @@ class OwnSpacePromise extends Promise { * 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 {API.SpaceProtocol & API.UsageProtocol} Protocol + * @template {API.SpaceProtocol & API.UsageProtocol & API.AccessProtocol} Protocol * @param {API.Connection} connection * @returns {Promise, never>>} */ @@ -236,7 +237,7 @@ class OwnSpace { * 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 {API.SpaceProtocol & API.UsageProtocol} Protocol + * @template {API.SpaceProtocol & API.UsageProtocol & API.AccessProtocol} Protocol * @param {API.Connection} connection * @returns {API.OwnSpaceSession} */ @@ -286,7 +287,7 @@ class OwnSpace { * Represents a remote replica of the owned space. It can be used to query * state of the replica and invoke actions on it. * - * @template {API.SpaceProtocol & API.UsageProtocol} [Protocol=API.W3UpProtocol] + * @template {API.SpaceProtocol & API.UsageProtocol & API.AccessProtocol} [Protocol=API.W3UpProtocol] * @implements {API.OwnSpaceSession} */ class OwnSpaceSession { @@ -299,9 +300,8 @@ class OwnSpaceSession { constructor(model) { this.model = model - this.delegations = Delegations.view( - /** @type {API.SpaceSession} */ (this) - ) + this.usage = Usage.view(this) + this.delegations = Delegations.view(this) } get signer() { diff --git a/packages/w3up-client/src/space/query.js b/packages/w3up-client/src/space/query.js index b8b972098..50290958f 100644 --- a/packages/w3up-client/src/space/query.js +++ b/packages/w3up-client/src/space/query.js @@ -62,7 +62,12 @@ export const explicit = ( } ) => { return DB.and( - Authorization.explicit(ucan, { authority, can, subject: space, time }), + Authorization.explicit(ucan, { + audience: authority, + can, + subject: space, + time, + }), named(ucan, name), Text.match(space, { glob: 'did:key:*' }) ) @@ -91,7 +96,12 @@ export const match = ( return DB.or( // It may be a an explicit delegation DB.and( - Authorization.explicit(ucan, { authority, can, subject: space, time }), + Authorization.explicit(ucan, { + audience: authority, + can, + subject: space, + time, + }), named(ucan, name) ), // Or it could be an implicit delegation issued by the space diff --git a/packages/w3up-client/src/space/shared.js b/packages/w3up-client/src/space/shared.js index b83648308..b2ad515f9 100644 --- a/packages/w3up-client/src/space/shared.js +++ b/packages/w3up-client/src/space/shared.js @@ -54,7 +54,7 @@ class SharedSpace { } /** - * @template {API.UsageProtocol & API.SpaceProtocol} Protocol + * @template {API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol} Protocol * @param {API.Connection} connection * @returns {API.SharedSpaceSession} */ @@ -74,7 +74,7 @@ class SharedSpace { */ /** - * @template {API.UsageProtocol & API.SpaceProtocol} [Protocol=API.W3UpProtocol] + * @template {API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol} [Protocol=API.W3UpProtocol] * @implements {API.SharedSpaceSession} */ class SharedSpaceSession { @@ -89,9 +89,7 @@ class SharedSpaceSession { this.model = model this.usage = Usage.view(this) - this.delegations = Delegations.view( - /** @type {API.SpaceSession} */ (this) - ) + this.delegations = Delegations.view(this) } get connection() { return this.model.connection diff --git a/packages/w3up-client/src/space/usage.js b/packages/w3up-client/src/space/usage.js index 912fe69d6..6f8ae8b7d 100644 --- a/packages/w3up-client/src/space/usage.js +++ b/packages/w3up-client/src/space/usage.js @@ -4,7 +4,7 @@ import * as Task from '../task.js' import * as Agent from '../agent.js' /** - * @param {API.SharedSpaceSession} session + * @param {API.Session} session * @returns */ export const view = (session) => new UsageSession(session) @@ -12,14 +12,14 @@ export const view = (session) => new UsageSession(session) /** * Get a usage report for the period. * - * @param {API.SharedSpaceSession} session + * @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 const report = async (session, { space, period, proofs = [] }) => - Task.execute(function* () { +export const report = async (session, { space, period }) => + Task.try(function* () { const auth = yield* Task.join( Agent.authorize(session.agent, { subject: space, @@ -47,25 +47,22 @@ export const report = async (session, { space, period, proofs = [] }) => return receipt.out }) +/** + * @implements {API.SpaceUsageView} + */ class UsageSession { /** - * @param {API.SharedSpaceSession} session + * @param {API.Session} session */ constructor(session) { this.session = session } + /** - * @param {object} options - * @param {API.SpaceDID} options.space - * @param {{ from: Date, to: Date }} options.period - * @param {API.Delegation[]} [options.proofs] + * @returns {Promise>} */ - report(options) { - return report(this.session, options) - } - async get() { - const space = /** @type {API.DIDKey} */ (this.session.did()) + const space = /** @type {API.DIDKey} */ (this.session.agent.signer.did()) const now = new Date() const period = { // we may not have done a snapshot for this month _yet_, so get report @@ -86,9 +83,20 @@ class UsageSession { return { /* c8 ignore next */ - ok: usage?.size.final == null ? undefined : BigInt(usage.size.final), + ok: BigInt(usage.size.final ?? -1), } } + + /** + * Get a usage report for the passed space in the given time period. + * + * @param {{from: Date, to: Date}} period + * @returns {Promise>} + */ + async report(period) { + const space = /** @type {API.DIDKey} */ (this.session.agent.signer.did()) + return report(this.session, { space, period }) + } } /** @param {string|number|Date} now */ diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 8336d792c..f11234867 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -98,16 +98,16 @@ import type { ProviderDID, AccessDenied, SpaceDID, + UsageData, } from '@web3-storage/capabilities' import { type Client } from './client.js' import { StorefrontService as FilecoinProtocol } from '@web3-storage/filecoin-client/storefront' -import exp from 'constants' + import { CID } from 'multiformats' import { Block } from '@ipld/car/buffer-reader' import { SpaceInfoFailure } from '@web3-storage/upload-api' import { EmailAddress, DidMailto } from '@web3-storage/did-mailto' -import { UTCUnixTimestamp } from '@ipld/dag-ucan' -import { extname } from 'path' +import { UTCUnixTimestamp, Signer as UCANSigner } from '@ipld/dag-ucan' export * from '@ipld/dag-ucan' export * from '@ucanto/interface' @@ -124,6 +124,7 @@ export type { ToString, View, } from '@ucanto/interface' + export type { UCAN } from '@web3-storage/capabilities' export type { Driver as Storage } @@ -330,6 +331,9 @@ export type { FilecoinInfo, FilecoinInfoSuccess, FilecoinInfoFailure, + UsageData, + UsageReportSuccess, + UsageReportFailure, } from '@web3-storage/capabilities/types' export type { @@ -345,8 +349,6 @@ export type { UploadRemoveSuccess, UploadListSuccess, UploadListItem, - UsageReportSuccess, - UsageReportFailure, ListResponse, AnyLink, CARLink, @@ -862,6 +864,7 @@ export interface Session { } export interface W3UpSession extends Session { + agent: AgentView spaces: SpacesSession accounts: AccountsSession } @@ -877,7 +880,10 @@ export interface SpacesSession extends Iterable { } export interface AccountsSession< - Protocol extends AccessRequestProvider & PlanProtocol & ProviderProtocol + Protocol extends AccessRequestProvider & + PlanProtocol & + ProviderProtocol & + SubscriptionProtocol > extends Iterable> { login(source: { email: EmailAddress @@ -966,10 +972,10 @@ export interface AccountPlans< } export interface BillingPlan< - Protocol extends ProviderProtocol & - PlanProtocol & - SubscriptionProtocol = ProviderProtocol & - PlanProtocol & + Protocol extends PlanProtocol & + ProviderProtocol & + SubscriptionProtocol = PlanProtocol & + ProviderProtocol & SubscriptionProtocol > { account: AccountSession @@ -1014,9 +1020,10 @@ export interface SpaceSessionView extends SpaceSession { info(): Promise> + usage: SpaceUsageView delegations: SpaceDelegationsView - blobs: SpaceBlobsView + // blobs: SpaceBlobsView } export interface SpaceUploadsView { @@ -1091,6 +1098,7 @@ export interface Uploader { upload(space?: SpaceView): Promise } +export interface SpaceView {} interface FileUploader extends Uploader {} interface DirectoryUploader extends Uploader {} @@ -1114,14 +1122,31 @@ interface StreambleBytes { stream(): ReadableStream } -interface SpaceDelegationsView { +export interface SpaceDelegationsView { add( authorization: Authorization - ): Promise> + ): Promise< + Result + > +} + +export interface SpaceUsageView { + report(period: { + from: Date + to: Date + }): Promise< + Result< + UsageReportSuccess, + UsageReportFailure | AccessDenied | InvocationError + > + > + get(): Promise< + Result + > } export interface ShareAccess { - can: Can + can?: Can expiration?: UTCUnixTimestamp } @@ -1131,12 +1156,12 @@ export interface OwnSpaceView extends OwnSpace { toMnemonic(): string - connect( + connect( connection: Connection ): OwnSpaceSession share( - authority: Signer, + authority: UCANSigner, access?: ShareAccess ): Promise> @@ -1154,7 +1179,7 @@ export interface OwnSpaceSession toMnemonic(): string share( - authority: Signer, + authority: UCANSigner, access?: ShareAccess ): Promise, Unit>> @@ -1165,9 +1190,9 @@ export interface OwnSpaceSession } export interface OwnSpacePromise extends Promise> { - connect( + connect( connection: Connection - ): Promise, Unit>> + ): Promise, never>> } export interface SharedSpace { @@ -1181,13 +1206,17 @@ export interface SharedSpaceView extends SharedSpace { name: string subject: DID - connect( + connect< + Protocol extends UsageProtocol & + SpaceProtocol & + AccessProtocol = W3UpProtocol + >( connection: Connection ): SharedSpaceSession } export interface SharedSpaceSession< - Protocol extends SpaceProtocol = W3UpProtocol + Protocol extends SpaceProtocol & UsageProtocol = W3UpProtocol > extends SharedSpace, SpaceSessionView { name: string diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index 4c7eee00e..c0886afb2 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -19,10 +19,10 @@ export const testAccess = { assert.deepEqual(message.to, email) await grantAccess(message) - assert.deepEqual(request.authority, session.agent.did()) + assert.deepEqual(request.authority, session.agent.signer.did()) assert.ok(request.expiration.getTime() >= Date.now()) - const access = Result.try(await request.claim()) + const access = Result.unwrap(await request.claim()) assert.ok(access.proofs.length > 0) const results = Authorization.find(session.agent.db, { @@ -59,7 +59,7 @@ export const testAccess = { assert, { session, provisionsStorage } ) => { - const space = await Space.generate({ name: 'main' }) + const space = await Space.create({ name: 'main' }) Result.unwrap( await DB.transact(session.agent.db, [ DB.assert({ proof: await space.createAuthorization(session.agent) }), diff --git a/packages/w3up-client/test/agent.test.js b/packages/w3up-client/test/agent.test.js index a88263566..7c0584909 100644 --- a/packages/w3up-client/test/agent.test.js +++ b/packages/w3up-client/test/agent.test.js @@ -78,7 +78,9 @@ export const testAgent = { const tr = await Agent.DB.transact(agent.db, [ { - signer: bob.toArchive(), + assert: { + signer: bob.toArchive(), + }, }, ]) diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/agent/db.test.js index 808f242f0..3b1601682 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/agent/db.test.js @@ -1,6 +1,6 @@ import * as DB from '../../src/agent/db.js' import * as Test from '../test.js' -import * as Space from '../../src/capability/space.js' +import * as Space from '../../src/space.js' import * as Account from '../../src/agent/login.js' import * as Delegation from '../../src/agent/delegation.js' import * as Spaces from '../../src/space/query.js' @@ -9,24 +9,27 @@ import { Absentee, Verifier } from '@ucanto/principal' import * as Capability from '@web3-storage/capabilities' import * as Cap from '../../src/agent/capability.js' import { fromEmail, toEmail } from '@web3-storage/did-mailto' +import * as Result from '../../src/result.js' import { alice, bob, mallory, w3up } from '../fixtures/principals.js' -import * as Authorization from '../../src/authorization/query.js' +import * as Authorization from '../../src/authorization.js' /** * @type {Test.BasicSuite} */ export const testDB = { 'test find space': async (assert) => { - const space = await Space.generate({ - name: 'beet-box', - }) - const proof = await space.createAuthorization(alice) - const db = DB.from({ proofs: [proof] }) + const space = Result.unwrap( + await Space.create({ + name: 'beet-box', + }) + ) + const { proofs } = Result.unwrap(await space.share(alice)) + const db = DB.from({ proofs }) const result = Authorization.find(db, { can: { 'store/add': [] }, - authority: alice.did(), + audience: alice.did(), }) assert.deepEqual(result, [ @@ -34,27 +37,33 @@ export const testDB = { authority: alice.did(), can: { 'store/add': [] }, subject: space.did(), - proofs: [proof], + proofs, }), ]) }, 'test find several spaces': async (assert) => { - const beetBox = await Space.generate({ - name: 'beet-box', - }) - const beetBoxAuth = await beetBox.createAuthorization(alice) + const beetBox = Result.unwrap( + await Space.create({ + name: 'beet-box', + }) + ) + const beetBoxAuth = Result.unwrap(await beetBox.share(alice)) - const plumBox = await Space.generate({ - name: 'plum-box', - }) - const plumBoxAuth = await plumBox.createAuthorization(alice) + const plumBox = Result.unwrap( + await Space.create({ + name: 'plum-box', + }) + ) + const plumBoxAuth = Result.unwrap(await plumBox.share(alice)) - const db = DB.from({ proofs: [beetBoxAuth, plumBoxAuth] }) + const db = DB.from({ + proofs: [...beetBoxAuth.proofs, ...plumBoxAuth.proofs], + }) const result = Authorization.find(db, { can: { 'store/add': [], 'store/remove': [] }, - authority: alice.did(), + audience: alice.did(), }) assert.deepEqual(result, [ @@ -62,13 +71,13 @@ export const testDB = { authority: alice.did(), can: { 'store/add': [], 'store/remove': [] }, subject: beetBox.did(), - proofs: [beetBoxAuth], + proofs: beetBoxAuth.proofs, }), Authorization.from({ authority: alice.did(), can: { 'store/add': [], 'store/remove': [] }, subject: plumBox.did(), - proofs: [plumBoxAuth], + proofs: plumBoxAuth.proofs, }), ]) }, @@ -90,7 +99,7 @@ export const testDB = { const result = Authorization.find(db, { can: { 'space/info': [], 'upload/list': [] }, - authority: bob.did(), + audience: bob.did(), }) assert.deepEqual(result, [ @@ -104,10 +113,12 @@ export const testDB = { }, 'test find accounts ignoring spaces': async (assert) => { - const localSpace = await Space.generate({ - name: 'local-box', - }) - const localAuth = await localSpace.createAuthorization(alice) + const localSpace = Result.unwrap( + await Space.create({ + name: 'local-box', + }) + ) + const localAuth = Result.unwrap(await localSpace.share(alice)) const { login, @@ -117,13 +128,13 @@ export const testDB = { } = await setupAccount() const db = DB.from({ - proofs: [login, attestation, localAuth], + proofs: [login, attestation, ...localAuth.proofs], }) const result = Authorization.find(db, { subject: { glob: 'did:mailto:*' }, can: { '*': [] }, - authority: alice.did(), + audience: alice.did(), }) assert.deepEqual(result, [ @@ -131,34 +142,35 @@ export const testDB = { subject: account.did(), authority: alice.did(), can: { '*': [] }, - proofs: [login], + proofs: [login, attestation], }), ]) - const spaces = Authorization.find(db, { + 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(spaces, [ - Authorization.from({ - authority: alice.did(), - can: { 'store/add': [] }, - subject: localSpace.did(), - proofs: [localAuth], - }), - Authorization.from({ - authority: alice.did(), - can: { 'store/add': [] }, - subject: remoteSpace.did(), - proofs: [login], - }), - ]) + assert.deepEqual(second.toJSON(), { + authority: alice.did(), + can: { 'store/add': [] }, + subject: remoteSpace.did(), + proofs: [login, attestation], + }) + + assert.deepEqual(rest, []) }, 'test find accounts and attestations': async (assert) => { - const { login, attestation, account } = await setupAccount() + const { login, attestation } = await setupAccount() const db = DB.from({ proofs: [login, attestation] }) @@ -193,23 +205,27 @@ export const testDB = { }, 'does not match expired ucans': async (assert) => { - const space = await Space.generate({ name: 'space' }) + const space = Result.unwrap(await Space.create({ name: 'space' })) const time = (Date.now() / 1000) | 0 - const expired = await space.createAuthorization(alice, { - expiration: time - 60 * 60 * 24, - }) + const expired = Result.unwrap( + await space.share(alice, { + expiration: time - 60 * 60 * 24, + }) + ) - const valid = await space.createAuthorization(alice, { - expiration: time + 60 * 60 * 24, - }) + const valid = Result.unwrap( + await space.share(alice, { + expiration: time + 60 * 60 * 24, + }) + ) const db = DB.from({ - proofs: [valid, expired], + proofs: [...valid.proofs, ...expired.proofs], }) const withoutExpired = Authorization.find(db, { can: { 'store/add': [] }, - authority: alice.did(), + audience: alice.did(), time, }) @@ -218,13 +234,13 @@ export const testDB = { authority: alice.did(), can: { 'store/add': [] }, subject: space.did(), - proofs: [valid], + proofs: valid.proofs, }), ]) const withExpired = Authorization.find(db, { can: { 'store/add': [] }, - authority: alice.did(), + audience: alice.did(), time: time - 60 * 60 * 24 * 2, }) @@ -233,30 +249,32 @@ export const testDB = { authority: alice.did(), can: { 'store/add': [] }, subject: space.did(), - proofs: [valid], + proofs: valid.proofs, }), Authorization.from({ authority: alice.did(), can: { 'store/add': [] }, subject: space.did(), - proofs: [expired], + proofs: expired.proofs, }), ]) }, 'does match non-expiring ucans': async (assert) => { - const space = await Space.generate({ name: 'space' }) - const proof = await space.createAuthorization(alice, { - expiration: Infinity, - }) + const space = Result.unwrap(await Space.create({ name: 'space' })) + const { proofs } = Result.unwrap( + await space.share(alice, { + expiration: Infinity, + }) + ) const db = DB.from({ - proofs: [proof], + proofs, }) const result = Authorization.find(db, { can: { 'store/add': [] }, - authority: alice.did(), + audience: alice.did(), }) assert.deepEqual(result, [ @@ -264,7 +282,7 @@ export const testDB = { authority: alice.did(), can: { 'store/add': [] }, subject: space.did(), - proofs: [proof], + proofs, }), ]) }, @@ -349,17 +367,19 @@ export const testDB = { agent: alice, }) - const space = await Space.generate({ name: 'space' }) - const proof = await space.createAuthorization(alice, { - expiration: Infinity, - }) + const space = Result.unwrap(await Space.create({ name: 'space' })) + const { proofs } = Result.unwrap( + await space.share(alice, { + expiration: Infinity, + }) + ) const db = DB.from({ proofs: [ ...aliceLogin.proofs, ...bobLogin.proofs, ...aliLogin.proofs, - proof, + ...proofs, ], }) @@ -439,7 +459,7 @@ export const testDB = { { audience: alice.did(), subject: space.did(), - 'store/*': proof.cid, + 'store/*': proofs[0].cid, }, ], 'finds spaces delegated to agent directly' @@ -483,26 +503,36 @@ export const testDB = { proofs: [], }) + const attestation = await Capability.UCAN.attest.delegate({ + issuer: w3up, + audience: alice, + with: w3up.did(), + nb: { proof: proof.cid }, + expiration: Infinity, + }) + const db = DB.from({ - proofs: [proof], + proofs: [proof, attestation], }) const result = Authorization.find(db, { can: { 'store/add': [] }, - authority: alice.did(), + audience: alice.did(), subject: account.did(), }) - assert.deepEqual(result, [ - { - model: { + assert.deepEqual( + result, + [ + Authorization.from({ authority: alice.did(), subject: account.did(), can: { 'store/add': [] }, - proofs: [proof], - }, - }, - ]) + proofs: [proof, attestation], + }), + ], + 'requires attestation' + ) }, 'account authority from authorization': async (assert) => { @@ -523,51 +553,74 @@ export const testDB = { proofs: [proof], }) - const result = Authorization.find(db, { - can: { 'store/add': [] }, - authority: alice.did(), - subject: account.did(), + assert.deepEqual( + Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + subject: account.did(), + }), + [], + 'can not find without attestation' + ) + + const attestation = await Capability.UCAN.attest.delegate({ + issuer: w3up, + audience: alice, + with: w3up.did(), + nb: { proof: proof.cid }, + expiration: Infinity, }) - assert.deepEqual(result, [ - { - model: { + // save attestation + Result.unwrap(await 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], - }, - }, - ]) + proofs: [proof, attestation], + }), + ], + 'found when attestation was added' + ) }, 'find whatever capabilities match': async (assert) => { - const space = await Space.generate({ - name: 'beet-box', - }) - const proof = await space.createAuthorization(alice) - const db = DB.from({ proofs: [proof] }) + const space = Result.unwrap( + await Space.create({ + name: 'beet-box', + }) + ) + const { proofs } = Result.unwrap(await space.share(alice)) + const db = DB.from({ proofs }) const result = Authorization.find(db, { - authority: alice.did(), + audience: alice.did(), }) assert.deepEqual( result, - proof.capabilities.map(({ can }) => + proofs[0].capabilities.map(({ can }) => Authorization.from({ authority: alice.did(), can: { [can]: [] }, subject: space.did(), - proofs: [proof], + proofs, }) ) ) }, 'find capabilities grouped by spaces': async (assert) => { - const beetBox = await Space.generate({ name: 'beet-box' }) - const yumBox = await Space.generate({ name: 'yum-box' }) + const beetBox = Result.unwrap(await Space.create({ name: 'beet-box' })) + const yumBox = Result.unwrap(await Space.create({ name: 'yum-box' })) const aliceLogin = await setupAccount({ name: 'Alice', email: 'alice@web.mail', @@ -582,12 +635,17 @@ export const testDB = { with: bob.did(), }), - await beetBox.createAuthorization(alice), - await beetBox.createAuthorization(alice, { access: { 'debug/*': {} } }), - await yumBox.createAuthorization(alice, { access: { 'store/*': {} } }), - await yumBox.createAuthorization(alice, { access: { 'upload/*': {} } }), - await yumBox.createAuthorization(alice, { access: { 'space/*': {} } }), - await yumBox.createAuthorization(alice, { access: { 'access/*': {} } }), + ...Result.unwrap(await beetBox.share(alice)).proofs, + ...Result.unwrap(await beetBox.share(alice, { can: { 'debug/*': [] } })) + .proofs, + ...Result.unwrap(await yumBox.share(alice, { can: { 'store/*': [] } })) + .proofs, + ...Result.unwrap(await yumBox.share(alice, { can: { 'upload/*': [] } })) + .proofs, + ...Result.unwrap(await yumBox.share(alice, { can: { 'space/*': [] } })) + .proofs, + ...Result.unwrap(await yumBox.share(alice, { can: { 'access/*': [] } })) + .proofs, aliceLogin.login, aliceLogin.attestation, ], @@ -634,12 +692,12 @@ export const testDB = { ], }) - assert.deepEqual(implicit, [ + assert.deepEqual( + Object.fromEntries(implicit.map(({ space, name }) => [space, name])), { - space: aliceLogin.space.did(), - name: 'Alice', - }, - ]) + [aliceLogin.space.did()]: 'Alice', + } + ) const all = DB.query(db.index, { select: { @@ -672,10 +730,10 @@ const setupAccount = async ({ name = 'stuff', agent = alice, } = {}) => { - const space = await Space.generate({ name }) + const space = Result.unwrap(await Space.create({ name })) const account = Absentee.from({ id: fromEmail(email) }) - const recovery = await space.createRecovery(account.did()) + const recovery = Result.unwrap(await space.createRecovery(account)) const login = await delegate({ issuer: account, audience: agent, @@ -685,7 +743,7 @@ const setupAccount = async ({ can: '*', }, ], - proofs: [recovery], + proofs: recovery.proofs, }) const attestation = await Capability.UCAN.attest.delegate({ 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 ec8352b0d..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/view/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/plan.test.js b/packages/w3up-client/test/plan.test.js deleted file mode 100644 index 1ea67cca9..000000000 --- a/packages/w3up-client/test/plan.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import * as Test from './test.js' -import * as Account from '../src/account.js' -import * as Task from '../src/task.js' - -/** - * @type {Test.Suite} - */ -export const testPlan = { - 'test account has no plans': async (assert, { session, mail, grantAccess }) => - Task.perform(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* Task.join(login) - yield* Task.join(alice.save()) - - assert.deepEqual( - Object.keys(Account.list(session)), - [alice.did()], - 'account was saved' - ) - - const plans = yield* Task.join(alice.plans.list()) - assert.deepEqual(plans, {}) - - return { ok: {} } - }), - 'test account with a plan': async ( - assert, - { session, mail, plansStorage, grantAccess } - ) => - Task.perform(function* () { - const product = 'did:web:test.web3.storage' - const email = 'alice@web.mail' - yield* Task.join( - 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* Task.join(login) - - const plans = yield* Task.join(alice.plans.list()) - const [plan] = Object.values(plans) - - assert.equal(plan.account, alice) - assert.equal(plan.customer, alice.did()) - assert.equal(plan.provider, session.connection.id.did()) - assert.ok(plan.subscriptions) - - return { ok: {} } - }), -} - -Test.test({ Plan: testPlan }) 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') }) }) From 6aa6c1dc3497b8c892b21c7eed0057bd99e329fa Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 4 Mar 2024 22:31:42 -0800 Subject: [PATCH 10/16] fix: broken tests --- packages/w3up-client/test/access.test.js | 34 +-- packages/w3up-client/test/account.test.js | 1 + packages/w3up-client/test/agent.test.js | 1 - packages/w3up-client/test/agent/store.test.js | 85 ------- .../w3up-client/test/fixtures/movie-facts.js | 238 ------------------ packages/w3up-client/test/space.test.js | 2 +- 6 files changed, 21 insertions(+), 340 deletions(-) delete mode 100644 packages/w3up-client/test/agent/store.test.js delete mode 100644 packages/w3up-client/test/fixtures/movie-facts.js diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index c0886afb2..e029ecd07 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -1,7 +1,7 @@ import * as Test from './test.js' import * as Access from '../src/access.js' import * as Result from '../src/result.js' -import * as Authorization from '../src/authorization/query.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' @@ -26,7 +26,7 @@ export const testAccess = { assert.ok(access.proofs.length > 0) const results = Authorization.find(session.agent.db, { - authority: session.agent.did(), + audience: session.agent.did(), can: { 'store/add': [] }, }) @@ -34,7 +34,7 @@ export const testAccess = { Result.unwrap(await access.save()) const [login] = Authorization.find(session.agent.db, { - authority: session.agent.did(), + audience: session.agent.did(), can: { 'store/add': [] }, }) assert.ok(login) @@ -45,7 +45,7 @@ export const testAccess = { const [auth] = Authorization.find(session.agent.db, { can: { 'store/add': [] }, - authority: session.agent.did(), + audience: session.agent.did(), subject: account, }) @@ -59,11 +59,13 @@ export const testAccess = { assert, { session, provisionsStorage } ) => { - const space = await Space.create({ name: 'main' }) + const space = Result.unwrap(await Space.create({ name: 'main' })) + const { proofs } = Result.unwrap(await space.share(session.agent.signer)) Result.unwrap( - await DB.transact(session.agent.db, [ - DB.assert({ proof: await space.createAuthorization(session.agent) }), - ]) + await DB.transact( + session.agent.db, + proofs.map((proof) => DB.assert({ proof })) + ) ) Result.unwrap( @@ -76,21 +78,23 @@ export const testAccess = { }) ) - const shared = await Space.generate({ name: 'shared' }) - const delegation = await shared.createAuthorization(session.agent) + const shared = Result.unwrap(await Space.create({ name: 'shared' })) + const { proofs: delegations } = Result.unwrap( + await shared.share(session.agent.signer) + ) const result = await Access.delegate(session, { - delegations: [delegation], + delegations, subject: space.did(), }) assert.ok(result.ok) const claim = Result.unwrap(await Access.claim(session)) - assert.deepEqual(claim.proofs, [delegation]) + assert.deepEqual(claim.proofs, delegations) const none = Authorization.find(session.agent.db, { - authority: session.agent.did(), + audience: session.agent.did(), subject: shared.did(), can: { 'store/add': [] }, }) @@ -100,7 +104,7 @@ export const testAccess = { Result.unwrap(await claim.save()) const [auth] = Authorization.find(session.agent.db, { - authority: session.agent.did(), + audience: session.agent.did(), subject: shared.did(), can: { 'store/add': [] }, }) @@ -111,7 +115,7 @@ export const testAccess = { authority: session.agent.did(), subject: shared.did(), can: { 'store/add': [] }, - proofs: [delegation], + proofs: delegations, }), 'claimed access has been added to an agent' ) diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index bd6dbc020..be6d25094 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -136,6 +136,7 @@ export const testAccount = { ) assert.deepEqual( + // @ts-expect-error space.agent.signer.encode(), signer.encode(), 'arrived to same signer' diff --git a/packages/w3up-client/test/agent.test.js b/packages/w3up-client/test/agent.test.js index 7c0584909..384ee6184 100644 --- a/packages/w3up-client/test/agent.test.js +++ b/packages/w3up-client/test/agent.test.js @@ -1,6 +1,5 @@ import * as Test from './test.js' import { alice, bob, mallory, service } from './fixtures/principals.js' -import * as API from '../src/types.js' import * as Agent from '../src/agent.js' import * as Result from '../src/result.js' diff --git a/packages/w3up-client/test/agent/store.test.js b/packages/w3up-client/test/agent/store.test.js deleted file mode 100644 index 6b1baab33..000000000 --- a/packages/w3up-client/test/agent/store.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import * as Test from '../test.js' -import { alice, bob, mallory } from '../fixtures/principals.js' -import * as Store from '../../src/agent/ex-store.js' - -/** - * @type {Test.Suite} - */ -export const testAgentStore = { - testLocalSpace: async (assert, { service, client }) => { - const space = await client.createSpace('example') - const proof = await space.createAuthorization(alice) - - const store = Store.from({ proofs: [proof] }) - - assert.deepEqual([...Store.query(store, {})], [proof]) - assert.deepEqual( - [...Store.query(store, { audience: Store.literal(alice.did()) })], - [proof] - ) - - assert.deepEqual( - [ - ...Store.query(store, { - expiration: Store.integer().lessThan(Store.now()), - }), - ], - [], - 'expires later than now' - ) - - assert.deepEqual( - [ - ...Store.query(store, { - expiration: Store.integer().greaterThan(Store.now()), - }), - ], - [proof], - 'expires after now' - ) - - assert.deepEqual( - [...Store.query(store, { audience: Store.literal(bob.did()) })], - [] - ) - - assert.deepEqual( - [...Store.query(store, { can: Store.ability('store/add') })], - [proof] - ) - - assert.deepEqual( - [ - ...Store.query(store, { - audience: Store.literal(alice.did()), - expiration: Store.integer().greaterThan(Store.now()), - with: Store.did({ method: 'key' }), - can: Store.ability('store/remove'), - nb: Store.struct({ - size: Store.integer().greaterThan(0), - }), - }), - ], - [], - 'nb.size is not specified' - ) - - assert.deepEqual( - [ - ...Store.query(store, { - audience: Store.literal(alice.did()), - expiration: Store.integer().greaterThan(Store.now()), - with: Store.did({ method: 'key' }), - can: Store.ability('store/remove'), - nb: Store.struct({ - size: Store.integer().greaterThan(0).optional(), - }), - }), - ], - [proof], - 'nb.size is optional' - ) - }, -} - -Test.test({ AgentStore: testAgentStore }) diff --git a/packages/w3up-client/test/fixtures/movie-facts.js b/packages/w3up-client/test/fixtures/movie-facts.js deleted file mode 100644 index 72ae78c2e..000000000 --- a/packages/w3up-client/test/fixtures/movie-facts.js +++ /dev/null @@ -1,238 +0,0 @@ -export const facts = /** @type {const} */ ([ - [100, 'person/name', 'James Cameron'], - [100, 'person/born', '1954-08-16T00:00:00Z'], - [101, 'person/name', 'Arnold Schwarzenegger'], - [101, 'person/born', '1947-07-30T00:00:00Z'], - [102, 'person/name', 'Linda Hamilton'], - [102, 'person/born', '1956-09-26T00:00:00Z'], - [103, 'person/name', 'Michael Biehn'], - [103, 'person/born', '1956-07-31T00:00:00Z'], - [104, 'person/name', 'Ted Kotcheff'], - [104, 'person/born', '1931-04-07T00:00:00Z'], - [105, 'person/name', 'Sylvester Stallone'], - [105, 'person/born', '1946-07-06T00:00:00Z'], - [106, 'person/name', 'Richard Crenna'], - [106, 'person/born', '1926-11-30T00:00:00Z'], - [106, 'person/death', '2003-01-17T00:00:00Z'], - [107, 'person/name', 'Brian Dennehy'], - [107, 'person/born', '1938-07-09T00:00:00Z'], - [108, 'person/name', 'John McTiernan'], - [108, 'person/born', '1951-01-08T00:00:00Z'], - [109, 'person/name', 'Elpidia Carrillo'], - [109, 'person/born', '1961-08-16T00:00:00Z'], - [110, 'person/name', 'Carl Weathers'], - [110, 'person/born', '1948-01-14T00:00:00Z'], - [111, 'person/name', 'Richard Donner'], - [111, 'person/born', '1930-04-24T00:00:00Z'], - [112, 'person/name', 'Mel Gibson'], - [112, 'person/born', '1956-01-03T00:00:00Z'], - [113, 'person/name', 'Danny Glover'], - [113, 'person/born', '1946-07-22T00:00:00Z'], - [114, 'person/name', 'Gary Busey'], - [114, 'person/born', '1944-07-29T00:00:00Z'], - [115, 'person/name', 'Paul Verhoeven'], - [115, 'person/born', '1938-07-18T00:00:00Z'], - [116, 'person/name', 'Peter Weller'], - [116, 'person/born', '1947-06-24T00:00:00Z'], - [117, 'person/name', 'Nancy Allen'], - [117, 'person/born', '1950-06-24T00:00:00Z'], - [118, 'person/name', 'Ronny Cox'], - [118, 'person/born', '1938-07-23T00:00:00Z'], - [119, 'person/name', 'Mark L. Lester'], - [119, 'person/born', '1946-11-26T00:00:00Z'], - [120, 'person/name', 'Rae Dawn Chong'], - [120, 'person/born', '1961-02-28T00:00:00Z'], - [121, 'person/name', 'Alyssa Milano'], - [121, 'person/born', '1972-12-19T00:00:00Z'], - [122, 'person/name', 'Bruce Willis'], - [122, 'person/born', '1955-03-19T00:00:00Z'], - [123, 'person/name', 'Alan Rickman'], - [123, 'person/born', '1946-02-21T00:00:00Z'], - [124, 'person/name', 'Alexander Godunov'], - [124, 'person/born', '1949-11-28T00:00:00Z'], - [124, 'person/death', '1995-05-18T00:00:00Z'], - [125, 'person/name', 'Robert Patrick'], - [125, 'person/born', '1958-11-05T00:00:00Z'], - [126, 'person/name', 'Edward Furlong'], - [126, 'person/born', '1977-08-02T00:00:00Z'], - [127, 'person/name', 'Jonathan Mostow'], - [127, 'person/born', '1961-11-28T00:00:00Z'], - [128, 'person/name', 'Nick Stahl'], - [128, 'person/born', '1979-12-05T00:00:00Z'], - [129, 'person/name', 'Claire Danes'], - [129, 'person/born', '1979-04-12T00:00:00Z'], - [130, 'person/name', 'George P. Cosmatos'], - [130, 'person/born', '1941-01-04T00:00:00Z'], - [130, 'person/death', '2005-04-19T00:00:00Z'], - [131, 'person/name', 'Charles Napier'], - [131, 'person/born', '1936-04-12T00:00:00Z'], - [131, 'person/death', '2011-10-05T00:00:00Z'], - [132, 'person/name', 'Peter MacDonald'], - [133, 'person/name', 'Marc de Jonge'], - [133, 'person/born', '1949-02-16T00:00:00Z'], - [133, 'person/death', '1996-06-06T00:00:00Z'], - [134, 'person/name', 'Stephen Hopkins'], - [135, 'person/name', 'Ruben Blades'], - [135, 'person/born', '1948-07-16T00:00:00Z'], - [136, 'person/name', 'Joe Pesci'], - [136, 'person/born', '1943-02-09T00:00:00Z'], - [137, 'person/name', 'Ridley Scott'], - [137, 'person/born', '1937-11-30T00:00:00Z'], - [138, 'person/name', 'Tom Skerritt'], - [138, 'person/born', '1933-08-25T00:00:00Z'], - [139, 'person/name', 'Sigourney Weaver'], - [139, 'person/born', '1949-10-08T00:00:00Z'], - [140, 'person/name', 'Veronica Cartwright'], - [140, 'person/born', '1949-04-20T00:00:00Z'], - [141, 'person/name', 'Carrie Henn'], - [142, 'person/name', 'George Miller'], - [142, 'person/born', '1945-03-03T00:00:00Z'], - [143, 'person/name', 'Steve Bisley'], - [143, 'person/born', '1951-12-26T00:00:00Z'], - [144, 'person/name', 'Joanne Samuel'], - [145, 'person/name', 'Michael Preston'], - [145, 'person/born', '1938-05-14T00:00:00Z'], - [146, 'person/name', 'Bruce Spence'], - [146, 'person/born', '1945-09-17T00:00:00Z'], - [147, 'person/name', 'George Ogilvie'], - [147, 'person/born', '1931-03-05T00:00:00Z'], - [148, 'person/name', 'Tina Turner'], - [148, 'person/born', '1939-11-26T00:00:00Z'], - [149, 'person/name', 'Sophie Marceau'], - [149, 'person/born', '1966-11-17T00:00:00Z'], - [200, 'movie/title', 'The Terminator'], - [200, 'movie/year', 1984], - [200, 'movie/director', 100], - [200, 'movie/cast', 101], - [200, 'movie/cast', 102], - [200, 'movie/cast', 103], - [200, 'movie/sequel', 207], - [201, 'movie/title', 'First Blood'], - [201, 'movie/year', 1982], - [201, 'movie/director', 104], - [201, 'movie/cast', 105], - [201, 'movie/cast', 106], - [201, 'movie/cast', 107], - [201, 'movie/sequel', 209], - [202, 'movie/title', 'Predator'], - [202, 'movie/year', 1987], - [202, 'movie/director', 108], - [202, 'movie/cast', 101], - [202, 'movie/cast', 109], - [202, 'movie/cast', 110], - [202, 'movie/sequel', 211], - [203, 'movie/title', 'Lethal Weapon'], - [203, 'movie/year', 1987], - [203, 'movie/director', 111], - [203, 'movie/cast', 112], - [203, 'movie/cast', 113], - [203, 'movie/cast', 114], - [203, 'movie/sequel', 212], - [204, 'movie/title', 'RoboCop'], - [204, 'movie/year', 1987], - [204, 'movie/director', 115], - [204, 'movie/cast', 116], - [204, 'movie/cast', 117], - [204, 'movie/cast', 118], - [205, 'movie/title', 'Commando'], - [205, 'movie/year', 1985], - [205, 'movie/director', 119], - [205, 'movie/cast', 101], - [205, 'movie/cast', 120], - [205, 'movie/cast', 121], - [ - 205, - 'trivia', - "In 1986, a sequel was written with an eye to having\n John McTiernan direct. Schwarzenegger wasn't interested in reprising\n the role. The script was then reworked with a new central character,\n eventually played by Bruce Willis, and became Die Hard", - ], - [206, 'movie/title', 'Die Hard'], - [206, 'movie/year', 1988], - [206, 'movie/director', 108], - [206, 'movie/cast', 122], - [206, 'movie/cast', 123], - [206, 'movie/cast', 124], - [207, 'movie/title', 'Terminator 2: Judgment Day'], - [207, 'movie/year', 1991], - [207, 'movie/director', 100], - [207, 'movie/cast', 101], - [207, 'movie/cast', 102], - [207, 'movie/cast', 125], - [207, 'movie/cast', 126], - [207, 'movie/sequel', 208], - [208, 'movie/title', 'Terminator 3: Rise of the Machines'], - [208, 'movie/year', 2003], - [208, 'movie/director', 127], - [208, 'movie/cast', 101], - [208, 'movie/cast', 128], - [208, 'movie/cast', 129], - [209, 'movie/title', 'Rambo: First Blood Part II'], - [209, 'movie/year', 1985], - [209, 'movie/director', 130], - [209, 'movie/cast', 105], - [209, 'movie/cast', 106], - [209, 'movie/cast', 131], - [209, 'movie/sequel', 210], - [210, 'movie/title', 'Rambo III'], - [210, 'movie/year', 1988], - [210, 'movie/director', 132], - [210, 'movie/cast', 105], - [210, 'movie/cast', 106], - [210, 'movie/cast', 133], - [211, 'movie/title', 'Predator 2'], - [211, 'movie/year', 1990], - [211, 'movie/director', 134], - [211, 'movie/cast', 113], - [211, 'movie/cast', 114], - [211, 'movie/cast', 135], - [212, 'movie/title', 'Lethal Weapon 2'], - [212, 'movie/year', 1989], - [212, 'movie/director', 111], - [212, 'movie/cast', 112], - [212, 'movie/cast', 113], - [212, 'movie/cast', 136], - [212, 'movie/sequel', 213], - [213, 'movie/title', 'Lethal Weapon 3'], - [213, 'movie/year', 1992], - [213, 'movie/director', 111], - [213, 'movie/cast', 112], - [213, 'movie/cast', 113], - [213, 'movie/cast', 136], - [214, 'movie/title', 'Alien'], - [214, 'movie/year', 1979], - [214, 'movie/director', 137], - [214, 'movie/cast', 138], - [214, 'movie/cast', 139], - [214, 'movie/cast', 140], - [214, 'movie/sequel', 215], - [215, 'movie/title', 'Aliens'], - [215, 'movie/year', 1986], - [215, 'movie/director', 100], - [215, 'movie/cast', 139], - [215, 'movie/cast', 141], - [215, 'movie/cast', 103], - [216, 'movie/title', 'Mad Max'], - [216, 'movie/year', 1979], - [216, 'movie/director', 142], - [216, 'movie/cast', 112], - [216, 'movie/cast', 143], - [216, 'movie/cast', 144], - [216, 'movie/sequel', 217], - [217, 'movie/title', 'Mad Max 2'], - [217, 'movie/year', 1981], - [217, 'movie/director', 142], - [217, 'movie/cast', 112], - [217, 'movie/cast', 145], - [217, 'movie/cast', 146], - [217, 'movie/sequel', 218], - [218, 'movie/title', 'Mad Max Beyond Thunderdome'], - [218, 'movie/year', 1985], - [218, 'movie/director', 'user'], - [218, 'movie/director', 147], - [218, 'movie/cast', 112], - [218, 'movie/cast', 148], - [219, 'movie/title', 'Braveheart'], - [219, 'movie/year', 1995], - [219, 'movie/director', 112], - [219, 'movie/cast', 112], - [219, 'movie/cast', 149], -]) diff --git a/packages/w3up-client/test/space.test.js b/packages/w3up-client/test/space.test.js index 8d871e11c..4ee1c59f2 100644 --- a/packages/w3up-client/test/space.test.js +++ b/packages/w3up-client/test/space.test.js @@ -97,7 +97,7 @@ export const testSpace = { return { ok: {} } - const size = 1138 + // const size = 1138 // const archive = await randomCAR(size) // await client.agent.invokeAndExecute(StoreCapabilities.add, { // nb: { From f2ee4e8008ff943be79204aff8656cb3485b94f0 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 5 Mar 2024 23:36:49 -0800 Subject: [PATCH 11/16] feat: coupon api --- packages/w3up-client/src/access.js | 8 +- packages/w3up-client/src/account.js | 14 +- packages/w3up-client/src/account/plan.js | 22 +- packages/w3up-client/src/account/query.js | 110 ++++++ packages/w3up-client/src/agent.js | 1 + .../w3up-client/src/authorization/query.js | 95 ++--- packages/w3up-client/src/coupon.js | 316 ++++++++++++---- packages/w3up-client/src/plan.js | 34 -- packages/w3up-client/src/space.js | 2 +- packages/w3up-client/src/space/query.js | 155 +------- packages/w3up-client/src/task.js | 12 +- packages/w3up-client/src/types.ts | 59 ++- packages/w3up-client/src/w3up.js | 2 + .../test/capability/access.test.js | 49 --- packages/w3up-client/test/coupon.test.js | 62 ++-- .../w3up-client/test/{agent => }/db.test.js | 340 +++++++++++------- 16 files changed, 777 insertions(+), 504 deletions(-) create mode 100644 packages/w3up-client/src/account/query.js delete mode 100644 packages/w3up-client/src/plan.js delete mode 100644 packages/w3up-client/test/capability/access.test.js rename packages/w3up-client/test/{agent => }/db.test.js (74%) diff --git a/packages/w3up-client/src/access.js b/packages/w3up-client/src/access.js index 720d5e596..31c4189a5 100644 --- a/packages/w3up-client/src/access.js +++ b/packages/w3up-client/src/access.js @@ -171,7 +171,7 @@ export const claim = async ( delegations.flatMap((proof) => bytesToDelegations(proof)) ) - return { ok: new GrantedAccess({ session, proofs }) } + return { ok: new GrantedAccess({ agent: session.agent, proofs }) } } } @@ -263,7 +263,7 @@ class PendingAccessRequest { else if (result.ok.length > 0) { return { ok: new GrantedAccess({ - session: this.session, + agent: this.session.agent, proofs: /** @type {API.Tuple} */ (result.ok), }), } @@ -313,7 +313,7 @@ class RequestExpired extends Failure { export class GrantedAccess { /** * @typedef {object} GrantedAccessModel - * @property {API.Session} session - Agent that processed the request. + * @property {API.Agent} agent - Agent that processed the request. * @property {API.Tuple} proofs - Delegations that grant access. * * @param {GrantedAccessModel} model @@ -332,7 +332,7 @@ export class GrantedAccess { * @param {object} input * @param {API.Agent} [input.agent] */ - save({ agent = this.model.session.agent } = {}) { + save({ agent = this.model.agent } = {}) { return DB.transact( agent.db, this.proofs.map((proof) => DB.assert({ proof })) diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index cf282bfe2..882e345a4 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -1,5 +1,5 @@ import * as API from './types.js' -import * as Login from './agent/login.js' +import * as Account from './account/query.js' import * as Access from './access.js' import * as DB from './agent/db.js' import * as DIDMailto from '@web3-storage/did-mailto' @@ -101,7 +101,7 @@ export const login = async (session, { email, signal }) => { if (error) { return { error } } else { - const login = Login.from({ account: id }) + const login = { id, attestations: new Map(), proofs: new Map() } for (const proof of ok.proofs) { if (proof.capabilities?.[0].can === 'ucan/attest') { login.attestations.set(`${proof.cid}`, proof) @@ -124,16 +124,16 @@ export const login = async (session, { email, signal }) => { */ export const list = (session) => { - const logins = Login.select( + const matches = Account.select( session.agent.db, DB.query( session.agent.db.index, - Login.query({ authority: session.agent.signer.did() }) + Account.query({ audience: session.agent.signer.did() }) ) ) return Object.fromEntries( - [...logins].map(([account, login]) => [ + [...matches].map(([account, login]) => [ account, AccountSession.from({ session, login }), ]) @@ -150,11 +150,11 @@ export const list = (session) => { */ export const get = (session, email) => { const account = DIDMailto.fromEmail(email) - const [login] = Login.select( + const [login] = Account.select( session.agent.db, DB.query( session.agent.db.index, - Login.query({ authority: session.agent.signer.did(), account }) + Account.query({ audience: session.agent.signer.did(), account }) ) ).values() diff --git a/packages/w3up-client/src/account/plan.js b/packages/w3up-client/src/account/plan.js index 0727beeb8..4573b0c2c 100644 --- a/packages/w3up-client/src/account/plan.js +++ b/packages/w3up-client/src/account/plan.js @@ -13,7 +13,7 @@ export const from = (account) => new AccountPlans(account) /** * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @param {API.AccountSession} account - * @returns {Promise>, API.AccessDenied | API.PlanNotFound | API.InvocationError>>} + * @returns {Promise, API.AccessDenied | API.PlanNotFound | API.InvocationError>>} */ export const list = async (account) => { const { session } = account @@ -37,17 +37,17 @@ export const list = async (account) => { }) .execute(/** @type {API.Session} */ (session).connection) + /** @type {API.AccountPlanList} */ + const plans = /** @type {any} */ (new AccountPlanList()) if (result.ok) { - const plans = { - [result.ok.product]: new BillingPlan({ - account: account, - plan: result.ok, - }), - } + plans[result.ok.product] = new BillingPlan({ + account: account, + plan: result.ok, + }) return { ok: plans } } else { - return result.error.name === 'PlanNotFound' ? { ok: {} } : result + return result.error.name === 'PlanNotFound' ? { ok: plans } : result } } @@ -67,6 +67,12 @@ class AccountPlans { } } +class AccountPlanList { + *[Symbol.iterator]() { + yield* Object.values(this) + } +} + /** * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] * @implements {API.BillingPlan} 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/agent.js b/packages/w3up-client/src/agent.js index 0fbac87a2..54a79596b 100644 --- a/packages/w3up-client/src/agent.js +++ b/packages/w3up-client/src/agent.js @@ -91,6 +91,7 @@ class AgentPromise extends Promise { * @param {object} source * @param {API.Signer} source.signer * @param {API.Database} source.db + * @returns {API.AgentView} */ export const view = (source) => new Agent(source) diff --git a/packages/w3up-client/src/authorization/query.js b/packages/w3up-client/src/authorization/query.js index c15d6dc16..b01bfb433 100644 --- a/packages/w3up-client/src/authorization/query.js +++ b/packages/w3up-client/src/authorization/query.js @@ -55,32 +55,25 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { })) // Here we generate selector clause for each proof that we will try to match - const where = proofs.map(({ proof, issuer, need, can, attestation }) => { - // main clause will find a relevant proof. - const clause = match(proof, { - subject, - can, - audience, - issuer, - time, - }) + 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, + }), - // Then we try to find an attestations for the proof, however attestations - // are required only for `did:mailto:` issued proofs, 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 - // the found proof. - const attestations = DB.or( - DB.not(DB.Constraint.glob(issuer, 'did:mailto:*')), - Attestation.match(attestation, { proof, time, audience }) + // 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)] : []) ) - - // 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. - return (need ? clause.and(DB.glob(need, can)) : clause).and(attestations) - }) + ) return { select: { @@ -100,10 +93,10 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { /** * Matches a delegation that authorizes the `selector.audience` with an ability - * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note - * that it will only match explicit authorization that is one that specifies - * `selector.subject` and will not match implicit authorizations that uses - * `ucan:*` capability. + * 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 @@ -113,11 +106,11 @@ export const query = ({ can = {}, time = Date.now() / 1000, ...selector }) => { * @param {DB.Term} [selector.audience] * @param {DB.Term} [selector.issuer] */ -export const explicit = ( +export const delegates = ( delegation, { - audience = DB.string(), issuer = DB.string(), + audience = DB.string(), subject = DB.string(), can = DB.string(), time = DB.integer(), @@ -136,11 +129,12 @@ export const explicit = ( } /** - * Matches a delegation that authorizes the `selector.audience` with an ability - * to invoke `selector.can` on `selector.subject` at `selector.time`. Please note - * that it will only match implicit authorization that is one that has `ucan:*` - * subject and is either issued by `selector.subject` or has a proof which - * explicitly delegates `selector.can` to `selector.subject`. + * 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 @@ -151,7 +145,7 @@ export const explicit = ( * @param {DB.Term} [selector.issuer] * @returns {DB.Clause} */ -export const implicit = ( +export const forwards = ( delegation, { subject = DB.string(), @@ -174,9 +168,9 @@ export const implicit = ( DB.and( Delegation.hasProof(delegation, proof), DB.or( - explicit(proof, { audience: issuer, subject, can, time }) - // TODO: Add support for recursive implicit delegation - // implicit(proof, { subject, can, time, audience }) + delegates(proof, { audience: issuer, subject, can, time }) + // TODO: Add support for recursive forwarding delegation + // forwards(proof, { subject, can, time, audience }) ) ) ) @@ -188,17 +182,19 @@ export const implicit = ( * to invoke `selector.can` on `selector.subject` at `selector.time`. It will * match both explicit and implicit authorizations. * - * @param {DB.Term} delegation + * @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 = ( - delegation, + ucan, { + attestation = DB.link(), audience = DB.string(), subject = DB.string(), issuer = DB.string(), @@ -206,7 +202,18 @@ export const match = ( time = DB.integer(), } ) => - DB.or( - explicit(delegation, { issuer, audience, can, subject, time }), - implicit(delegation, { issuer, audience, can, subject, time }) + 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/coupon.js b/packages/w3up-client/src/coupon.js index 032fcc866..83f054abd 100644 --- a/packages/w3up-client/src/coupon.js +++ b/packages/w3up-client/src/coupon.js @@ -2,57 +2,132 @@ import * as API from './types.js' import { sha256, delegate, Delegation } from '@ucanto/core' import { ed25519 } from '@ucanto/principal' import * as Result from './result.js' -import { GrantedAccess } from './capability/access.js' -import { Client } from './client/client.js' +import * as Agent from './agent.js' +import * as Task from './task.js' +import * as Space from './space.js' +import * as Account from './account.js' /** - * @extends {Client} + * @template {API.UnknownProtocol} Protocol + * @param {API.Session} session + * @returns {API.CouponAPI} */ -export class CouponAPI extends Client { +export const view = (session) => new CouponAPI(session) + +/** + * @template {API.UnknownProtocol} Protocol + * @implements {API.CouponAPI} + */ +class CouponAPI { + /** + * @param {API.Session} session + */ + constructor(session) { + this.session = session + } + + /** + * 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} coupon + * @param {object} [options] + * @param {string} [options.secret] + */ + async open(coupon, options = {}) { + const result = await open(coupon, { ...options }) + if (result.error) { + return result + } else { + return { ok: result.ok.connect(this.session.connection) } + } + } + /** * 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 {Uint8Array} coupon * @param {object} [options] - * @param {string} [options.password] + * @param {string} [options.secret] + * @returns {Promise, Error>>} */ - async redeem(archive, options = {}) { - const { agent } = this - const coupon = Result.unwrap(await extract(archive)) - return Result.unwrap(await redeem(coupon, { ...options, agent })) + async redeem(coupon, options = {}) { + const result = await open(coupon, { ...options }) + if (result.error) { + return result + } else { + return await redeem(result.ok, { session: this.session }) + } } /** * Issues a coupon for the given delegation. * - * @param {Omit} options + * @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 {Promise, Error>>} */ - async issue({ proofs = [], ...options }) { - const { agent } = this - return await issue({ - ...options, - issuer: agent.issuer, - proofs: [...proofs, ...agent.proofs(options.capabilities)], - }) + async issue(access) { + const result = await issue(this.session.agent, access) + if (result.error) { + return result + } else { + return { ok: result.ok.connect(this.session.connection) } + } + } + + /** + * @param {API.Coupon} coupon + */ + add(coupon) { + return add(this.session, coupon) + } + /** + * @param {API.Coupon} coupon + */ + remove(coupon) { + return remove(this.session, coupon) } } /** - * Extracts coupon from the archive. - * - * @param {Uint8Array} archive - * @returns {Promise>} + * @param {{agent: API.Agent}} session + * @param {API.Coupon} coupon */ -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 remove = ({ agent }, coupon) => + Agent.DB.transact( + agent.db, + coupon.proofs.map((proof) => Agent.DB.retract({ proof })) + ) + +/** + * @param {{agent: API.Agent}} session + * @param {API.Coupon} coupon + */ +export const add = async ({ agent }, coupon) => { + if (coupon.signer.did() === agent.signer.did()) { + return await Agent.DB.transact( + agent.db, + coupon.proofs.map((proof) => Agent.DB.assert({ proof })) + ) + } else { + return { + error: new RangeError( + `Space is shared with ${coupon.signer.did()} not ${agent.signer.did()}` + ), + } + } } /** * Encodes coupon into an archive. * - * @param {Model} coupon + * @param {API.Coupon} coupon */ export const archive = async (coupon) => { const [delegation] = coupon.proofs @@ -62,57 +137,111 @@ export const archive = async (coupon) => { /** * Issues a coupon for the given delegation. * - * @typedef {Omit, 'audience'> & { password?: string }} CouponOptions - * @param {CouponOptions} options + * @param {API.Agent} agent + * @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 {Promise>} */ -export const issue = async ({ password = '', ...options }) => { - const audience = await deriveSigner(password) +export const issue = async ( + agent, + { secret = '', subject, can, ...options } +) => { + const authorization = Agent.authorize(agent, { + subject, + can, + }) + if (authorization.error) { + return authorization + } + + const audience = await deriveSigner(secret) + + const capabilities = /** @type {API.Capabilities} */ ( + Object.entries(can).map(([can, policy]) => ({ + with: subject, + can, + nb: policy, + })) + ) + const delegation = await delegate({ ...options, + issuer: agent.signer, audience, + capabilities, + proofs: authorization.ok.proofs, }) - return new Coupon({ proofs: [delegation] }) + const coupon = new Coupon({ + signer: audience, + db: Agent.DB.from({ proofs: [delegation] }), + }) + + return { ok: coupon } } /** - * @typedef {object} Model - * @property {[API.Delegation]} proofs + * + * @param {Uint8Array} archive + * @param {object} [options] + * @param {string} [options.secret] + * @returns {Promise>} */ +export const open = (archive, { secret = '' } = {}) => + Task.try(function* () { + const proof = yield* Task.join(Delegation.extract(archive)) + const signer = yield* Task.wait(deriveSigner(secret)) + + if (proof.audience.did() !== signer.did()) { + return Result.error( + new RangeError( + secret === '' + ? 'Extracting account requires a secret' + : 'Provided secret is invalid' + ) + ) + } + + const coupon = new Coupon({ + signer, + db: Agent.DB.from({ proofs: [proof] }), + }) + + return Result.ok(coupon) + }) /** * Redeems granted access with the given agent from the given coupon. * - * @param {Model} coupon + * @template {API.UnknownProtocol} Protocol + * @param {API.Coupon} coupon * @param {object} options - * @param {API.AgentView} options.agent - * @param {string} [options.password] - * @returns {Promise>} + * @param {API.Session} options.session + * @returns {Promise, Error>>} */ -export const redeem = async (coupon, { agent, password = '' }) => { - const audience = await deriveSigner(password) +export const redeem = async (coupon, { session }) => { const [delegation] = coupon.proofs - if (delegation.audience.did() !== audience.did()) { - return Result.error( - new RangeError( - password === '' - ? 'Extracting account requires a password' - : 'Provided password is invalid' - ) - ) - } else { - const authorization = await delegate({ - issuer: audience, - audience: agent, - capabilities: delegation.capabilities, - expiration: delegation.expiration, - notBefore: delegation.notBefore, - proofs: [delegation], - }) + const proof = await delegate({ + issuer: coupon.signer, + audience: session.agent.signer, + capabilities: delegation.capabilities, + expiration: delegation.expiration, + notBefore: delegation.notBefore, + proofs: [delegation], + }) - return Result.ok(new GrantedAccess({ agent, proofs: [authorization] })) - } + const db = Agent.DB.from({ proofs: [proof] }) + return Result.ok( + new CouponSession({ + agent: { signer: session.agent.signer, db }, + connection: session.connection, + }) + ) } /** @@ -123,29 +252,84 @@ const deriveSigner = async (password) => { return await ed25519.Signer.derive(digest) } +/** + * @implements {API.Coupon} + */ export class Coupon { /** - * @param {Model} model + * @param {API.Agent} model */ constructor(model) { this.model = model } + get signer() { + return this.model.signer + } get proofs() { - return this.model.proofs + return /** @type {[API.Delegation]} */ ( + [...this.model.db.proofs.values()].map(({ delegation }) => delegation) + ) + } + + archive() { + return archive(this) } /** - * - * @param {API.AgentView} agent - * @param {object} [options] - * @param {string} [options.password] + * @template {API.UnknownProtocol} Protocol + * @param {API.Connection} connection + * @returns {API.CouponSession} */ - redeem(agent, options = {}) { - return redeem(this, { ...options, agent }) + connect(connection) { + return new CouponSession({ + agent: this.model, + connection, + }) + } +} + +/** + * @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 archive(this) } + /** + * @param {object} options + * @param {API.Agent} options.agent + */ + redeem({ agent }) { + return redeem(this, { + session: { agent, connection: this.connection }, + }) + } } diff --git a/packages/w3up-client/src/plan.js b/packages/w3up-client/src/plan.js deleted file mode 100644 index cabf099b9..000000000 --- a/packages/w3up-client/src/plan.js +++ /dev/null @@ -1,34 +0,0 @@ -import * as API from './types.js' -import * as Plan from '@web3-storage/capabilities/plan' -import * as Authorization from './authorization/query.js' - -/** - * Gets the plan currently associated with the account. - * - * @param {API.Session} session - * @param {object} options - * @param {API.AccountDID} options.account - * @param {API.Delegation[]} [options.proofs] - */ -export const get = async (session, { account, proofs = [] }) => { - const auth = Authorization.get(session.agent.db, { - can: { 'plan/get': [] }, - authority: session.agent.did(), - subject: account, - }) - - if (auth.error) { - return auth - } - - const { out: result } = await Plan.get - .invoke({ - issuer: session.agent.signer, - audience: session.connection.id, - with: account, - proofs: auth.ok.proofs, - }) - .execute(session.connection) - - return result -} diff --git a/packages/w3up-client/src/space.js b/packages/w3up-client/src/space.js index df01bda6f..d313aaa61 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -18,7 +18,7 @@ export const { create, fromMnemonic } = OwnSpace export const list = (session) => { const results = DB.query( session.agent.db.index, - Query.query({ authority: session.agent.signer.did() }) + Query.query({ audience: session.agent.signer.did() }) ) return build(session, results) diff --git a/packages/w3up-client/src/space/query.js b/packages/w3up-client/src/space/query.js index 50290958f..7e05f927d 100644 --- a/packages/w3up-client/src/space/query.js +++ b/packages/w3up-client/src/space/query.js @@ -1,6 +1,5 @@ import * as API from '../types.js' import * as Delegation from '../agent/delegation.js' -import * as Capability from '../agent/capability.js' import * as Text from '../agent/db/text.js' import * as DB from 'datalogia' import * as Authorization from '../authorization/query.js' @@ -10,7 +9,7 @@ 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.authority] + * @param {DB.Term} [constraints.audience] * @param {DB.Term} [constraints.space] * @param {DB.Term} [constraints.can] * @param {DB.Term} [constraints.name] @@ -46,7 +45,7 @@ export const named = (ucan, name) => * @param {DB.Term} ucan * @param {object} constraints * @param {DB.Term} [constraints.time] - * @param {DB.Term} [constraints.authority] + * @param {DB.Term} [constraints.audience] * @param {DB.Term} [constraints.space] * @param {DB.Term} [constraints.can] * @param {DB.Term} [constraints.name] @@ -55,15 +54,15 @@ export const explicit = ( ucan, { time = Date.now() / 1000, - authority = DB.string(), + audience = DB.string(), space = DB.string(), can = DB.string(), name = DB.string(), } ) => { return DB.and( - Authorization.explicit(ucan, { - audience: authority, + Authorization.delegates(ucan, { + audience, can, subject: space, time, @@ -77,7 +76,7 @@ export const explicit = ( * @param {DB.Term} ucan * @param {object} constraints * @param {DB.Term} [constraints.time] - * @param {DB.Term} [constraints.authority] + * @param {DB.Term} [constraints.audience] * @param {DB.Term} [constraints.space] * @param {DB.Term} [constraints.can] * @param {DB.Term} [constraints.name] @@ -86,7 +85,7 @@ export const match = ( ucan, { time = Date.now() / 1000, - authority = DB.string(), + audience = DB.string(), space = DB.string(), can = DB.string(), name = DB.string(), @@ -96,8 +95,8 @@ export const match = ( return DB.or( // It may be a an explicit delegation DB.and( - Authorization.explicit(ucan, { - audience: authority, + Authorization.delegates(ucan, { + audience, can, subject: space, time, @@ -106,15 +105,15 @@ export const match = ( ), // Or it could be an implicit delegation issued by the space DB.and( - Delegation.forwards(ucan, { audience: authority, time }), + 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: authority, time }), + Delegation.forwards(ucan, { audience, time }), Delegation.hasProof(ucan, proof), - Authorization.explicit(proof, { subject: space, time }), + Authorization.delegates(proof, { subject: space, time }), named(proof, name) ) ).and(Text.match(space, { glob: 'did:key:*' })) @@ -125,7 +124,7 @@ export const match = ( * @param {object} constraints * @param {DB.Term} [constraints.time] * @param {DB.Term} [constraints.name] - * @param {DB.Term} [constraints.authority] + * @param {DB.Term} [constraints.audience] * @param {DB.Term} [constraints.account] * @param {DB.Term} [constraints.space] * @param {DB.Term} [constraints.name] @@ -134,139 +133,17 @@ export const implicit = ( ucan, { time = Date.now() / 1000, - authority = DB.string(), + audience = DB.string(), space = DB.string(), name = DB.string(), } ) => { const proof = DB.link() return DB.and( - Delegation.forwards(ucan, { audience: authority, time }), + Delegation.forwards(ucan, { audience, time }), Delegation.hasProof(ucan, proof), - Authorization.explicit(proof, { subject: space, time }), + Authorization.delegates(proof, { subject: space, time }), Text.match(space, { glob: 'did:key:*' }), named(proof, name) ) } - -/** - * Creates a query that selects delegations to the `selector.audience` that - * delegate `selector.can` access on the `selector.subject` space. It only - * includes direct delegations and not the ones that have been re-delegated. - * - * @param {object} selector - * @param {API.TextConstraint} selector.audience - * @param {API.TextConstraint} [selector.subject] - * @param {API.Can} [selector.can] - * @param {API.UTCUnixTimestamp} [selector.time] - */ -export const direct = ({ - subject = { glob: '*' }, - audience, - time = Date.now() / 1000, - can = {}, -}) => { - const abilities = Object.keys(can) - const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) - const space = DB.string() - - const delegate = DB.string() - - return { - select: { - ...proofs, - subject: space, - audience: delegate, - }, - where: [ - ...Object.entries(proofs).flatMap(([can, delegation]) => { - const capability = DB.link() - return [ - Capability.match(capability, { - subject: space, - can, - }).and( - Delegation.match(delegation, { - capability, - audience: delegate, - time: time, - }) - ), - ] - }), - Text.match(delegate, audience), - Text.match(space, subject), - ], - } -} - -/** - * @param {object} selector - * @param {API.TextConstraint} [selector.audience] - * @param {API.TextConstraint} [selector.subject] - * @param {API.Can} [selector.can] - * @param {API.TextConstraint} [selector.account] - * @param {API.UTCUnixTimestamp} [selector.time] - */ -export const indirect = ({ - subject = { glob: '*' }, - audience = { glob: '*' }, - time = Date.now() / 1000, - account = { glob: 'did:mailto:*' }, - can = { '*': [] }, -}) => { - const abilities = Object.keys(can) - const proofs = Object.fromEntries(abilities.map((can) => [can, DB.link()])) - const space = DB.string() - - const delegate = DB.string() - const accountPrincipal = DB.string() - - return { - select: { - ...proofs, - subject: space, - audience: delegate, - account: accountPrincipal, - }, - where: [ - ...Object.entries(proofs).flatMap(([need, delegation]) => { - const can = DB.string() - const login = DB.link() - const proof = DB.link() - const capability = DB.link() - return [ - Capability.match(login, { - subject: 'ucan:*', - can: '*', - }) - .and( - Delegation.match(delegation, { - capability: login, - audience: delegate, - time: time, - }) - ) - .and(Delegation.issuedBy(delegation, accountPrincipal)) - .and(DB.match([delegation, 'ucan/proof', proof])) - .and( - Delegation.match(proof, { - capability, - audience: accountPrincipal, - time: time, - }) - ) - .and( - Capability.match(capability, { - subject: space, - can, - }).and(DB.glob(need, can)) - ), - ] - }), - Text.match(delegate, audience), - Text.match(space, subject), - Text.match(accountPrincipal, account), - ], - } -} diff --git a/packages/w3up-client/src/task.js b/packages/w3up-client/src/task.js index 94bdb8904..392512792 100644 --- a/packages/w3up-client/src/task.js +++ b/packages/w3up-client/src/task.js @@ -33,7 +33,7 @@ export const wait = function* (source) { /** * @template {API.Result} R * @param {PromiseLike|R} source - * @returns {Generator} + * @returns {Generator['ok']>} */ export const join = function* (source) { const { ok, error } = yield* wait(source) @@ -46,10 +46,10 @@ export const join = function* (source) { /** * @template {API.Result} R - * @template {{}} Ok + * @template Ok * @template {globalThis.Error} [Error=never] - * @param {() => Generator, void>} task - * @returns {Promise>} + * @param {() => Generator, void>} task + * @returns {Promise['error'] | Error>>} */ const execute = async (task) => { const process = task() @@ -75,9 +75,9 @@ export { execute as try } /** * @template {API.Result} R - * @template {{}} Ok + * @template Ok * @template {globalThis.Error} [Error=never] - * @param {() => Generator, void>} task + * @param {() => Generator, void>} task */ export const perform = async (task) => { const result = await execute(task) diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index f11234867..a73dacbff 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -65,6 +65,7 @@ import type { Protocol, InvocationError, MultihashDigest, + Tuple, } from '@ucanto/interface' import type { @@ -108,6 +109,8 @@ import { Block } from '@ipld/car/buffer-reader' import { SpaceInfoFailure } from '@web3-storage/upload-api' import { EmailAddress, DidMailto } from '@web3-storage/did-mailto' import { UTCUnixTimestamp, Signer as UCANSigner } from '@ipld/dag-ucan' +import exp from 'constants' +import { Delegation } from '@ucanto/core' export * from '@ipld/dag-ucan' export * from '@ucanto/interface' @@ -867,6 +870,50 @@ export interface W3UpSession extends Session { agent: AgentView spaces: SpacesSession accounts: AccountsSession + + coupons: CouponAPI +} + +export interface CouponSession + extends Session, + Coupon { + spaces: SpacesSession + accounts: AccountsSession + + redeem(options: { + agent: Agent + }): Promise, Error>> + + archive(): Promise> +} + +export interface CouponAPI { + issue(access: { + subject: DID + can: Can + expiration?: UTCUnixTimestamp + notBefore?: UTCUnixTimestamp + secret?: string + }): Promise, Error>> + redeem( + coupon: Uint8Array, + options?: { secret?: string } + ): Promise, Error>> + + add(coupon: Coupon): Promise> + remove(coupon: Coupon): Promise> +} + +export interface Coupon { + signer: Signer + proofs: [Delegation] +} + +export interface CouponView extends Coupon { + archive(): Promise> + connect( + connection: Connection + ): CouponSession } export interface SpacesSession extends Iterable { @@ -965,12 +1012,22 @@ export interface AccountPlans< > { list(): Promise< Result< - Record>, + AccountPlanList, AccessDenied | PlanNotFound | InvocationError > > } +export interface AccountPlanList< + Protocol extends PlanProtocol & + ProviderProtocol & + SubscriptionProtocol = PlanProtocol & + ProviderProtocol & + SubscriptionProtocol +> extends Iterable> { + [key: string]: BillingPlan +} + export interface BillingPlan< Protocol extends PlanProtocol & ProviderProtocol & diff --git a/packages/w3up-client/src/w3up.js b/packages/w3up-client/src/w3up.js index 9bb75cc5b..c474075de 100644 --- a/packages/w3up-client/src/w3up.js +++ b/packages/w3up-client/src/w3up.js @@ -1,6 +1,7 @@ import * as API from './types.js' import * as Space from './space.js' import * as Account from './account.js' +import * as Coupon from './coupon.js' /** * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] @@ -22,6 +23,7 @@ class Session { 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 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/coupon.test.js b/packages/w3up-client/test/coupon.test.js index 5e5d1bea8..cb001b965 100644 --- a/packages/w3up-client/test/coupon.test.js +++ b/packages/w3up-client/test/coupon.test.js @@ -1,51 +1,65 @@ import * as Test from './test.js' import * as Result from '../src/result.js' +import * as Coupon from '../src/coupon.js' +import * as Agent from '../src/agent.js' +import { alice } from './fixtures/principals.js' /** * @type {Test.Suite} */ export const testCoupon = { - 'account.coupon': async ( + 'only 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 + const account = Result.unwrap(await login) + Result.unwrap(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 = Result.unwrap( + await 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 = Result.unwrap(await issued.archive()) + + const agent = Result.unwrap(await Coupon.open(archive)) + + const coupon = agent.connect(session.connection) + const [...accounts] = coupon.accounts + const [...spaces] = coupon.spaces - // alice join the workshop and redeem the coupon - const alice = await connect() - const access = await alice.coupon.redeem(archive) + assert.deepEqual(accounts.length, 1) + assert.deepEqual(spaces.length, 0) - // 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 [redeemedAccount] = accounts - assert.ok(result.ok) + assert.deepEqual(accounts[0].did(), account.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']) + const [plan] = Result.unwrap(await redeemedAccount.plans.list()) + + const space = Result.unwrap(await coupon.spaces.create({ name: 'home' })) + Result.unwrap(await plan.subscriptions.add({ consumer: space.did() })) + + assert.deepEqual(Result.unwrap(await space.info()), { + did: space.did(), + providers: ['did:web:test.web3.storage'], + }) }, 'coupon with password': async ( diff --git a/packages/w3up-client/test/agent/db.test.js b/packages/w3up-client/test/db.test.js similarity index 74% rename from packages/w3up-client/test/agent/db.test.js rename to packages/w3up-client/test/db.test.js index 3b1601682..89a16cbab 100644 --- a/packages/w3up-client/test/agent/db.test.js +++ b/packages/w3up-client/test/db.test.js @@ -1,18 +1,18 @@ -import * as DB from '../../src/agent/db.js' -import * as Test from '../test.js' -import * as Space from '../../src/space.js' -import * as Account from '../../src/agent/login.js' -import * as Delegation from '../../src/agent/delegation.js' -import * as Spaces from '../../src/space/query.js' -import { createLegacyLink, delegate } from '@ucanto/core' -import { Absentee, Verifier } from '@ucanto/principal' +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 * as Cap from '../../src/agent/capability.js' -import { fromEmail, toEmail } from '@web3-storage/did-mailto' -import * as Result from '../../src/result.js' +import { fromEmail } from '@web3-storage/did-mailto' +import * as Result from '../src/result.js' +import * as Task from '../src/task.js' -import { alice, bob, mallory, w3up } from '../fixtures/principals.js' -import * as Authorization from '../../src/authorization.js' +import { alice, bob, mallory, w3up } from './fixtures/principals.js' +import * as Authorization from '../src/authorization.js' /** * @type {Test.BasicSuite} @@ -112,7 +112,7 @@ export const testDB = { ]) }, - 'test find accounts ignoring spaces': async (assert) => { + 'test find accounts': async (assert) => { const localSpace = Result.unwrap( await Space.create({ name: 'local-box', @@ -314,7 +314,7 @@ export const testDB = { where: [ Account.match(ucan, { time, - authority: audience, + audience, account, }), ], @@ -343,7 +343,7 @@ export const testDB = { where: [ Account.match(ucan, { time, - authority: audience, + audience, account, }), ], @@ -392,101 +392,101 @@ export const testDB = { // 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, - }, - ] - ) + // 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': async (assert) => { @@ -662,7 +662,7 @@ export const testDB = { }, where: [ Spaces.explicit(proof, { - authority: alice.did(), + audience: alice.did(), name, space, }), @@ -685,7 +685,7 @@ export const testDB = { }, where: [ Spaces.implicit(proof, { - authority: alice.did(), + audience: alice.did(), name, space, }), @@ -706,7 +706,7 @@ export const testDB = { }, where: [ Spaces.match(proof, { - authority: alice.did(), + audience: alice.did(), name, space, }), @@ -723,6 +723,110 @@ export const testDB = { } ) }, + + 'find re-delegated account capabilities': (assert) => + Task.perform(function* () { + const { + account, + space: subject, + login, + proofs, + } = yield* Task.wait(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 }, + ]) + + return { ok: {} } + }), +} + +/** + * + * @param {API.Delegation} delegation + * @returns {Promise>> + */ + +const attest = async (delegation) => { + const attestation = await Capability.UCAN.attest.delegate({ + issuer: w3up, + audience: delegation.audience, + with: w3up.did(), + nb: { proof: delegation.cid }, + expiration: Infinity, + }) + + return { ok: attestation } } const setupAccount = async ({ @@ -746,13 +850,7 @@ const setupAccount = async ({ proofs: recovery.proofs, }) - const attestation = await Capability.UCAN.attest.delegate({ - issuer: w3up, - audience: agent, - with: w3up.did(), - nb: { proof: login.cid }, - expiration: Infinity, - }) + const attestation = Result.unwrap(await attest(login)) return { space, From a2a2a540cf5d5b45b3ed000673a3a472685b9a22 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 6 Mar 2024 15:30:06 -0800 Subject: [PATCH 12/16] fix: coupon tests --- packages/w3up-client/src/coupon.js | 4 +- packages/w3up-client/src/types.ts | 9 +- packages/w3up-client/test/coupon.test.js | 109 ++++++++++++++++------- 3 files changed, 87 insertions(+), 35 deletions(-) diff --git a/packages/w3up-client/src/coupon.js b/packages/w3up-client/src/coupon.js index 83f054abd..2e3f09cc9 100644 --- a/packages/w3up-client/src/coupon.js +++ b/packages/w3up-client/src/coupon.js @@ -118,7 +118,7 @@ export const add = async ({ agent }, coupon) => { } else { return { error: new RangeError( - `Space is shared with ${coupon.signer.did()} not ${agent.signer.did()}` + `Coupon audience is ${coupon.signer.did()} not ${agent.signer.did()}` ), } } @@ -200,7 +200,7 @@ export const open = (archive, { secret = '' } = {}) => return Result.error( new RangeError( secret === '' - ? 'Extracting account requires a secret' + ? 'Redeeming a coupon requires a secret' : 'Provided secret is invalid' ) ) diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index a73dacbff..260217a70 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -880,9 +880,12 @@ export interface CouponSession spaces: SpacesSession accounts: AccountsSession - redeem(options: { - agent: Agent - }): Promise, Error>> + redeem( + session: { + agent: Agent + }, + options?: { secret?: string } + ): Promise, Error>> archive(): Promise> } diff --git a/packages/w3up-client/test/coupon.test.js b/packages/w3up-client/test/coupon.test.js index cb001b965..925a85704 100644 --- a/packages/w3up-client/test/coupon.test.js +++ b/packages/w3up-client/test/coupon.test.js @@ -1,14 +1,16 @@ import * as Test from './test.js' import * as Result from '../src/result.js' import * as Coupon from '../src/coupon.js' -import * as Agent from '../src/agent.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 = { - 'only account.coupon': async ( + 'account.coupon': async ( assert, { mail, session, grantAccess, plansStorage } ) => { @@ -62,42 +64,89 @@ export const testCoupon = { }) }, - 'coupon with password': async ( - assert, - { client, mail, connect, grantAccess, plansStorage } - ) => { - const coupon = await client.coupon.issue({ - capabilities: [ - { - with: client.did(), - can: 'store/list', - }, - ], - password: 'secret', - }) + 'saving a coupon': async (assert, { session, provisionsStorage }) => + Task.perform(function* () { + const now = (Date.now() / 1000) | 0 + const space = yield* Task.join(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* Task.join( + Coupon.issue(space.agent, { + subject: space.did(), + can: { 'space/*': [] }, + }) + ) + + const [...none] = session.spaces + assert.deepEqual([], none) + + const result = yield* Task.wait(session.coupons.add(coupon)) + assert.match(result.error?.message ?? '', /Coupon audience is/) + + const archive = yield* Task.join(coupon.archive()) + + const redeemed = yield* Task.join(session.coupons.redeem(archive)) + + yield* Task.join(session.coupons.add(redeemed)) + + const [one, ...rest] = session.spaces + assert.deepEqual(rest.length, 0) + assert.deepEqual(one.did(), space.did()) + + const info = yield* Task.join(one.info()) + + assert.deepEqual(info, { + did: space.did(), + providers: ['did:web:test.web3.storage'], + }) - const archive = Result.unwrap(await coupon.archive()) + return { ok: {} } + }), - const wrongPassword = await client.coupon - .redeem(archive, { password: 'wrong' }) - .catch((e) => e) + 'coupon with secret': async (assert, { session }) => + Task.perform(function* () { + const coupon = yield* Task.join( + session.coupons.issue({ + subject: session.agent.did(), + can: { + 'store/list': [], + }, + secret: 'secret', + }) + ) - assert.match(String(wrongPassword), /password is invalid/) + const archive = yield* Task.join(coupon.archive()) - const requiresPassword = await client.coupon.redeem(archive).catch((e) => e) + const wrongPassword = yield* Task.wait( + session.coupons.redeem(archive, { secret: 'wrong' }) + ) - assert.match(String(requiresPassword), /requires a password/) + assert.match(String(wrongPassword.error), /secret is invalid/) - const redeem = await coupon.redeem(client.agent, { password: 'secret' }) - assert.ok(redeem.ok) - }, + const requiresPassword = yield* Task.wait(session.coupons.redeem(archive)) + + assert.match(String(requiresPassword.error), /requires a secret/) + + const redeem = yield* Task.join( + coupon.redeem(session, { secret: 'secret' }) + ) + assert.ok(redeem) + + return { ok: {} } + }), - 'corrupt coupon': async (assert, { client, mail, connect, grantAccess }) => { - const fail = await client.coupon - .redeem(new Uint8Array(32).fill(1)) - .catch((e) => e) + 'corrupt coupon': async (assert, { session }) => { + const result = await session.coupons.redeem(new Uint8Array(32).fill(1)) - assert.match(fail.message, /Invalid CAR header format/) + assert.match(String(result.error), /Invalid CAR header format/) }, } From b8e61b0ae2a79bd2678f8da302664cc6dd7bea28 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 6 Mar 2024 16:00:08 -0800 Subject: [PATCH 13/16] chore: migrate usage tests --- .../w3up-client/test/capability/space.test.js | 61 ----------- .../test/capability/subscription.test.js | 102 ------------------ .../w3up-client/test/capability/usage.test.js | 96 ----------------- packages/w3up-client/test/usage.test.js | 49 +++++++++ 4 files changed, 49 insertions(+), 259 deletions(-) delete mode 100644 packages/w3up-client/test/capability/space.test.js delete mode 100644 packages/w3up-client/test/capability/subscription.test.js delete mode 100644 packages/w3up-client/test/capability/usage.test.js create mode 100644 packages/w3up-client/test/usage.test.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/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/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/usage.test.js b/packages/w3up-client/test/usage.test.js new file mode 100644 index 000000000..c476a075b --- /dev/null +++ b/packages/w3up-client/test/usage.test.js @@ -0,0 +1,49 @@ +import * as Test from './test.js' +import * as Result from '../src/result.js' + +/** + * @type {Test.Suite} + */ +export const testUsage = { + 'space.usage.report()': async ( + assert, + { mail, session, grantAccess, plansStorage } + ) => { + // First we login to the workshop account + const login = session.accounts.login({ email: 'alice@web.mail' }) + const message = await mail.take() + await grantAccess(message) + const account = Result.unwrap(await login) + // Result.unwrap(await session.accounts.add(account)) + + // Then we setup a billing for this account + await plansStorage.set(account.did(), 'did:web:test.web3.storage') + + const space = Result.unwrap(await account.spaces.create({ name: 'home' })) + + const [plan] = Result.unwrap(await account.plans.list()) + Result.unwrap(await plan.subscriptions.add({ consumer: space.did() })) + + const period = { from: new Date(0), to: new Date(1709769229000) } + + const report = Result.unwrap(await 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 = Result.unwrap(await space.usage.get()) + assert.deepEqual(usage, 0n) + }, +} + +Test.test({ Access: testUsage }) From 22f19d144e7ef22ae9f923466317068853ad4862 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 14 Mar 2024 00:26:11 -0700 Subject: [PATCH 14/16] feat: enable tests --- package.json | 2 +- packages/access-client/package.json | 2 +- packages/access-client/src/drivers/conf.js | 2 +- .../access-client/src/drivers/indexeddb.js | 4 +- packages/access-client/src/drivers/memory.js | 2 +- packages/access-client/src/drivers/types.ts | 2 +- .../stores/store-indexeddb.browser.test.js | 12 +- packages/capabilities/package.json | 2 +- packages/capabilities/src/types.ts | 19 + packages/did-mailto/package.json | 2 +- packages/filecoin-api/package.json | 2 +- packages/filecoin-client/package.json | 2 +- packages/upload-api/package.json | 2 +- packages/upload-api/src/types.ts | 13 +- packages/upload-client/package.json | 2 +- packages/w3up-client/package.json | 18 +- packages/w3up-client/src/access.js | 261 ++-- packages/w3up-client/src/account.js | 210 +-- packages/w3up-client/src/account/account.js | 187 +++ packages/w3up-client/src/account/plan.js | 71 +- .../w3up-client/src/account/subscription.js | 119 +- packages/w3up-client/src/agent.js | 899 +---------- packages/w3up-client/src/agent/connection.js | 13 + packages/w3up-client/src/agent/db.js | 177 +-- .../w3up-client/src/agent/signer.browser.js | 1 + packages/w3up-client/src/agent/signer.js | 1 + .../w3up-client/src/authorization/query.js | 9 + packages/w3up-client/src/client.js | 317 ---- packages/w3up-client/src/coupon.js | 348 +---- packages/w3up-client/src/coupon/coupon.js | 271 ++++ packages/w3up-client/src/index.node.js | 43 - packages/w3up-client/src/lib.js | 2 + packages/w3up-client/src/session.js | 195 +++ packages/w3up-client/src/space.js | 91 +- packages/w3up-client/src/space/delegations.js | 5 +- packages/w3up-client/src/space/filecoin.js | 80 + packages/w3up-client/src/space/own.js | 383 ----- packages/w3up-client/src/space/session.js | 43 - packages/w3up-client/src/space/shared.js | 119 -- packages/w3up-client/src/space/space.js | 423 ++++++ packages/w3up-client/src/space/usage.js | 101 +- packages/w3up-client/src/store.js | 1 + packages/w3up-client/src/store.node.js | 1 + packages/w3up-client/src/store/conf.js | 120 ++ packages/w3up-client/src/store/indexed-db.js | 193 +++ packages/w3up-client/src/store/memory.js | 41 +- packages/w3up-client/src/task.js | 382 ++++- packages/w3up-client/src/task/constant.js | 2 + packages/w3up-client/src/task/task.js | 1 + packages/w3up-client/src/task/task.ts | 79 + packages/w3up-client/src/types.ts | 517 ++++--- packages/w3up-client/src/w3up.js | 137 +- packages/w3up-client/test/access.test.js | 196 ++- packages/w3up-client/test/account.test.js | 154 +- packages/w3up-client/test/agent.test.js | 20 +- .../{filecoin.test.js => filecoin.nottest.js} | 0 .../{store.test.js => store.nottest.js} | 0 .../{upload.test.js => upload.nottest.js} | 0 .../{client.test.js => client.ignoretest.js} | 0 packages/w3up-client/test/coupon.test.js | 90 +- packages/w3up-client/test/db.test.js | 1324 +++++++++-------- .../w3up-client/test/index.browser.test.js | 9 +- packages/w3up-client/test/index.node.test.js | 38 +- packages/w3up-client/test/session.test.js | 72 + packages/w3up-client/test/space.test.js | 49 +- packages/w3up-client/test/task.test.js | 56 + packages/w3up-client/test/test.js | 18 +- packages/w3up-client/test/usage.test.js | 74 +- pnpm-lock.yaml | 219 +-- 69 files changed, 4244 insertions(+), 4006 deletions(-) create mode 100644 packages/w3up-client/src/account/account.js create mode 100644 packages/w3up-client/src/agent/signer.browser.js create mode 100644 packages/w3up-client/src/agent/signer.js delete mode 100644 packages/w3up-client/src/client.js create mode 100644 packages/w3up-client/src/coupon/coupon.js delete mode 100644 packages/w3up-client/src/index.node.js create mode 100644 packages/w3up-client/src/lib.js create mode 100644 packages/w3up-client/src/session.js create mode 100644 packages/w3up-client/src/space/filecoin.js delete mode 100644 packages/w3up-client/src/space/own.js delete mode 100644 packages/w3up-client/src/space/session.js delete mode 100644 packages/w3up-client/src/space/shared.js create mode 100644 packages/w3up-client/src/space/space.js create mode 100644 packages/w3up-client/src/store.js create mode 100644 packages/w3up-client/src/store.node.js create mode 100644 packages/w3up-client/src/store/conf.js create mode 100644 packages/w3up-client/src/store/indexed-db.js create mode 100644 packages/w3up-client/src/task/constant.js create mode 100644 packages/w3up-client/src/task/task.js create mode 100644 packages/w3up-client/src/task/task.ts rename packages/w3up-client/test/capability/{filecoin.test.js => filecoin.nottest.js} (100%) rename packages/w3up-client/test/capability/{store.test.js => store.nottest.js} (100%) rename packages/w3up-client/test/capability/{upload.test.js => upload.nottest.js} (100%) rename packages/w3up-client/test/{client.test.js => client.ignoretest.js} (100%) create mode 100644 packages/w3up-client/test/session.test.js create mode 100644 packages/w3up-client/test/task.test.js 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/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/types.ts b/packages/capabilities/src/types.ts index 59b4a2e46..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 @@ -242,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/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 2f81629f6..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", @@ -101,6 +110,7 @@ "@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:^", @@ -126,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 index 31c4189a5..94f0def51 100644 --- a/packages/w3up-client/src/access.js +++ b/packages/w3up-client/src/access.js @@ -9,6 +9,8 @@ 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 @@ -21,37 +23,31 @@ import * as Agent from './agent.js' * @param {object} input * @param {API.Delegation[]} input.delegations - Delegations to propagate. * @param {API.SpaceDID} input.subject - Space to propagate through. - * @returns {Promise>} + * @returns {Task.Task, API.AccessDenied | API.OfflineError>} */ -export const delegate = async (session, { delegations, subject }) => { +export function* delegate(session, { delegations, subject }) { const entries = Object.values(delegations).map((proof) => [ proof.cid.toString(), proof.cid, ]) - const auth = Agent.authorize(session.agent, { + const { proofs } = yield* Agent.authorize(session.agent, { subject, can: { 'access/delegate': [] }, }) - if (auth.error) { - return auth - } + 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], + }) - const { out } = await 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: [...auth.ok.proofs, ...delegations], - }) - .execute(session.connection) - - return out + return yield* Session.execute(session, task).receipt() } /** @@ -60,16 +56,15 @@ export const delegate = async (session, { delegations, subject }) => { * `PendingAccessRequest` object that can be used to poll for the requested * delegation through `access/claim` capability. * - * @template {API.AccessRequestProvider} Protocol - * @param {API.Session} session + * @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. - * @returns {Promise, API.AccessAuthorizeFailure|API.AccessDenied|API.InvocationError>>} + */ -export const request = async ( +export function* request( session, { account, @@ -77,118 +72,103 @@ export const request = async ( 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 auth = Agent.authorize(session.agent, { + const { proofs } = yield* Agent.authorize(session.agent, { subject: authority, can: { 'access/authorize': [] }, }) - if (auth.error) { - return auth + // 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) } - // Build an invocation and execute it. - const { out: result } = await 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: auth.ok.proofs, - }) - .execute( - /** @type {API.Connection} */ ( - session.connection - ) - ) + const { request, expiration } = receipt.out.ok - return result.error - ? result - : { - ok: new PendingAccessRequest({ - ...result.ok, - authority, - session, - provider, - }), - } + 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. * - * @template {API.AccessClaimProvider} Protocol - * @param {API.Session} session + * @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. - * @returns {Promise, API.AccessClaimFailure|API.InvocationError|API.AccessDenied>>} */ -export const claim = async ( +export function* claim( session, { provider = /** @type {API.ProviderDID} */ (session.connection.id.did()), authority = /** @type {API.DIDKey} */ (session.agent.signer.did()), } = {} -) => { - const auth = Agent.authorize(session.agent, { +) { + const auth = yield* Agent.authorize(session.agent, { subject: authority, can: { 'access/claim': [] }, }) - if (auth.error) { - return auth - } + const task = Access.claim.invoke({ + issuer: session.agent.signer, + audience: DID.parse(provider), + with: authority, + proofs: auth.proofs, + }) - const { out: result } = await Access.claim - .invoke({ - issuer: session.agent.signer, - audience: DID.parse(provider), - with: authority, - proofs: auth.ok.proofs, - }) - .execute( - /** @type {API.Connection} */ ( - session.connection - ) - ) + const receipt = yield* Session.execute(session, task).receipt() - if (result.error) { - return { error: result.error } - } else { - const delegations = Object.values(result.ok.delegations) + const { delegations } = yield* Task.ok(receipt.out) - const proofs = /** @type {API.Tuple} */ ( - delegations.flatMap((proof) => bytesToDelegations(proof)) - ) + const proofs = /** @type {API.Tuple} */ ( + Object.values(delegations).flatMap((proof) => bytesToDelegations(proof)) + ) - return { ok: new GrantedAccess({ agent: session.agent, proofs }) } - } + 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. * - * @template {API.AccessClaimProvider} Protocol */ class PendingAccessRequest { /** - * @typedef {object} PendingAccessRequestModel - * @property {API.Session} session - Session with a service. - * @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. + * * @param {PendingAccessRequestModel} model */ @@ -215,6 +195,10 @@ class PendingAccessRequest { 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 @@ -223,23 +207,10 @@ class PendingAccessRequest { * If you do want to continuously poll until request is approved or expired, you should use * `.claim` method instead. * - * @returns {Promise>} + * @returns {Task.Invocation} */ - async poll() { - const { session, provider, expiration, authority } = this.model - const timeout = expiration * 1000 - Date.now() - if (timeout <= 0) { - return { error: new RequestExpired(this.model) } - } else { - const result = await claim(session, { authority, provider }) - return result.error - ? result - : { - ok: result.ok.proofs.filter((proof) => - isRequestedAccess(proof, this.model) - ), - } - } + poll() { + return Task.perform(PendingAccessRequest.poll(this)) } /** @@ -247,34 +218,60 @@ class PendingAccessRequest { * 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 {Promise, Error>>} + * @returns {Task.Task} */ - async claim({ signal, interval = 250 } = {}) { + static *claim(self, { signal, interval = 250 } = {}) { while (signal?.aborted !== true) { - const result = await this.poll() - // If polling failed, return the error. - if (result.error) { - return result - } + 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. - else if (result.ok.length > 0) { - return { - ok: new GrantedAccess({ - agent: this.session.agent, - proofs: /** @type {API.Tuple} */ (result.ok), - }), - } + if (proofs.length > 0) { + return new GrantedAccess({ + agent: self.session.agent, + proofs, + receipt: access.receipt(), + }) } - await new Promise((resolve) => setTimeout(resolve, interval)) + yield* Task.sleep(interval) } - return { - error: Object.assign(new Error('Aborted'), { reason: signal.reason }), - } + return yield* Task.fail( + /** @type {Error & {reason:unknown}} */ ( + new Error('Aborted'), { reason: signal.reason } + ) + ) } } @@ -307,7 +304,6 @@ class RequestExpired extends Failure { } /** - * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * View over the UCAN Delegations that grant access to a specific principal. */ export class GrantedAccess { @@ -315,12 +311,17 @@ 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 } diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 882e345a4..53ff0a9b5 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -1,22 +1,24 @@ import * as API from './types.js' -import * as Account from './account/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' +import * as Account from './account/account.js' export { DIDMailto } +export { login, list, get } from './account/account.js' + /** - * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] + * @template {API.AccountProtocol & API.AccessRequestProvider} [Protocol=API.W3UpProtocol] * @param {API.Session} session - * @returns {API.AccountsSession} + * @returns {API.AccountManager} */ export const view = (session) => new AccountsView(session) /** - * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] + * @template {API.AccountProtocol & API.AccessRequestProvider} [Protocol=API.W3UpProtocol] + * @implements {API.AccountManager} */ export class AccountsView { /** @@ -24,11 +26,12 @@ export class AccountsView { */ constructor(session) { this.session = session + this.spaces = Space.view(/** @type {API.Session} */ (this.session)) } *[Symbol.iterator]() { - yield* Object.values(list(this.session)) + yield* Object.values(Account.list(this.session)) } /** @@ -37,14 +40,14 @@ export class AccountsView { * @param {AbortSignal} [source.signal] */ login(source) { - return login(this.session, source) + return Task.perform(Account.login(this.session, source)) } /** * Returns iterable of all the accounts saved in the agent's database. */ list() { - return list(this.session) + return Account.list(this.session) } /** @@ -54,111 +57,22 @@ export class AccountsView { * @param {API.EmailAddress} email */ get(email) { - return get(this.session, email) + return Account.get(this.session, email) } /** - * @param {API.AccountSession} account + * @param {API.AccountView} account */ add(account) { - return add(this.session, account) + return Task.perform(add(this.session, account)) } /** - * @param {API.AccountSession} account + * @param {API.AccountView} account */ remove(account) { - return remove(this.session, account) - } -} - -/** - * @template {API.AccessRequestProvider & API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - * @param {API.Session} session - * @param {object} source - * @param {API.EmailAddress} source.email - * @param {AbortSignal} [source.signal] - * @returns {Promise, API.AccessDenied|API.InvocationError|API.AccessAuthorizeFailure>>} - */ -export const login = async (session, { email, signal }) => { - const account = get(session, email) - if (account) { - return { ok: account } + return Task.perform(remove(this.session, account)) } - - const id = DIDMailto.fromEmail(email) - const { ok: access, error } = await Access.request(session, { - account: id, - can: Access.accountAccess, - }) - - /* c8 ignore next 2 - don't know how to test this */ - if (error) { - return { error } - } else { - const { ok, error } = await access.claim({ signal }) - /* c8 ignore next 2 - don't know how to test this */ - if (error) { - return { error } - } else { - const login = { id, attestations: new Map(), proofs: new Map() } - for (const proof of ok.proofs) { - if (proof.capabilities?.[0].can === 'ucan/attest') { - login.attestations.set(`${proof.cid}`, proof) - } else { - login.proofs.set(`${proof.cid}`, proof) - } - } - - return { - ok: AccountSession.from({ session, login }), - } - } - } -} - -/** - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - * @param {API.Session} session - * @returns {Record>} - */ - -export const list = (session) => { - const matches = Account.select( - session.agent.db, - DB.query( - session.agent.db.index, - Account.query({ audience: session.agent.signer.did() }) - ) - ) - - return Object.fromEntries( - [...matches].map(([account, login]) => [ - account, - AccountSession.from({ session, login }), - ]) - ) -} - -/** - * Gets the account view for the login with a given email address. Returns - * `undefined` if no matching login is found. - * - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - * @param {API.Session} session - * @param {API.EmailAddress} email - */ -export const get = (session, email) => { - const account = DIDMailto.fromEmail(email) - const [login] = Account.select( - session.agent.db, - DB.query( - session.agent.db.index, - Account.query({ audience: session.agent.signer.did(), account }) - ) - ).values() - - return login ? AccountSession.from({ session, login }) : undefined } /** @@ -168,97 +82,29 @@ export const get = (session, email) => { * ⚠️ 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 {object} session - * @param {API.Agent} session.agent - * @param {API.AccountSession} account + * @param {API.Session} session + * @param {API.AccountView} account */ -export const add = async ({ agent }, account) => { - return await DB.transact( +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 {object} session - * @param {API.Agent} session.agent - * @param {API.AccountSession} account + * @param {API.Session} session + * @param {API.AccountView} account */ -export const remove = async ({ agent }, account) => { - return DB.transact( +export function* remove({ agent }, account) { + yield* DB.transact( agent.db, [...account.proofs].map((proof) => DB.retract({ proof })) ) -} - -/** - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - */ -class AccountSession { - /** - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [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 - */ - static from({ login, session }) { - return new AccountSession({ - id: login.id, - session: { - agent: { - signer: session.agent.signer, - db: DB.fromProofs([ - ...login.proofs.values(), - ...login.attestations.values(), - ]), - }, - connection: session.connection, - }, - }) - } - - /** - * @param {object} source - * @param {API.DidMailto} source.id - * @param {API.Session} source.session - */ - constructor(source) { - this.model = source - /** @type {API.AccountPlans} */ - 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], - } - } + 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 index 4573b0c2c..63ed226a9 100644 --- a/packages/w3up-client/src/account/plan.js +++ b/packages/w3up-client/src/account/plan.js @@ -2,58 +2,68 @@ 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.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] * @param {API.AccountSession} account - * @returns {API.AccountPlans} + * @returns {API.AccountPlans} */ export const from = (account) => new AccountPlans(account) /** - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - * @param {API.AccountSession} account - * @returns {Promise, API.AccessDenied | API.PlanNotFound | API.InvocationError>>} + * @param {API.AccountSession} account */ -export const list = async (account) => { +export function* list(account) { const { session } = account - const auth = Agent.authorize(account.session.agent, { + const auth = yield* Agent.authorize(account.session.agent, { subject: account.did(), can: { 'plan/get': [], }, }) - if (auth.error) { - return auth - } + const task = Plan.get.invoke({ + issuer: session.agent.signer, + audience: session.connection.id, + with: account.did(), + proofs: auth.proofs, + }) - const { out: result } = await Plan.get - .invoke({ - issuer: session.agent.signer, - audience: session.connection.id, - with: account.did(), - proofs: auth.ok.proofs, - }) - .execute(/** @type {API.Session} */ (session).connection) + const receipt = yield* Session.execute(session, task).receipt() - /** @type {API.AccountPlanList} */ + /** @type {API.AccountPlanList} */ const plans = /** @type {any} */ (new AccountPlanList()) - if (result.ok) { - plans[result.ok.product] = new BillingPlan({ + if (receipt.out.ok) { + plans[receipt.out.ok.product] = new BillingPlan({ account: account, - plan: result.ok, + // 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 { ok: plans } + return plans + } else if (receipt.out.error.name === 'PlanNotFound') { + return plans } else { - return result.error.name === 'PlanNotFound' ? { ok: plans } : result + 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.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - * @implements {API.AccountPlans} + * @template {API.AccountProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.AccountPlans} */ class AccountPlans { /** @@ -63,7 +73,7 @@ class AccountPlans { this.account = account } list() { - return list(this.account) + return Task.perform(list(this.account)) } } @@ -74,18 +84,19 @@ class AccountPlanList { } /** - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] - * @implements {API.BillingPlan} + * @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 - /** @type {API.AccountSubscriptions} */ this.subscriptions = Subscriptions.from(this) } get account() { diff --git a/packages/w3up-client/src/account/subscription.js b/packages/w3up-client/src/account/subscription.js index 2be4cc6f4..7a2a0f6b2 100644 --- a/packages/w3up-client/src/account/subscription.js +++ b/packages/w3up-client/src/account/subscription.js @@ -1,76 +1,84 @@ 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.BillingPlan} plan + * @param {API.BillingPlanSession} source * @returns {API.AccountSubscriptions} */ -export const from = (plan) => new AccountSubscriptions(plan) +export const from = (source) => new AccountSubscriptions(source) /** - * @param {API.BillingPlan} plan + * @param {API.BillingPlanSession} session * @param {object} subscription * @param {API.SpaceDID} subscription.consumer * @param {API.Limit} [subscription.limit] */ -export const add = async ({ account, provider }, { consumer }) => { +export function* add({ account, provider }, { consumer }) { const { session } = account + const auth = yield* Agent.authorize(account.session.agent, { + subject: account.did(), + can: { + 'provider/add': [], + }, + }) - const { out: result } = await Provider.add - .invoke({ - issuer: session.agent.signer, - audience: session.connection.id, - with: account.did(), - nb: { - provider: Provider.Provider.from(provider), - consumer, - }, - proofs: account.proofs, - }) - .execute(session.connection) + 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 result + return yield* Session.execute(session, task).receipt() } /** - * @param {API.BillingPlan} plan - * @returns {Promise>} + * @param {API.BillingPlanSession} session */ - -export const list = async ({ account }) => { +export function* list({ account }) { const { session } = account const customer = account.did() - const { out: result } = await Subscription.list - .invoke({ - issuer: session.agent.signer, - audience: session.connection.id, - with: customer, - proofs: account.proofs, - nb: {}, - }) - .execute(session.connection) + 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: {}, + }) - if (result.error) { - return result - } else { - /** @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 result.ok.results) { - for (const consumer of consumers) { - subscriptions[`${consumer}:${customer}@${provider}`] = { - customer, - consumer, - provider, - limit: {}, - } + 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 { ok: Object.assign(new Subscriptions(), subscriptions) } } + + return subscriptions } class Subscriptions { @@ -80,17 +88,17 @@ class Subscriptions { } /** - * @template {API.PlanProtocol & API.ProviderProtocol & API.SubscriptionProtocol} [Protocol=API.W3UpProtocol] + * @implements {API.AccountSubscriptions} */ class AccountSubscriptions { /** - * @param {API.BillingPlan} plan + * @param {API.BillingPlanSession} model */ - constructor(plan) { - this.plan = plan + constructor(model) { + this.model = model } get account() { - return this.plan.account + return this.model.account.did() } /** @@ -99,13 +107,10 @@ class AccountSubscriptions { * @param {API.Limit} [subscription.limit] */ add(subscription) { - return add(this.plan, subscription) + return Session.perform(add(this.model, subscription)) } - /** - * - */ list() { - return list(this.plan) + return Task.perform(list(this.model)) } } diff --git a/packages/w3up-client/src/agent.js b/packages/w3up-client/src/agent.js index 54a79596b..6589e0f5d 100644 --- a/packages/w3up-client/src/agent.js +++ b/packages/w3up-client/src/agent.js @@ -1,90 +1,112 @@ -// import * as Client from '@ucanto/client' -// import * as CAR from '@ucanto/transport/car' -// import * as HTTP from '@ucanto/transport/http' import * as DB from './agent/db.js' -import { Signer, ed25519 } from '@ucanto/principal' +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 './w3up.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() + /** - * @type {API.DataStore} + * @param {API.AgentFrom} source */ -export const ephemeral = { - open: async () => {}, - close: async () => {}, - save: async () => {}, - /** - * - * @returns {Promise} - */ - load: async () => { - return undefined - }, - reset: async () => {}, -} +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.W3UpOpen} source - * @returns {AgentPromise} + * @param {API.AgentOpen} source */ -export const open = (source) => AgentPromise.from(Agent.open(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.W3Load} source + * @param {API.AgentLoad} source */ -export const load = (source) => AgentPromise.from(Agent.load(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.W3Create} source + * @param {API.AgentCreate} source */ -export const create = (source) => AgentPromise.from(Agent.create(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 const authorize = (agent, { subject, can }) => - Authorization.get(agent.db, { +export function* authorize(agent, { subject, can }) { + const result = Authorization.get(agent.db, { authority: agent.signer.did(), subject, can, }) -/** - * @extends {Promise>} - */ -class AgentPromise extends Promise { - /** - * - * @param {Promise>} promise - * @returns {AgentPromise} - */ - static from(promise) { - return new AgentPromise((resolve, reject) => promise.then(resolve, reject)) - } - /** - * @template {API.UnknownProtocol} Protocol - * @param {API.ConnectionView} [connection] - * @returns {Promise>} - */ - async connect(connection) { - const result = await this - if (result.ok) { - return result.ok.connect(connection) - } else { - return result - } - } + return yield* Task.ok(result) } /** @@ -126,780 +148,19 @@ class Agent { * @param {API.Can} access.can */ authorize(access) { - return authorize(this, access) + return Task.perform(authorize(this, access)) } /** * @template {API.UnknownProtocol} Protocol * @param {API.Connection} [connection] - * @returns {Promise>} - */ - async connect(connection = Connection.open()) { - return { - ok: Session.create({ agent: this, connection }), - } - } - /** - * @param {API.W3Create} source + * @returns {API.W3UpSession} */ - static async create({ store, as }) { - const { ok: db, error } = await DB.open({ store }) - if (error) { - return { error } - } - const signer = as ?? (await ed25519.generate()) - const archive = signer.toArchive() - const result = await DB.transact(db, [DB.assert({ signer: archive })]) - if (result.error) { - return result - } - - return { ok: new Agent({ db, signer }) } - } - /** - * @param {API.W3Load} source - * @returns {Promise>} - */ - static async load({ store, as }) { - const { ok: db, error } = await DB.open({ store }) - if (error) { - return { error } - } - - if (as != null) { - return { ok: new Agent({ db, signer: as }) } - } else if (db?.signer != null) { - const signer = Signer.from( - /** @type {API.SignerArchive} */ (db.signer) - ) - - return { ok: new Agent({ db, signer }) } - } else { - return { - error: new SignerLoadError( - 'Signer key material is not stored in storage' - ), - } - } - } - /** - * @param {API.W3UpOpen} source - * @returns {Promise>} - */ - static async open({ store, as }) { - const { ok: db, error } = await DB.open({ store }) - if (error) { - return { error } - } - - if (as) { - return { ok: new Agent({ db, signer: as }) } - } else if (db.signer) { - return { - ok: new Agent({ - signer: Signer.from(db.signer), - db, - }), - } - } else { - const signer = await ed25519.generate() - - const { error } = await DB.transact(db, [ - DB.assert({ signer: signer.toArchive() }), - ]) - if (error) { - return { error } - } else { - return { - ok: new Agent({ db, signer }), - } - } - } + connect(connection = Connection.open()) { + return Session.create({ agent: this, connection }) } } class SignerLoadError extends Error { name = /** @type {const} */ ('SignerLoadError') } - -// /** -// * Keeps track of AgentData for all Agents constructed. -// * Used by addSpacesFromDelegations - so it can only accept Agent as param, but -// * still mutate corresponding AgentData -// * -// * @deprecated - remove this when deprecated addSpacesFromDelegations is removed -// */ -// /** @type {WeakMap>, AgentData>} */ -// const agentToData = new WeakMap() - -/** - * @typedef {API.W3UpProtocol} Service - * @typedef {API.Receipt} Receipt - */ - -// /** -// * @template {Record} [Protocol={}] -// * @typedef {object} Model -// * @property {API.AgentMeta} meta -// * @property {Connection.Model} connection -// * @property {API.Signer} principal -// * @property {API.Storage} store -// * @property {DB.DB} db -// * @property {URL} [receiptsEndpoint] -// */ - -// /** -// * @typedef {object} Archive -// * @property {Connection.Archive} connection -// * @property {API.AgentMeta} meta -// * @property {API.SignerArchive} principal -// * @property {API.DatabaseArchive} delegations -// * @property {API.DIDKey} [currentSpace] -// */ - -// /** -// * @param {Model} model -// * @returns {Archive} -// */ -// export const toArchive = (model) => { -// return { -// connection: Connection.toArchive(model.connection), -// meta: model.meta, -// principal: model.principal.toArchive(), -// delegations: DB.toArchive(model.db), -// } -// } - -// /** -// * @template {Record} [Service={}] -// * @typedef {object} AgentModel -// * @property {AgentData} data -// * @property {API.ConnectionView} connection -// */ - -// /** -// * @template {Record} Protocol -// * @param {object} source -// * @param {API.DataStore} source.store -// * @param {API.ConnectionView} source.connection -// * @param {URL} [source.receiptsEndpoint] -// * @returns {Agent} -// */ -// export const from = (source) => new Agent(source.data, source) - -// /** -// * Agent -// * -// * Usage: -// * -// * ```js -// * import { Agent } from '@web3-storage/w3up-client' -// * ``` -// * -// * @template {Record} [S=Record] - Service -// */ -// export class ExAgent { -// /** @type {AgentData} */ -// #data - -// /** -// * @param {AgentData} data - Agent data -// * @param {API.AgentOptions} [options] -// */ -// constructor(data, options = {}) { -// /** @type { Client.Channel & { url?: URL } | undefined } */ -// const channel = options.connection?.channel -// this.url = options.url ?? channel?.url ?? new URL(HOST) -// this.connection = -// options.connection ?? -// connection({ -// principal: options.servicePrincipal, -// url: this.url, -// }) -// this.receiptsEndpoint = options.receiptsEndpoint ?? Config.receiptsEndpoint -// this.#data = data -// agentToData.set(this, this.#data) -// } - -// get data() { -// return this.#data -// } - -// /** -// * Create a new Agent instance, optionally with the passed initialization data. -// * -// * @template {Record} [R=Service] -// * @param {Partial} [init] -// * @param {API.AgentOptions & API.AgentDataOptions} [options] -// */ -// static async create(init, options = {}) { -// const data = await AgentData.create(init, options) -// return new Agent(data, options) -// } - -// /** -// * Instantiate an Agent from pre-exported agent data. -// * -// * @template {Record} [R=Service] -// * @param {API.AgentDataExport} raw -// * @param {API.AgentOptions & API.AgentDataOptions} [options] -// */ -// static from(raw, options = {}) { -// const data = AgentData.fromExport(raw, options) -// return new Agent(data, options) -// } - -// get issuer() { -// return this.#data.principal -// } - -// get meta() { -// return this.#data.meta -// } - -// get spaces() { -// return this.#data.spaces -// } - -// did() { -// return this.#data.principal.did() -// } - -// /** -// * Import a space from a delegation. -// * -// * @param {API.Delegation} delegation -// * @param {object} options -// * @param {string} [options.name] -// */ -// async importSpaceFromDelegation(delegation, { name = '' } = {}) { -// const space = -// name === '' -// ? Space.fromDelegation(delegation) -// : Space.fromDelegation(delegation).withName(name) - -// this.#data.spaces.set(space.did(), { ...space.meta, name: space.name }) - -// await addProofs(this.#data, space.proofs) - -// // if we do not have a current space, make this one current -// if (!this.#data.currentSpace) { -// await this.#data.setCurrentSpace(space.did()) -// } - -// return space -// } - -// /** -// * Execute invocations on the agent's connection -// * -// * @example -// * ```js -// * const i1 = await agent.invoke(Space.info, {}) -// * const i2 = await agent.invoke(Space.recover, { -// * nb: { -// * identity: 'mailto:hello@web3.storage', -// * }, -// * }) -// * -// * const results = await agent.execute2(i1, i2) -// * -// * ``` -// * @template {API.Capability} C -// * @template {API.Tuple>} I -// * @param {I} invocations -// */ -// execute(...invocations) { -// return this.connection.execute(...invocations) -// } -// } - -// /** -// * Given a list of delegations, add to agent data spaces list. -// * -// * @deprecated - trying to remove explicit space tracking from Agent/AgentData -// * in favor of functions that derive the space set from access.delegations -// * -// * @template {Record} [S=Service] -// * @param {Agent} agent -// * @param {API.Delegation[]} delegations -// */ -// export async function addSpacesFromDelegations(agent, delegations) { -// const data = agentToData.get(agent) -// if (!data) { -// throw Object.assign(new Error(`cannot determine AgentData for Agent`), { -// agent: agent, -// }) -// } - -// // spaces we find along the way. -// const spaces = new Map() -// // only consider ucans with this agent as the audience -// const ours = delegations.filter((x) => x.audience.did() === agent.did()) -// // space names are stored as facts in proofs in the special `ucan:*` delegation from email to agent. -// const ucanStars = ours.filter( -// (x) => x.capabilities[0].can === '*' && x.capabilities[0].with === 'ucan:*' -// ) -// for (const delegation of ucanStars) { -// for (const proof of delegation.proofs) { -// if ( -// !isDelegation(proof) || -// !proof.capabilities[0].with.startsWith('did:key') -// ) { -// continue -// } -// const space = Space.fromDelegation(proof) -// spaces.set(space.did(), space.meta) -// } -// } - -// // Find any other spaces the user may have access to -// for (const delegation of ours) { -// // TODO: we need a more robust way to determine which spaces a user has access to -// // it may or may not involve look at delegations -// const allows = ucanto.Delegation.allows(delegation) -// for (const [resource, value] of Object.entries(allows)) { -// // If we discovered a delegation to any DID, we add it to the spaces list. -// if (resource.startsWith('did:key') && Object.keys(value).length > 0) { -// if (!spaces.has(resource)) { -// spaces.set(resource, {}) -// } -// } -// } -// } - -// for (const [did, meta] of spaces) { -// await data.addSpace(did, meta) -// } -// } - -// /** -// * Stores given delegations in the agent's data store and adds discovered spaces -// * to the agent's space list. -// * -// * @param {Agent<{}>} agent -// * @param {object} authorization -// * @param {API.Delegation[]} authorization.proofs -// * @returns {Promise>} -// */ -// export const importAuthorization = async (agent, { proofs }) => { -// try { -// await addProofs(agent.data, proofs) -// await addSpacesFromDelegations(agent, proofs) -// return { ok: {} } -// } catch (error) { -// return /** @type {{error:Error}} */ ({ error }) -// } -// } - -// /** -// * Get all the proofs matching the capabilities. -// * -// * Proofs are delegations with an audience matching agent DID, or with an -// * audience matching the session DID. -// * -// * Attestations will also be included in the returned proofs require them. -// * -// * @param {object} agent -// * @param {AgentData} agent.data -// * @param {API.Principal} agent.issuer -// * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the proofs. -// * @param {object} [options] -// * @param {API.UTCUnixTimestamp} [options.time] - Time when the capability should be valid -// * @param {API.DID} [options.sessionProofIssuer] - only include session proofs for this issuer -// */ -// export const selectAuthorization = ({ data, issuer }, caps, options) => { -// const authorizations = [] -// for (const { delegation } of selectProofs(data, caps, options)) { -// if (delegation.audience.did() === issuer.did()) { -// authorizations.push(delegation) -// } -// } - -// // now let's add any session proofs that refer to those authorizations -// const sessions = getAttestations(data, options) -// for (const proof of authorizations) { -// const proofsByIssuer = sessions[proof.asCID.toString()] ?? {} -// const sessionProofs = options?.sessionProofIssuer -// ? proofsByIssuer[options.sessionProofIssuer] ?? [] -// : Object.values(proofsByIssuer).flat() -// if (sessionProofs.length) { -// authorizations.push(...sessionProofs) -// } -// } - -// return authorizations -// } - -// /** -// * Query the delegations store for all the delegations matching the capabilities provided. -// * -// * @param {AgentData} store -// * @param {API.CapabilityQuery[]} caps -// * @param {object} [options] -// * @param {API.UTCUnixTimestamp} [options.time] -// */ -// export const selectProofs = (store, caps, { time } = {}) => { -// const _caps = new Set(caps) -// /** @type {Array<{ delegation: API.Delegation, meta: API.DelegationMeta }>} */ -// const values = [] -// for (const [, value] of store.delegations) { -// // check expiration -// if (!time || isValid(value.delegation, time)) { -// // check if we need to filter for caps -// if (Array.isArray(caps) && caps.length > 0) { -// for (const cap of _caps) { -// if (canDelegateCapability(value.delegation, cap)) { -// values.push(value) -// } -// } -// } else { -// values.push(value) -// } -// } -// } - -// return values -// } - -// /** -// * Get delegations created by the agent for others and their metadata. -// * -// * @param {AgentData} data -// * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. -// */ -// export const selectIssuedDelegationsWithMeta = (data, caps) => { -// const arr = [] - -// for (const value of selectProofs(data, caps)) { -// const { delegation } = value -// const isAttestation = delegation.capabilities.some( -// (c) => c.can === attest.can -// ) - -// if (!isAttestation && delegation.audience.did() !== data.principal.did()) { -// arr.push(value) -// } -// } - -// return arr -// } - -// /** -// * Get delegations created by the agent for others. -// * -// * @param {AgentData} data -// * @param {API.CapabilityQuery[]} caps - Capabilities to filter by. Empty or undefined caps with return all the delegations. -// */ -// export const selectIssuedDelegations = (data, caps) => { -// const arr = [] - -// for (const { delegation } of selectIssuedDelegationsWithMeta(data, caps)) { -// arr.push(delegation) -// } - -// return arr -// } - -// /** -// * Add a proof to the agent store. -// * -// * @param {AgentData} data -// * @param {API.Delegation} delegation -// */ -// export const addProof = async (data, delegation) => { -// return await addProofs(data, [delegation]) -// } - -// /** -// * Adds set of proofs to the agent store. -// * -// * @param {AgentData} data -// * @param {Iterable} delegations -// */ -// export const addProofs = async (data, delegations) => { -// for (const proof of delegations) { -// await data.addDelegation(proof, { audience: data.meta }) -// } - -// await removeExpiredDelegations(data, { time: Date.now() / 1000 }) - -// return {} -// } - -// /** -// * Clean up any expired delegations. -// * -// * @param {AgentData} data -// * @param {object} options -// * @param {API.UTCUnixTimestamp} options.time -// */ -// export const removeExpiredDelegations = async (data, options) => { -// for (const [, value] of data.delegations) { -// if (isExpired(value.delegation, options.time)) { -// await data.removeDelegation(value.delegation.cid) -// } -// } -// } - -// /** -// * Get current space DID, proofs and abilities -// * -// * @param {object} agent -// * @param {AgentData} agent.data -// * @param {API.Principal} agent.issuer -// */ -// export const currentSpaceWithMeta = ({ data, issuer }) => { -// const space = data.currentSpace -// if (!space) { -// return -// } - -// const proofs = selectAuthorization({ data, issuer }, [ -// { -// can: 'space/info', -// with: space, -// }, -// ]) - -// const abilities = new Set() -// for (const { capabilities } of proofs) { -// for (const { can } of capabilities) { -// abilities.add(can) -// } -// } - -// return { -// did: data.currentSpace, -// proofs: proofs, -// capabilities: [...abilities], -// meta: data.spaces.get(space), -// } -// } - -// /** -// * @param {object} agent -// * @param {AgentData} agent.data -// * @param {API.Signer} agent.issuer -// * @param {API.DelegateOptions} options -// */ -// export const issueDelegation = async ({ data, issuer }, options) => { -// const time = Date.now() / 1000 -// const space = currentSpaceWithMeta({ data, issuer }) -// if (!space) { -// throw new Error('no space selected.') -// } - -// const capabilities = /** @type {API.Capabilities} */ ( -// options.abilities.map((can) => { -// return { -// with: space.did, -// can, -// } -// }) -// ) - -// // Verify agent can provide proofs for each requested capability -// for (const capability of capabilities) { -// if (!selectAuthorization({ data, issuer }, [capability], { time }).length) { -// throw new Error( -// `cannot delegate capability ${capability.can} with ${capability.with}` -// ) -// } -// } - -// const delegation = await Delegation.delegate({ -// issuer, -// capabilities, -// proofs: selectAuthorization({ data, issuer }, capabilities, { time }), -// facts: [{ space: space.meta ?? {} }], -// ...options, -// }) - -// await data.addDelegation(delegation, { -// audience: options.audienceMeta, -// }) -// await removeExpiredDelegations(data, { time }) - -// return delegation -// } - -// /** -// * Creates an invocation for the given capability with Agent's proofs, service, issuer and space. -// * -// * @example -// * ```js -// * const spaceList = await Agent.issueInvocation(agent, Store.list, { -// * nb: { -// * size: 10, -// * }, -// * }) -// * -// * await spaceList.execute(agent.connection) -// * ``` -// * -// * @template {API.Ability} A -// * @template {API.URI} R -// * @template {API.TheCapabilityParser>} CAP -// * @template {API.Caveats} [C={}] -// * -// * @param {object} agent -// * @param {AgentData} agent.data -// * @param {API.Signer} agent.issuer -// * @param {{id: API.Principal}} agent.connection -// * @param {CAP} cap -// * @param {API.InvokeOptions} options -// */ -// export const issueInvocation = async ( -// { connection, issuer, data }, -// cap, -// options -// ) => { -// const audience = options.audience || connection.id -// const time = Date.now() / 1000 - -// const space = options.with || data.currentSpace -// if (!space) { -// throw new Error('No space or resource selected, you need pass a resource.') -// } - -// const proofs = [ -// ...(options.proofs || []), -// ...selectAuthorization( -// { data, issuer }, -// [ -// { -// with: space, -// can: cap.can, -// }, -// ], -// { sessionProofIssuer: audience.did(), time } -// ), -// ] - -// if (proofs.length === 0 && options.with !== issuer.did()) { -// throw new Error( -// `no proofs available for resource ${space} and ability ${cap.can}` -// ) -// } - -// const inv = invoke({ -// ...options, -// issuer, -// audience, -// // @ts-ignore -// capability: cap.create({ -// with: space, -// nb: options.nb, -// }), -// proofs: [...proofs], -// }) - -// return /** @type {API.IssuedInvocationView>} */ ( -// inv -// ) -// } - -// /** -// * Returns iterable of all the proofs that contain capabilities matching -// * passed query. -// * -// * @param {AgentData} store -// * @param {object} query -// * @param {API.CapabilityParser} [query.capability] - Capability to match -// * @param {API.UTCUnixTimestamp} [query.time] - Time when the capability should be valid -// * @param {API.DID} [query.audience] - Audience of the capability -// */ -// export const selectAccess = function* (store, { capability, audience, time }) { -// for (const [, { delegation }] of store.delegations) { -// // Skip if delegated to a different audience -// if (audience && delegation.audience.did() !== audience) { -// continue -// } - -// // Skip if not valid at the given time -// if (time && !isValid(delegation, time)) { -// continue -// } - -// // If proof matches the capability, yield it otherwise skip -// const proof = capability ? matchProof(delegation, capability) : delegation -// if (proof) { -// yield proof -// } -// } -// } - -// /** -// * Select all attestations matching the given query. -// * -// * @param {AgentData} store -// * @param {object} query -// * @param {API.UCANLink[]} query.proofs -// * @param {API.DID} [query.audience] - Audience of the capability -// */ -// const selectAttestations = (store, { proofs, audience }) => { -// const proof = proofs -// .map((proof) => Schema.link(proof)) -// .reduce((left, right) => Schema.or(left, right)) - -// const selector = capability({ -// can: UCAN.attest.can, -// with: Schema.did(), -// nb: Schema.struct({ proof }), -// }) - -// return selectAccess(store, { capability: selector, audience }) -// } - -// /** -// * -// * @param {API.Delegation} delegation -// * @param {API.CapabilityParser} query -// */ - -// const matchProof = (delegation, query) => { -// for (const capability of delegation.capabilities) { -// const result = query.match( -// /** @type {API.Source} */ ({ capability, delegation }) -// ) -// if (result.ok) { -// return delegation -// } -// } -// } -// /** -// * Invoke and execute the given capability on the Access service connection -// * -// * ```js -// * -// * await Agent.invokeAndExecute(agent, Store.list, { -// * nb: { -// * size: 10, -// * }, -// * }) -// * ``` -// * -// * @deprecated - use following instead -// * ```js -// * const task = await Agent.issueInvocation(agent, cap, opts) -// * await task.execute(connection) instead -// * ``` -// * -// * @template {API.Ability} A -// * @template {API.URI} R -// * @template {API.Caveats} C -// * @template {Record} Protocol -// * @param {object} agent -// * @param {AgentData} agent.data -// * @param {API.Signer} agent.issuer -// * @param {API.ConnectionView} agent.connection -// * @param {API.TheCapabilityParser>} cap -// * @param {API.InvokeOptions>>} options -// * @returns {Promise, Protocol>>} -// */ -// export const invokeAndExecute = async (agent, cap, options) => { -// const invocation = await issueInvocation(agent, cap, options) -// const out = invocation.execute(/** @type {*} */ (agent.connection)) -// return /** @type {*} */ (out) -// } diff --git a/packages/w3up-client/src/agent/connection.js b/packages/w3up-client/src/agent/connection.js index 31a882c50..7bae83c22 100644 --- a/packages/w3up-client/src/agent/connection.js +++ b/packages/w3up-client/src/agent/connection.js @@ -8,6 +8,11 @@ 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` @@ -38,3 +43,11 @@ export const open = ({ address, } ) + +/** + * @type {API.Offline} + */ +export const offline = { + id: id, + address: { id, url }, +} diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js index 2143c5f9c..2aae4cb15 100644 --- a/packages/w3up-client/src/agent/db.js +++ b/packages/w3up-client/src/agent/db.js @@ -4,6 +4,7 @@ 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' /** * @@ -95,117 +96,119 @@ export const fromProofs = (source) => { /** * @param {object} source * @param {API.DataStore} [source.store] - * @returns {Promise>} + * @returns {Task.Invocation} */ -export const open = async ({ store }) => { - try { - const archive = store ? await store.load() : null - const db = fromArchive(archive ?? {}) - return { ok: { ...db, store } } - } catch (cause) { - return { - error: new DataStoreOpenError('Failed to open a datastore', { - cause, - }), +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 {Promise>} + * @returns {Task.Invocation} */ -export const save = async (db) => { - const archive = toArchive(db) - if (db.store) { - try { - await db.store.save(archive) - } catch (cause) { - return { - error: new DataStoreSaveError('Failed to store data', { cause }), +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 { ok: {} } -} + return {} + }) /** * @param {API.Database} db * @param {API.DBTransaction} transaction - * @returns {Promise>} + * @returns {Task.Task} */ -export const transact = async (db, transaction) => { - 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 { - error: new DatabaseTransactionError( - `Transaction contains unknown assertion`, - { cause: assert, transaction } - ), +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 { - error: new DatabaseTransactionError( - `Transaction contains unknown retraction`, - { cause: retract, 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 = await db.transactor.transact(assertions) - if (commit.error) { - return { - error: new DatabaseTransactionError(commit.error.message, { - cause: commit.error, - 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 - } + // 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. - const { error } = await save(db) - if (error) { - return { error } - } + // Finally we save changes in the database store. + yield* save(db) - return { ok: db } -} + return db + }) /** * Creates a retraction instruction. 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/authorization/query.js b/packages/w3up-client/src/authorization/query.js index b01bfb433..d3c9aec62 100644 --- a/packages/w3up-client/src/authorization/query.js +++ b/packages/w3up-client/src/authorization/query.js @@ -164,7 +164,16 @@ export const forwards = ( 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( diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js deleted file mode 100644 index 0c7aafaf2..000000000 --- a/packages/w3up-client/src/client.js +++ /dev/null @@ -1,317 +0,0 @@ -import { CAR } from '@ucanto/transport' -import * as Account from './view/account.js' -import { Space as SpaceView } from './space.js' -import { Delegation as AgentDelegation } from './delegation.js' -import { StoreClient } from './client/store.js' -import { UploadClient } from './client/upload.js' -import { SpaceClient } from './client/space.js' -import { SubscriptionClient } from './client/subscription.js' -import { UsageClient } from './client/usage.js' -import { AccessClient } from './client/access.js' -import { FilecoinClient } from './client/filecoin.js' -import { CouponAPI } from './coupon.js' -import * as Agent from './agent.js' -export * as Access from './capability/access.js' -import * as Space from './capability/space.js' -import * as Result from './result.js' -import * as API from './types.js' -import * as Config from './service.js' - -export { - AccessClient, - FilecoinClient, - StoreClient, - SpaceClient, - SubscriptionClient, - UploadClient, - UsageClient, -} - -export class Client { - /** - * @param {API.AgentData} data - * @param {object} [options] - * @param {API.ServiceConf} [options.serviceConf] - * @param {URL} [options.receiptsEndpoint] - */ - constructor( - data, - { - serviceConf = Config.serviceConf, - receiptsEndpoint = Config.receiptsEndpoint, - } = {} - ) { - this.agents = { - access: Agent.from({ - data, - connection: serviceConf.access, - receiptsEndpoint, - }), - upload: Agent.from({ - data, - connection: serviceConf.upload, - receiptsEndpoint, - }), - filecoin: Agent.from({ - data, - connection: serviceConf.filecoin, - receiptsEndpoint, - }), - } - - const upload = new UploadClient(this.agents.upload) - this.data = data - this._receiptsEndpoint = receiptsEndpoint - this.capability = { - access: new AccessClient(this.agents.access), - filecoin: new FilecoinClient(this.agents.filecoin), - space: new SpaceClient(this.agents.access), - store: new StoreClient(this.agents.upload), - upload, - subscription: new SubscriptionClient(this.agents.upload), - usage: new UsageClient(this.agents.upload), - } - this.coupon = new CouponAPI(this.agents.upload) - - this.uploadFile = upload.uploadFile.bind(upload) - this.uploadDirectory = upload.uploadDirectory.bind(upload) - this.uploadCAR = upload.uploadCAR.bind(upload) - } - - get issuer() { - return this.data.principal - } - - did() { - return this.data.principal.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 {API.EmailAddress} email - * @param {object} [options] - * @param {AbortSignal} [options.signal] - */ - async login(email, options = {}) { - const account = Result.unwrap( - await Account.login(this.agents.access, 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.agents.access) - } - - /** - * 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.agents.upload.connection.id.did() - } - - /** - * The current space. - */ - currentSpace() { - const id = this.data.currentSpace - if (!id) return - const meta = this.data.spaces.get(id) - const proofs = Agent.selectAuthorization(this, [{ with: id }]) - - return new SpaceView({ id, meta, agent: this.agents.upload }) - } - - /** - * Use a specific space. - * - * @param {API.SpaceDID} did - */ - async setCurrentSpace(did) { - await this.data.setCurrentSpace(did) - } - - /** - * Spaces available to this agent. - */ - spaces() { - return [...this.data.spaces].map(([did, meta]) => { - const id = /** @type {API.SpaceDID} */ (did) - const proofs = Agent.selectAuthorization(this, [{ with: id }]) - return new SpaceView({ id, meta, agent: this.agents.upload }) - }) - } - - /** - * Create a new space with a given name. - * - * @param {string} name - */ - async createSpace(name) { - return await Space.generate({ name, agent: this.agents.access }) - } - - /** - * @param {string} secret - * @param {object} options - * @param {string} options.name - */ - async recoverSpace(secret, { name }) { - return await Space.fromMnemonic(secret, { name, agent: this.agents.access }) - } - - /* c8 ignore stop */ - - /** - * Add a space from a received proof. - * - * @param {API.Delegation} proof - */ - async addSpace(proof) { - const space = Space.fromDelegation(proof) - - // save space in agent's space store - this.data.spaces.set(space.did(), { ...space.meta, name: space.name }) - // save the proof in the agent's delegation store - await Agent.addProofs(this.data, space.proofs) - - // If we do not have a current space, make this one the current space - if (!this.currentSpace()) { - await this.data.setCurrentSpace(space.did()) - } - - return space - } - - /** - * 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 Agent.selectAuthorization(this, 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 {API.Capability[]} [caps] - Capabilities to - * filter by. Empty or undefined caps with return all the delegations. - */ - delegations(caps) { - const delegations = [] - for (const { delegation, meta } of Agent.selectIssuedDelegationsWithMeta( - this.data, - 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 Agent.issueDelegation(this, { - ...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/coupon.js b/packages/w3up-client/src/coupon.js index 2e3f09cc9..4223167ef 100644 --- a/packages/w3up-client/src/coupon.js +++ b/packages/w3up-client/src/coupon.js @@ -1,11 +1,7 @@ import * as API from './types.js' -import { sha256, delegate, Delegation } from '@ucanto/core' -import { ed25519 } from '@ucanto/principal' -import * as Result from './result.js' import * as Agent from './agent.js' import * as Task from './task.js' -import * as Space from './space.js' -import * as Account from './account.js' +import * as Coupon from './coupon/coupon.js' /** * @template {API.UnknownProtocol} Protocol @@ -15,321 +11,119 @@ import * as Account from './account.js' export const view = (session) => new CouponAPI(session) /** + * Redeems coupon from the the the archive. Throws an error if the coupon + * password is invalid or if provided archive is not a valid. + * * @template {API.UnknownProtocol} Protocol - * @implements {API.CouponAPI} - */ -class CouponAPI { - /** - * @param {API.Session} session - */ - constructor(session) { - this.session = session - } - - /** - * 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} coupon - * @param {object} [options] - * @param {string} [options.secret] - */ - async open(coupon, options = {}) { - const result = await open(coupon, { ...options }) - if (result.error) { - return result - } else { - return { ok: result.ok.connect(this.session.connection) } - } - } - - /** - * 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} coupon - * @param {object} [options] - * @param {string} [options.secret] - * @returns {Promise, Error>>} - */ - async redeem(coupon, options = {}) { - const result = await open(coupon, { ...options }) - if (result.error) { - return result - } else { - return await redeem(result.ok, { session: this.session }) - } - } + * @param {API.Session} session - /** - * Issues a coupon for the given delegation. - * - * @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 {Promise, Error>>} - */ - async issue(access) { - const result = await issue(this.session.agent, access) - if (result.error) { - return result - } else { - return { ok: result.ok.connect(this.session.connection) } - } - } + * @param {object} options + * @param {Uint8Array} options.archive + * @param {string} [options.secret] + * @returns {Task.Task, Error>} + */ +export function* redeem(session, { archive, secret = '' }) { + const coupon = yield* Coupon.open(archive, { secret }) + return yield* Coupon.redeem(coupon, { session }) +} - /** - * @param {API.Coupon} coupon - */ - add(coupon) { - return add(this.session, coupon) - } - /** - * @param {API.Coupon} coupon - */ - remove(coupon) { - return remove(this.session, coupon) - } +/** + * @template {API.UnknownProtocol} Protocol + * @param {API.Session} session + * @param {Coupon.Access} access + * @returns {Task.Task, Error>} + */ +export function* issue({ agent, connection }, access) { + const coupon = yield* Coupon.issue(agent, access) + return coupon.connect(connection) } /** * @param {{agent: API.Agent}} session * @param {API.Coupon} coupon + * @returns {Task.Task} */ -export const remove = ({ agent }, coupon) => - Agent.DB.transact( +export function* remove({ agent }, coupon) { + yield* Agent.DB.transact( agent.db, coupon.proofs.map((proof) => Agent.DB.retract({ proof })) ) + return {} +} + /** * @param {{agent: API.Agent}} session * @param {API.Coupon} coupon + * @returns {Task.Task} */ -export const add = async ({ agent }, coupon) => { +export function* add({ agent }, coupon) { if (coupon.signer.did() === agent.signer.did()) { - return await Agent.DB.transact( + yield* Agent.DB.transact( agent.db, coupon.proofs.map((proof) => Agent.DB.assert({ proof })) ) + + return {} } else { - return { - error: new RangeError( + return yield* Task.fail( + new RangeError( `Coupon audience is ${coupon.signer.did()} not ${agent.signer.did()}` - ), - } - } -} - -/** - * Encodes coupon into an archive. - * - * @param {API.Coupon} coupon - */ -export const archive = async (coupon) => { - const [delegation] = coupon.proofs - return await Delegation.archive(delegation) -} - -/** - * Issues a coupon for the given delegation. - * - * @param {API.Agent} agent - * @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 {Promise>} - */ -export const issue = async ( - agent, - { secret = '', subject, can, ...options } -) => { - const authorization = Agent.authorize(agent, { - subject, - can, - }) - if (authorization.error) { - return authorization + ) + ) } - - const audience = await deriveSigner(secret) - - const capabilities = /** @type {API.Capabilities} */ ( - Object.entries(can).map(([can, policy]) => ({ - with: subject, - can, - nb: policy, - })) - ) - - const delegation = await delegate({ - ...options, - issuer: agent.signer, - audience, - capabilities, - proofs: authorization.ok.proofs, - }) - - const coupon = new Coupon({ - signer: audience, - db: Agent.DB.from({ proofs: [delegation] }), - }) - - return { ok: coupon } } /** - * - * @param {Uint8Array} archive - * @param {object} [options] - * @param {string} [options.secret] - * @returns {Promise>} - */ -export const open = (archive, { secret = '' } = {}) => - Task.try(function* () { - const proof = yield* Task.join(Delegation.extract(archive)) - const signer = yield* Task.wait(deriveSigner(secret)) - - if (proof.audience.did() !== signer.did()) { - return Result.error( - new RangeError( - secret === '' - ? 'Redeeming a coupon requires a secret' - : 'Provided secret is invalid' - ) - ) - } - - const coupon = new Coupon({ - signer, - db: Agent.DB.from({ proofs: [proof] }), - }) - - return Result.ok(coupon) - }) - -/** - * 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 {Promise, Error>>} - */ -export const redeem = async (coupon, { session }) => { - const [delegation] = coupon.proofs - - const proof = await delegate({ - issuer: coupon.signer, - audience: session.agent.signer, - capabilities: delegation.capabilities, - expiration: delegation.expiration, - notBefore: delegation.notBefore, - proofs: [delegation], - }) - - const db = Agent.DB.from({ proofs: [proof] }) - return Result.ok( - new CouponSession({ - agent: { signer: session.agent.signer, db }, - connection: session.connection, - }) - ) -} - -/** - * @param {string} password - */ -const deriveSigner = async (password) => { - const { digest } = await sha256.digest(new TextEncoder().encode(password)) - return await ed25519.Signer.derive(digest) -} - -/** - * @implements {API.Coupon} + * @implements {API.CouponAPI} */ -export class Coupon { +class CouponAPI { /** - * @param {API.Agent} model + * @param {API.Session} session */ - constructor(model) { - this.model = model - } - get signer() { - return this.model.signer - } - - get proofs() { - return /** @type {[API.Delegation]} */ ( - [...this.model.db.proofs.values()].map(({ delegation }) => delegation) - ) - } - - archive() { - return archive(this) + constructor(session) { + this.session = session } /** - * @template {API.UnknownProtocol} Protocol - * @param {API.Connection} connection - * @returns {API.CouponSession} + * 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>} */ - connect(connection) { - return new CouponSession({ - agent: this.model, - connection, - }) + redeem(archive, options = {}) { + return Task.perform(redeem(this.session, { archive, ...options })) } -} -/** - * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] - * @implements {API.CouponSession} - */ -class CouponSession { /** - * @param {API.Session} model + * Issues a coupon for the given delegation. + * + * @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>} */ - 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 - ) - ) + 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 {object} options - * @param {API.Agent} options.agent + * @param {API.Coupon} coupon */ - redeem({ agent }) { - return redeem(this, { - session: { agent, connection: this.connection }, - }) + 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.node.js b/packages/w3up-client/src/index.node.js deleted file mode 100644 index 265764baa..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 './view/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/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 d313aaa61..b6218034e 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -1,17 +1,17 @@ import * as API from './types.js' import * as DB from './agent/db.js' import * as Query from './space/query.js' -import * as SharedSpace from './space/shared.js' -import * as OwnSpace from './space/own.js' +import * as Space from './space/space.js' +import * as Task from './task.js' + +export * from './space/space.js' /** * @param {API.Session} session - * @returns {API.SpacesSession} + * @returns {API.SpaceManager} */ export const view = (session) => new SessionSpaces(session) -export const { create, fromMnemonic } = OwnSpace - /** * @param {API.Session} session */ @@ -27,22 +27,25 @@ export const list = (session) => { /** * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session - * @param {API.SharedSpace} space + * @param {API.SpaceView} space + * @returns {Task.Task} */ -export const add = async (session, space) => { +export function* add(session, space) { if (space.authority === session.agent.signer.did()) { - return await DB.transact( + return yield* DB.transact( session.agent.db, space.proofs.map((proof) => DB.assert({ proof })) ) } else { - return { - error: new PrincipalAlignmentError( - `Space is shared with ${ + return yield* Task.fail( + new PrincipalAlignmentError({ + message: `Space is shared with ${ space.authority - } not ${session.agent.signer.did()}` - ), - } + } not ${session.agent.signer.did()}`, + expect: space.authority, + actual: session.agent.signer.did(), + }) + ) } } @@ -54,22 +57,37 @@ export const add = async (session, space) => { * * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] * @param {API.Session} session - * @param {API.SharedSpace} space + * @param {API.SpaceView} space + * @returns {Task.Task} */ -export const remove = (session, space) => - DB.transact( +export function* remove(session, space) { + yield* DB.transact( session.agent.db, space.proofs.map((proof) => DB.retract({ proof })) ) + return {} +} + /** - * @template {API.PlanProtocol & API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol} [Protocol=API.W3UpProtocol] + * @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} */ + /** @type {Record>} */ const result = {} for (const { space: subject, name = '', proof } of spaces) { const { delegation } = /** @type {{delegation: API.Delegation}} */ ( @@ -82,6 +100,7 @@ const build = (session, spaces) => { signer: /** @type {API.Signer} */ (session.agent.signer), name, proofs: [delegation], + session, } } else { result[subject].proofs.push(delegation) @@ -89,15 +108,12 @@ const build = (session, spaces) => { } return Object.fromEntries( - Object.entries(result).map(([k, v]) => [ - k, - SharedSpace.create(v).connect(session.connection), - ]) + Object.entries(result).map(([k, model]) => [k, Space.view(model)]) ) } /** - * @implements {API.SpacesSession} + * @implements {API.SpaceManager} */ class SessionSpaces { /** @@ -110,16 +126,16 @@ class SessionSpaces { return list(this.session) } /** - * @param {API.SharedSpace} space + * @param {API.SpaceView} space */ add(space) { - return add(this.session, space) + return Task.perform(add(this.session, space)) } /** - * @param {API.SharedSpace} space + * @param {API.SpaceView} space */ remove(space) { - return remove(this.session, space) + return Task.perform(remove(this.session, space)) } *[Symbol.iterator]() { @@ -127,16 +143,27 @@ class SessionSpaces { } /** - * * @param {object} source * @param {string} source.name - * @returns {Promise, never>>} + * @returns {Task.Invocation} */ - async create(source) { - return OwnSpace.create(source).connect(this.session.connection) + create(source) { + return Task.perform(Space.create({ ...source, session: this.session })) } } class PrincipalAlignmentError extends Error { + /** + * + * @param {object} options + * @param {string} options.message + * @param {API.DID} options.expect + * @param {API.DID} options.actual + */ + constructor({ message, expect, actual }) { + super(message) + this.expect = expect + this.actual = actual + } name = /** @type {const} */ ('PrincipalAlignmentError') } diff --git a/packages/w3up-client/src/space/delegations.js b/packages/w3up-client/src/space/delegations.js index 89d830cca..87cc551e8 100644 --- a/packages/w3up-client/src/space/delegations.js +++ b/packages/w3up-client/src/space/delegations.js @@ -1,5 +1,6 @@ import * as API from '../types.js' import * as Access from '../access.js' +import * as Session from '../session.js' /** * @param {API.Session} session @@ -23,10 +24,12 @@ class Delegations { * @param {API.Authorization} authorization */ add(authorization) { - return Access.delegate(this.session, { + 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/own.js b/packages/w3up-client/src/space/own.js deleted file mode 100644 index 2986772a7..000000000 --- a/packages/w3up-client/src/space/own.js +++ /dev/null @@ -1,383 +0,0 @@ -import * as API from '../types.js' -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 SharedSpace from './shared.js' -import { delegate, UCAN } from '@ucanto/core' -import * as DB from '../agent/db.js' -import * as Session from './session.js' -import * as Authorization from '../authorization.js' -import * as Delegations from './delegations.js' -import * as Usage from './usage.js' - -/** - * @param {object} options - * @param {string} options.name - * @returns {API.OwnSpacePromise} - */ -export const create = ({ name }) => - OwnSpacePromise.from({ name, promise: ED25519.generate() }) - -/** - * Recovers space from the saved mnemonic. - * - * @param {string} mnemonic - * @param {object} options - * @param {string} options.name - Name to give to the recovered space. - */ -export const fromMnemonic = async (mnemonic, { name }) => { - const secret = BIP39.mnemonicToEntropy(mnemonic, wordlist) - const signer = await ED25519.derive(secret) - return new OwnSpace({ signer, name }) -} - -/** - * 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) -} - -/** - * @param {API.OwnSpace} space - * @param {object} access - * @param {API.Signer} access.authority - * @param {API.Can} [access.can] - * @param {API.UTCUnixTimestamp} [access.expiration] - * @returns {Promise>} - */ -export const share = async ({ signer, name }, access) => { - const result = await authorize({ signer, name }, access) - return result.error - ? result - : { - ok: SharedSpace.create({ - signer: access.authority, - subject: signer.did(), - name, - proofs: result.ok.proofs, - }), - } -} - -// Default authorization session is valid for 1 year -export const SESSION_LIFETIME = 60 * 60 * 24 * 365 - -/** - * @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) -} - -/** - * 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.W3Protocol} - * capabilities on the space that are needed to use the space. - * - * @param {API.OwnSpace} space - * @param {object} access - * @param {API.Principal} access.authority - * @param {API.Can} [access.can] - * @param {API.UTCUnixTimestamp} [access.expiration] - * @returns {Promise>} - */ -export const authorize = async ( - { signer, name }, - { - authority, - can = Access.spaceAccess, - expiration = UCAN.now() + SESSION_LIFETIME, - } -) => { - const proof = await delegate({ - issuer: signer, - audience: authority, - capabilities: toCapabilities({ subject: signer.did(), can }), - expiration, - facts: [{ space: { name } }], - }) - - return { - ok: Authorization.from({ - authority: authority.did(), - subject: signer.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.W3Protocol} - * capabilities on the space that are needed to use the space. - * - * @param {API.OwnSpace} space - * @param {object} access - * @param {API.Principal} access.authority - * @param {API.UTCUnixTimestamp} [access.expiration] - * @returns {Promise>} - */ export const createRecovery = async ( - space, - { authority, expiration = Infinity } -) => authorize(space, { can: Access.accountAccess, authority, expiration }) - -/** - * @extends {Promise>} - * @implements {API.OwnSpacePromise} - */ -class OwnSpacePromise extends Promise { - /** - * @param {object} options - * @param {string} options.name - * @param {Promise} options.promise - * @returns {API.OwnSpacePromise} - */ - static from({ name, promise }) { - return new OwnSpacePromise((resolve, reject) => { - promise - .then((signer) => { - resolve({ ok: new OwnSpace({ name, signer }) }) - }) - .catch(reject) - }) - } - - /** - * 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 {API.SpaceProtocol & API.UsageProtocol & API.AccessProtocol} Protocol - * @param {API.Connection} connection - * @returns {Promise, never>>} - */ - async connect(connection) { - const result = await this - if (result.ok) { - return { ok: result.ok.connect(connection) } - } else { - return result - } - } -} - -/** - * Represents an owned space, meaning a space for which we have a private key - * and consequently have full authority over. - * - * @implements {API.OwnSpaceView} - */ -class OwnSpace { - /** - * @param {object} model - * @param {string} model.name - * @param {ED25519.EdSigner} model.signer - */ - 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 - */ - 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) - } - - /** - * 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 {API.SpaceProtocol & API.UsageProtocol & API.AccessProtocol} Protocol - * @param {API.Connection} connection - * @returns {API.OwnSpaceSession} - */ - connect(connection) { - return new OwnSpaceSession({ - session: { - agent: { signer: this.signer, db: DB.from({ proofs: [] }) }, - connection, - }, - name: this.name, - }) - } - - /** - * 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.Signer} authority - * @param {object} access - * @param {API.Can} access.can - * @param {API.UTCUnixTimestamp} [access.expiration] - */ - share(authority, access) { - return share(this, { ...access, authority }) - } - - /** - * @param {API.Principal} authority - * @param {object} access - * @param {API.UTCUnixTimestamp} [access.expiration] - */ - createRecovery(authority, access) { - return createRecovery(this, { ...access, authority }) - } - - /** - * @param {API.Principal} authority - * @param {API.ShareAccess} access - */ - authorize(authority, access) { - return authorize(this, { ...access, authority }) - } -} - -/** - * Represents a remote replica of the owned space. It can be used to query - * state of the replica and invoke actions on it. - * - * @template {API.SpaceProtocol & API.UsageProtocol & API.AccessProtocol} [Protocol=API.W3UpProtocol] - * @implements {API.OwnSpaceSession} - */ -class OwnSpaceSession { - /** - * - * @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) - } - - get signer() { - return this.agent.signer - } - - get agent() { - return this.model.session.agent - } - - get connection() { - return this.model.session.connection - } - - 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 - * @returns {API.OwnSpaceSession} - */ - rename(name) { - return new OwnSpaceSession({ ...this.model, name }) - } - - /** - * Derives BIP39 mnemonic that can be used to recover the space. - * - * @returns {string} - */ - toMnemonic() { - return toMnemonic({ signer: this.agent.signer }) - } - - /** - * 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.Signer} authority - * @param {object} access - * @param {API.Can} [access.can] - */ - async share(authority, { can } = {}) { - const result = await share(this, { authority, can }) - if (result.error) { - return result - } else { - return { ok: result.ok.connect(this.connection) } - } - } - - /** - * @param {API.Principal} authority - * @param {object} [access] - * @param {API.UTCUnixTimestamp} [access.expiration] - */ - createRecovery(authority, access = {}) { - return authorize(this, { ...access, authority }) - } - - /** - * @param {API.Principal} authority - * @param {API.ShareAccess} [access] - */ - authorize(authority, access) { - return authorize(this, { ...access, authority }) - } - - info() { - return Session.info(this) - } -} diff --git a/packages/w3up-client/src/space/session.js b/packages/w3up-client/src/space/session.js deleted file mode 100644 index 5e7404d6f..000000000 --- a/packages/w3up-client/src/space/session.js +++ /dev/null @@ -1,43 +0,0 @@ -import * as API from '../types.js' -import * as Agent from '../agent.js' -import * as Space from '@web3-storage/capabilities/space' - -/** - * - * Get Space information from Access service - * - * @param {API.SpaceSession} session - */ -export const info = async (session) => { - const auth = Agent.authorize(session.agent, { - subject: session.did(), - can: { 'space/info': [] }, - }) - - if (auth.error) { - return auth - } - - const { out: result } = await Space.info - .invoke({ - issuer: session.agent.signer, - audience: session.connection.id, - with: session.did(), - proofs: auth.ok.proofs, - }) - .execute(session.connection) - - return result -} - -export class SpaceSessionView { - /** - * @param {API.SpaceSession} session - */ - constructor(session) { - this.session = session - } - info() { - return info(this.session) - } -} diff --git a/packages/w3up-client/src/space/shared.js b/packages/w3up-client/src/space/shared.js deleted file mode 100644 index b2ad515f9..000000000 --- a/packages/w3up-client/src/space/shared.js +++ /dev/null @@ -1,119 +0,0 @@ -import * as API from '../types.js' -import * as Usage from './usage.js' -import * as DB from '../agent/db.js' -import * as Session from './session.js' -import * as Delegations from './delegations.js' - -/** - * @param {object} model - * @param {API.DIDKey} model.subject - * @param {API.Signer} model.signer - * @param {string} model.name - * @param {API.Delegation[]} model.proofs - */ -export const create = ({ signer, name, subject, proofs }) => { - const agent = { signer, db: DB.from({ proofs }) } - - return new SharedSpace({ - agent, - name, - subject, - }) -} - -/** - * @implements {API.SharedSpaceView} - */ -class SharedSpace { - /** - * @param {object} model - * @param {API.Agent} model.agent - * @param {API.DIDKey} model.subject - * @param {string} model.name - */ - constructor(model) { - this.model = model - } - get authority() { - return this.model.agent.signer.did() - } - get subject() { - return this.model.subject - } - did() { - return this.model.subject - } - get name() { - return this.model.name - } - - get proofs() { - return [...this.model.agent.db.proofs.values()].map( - ({ delegation }) => delegation - ) - } - - /** - * @template {API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol} Protocol - * @param {API.Connection} connection - * @returns {API.SharedSpaceSession} - */ - connect(connection) { - return new SharedSpaceSession({ - ...this.model, - connection, - }) - } -} - -/** - * @template {API.PlanProtocol} [Protocol=API.W3UpProtocol] - * @typedef {object} Model - * @property {API.DIDKey} id - * @property {API.Session} session - */ - -/** - * @template {API.UsageProtocol & API.SpaceProtocol & API.AccessProtocol} [Protocol=API.W3UpProtocol] - * @implements {API.SharedSpaceSession} - */ -class SharedSpaceSession { - /** - * @param {object} model - * @param {API.DIDKey} model.subject - * @param {string} model.name - * @param {API.Connection} model.connection - * @param {API.Agent} model.agent - */ - constructor(model) { - this.model = model - this.usage = Usage.view(this) - - this.delegations = Delegations.view(this) - } - get connection() { - return this.model.connection - } - get agent() { - return this.model.agent - } - get authority() { - return this.agent.signer.did() - } - did() { - return this.model.subject - } - get name() { - return this.model.name - } - - info() { - return Session.info(this) - } - - get proofs() { - return [...this.model.agent.db.proofs.values()].map( - ({ delegation }) => delegation - ) - } -} 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 index 6f8ae8b7d..3132f7dba 100644 --- a/packages/w3up-client/src/space/usage.js +++ b/packages/w3up-client/src/space/usage.js @@ -2,6 +2,7 @@ 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 @@ -18,35 +19,50 @@ export const view = (session) => new UsageSession(session) * @param {{ from: Date, to: Date }} options.period * @param {API.Delegation[]} [options.proofs] */ -export const report = async (session, { space, period }) => - Task.try(function* () { - const auth = yield* Task.join( - Agent.authorize(session.agent, { - subject: space, - can: { 'usage/report': [] }, - }) - ) - - const receipt = yield* Task.wait( - Usage.report - .invoke({ - issuer: session.agent.signer, - audience: session.connection.id, - with: space, - proofs: auth.proofs, - nb: { - period: { - from: Math.floor(period.from.getTime() / 1000), - to: Math.ceil(period.to.getTime() / 1000), - }, - }, - }) - .execute(session.connection) - ) +export function* report(session, { space, period }) { + const { proofs } = yield* Agent.authorize(session.agent, { + subject: space, + can: { 'usage/report': [] }, + }) - return receipt.out + 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} */ @@ -59,43 +75,20 @@ class UsageSession { } /** - * @returns {Promise>} + * @returns {Task.Invocation} */ - async get() { - const space = /** @type {API.DIDKey} */ (this.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 = await report(this.session, { space, period }) - - /* c8 ignore next */ - if (result.error) return result - - const provider = /** @type {API.ProviderDID} */ ( - this.session.connection.id.did() - ) - const usage = result.ok[provider] - - return { - /* c8 ignore next */ - ok: BigInt(usage.size.final ?? -1), - } + 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 - * @returns {Promise>} */ - async report(period) { + report(period) { const space = /** @type {API.DIDKey} */ (this.session.agent.signer.did()) - return report(this.session, { space, period }) + return Session.perform(report(this.session, { space, period })) } } 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 index d9a900663..a61d866d9 100644 --- a/packages/w3up-client/src/store/memory.js +++ b/packages/w3up-client/src/store/memory.js @@ -1,25 +1,27 @@ import * as API from '../types.js' /** - * @template T - * @typedef {API.Driver} Driver - */ - -/** - * Driver implementation that stores data in memory." - * - * Usage: + * Opens in-memory data store. * + * @example * ```js - * import { MemoryDriver } from '@web3-storage/access/drivers/memory' + * import * as Memory from '@web3-storage/w3up-client/store/memory' + * const store = Memory.open() * ``` * - * @template {Record} T - * @implements {Driver} + * @template Model + * @returns {API.DataStore} + */ +export const open = () => + /** @type {API.DataStore} */ (new MemoryStore()) + +/** + * @template {Record} Model + * @implements {API.DataStore} */ -class MemoryDriver { +class MemoryStore { /** - * @type {T|undefined} + * @type {Model|undefined} */ #data @@ -27,7 +29,7 @@ class MemoryDriver { this.#data = undefined } - async open() {} + async connect() {} async close() {} @@ -35,20 +37,17 @@ class MemoryDriver { this.#data = undefined } - /** @param {T} data */ + /** + * @param {Model} data + */ async save(data) { this.#data = { ...data } } - /** @returns {Promise} */ + /** @returns {Promise} */ async load() { if (this.#data === undefined) return if (Object.keys(this.#data).length === 0) return return this.#data } } - -/** - * @returns {API.DataStore} - */ -export const memory = () => new MemoryDriver() diff --git a/packages/w3up-client/src/task.js b/packages/w3up-client/src/task.js index 392512792..8124f64d2 100644 --- a/packages/w3up-client/src/task.js +++ b/packages/w3up-client/src/task.js @@ -1,4 +1,9 @@ -import * as API from '@ucanto/interface' +/* 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 @@ -10,79 +15,356 @@ const isPromiseLike = (value) => typeof (/** @type {{then?:unknown}} */ (value).then) === 'function' /** - * @typedef {PromiseLike} Wait - */ - -/** - * @template T - * @param {T} source - * @returns {Generator, void>} + * 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 const wait = function* (source) { +export function* wait(source) { if (isPromiseLike(source)) { + const invocation = yield* fork(suspend()) let ok - yield source.then((value) => { - ok = value - }) - return /** @type {Awaited} */ (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 {Awaited} */ (source) + return /** @type {any} */ (source) } } /** - * @template {API.Result} R - * @param {PromiseLike|R} source - * @returns {Generator['ok']>} + * Returns a task that is suspended for a given duration in milliseconds. + * + * @param {number} duration + * @returns {Task.Task} */ -export const join = function* (source) { - const { ok, error } = yield* wait(source) - if (ok) { - return ok - } else { - throw error +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 {API.Result} R * @template Ok - * @template {globalThis.Error} [Error=never] - * @param {() => Generator, void>} task - * @returns {Promise['error'] | Error>>} + * @template {globalThis.Error} Fail + * @template {Task.Suspend|Task.Join|Task.Throw} Command + * @param {Task.Task} task + * @returns {Task.Invocation>} */ -const execute = async (task) => { - const process = task() - let state = process.next() +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 (!state.done) { - if (isPromiseLike(state.value)) { - await state.value - state = process.next() - } else if (state.value.error) { - return state.value - } else { - state = process.next() - } + while (true) { + yield SUSPEND } - return state.value } catch (cause) { - return { error: /** @type {Error} */ (cause) } + if (/** @type {Task.AbortError} */ (cause).reason !== RESUME) { + throw cause + } } } -export { execute as try } +/** + * @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 {API.Result} R * @template Ok - * @template {globalThis.Error} [Error=never] - * @param {() => Generator, void>} task + * @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} */ -export const perform = async (task) => { - const result = await execute(task) - if (result.ok) { - return result.ok +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) } - throw result.error } + +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 260217a70..714c2f7c7 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -32,6 +32,12 @@ import { UsageReportSuccess, UsageReportFailure, PlanNotFound, + FilecoinOffer as FilecoinOfferCapability, + FilecoinOfferSuccess, + FilecoinOfferFailure, + FilecoinInfo as FilecoinInfoCapability, + FilecoinInfoSuccess, + FilecoinInfoFailure, } from '@web3-storage/capabilities/types' export type { Querier, Transactor } @@ -63,9 +69,12 @@ import type { IPLDBlock, DIDKey, Protocol, + Capability, InvocationError, MultihashDigest, + Receipt, Tuple, + InferReceipt, } from '@ucanto/interface' import type { @@ -86,6 +95,7 @@ import type { ProviderAddSuccess, ProviderAddFailure, SpaceInfo, + SpaceInfoFailure, SubscriptionList, SubscriptionListSuccess, SubscriptionListFailure, @@ -95,22 +105,30 @@ import type { 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 as FilecoinProtocol } 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 { SpaceInfoFailure } from '@web3-storage/upload-api' 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 { Delegation } from '@ucanto/core' +import { info } from 'console' export * from '@ipld/dag-ucan' export * from '@ucanto/interface' @@ -143,7 +161,7 @@ export interface SpaceUnknown extends Failure { name: 'SpaceUnknown' } -export interface SpaceInfoResult { +export interface SpaceInfoSuccess { // space did did: DID<'key'> providers: Array> @@ -166,7 +184,10 @@ export interface AccessAuthorizeProvider { } export interface AccessRequestProvider { - access: AccessAuthorizeProvider['access'] & AccessClaimProvider['access'] + access: { + authorize: AccessAuthorizeProvider['access']['authorize'] + claim: AccessClaimProvider['access']['claim'] + } } export interface AccessClaimProvider { @@ -210,7 +231,7 @@ export interface AccessProtocol { export interface SpaceProtocol { space: { - info: ServiceMethod + info: ServiceMethod } } @@ -230,7 +251,29 @@ export interface SubscriptionProtocol { } } -export type { FilecoinProtocol } +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: { @@ -269,6 +312,7 @@ export interface W3UpProtocol StoreProtocol, UploadProtocol, UsageProtocol, + ConsoleProtocol, FilecoinProtocol {} export interface ClientFactoryOptions { @@ -322,18 +366,12 @@ export type { PlanGet, PlanGetSuccess, PlanGetFailure, - FilecoinOffer, - FilecoinOfferSuccess, - FilecoinOfferFailure, FilecoinSubmit, FilecoinSubmitSuccess, FilecoinSubmitFailure, FilecoinAccept, FilecoinAcceptSuccess, FilecoinAcceptFailure, - FilecoinInfo, - FilecoinInfoSuccess, - FilecoinInfoFailure, UsageData, UsageReportSuccess, UsageReportFailure, @@ -610,9 +648,28 @@ export type Can = ['*']?: CapabilityConstraint[] } -export type { Driver } - -export interface DataStore extends Driver {} +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 @@ -721,27 +778,48 @@ export interface AddressArchive< // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface UnknownProtocol extends Record {} -export interface W3UpOpen { +export interface AgentOpen { as?: Signer store: DataStore } -export interface W3Load { +export interface AgentLoad { as?: Signer store: DataStore } -export interface W3Create { +export interface AgentCreate { as?: Signer store: DataStore } - -export type W3From = Variant<{ - load: W3Load - open: W3UpOpen - create: W3Create +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. */ @@ -773,7 +851,7 @@ export interface W3Up { * If you do not pass a signer and one is not persisted in the store load will * fail. */ - load(source: W3Load): AgentView + load(source: W3UpLoad): AgentView /** * Creates a new agent that will be persisted in the given {@link DataStore}. @@ -786,12 +864,12 @@ export interface W3Up { * ⚠️ Please note that if store already contains a principal calling this * method will overwrite it. */ - create(source: W3Create): AgentView + create(source: W3UpCreate): AgentView /** * General function that does `load`, `create` or `open` based on input. */ - from(source: W3From): AgentView + from(source: W3UpFrom): AgentView } export interface Agent { @@ -818,18 +896,13 @@ export interface AgentView extends Agent { * invoke capabilities provided by the service. */ connect( - connection?: ConnectionView - ): Promise< - Result< - W3UpSession, - SignerLoadError | DataStoreOpenError | DataStoreSaveError - > - > + connection?: Connection + ): W3UpSession authorize(access: { subject: DID can: Can - }): Result + }): Task.Invocation } export type ConnectError = @@ -866,10 +939,53 @@ export interface Session { connection: Connection } -export interface W3UpSession extends Session { +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: SpacesSession - accounts: AccountsSession + spaces: SpaceManager + accounts: AccountManager coupons: CouponAPI } @@ -877,17 +993,14 @@ export interface W3UpSession extends Session { export interface CouponSession extends Session, Coupon { - spaces: SpacesSession - accounts: AccountsSession + spaces: SpaceManager + accounts: AccountManager - redeem( - session: { - agent: Agent - }, - options?: { secret?: string } - ): Promise, Error>> + redeem( + session: Session + ): Task.Invocation, Error | Task.AbortError> - archive(): Promise> + archive(): Task.Invocation } export interface CouponAPI { @@ -897,14 +1010,21 @@ export interface CouponAPI { expiration?: UTCUnixTimestamp notBefore?: UTCUnixTimestamp secret?: string - }): Promise, Error>> + }): Task.Invocation, Error> redeem( coupon: Uint8Array, options?: { secret?: string } - ): Promise, Error>> + ): Task.Invocation, Error> - add(coupon: Coupon): Promise> - remove(coupon: Coupon): Promise> + add( + coupon: Coupon + ): Task.Invocation< + Unit, + DatabaseTransactionError | DataStoreSaveError | RangeError + > + remove( + coupon: Coupon + ): Task.Invocation } export interface Coupon { @@ -913,54 +1033,59 @@ export interface Coupon { } export interface CouponView extends Coupon { - archive(): Promise> + archive(): Task.Invocation + connect( connection: Connection ): CouponSession + + redeem( + session: Session + ): Task.Invocation, Error> } -export interface SpacesSession extends Iterable { - create(source: { - name: string - }): Promise, never>> - list(): Record +export interface SpaceManager extends Iterable { + create(source: { name: string }): Task.Invocation + list(): Record - add(space: SharedSpace): Promise> - remove(space: SharedSpace): Promise> + add(space: SpaceView): Task.Invocation + remove(space: SpaceView): Task.Invocation } -export interface AccountsSession< - Protocol extends AccessRequestProvider & - PlanProtocol & - ProviderProtocol & - SubscriptionProtocol -> extends Iterable> { +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 - }): Promise< - Result< - AccountSession, - AccessDenied | InvocationError | AccessAuthorizeFailure - > + }): Task.Invocation< + AccountView, + AccessDenied | InvocationError | AccessAuthorizeFailure > - list(): Record> + list(): Record - get(email: EmailAddress): AccountSession | undefined + get(email: EmailAddress): AccountView | undefined add( - account: AccountSession - ): Promise> + account: AccountView + ): Task.Invocation< + Unit, + DataStoreSaveError | DatabaseTransactionError | AbortError + > remove( - account: AccountSession - ): Promise> -} - -export interface Connection - extends ConnectionView { - address: Address + account: AccountView + ): Task.Invocation } export interface Authorization { @@ -993,65 +1118,74 @@ export interface Authorization { */ 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 = W3UpProtocol + Protocol extends UnknownProtocol = UnknownProtocol > { did(): AccountDID session: Session +} + +export interface AccountView { + did(): AccountDID proofs: Delegation[] toEmail(): EmailAddress plans: AccountPlans - spaces: SpacesSession + spaces: SpaceManager } -export interface AccountPlans< - Protocol extends ProviderProtocol & - PlanProtocol & - SubscriptionProtocol = ProviderProtocol & - PlanProtocol & - SubscriptionProtocol -> { - list(): Promise< - Result< - AccountPlanList, - AccessDenied | PlanNotFound | InvocationError - > +export interface AccountPlans { + list(): Task.Invocation< + AccountPlanList, + | AccessDenied + | PlanNotFound + | OfflineError + | InvocationError + | Task.AbortError > } -export interface AccountPlanList< - Protocol extends PlanProtocol & - ProviderProtocol & - SubscriptionProtocol = PlanProtocol & - ProviderProtocol & - SubscriptionProtocol -> extends Iterable> { - [key: string]: BillingPlan +export interface AccountPlanList extends Iterable { + [key: string]: BillingPlan } -export interface BillingPlan< - Protocol extends PlanProtocol & - ProviderProtocol & - SubscriptionProtocol = PlanProtocol & - ProviderProtocol & - SubscriptionProtocol -> { - account: AccountSession +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 - }): Promise> + }): TaskInvocation< + Unit, + ProviderAddFailure | InvocationError, + OfflineError | AccessDenied + > - list(): Promise> + list(): Task.Invocation< + Subscriptions, + SubscriptionListFailure | InvocationError | AccessDenied | OfflineError + > } export interface Subscription { @@ -1065,27 +1199,12 @@ export interface Subscriptions extends Iterable { [key: string]: Subscription } -export interface OwnSpace { - signer: Signer - name: string -} - export interface SpaceSession extends Session { name: string did(): DIDKey } -export interface SpaceSessionView - extends SpaceSession { - info(): Promise> - - usage: SpaceUsageView - delegations: SpaceDelegationsView - - // blobs: SpaceBlobsView -} - export interface SpaceUploadsView { create(source: UploadSource): UploadSession @@ -1146,6 +1265,8 @@ export interface SpaceBlobsView { ): Promise> } +export interface BlobInfo {} + export interface Uploader { /** * Writes contents of the upload to the space without adding it to the upload @@ -1158,7 +1279,6 @@ export interface Uploader { upload(space?: SpaceView): Promise } -export interface SpaceView {} interface FileUploader extends Uploader {} interface DirectoryUploader extends Uploader {} @@ -1185,8 +1305,28 @@ interface StreambleBytes { export interface SpaceDelegationsView { add( authorization: Authorization - ): Promise< - Result + ): 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 > } @@ -1194,90 +1334,81 @@ export interface SpaceUsageView { report(period: { from: Date to: Date - }): Promise< - Result< - UsageReportSuccess, - UsageReportFailure | AccessDenied | InvocationError - > + }): TaskInvocation< + UsageReportSuccess, + UsageReportFailure | InvocationError, + AccessDenied | OfflineError > - get(): Promise< - Result + get(): Task.Invocation< + bigint, + UsageReportFailure | InvocationError | AccessDenied | OfflineError > } -export interface ShareAccess { +export interface SpaceAccess { + audience: Principal can?: Can expiration?: UTCUnixTimestamp + notBefore?: UTCUnixTimestamp } -export interface OwnSpaceView extends OwnSpace { - did(): DIDKey - rename(name: string): OwnSpace - - toMnemonic(): string - - connect( - connection: Connection - ): OwnSpaceSession - - share( - authority: UCANSigner, - access?: ShareAccess - ): Promise> +export interface SpaceRecovery { + audience: Principal + expiration?: UTCUnixTimestamp + notBefore?: UTCUnixTimestamp +} - createRecovery( - authority: Principal, - access?: { expiration?: UTCUnixTimestamp } - ): Promise> +export interface ShareAccess extends SpaceAccess { + audience: Signer } -export interface OwnSpaceSession - extends SpaceSessionView { - rename(name: string): OwnSpaceSession +export interface SpaceView { + authority: DID + name: string + did(): DIDKey - rename(name: string): OwnSpaceSession - toMnemonic(): string + info(): TaskInvocation< + SpaceInfoSuccess, + SpaceInfoFailure | InvocationError, + AccessDenied | OfflineError + > - share( - authority: UCANSigner, - access?: ShareAccess - ): Promise, Unit>> + authorize(access: SpaceAccess): Task.Invocation - createRecovery( - authority: Principal, - access?: ShareAccess - ): Promise> -} + share(access: SpaceAccess): Task.Invocation -export interface OwnSpacePromise extends Promise> { - connect( + connect< + Protocol extends SpaceProtocol & + UsageProtocol & + AccessProtocol & + FilecoinProtocol + >( connection: Connection - ): Promise, never>> -} - -export interface SharedSpace { - did(): DIDKey - authority: DID + ): SpaceView proofs: Delegation[] + + // APIs + usage: SpaceUsageView + delegations: SpaceDelegationsView + + filecoin: SpaceFilecoinView + // blobs: SpaceBlobsView } -export interface SharedSpaceView extends SharedSpace { - name: string - subject: DID +export interface OwnSpaceView extends SpaceView { + rename(name: string): OwnSpaceView + toMnemonic(): string + createRecovery( + access: SpaceRecovery + ): Task.Invocation connect< - Protocol extends UsageProtocol & - SpaceProtocol & - AccessProtocol = W3UpProtocol + Protocol extends SpaceProtocol & + UsageProtocol & + AccessProtocol & + FilecoinProtocol >( connection: Connection - ): SharedSpaceSession -} - -export interface SharedSpaceSession< - Protocol extends SpaceProtocol & UsageProtocol = W3UpProtocol -> extends SharedSpace, - SpaceSessionView { - name: string + ): OwnSpaceView } diff --git a/packages/w3up-client/src/w3up.js b/packages/w3up-client/src/w3up.js index c474075de..52de22f94 100644 --- a/packages/w3up-client/src/w3up.js +++ b/packages/w3up-client/src/w3up.js @@ -1,34 +1,117 @@ 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 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) + }) /** - * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] - * @param {API.Session} model - * @returns {API.W3UpSession} + * 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 create = (model) => - new Session(/** @type {API.Session} */ (model)) +export const load = (source) => + Task.spawn(function* () { + const agent = yield* Agent.load(source) + return agent.connect(source.connection) + }) /** - * @template {API.UnknownProtocol} [Protocol=API.W3UpProtocol] - * @implements {API.Session} + * 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 */ -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 this.model.agent - } -} +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 e029ecd07..58ccb0666 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -1,125 +1,123 @@ import * as Test from './test.js' import * as Access from '../src/access.js' -import * as Result from '../src/result.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 = { - 'access.request': async (assert, { session, mail, grantAccess }) => { - const email = 'alice@web.mail' - - const account = Access.DIDMailto.fromEmail(email) - const request = Result.unwrap(await Access.request(session, { account })) - const message = await mail.take() - assert.deepEqual(message.to, email) - await grantAccess(message) - - assert.deepEqual(request.authority, session.agent.signer.did()) - assert.ok(request.expiration.getTime() >= Date.now()) - - const access = Result.unwrap(await 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, []) - - Result.unwrap(await 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': async ( - assert, - { session, provisionsStorage } - ) => { - const space = Result.unwrap(await Space.create({ name: 'main' })) - const { proofs } = Result.unwrap(await space.share(session.agent.signer)) - Result.unwrap( - await DB.transact( - session.agent.db, - proofs.map((proof) => DB.assert({ proof })) - ) - ) - - Result.unwrap( - await provisionsStorage.put({ - // @ts-ignore - cause: null, - consumer: space.did(), - customer: 'did:mailto:mail.com:user', - provider: /** @type {API.ProviderDID} */ (session.connection.id.did()), + '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': [] }, }) - ) - const shared = Result.unwrap(await Space.create({ name: 'shared' })) - const { proofs: delegations } = Result.unwrap( - await shared.share(session.agent.signer) - ) + assert.deepEqual(results, []) - const result = await Access.delegate(session, { - delegations, - subject: space.did(), - }) + 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) - assert.ok(result.ok) + const [auth] = Authorization.find(session.agent.db, { + can: { 'store/add': [] }, + audience: session.agent.did(), + subject: account, + }) - const claim = Result.unwrap(await Access.claim(session)) - assert.deepEqual(claim.proofs, delegations) + 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 })) + ) - const none = Authorization.find(session.agent.db, { - audience: session.agent.did(), - subject: shared.did(), - can: { 'store/add': [] }, - }) + 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() + ), + }) + ) - assert.deepEqual(none, [], 'claimed access has not been added to an agent') + const shared = yield* Space.create({ name: 'shared', session }) + const { proofs: delegations } = yield* shared.share({ + audience: session.agent.signer, + }) - Result.unwrap(await claim.save()) + yield* Access.delegate(session, { + delegations, + subject: space.did(), + }) - const [auth] = Authorization.find(session.agent.db, { - audience: session.agent.did(), - subject: shared.did(), - can: { 'store/add': [] }, - }) + 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() - assert.deepEqual( - auth, - Authorization.from({ - authority: session.agent.did(), + const [auth] = Authorization.find(session.agent.db, { + audience: session.agent.did(), subject: shared.did(), can: { 'store/add': [] }, - proofs: delegations, - }), - 'claimed access has been added to an agent' - ) - }, + }) + + 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 be6d25094..5c59dd9c5 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -1,16 +1,14 @@ 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' -import * as DB from '../src/agent/db.js' /** * @type {Test.Suite} */ export const testAccount = { 'list accounts': async (assert, { session, mail, grantAccess }) => - Task.perform(function* () { + Task.spawn(function* () { const email = 'alice@web.mail' assert.deepEqual(session.accounts.list(), {}, 'no accounts yet') @@ -20,13 +18,13 @@ export const testAccount = { const message = yield* Task.wait(mail.take()) assert.deepEqual(message.to, email) yield* Task.wait(grantAccess(message)) - const account = yield* Task.join(login) + 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* Task.join(session.accounts.add(account)) + yield* session.accounts.add(account) const accounts = session.accounts.list() assert.deepEqual(Object.values(accounts).length, 1) @@ -36,12 +34,10 @@ export const testAccount = { assert.equal(savedAccount.toEmail(), email) assert.equal(savedAccount.did(), Account.DIDMailto.fromEmail(email)) assert.equal([...savedAccount.proofs].length, 2) - - return { ok: {} } }), 'two logins': async (assert, { session, mail, grantAccess }) => - Task.perform(function* () { + Task.spawn(function* () { const aliceEmail = 'alice@web.mail' const bobEmail = 'bob@web.mail' @@ -49,7 +45,7 @@ export const testAccount = { const aliceLogin = session.accounts.login({ email: aliceEmail }) const aliceConfirm = yield* Task.wait(mail.take()) yield* Task.wait(grantAccess(aliceConfirm)) - const alice = yield* Task.join(aliceLogin) + const alice = yield* aliceLogin assert.deepEqual(alice.toEmail(), aliceEmail) assert.deepEqual( @@ -57,7 +53,7 @@ export const testAccount = { {}, 'no accounts have been saved' ) - yield* Task.join(session.accounts.add(alice)) + yield* session.accounts.add(alice) const [one] = session.accounts assert.equal(one.did(), alice.did(), 'alice in the account list') @@ -65,9 +61,9 @@ export const testAccount = { const bobLogin = Account.login(session, { email: bobEmail }) const bobConfirm = yield* Task.wait(mail.take()) yield* Task.wait(grantAccess(bobConfirm)) - const bob = yield* Task.join(bobLogin) + const bob = yield* bobLogin assert.deepEqual(bob.toEmail(), bobEmail) - yield* Task.join(session.accounts.add(bob)) + yield* session.accounts.add(bob) const two = Account.list(session) assert.deepEqual(Object.values(two).length, 2) @@ -77,19 +73,17 @@ export const testAccount = { aliceEmail ) assert.ok(two[Account.DIDMailto.fromEmail(bobEmail)].toEmail(), bobEmail) - - return { ok: {} } }), 'login idempotence': (assert, { session, mail, grantAccess }) => - Task.perform(function* () { + 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* Task.join(login) + const alice = yield* login - yield* Task.join(session.accounts.add(alice)) + yield* session.accounts.add(alice) assert.deepEqual( Object.keys(Account.list(session)), @@ -97,7 +91,7 @@ export const testAccount = { 'account was saved' ) - const retry = yield* Task.join(Account.login(session, { email })) + const retry = yield* Account.login(session, { email }) assert.deepEqual( alice.toJSON(), retry.toJSON(), @@ -107,83 +101,73 @@ export const testAccount = { return { ok: {} } }), 'account login': async (assert, { session, mail, grantAccess }) => - Task.perform(function* () { + 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* Task.join(login) + const alice = yield* login assert.deepEqual(alice.toEmail(), 'alice@web.mail') - yield* Task.join(session.accounts.add(alice)) + yield* session.accounts.add(alice) const accounts = session.accounts.list() assert.deepEqual(Object.keys(accounts), [alice.did()]) - - return { ok: {} } }), 'create account and provision space': async ( assert, { session, mail, grantAccess, plansStorage } ) => - Task.perform(function* () { - const space = yield* Task.join(session.spaces.create({ name: 'test' })) + Task.spawn(function* () { + const space = yield* session.spaces.create({ name: 'test' }) const mnemonic = space.toMnemonic() - const { signer } = yield* Task.wait( - Space.fromMnemonic(mnemonic, { name: 'import' }) - ) + const imported = yield* Space.fromMnemonic(session, { + name: 'import', + mnemonic, + }) - assert.deepEqual( - // @ts-expect-error - space.agent.signer.encode(), - signer.encode(), - 'arrived to same signer' - ) + 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* Task.join(login) + const account = yield* login - yield* Task.join( + yield* Task.wait( plansStorage.set(account.did(), 'did:web:free.web3.storage') ) - const plans = yield* Task.join(account.plans.list()) + const plans = yield* account.plans.list() const [{ subscriptions }] = Object.values(plans) - yield* Task.join(subscriptions.add({ consumer: space.did() })) + yield* subscriptions.add({ consumer: space.did() }) // authorize agent to use space - const shared = yield* Task.join( - space.share(session.agent.signer, { - can: { 'space/info': [] }, - }) - ) - - // yield* Task.join(session.spaces.add(shared)) + const shared = yield* space.share({ + audience: session.agent.signer, + can: { 'space/info': [] }, + }) - const info = yield* Task.join(shared.info()) + const info = yield* shared.info() assert.deepEqual(info, { did: space.did(), providers: [session.connection.id.did()], }) - - return { ok: {} } }), 'multi device workflow': async ( assert, { connection, mail, grantAccess, plansStorage } ) => - Task.perform(function* () { - const laptop = yield* Task.join(Test.connect(connection)) - const space = yield* Task.join(laptop.spaces.create({ name: 'main' })) + 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' @@ -191,78 +175,65 @@ export const testAccount = { // confirm by clicking a link const laptopMessage = yield* Task.wait(mail.take()) yield* Task.wait(grantAccess(laptopMessage)) - const account = yield* Task.join(login) + const account = yield* login // setup billing - yield* Task.join( + yield* Task.ok.wait( plansStorage.set(account.did(), 'did:web:free.web3.storage') ) + // Authorized account can provision space - const plans = yield* Task.join(account.plans.list()) + const plans = yield* account.plans.list() const [{ subscriptions }] = Object.values(plans) - yield* Task.join(subscriptions.add({ consumer: space.did() })) + yield* subscriptions.add({ consumer: space.did() }) - // // Want to setup a recovery for this space ? - const recovery = yield* Task.join(space.createRecovery(account)) + // 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* Task.join(space.delegations.add(recovery)) + yield* space.delegations.add(recovery) // now connect with a second device - const phone = yield* Task.join(Test.connect(connection)) + 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* Task.join(phoneLogin) + const phoneAccount = yield* phoneLogin const [phoneSpace] = phoneAccount.spaces assert.deepEqual(phoneSpace.did(), space.did()) - - return { ok: {} } }), 'check account plan': async ( assert, { session, mail, grantAccess, plansStorage } - ) => { - const result = Task.perform(function* () { + ) => + 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 account = yield* Task.join(login) + const account = yield* login - const plans = yield* Task.join(account.plans.list()) + const plans = yield* account.plans.list() assert.deepEqual(plans, {}, 'no plans yet') - yield* Task.join( + yield* Task.wait( plansStorage.set(account.did(), 'did:web:free.web3.storage') ) - const updatePlans = yield* Task.join(account.plans.list()) + const updatePlans = yield* account.plans.list() assert.deepEqual(Object.keys(updatePlans), ['did:web:free.web3.storage']) - - return { ok: {} } - }) - - try { - await result - } catch (error) { - // throw new Error(error) - console.log('error', error) - throw error - } - }, - + }), 'check account subscriptions': async ( assert, { session, mail, grantAccess, plansStorage } ) => - Task.perform(function* () { - const space = yield* Task.join(session.spaces.create({ name: 'test' })) + Task.spawn(function* () { + const space = yield* session.spaces.create({ name: 'test' }) const email = 'alice@web.mail' const login = session.accounts.login({ email }) @@ -270,19 +241,19 @@ export const testAccount = { const message = yield* Task.wait(mail.take()) assert.deepEqual(message.to, email) yield* Task.wait(grantAccess(message)) - const account = yield* Task.join(login) + const account = yield* login // setup billing - yield* Task.join( + yield* Task.ok.wait( plansStorage.set(account.did(), 'did:web:test.web3.storage') ) // Authorized account can provision space - const plans = yield* Task.join(account.plans.list()) + const plans = yield* account.plans.list() const [{ subscriptions }] = Object.values(plans) - yield* Task.join(subscriptions.add({ consumer: space.did() })) + yield* subscriptions.add({ consumer: space.did() }) - const [...subs] = yield* Task.join(subscriptions.list()) + const [...subs] = yield* subscriptions.list() assert.deepEqual(subs, [ { @@ -293,10 +264,11 @@ export const testAccount = { }, ]) - const second = yield* Task.join(session.spaces.create({ name: 'second' })) - yield* Task.join(subscriptions.add({ consumer: second.did() })) + const second = yield* session.spaces.create({ name: 'second' }) + + yield* subscriptions.add({ consumer: second.did() }) - const [...subs2] = yield* Task.join(subscriptions.list()) + const [...subs2] = yield* subscriptions.list() assert.deepEqual(subs2, [ { diff --git a/packages/w3up-client/test/agent.test.js b/packages/w3up-client/test/agent.test.js index 384ee6184..290c6817a 100644 --- a/packages/w3up-client/test/agent.test.js +++ b/packages/w3up-client/test/agent.test.js @@ -8,13 +8,13 @@ import * as Result from '../src/result.js' */ export const testAgent = { 'agent has did method': async (assert) => { - const result = await Agent.open({ + const agent = await Agent.open({ as: alice, store: Agent.ephemeral, - }).connect() + }) + + const session = agent.connect() - assert.ok(result.ok) - const session = Result.unwrap(result) assert.ok(session.agent) assert.ok(session.connection) @@ -27,13 +27,13 @@ export const testAgent = { }, 'agent fails load if no principal': async (assert) => { - const result = await Agent.load({ store: Agent.ephemeral }) + const result = await Agent.load({ store: Agent.ephemeral }).result() assert.equal(result?.error?.name, 'SignerLoadError') }, 'agent loads from store': async (assert) => { - const result = await Agent.load({ + const agent = await Agent.load({ store: { ...Agent.ephemeral, async load() { @@ -47,11 +47,10 @@ export const testAgent = { }, }) - assert.ok(result?.ok) - assert.deepEqual(result?.ok?.did(), alice.did()) + assert.deepEqual(agent.did(), alice.did()) }, 'load from store but use different signer': async (assert) => { - const result = await Agent.load({ + const agent = await Agent.load({ as: bob, store: { ...Agent.ephemeral, @@ -66,8 +65,6 @@ export const testAgent = { }, }) - assert.ok(result?.ok) - const agent = Result.unwrap(result) assert.deepEqual(agent.did(), bob.did()) assert.deepEqual( agent.db.signer?.id, @@ -83,7 +80,6 @@ export const testAgent = { }, ]) - assert.ok(tr.ok) assert.deepEqual(agent.db.signer?.id, bob.did(), 'signer was updated') }, } 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/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/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/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 925a85704..911e8b12a 100644 --- a/packages/w3up-client/test/coupon.test.js +++ b/packages/w3up-client/test/coupon.test.js @@ -1,6 +1,5 @@ import * as Test from './test.js' -import * as Result from '../src/result.js' -import * as Coupon from '../src/coupon.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' @@ -19,15 +18,15 @@ export const testCoupon = { const login = session.accounts.login({ email: 'workshop@web3.storage' }) const message = await mail.take() await grantAccess(message) - const account = Result.unwrap(await login) - Result.unwrap(await session.accounts.add(account)) + 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 issued = Result.unwrap( - await Coupon.issue(session.agent, { + const issued = await Task.perform( + Coupon.issue(session.agent, { subject: account.did(), can: { 'plan/get': [], @@ -38,11 +37,11 @@ export const testCoupon = { ) // We encode coupon and share it with the participants - const archive = Result.unwrap(await issued.archive()) + const archive = await issued.archive() - const agent = Result.unwrap(await Coupon.open(archive)) + const agent = await Task.perform(Coupon.open(archive)) - const coupon = agent.connect(session.connection) + const coupon = await agent.connect(session.connection) const [...accounts] = coupon.accounts const [...spaces] = coupon.spaces @@ -53,21 +52,21 @@ export const testCoupon = { assert.deepEqual(accounts[0].did(), account.did()) - const [plan] = Result.unwrap(await redeemedAccount.plans.list()) + const [plan] = await redeemedAccount.plans.list() - const space = Result.unwrap(await coupon.spaces.create({ name: 'home' })) - Result.unwrap(await plan.subscriptions.add({ consumer: space.did() })) + const space = await coupon.spaces.create({ name: 'home' }) + await plan.subscriptions.add({ consumer: space.did() }) - assert.deepEqual(Result.unwrap(await space.info()), { + assert.deepEqual(await space.info(), { did: space.did(), providers: ['did:web:test.web3.storage'], }) }, 'saving a coupon': async (assert, { session, provisionsStorage }) => - Task.perform(function* () { + Task.spawn(function* () { const now = (Date.now() / 1000) | 0 - const space = yield* Task.join(session.spaces.create({ name: 'test' })) + const space = yield* session.spaces.create({ name: 'test' }) yield* Task.wait( provisionsStorage.put({ provider: /** @type {API.ProviderDID} */ ( @@ -78,73 +77,64 @@ export const testCoupon = { cause: parseLink('bafkqaaa'), }) ) - const coupon = yield* Task.join( - Coupon.issue(space.agent, { - subject: space.did(), - can: { 'space/*': [] }, - }) - ) + const coupon = yield* Coupon.issue(space.agent, { + subject: space.did(), + can: { 'space/*': [] }, + }) const [...none] = session.spaces assert.deepEqual([], none) - const result = yield* Task.wait(session.coupons.add(coupon)) + const result = yield* session.coupons.add(coupon).result() assert.match(result.error?.message ?? '', /Coupon audience is/) - const archive = yield* Task.join(coupon.archive()) + const archive = yield* coupon.archive() - const redeemed = yield* Task.join(session.coupons.redeem(archive)) + const redeemed = yield* session.coupons.redeem(archive) - yield* Task.join(session.coupons.add(redeemed)) + yield* session.coupons.add(redeemed) const [one, ...rest] = session.spaces assert.deepEqual(rest.length, 0) assert.deepEqual(one.did(), space.did()) - const info = yield* Task.join(one.info()) + const info = yield* one.info() assert.deepEqual(info, { did: space.did(), providers: ['did:web:test.web3.storage'], }) - - return { ok: {} } }), 'coupon with secret': async (assert, { session }) => - Task.perform(function* () { - const coupon = yield* Task.join( - session.coupons.issue({ - subject: session.agent.did(), - can: { - 'store/list': [], - }, - secret: 'secret', - }) - ) + Task.spawn(function* () { + const coupon = yield* session.coupons.issue({ + subject: session.agent.did(), + can: { + 'store/list': [], + }, + secret: 'secret', + }) - const archive = yield* Task.join(coupon.archive()) + const archive = yield* coupon.archive() - const wrongPassword = yield* Task.wait( - session.coupons.redeem(archive, { secret: 'wrong' }) - ) + const wrongPassword = yield* session.coupons + .redeem(archive, { secret: 'wrong' }) + .result() assert.match(String(wrongPassword.error), /secret is invalid/) - const requiresPassword = yield* Task.wait(session.coupons.redeem(archive)) + const requiresPassword = yield* session.coupons.redeem(archive).result() assert.match(String(requiresPassword.error), /requires a secret/) - const redeem = yield* Task.join( - coupon.redeem(session, { secret: 'secret' }) - ) - assert.ok(redeem) - - return { ok: {} } + const redeem = yield* coupon.redeem(session) }), 'corrupt coupon': async (assert, { session }) => { - const result = await session.coupons.redeem(new Uint8Array(32).fill(1)) + const result = await session.coupons + .redeem(new Uint8Array(32).fill(1)) + .result() 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 index 89a16cbab..d1afed65d 100644 --- a/packages/w3up-client/test/db.test.js +++ b/packages/w3up-client/test/db.test.js @@ -10,6 +10,7 @@ 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' @@ -18,720 +19,730 @@ import * as Authorization from '../src/authorization.js' * @type {Test.BasicSuite} */ export const testDB = { - 'test find space': async (assert) => { - const space = Result.unwrap( - await Space.create({ + 'test find space': (assert) => + Task.spawn(function* () { + const space = yield* Space.create({ name: 'beet-box', }) - ) - const { proofs } = Result.unwrap(await space.share(alice)) - const db = DB.from({ proofs }) - const result = Authorization.find(db, { - can: { 'store/add': [] }, - audience: alice.did(), - }) + const { proofs } = yield* space.share({ audience: alice }) + const db = DB.from({ proofs }) - assert.deepEqual(result, [ - Authorization.from({ - authority: alice.did(), + const result = Authorization.find(db, { can: { 'store/add': [] }, - subject: space.did(), - proofs, - }), - ]) - }, + audience: alice.did(), + }) - 'test find several spaces': async (assert) => { - const beetBox = Result.unwrap( - await Space.create({ + 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 = Result.unwrap(await beetBox.share(alice)) - const plumBox = Result.unwrap( - await Space.create({ + const beetBoxAuth = yield* beetBox.share({ audience: bob }) + + const plumBox = yield* Space.create({ name: 'plum-box', }) - ) - const plumBoxAuth = Result.unwrap(await plumBox.share(alice)) - const db = DB.from({ - proofs: [...beetBoxAuth.proofs, ...plumBoxAuth.proofs], - }) + const plumBoxAuth = yield* plumBox.share({ audience: bob }) - const result = Authorization.find(db, { - can: { 'store/add': [], 'store/remove': [] }, - audience: alice.did(), - }) + // create db from the proofs from both shares. + const db = DB.from({ + proofs: [...beetBoxAuth.proofs, ...plumBoxAuth.proofs], + }) - assert.deepEqual(result, [ - Authorization.from({ - authority: alice.did(), - can: { 'store/add': [], 'store/remove': [] }, - subject: beetBox.did(), - proofs: beetBoxAuth.proofs, - }), - Authorization.from({ - authority: alice.did(), + const result = Authorization.find(db, { can: { 'store/add': [], 'store/remove': [] }, - subject: plumBox.did(), - proofs: plumBoxAuth.proofs, - }), - ]) - }, - - 'test finds authorization across multiple ucans': async (assert) => { - const spaceInfo = await Capability.Space.info.delegate({ - issuer: alice, - audience: bob, - with: alice.did(), - }) - - const uploadList = await Capability.Upload.list.delegate({ - issuer: alice, - audience: bob, - with: alice.did(), - }) + audience: bob.did(), + }) - const db = DB.from({ proofs: [spaceInfo, uploadList] }) + 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, + }), + ]) + }), - const result = Authorization.find(db, { - can: { 'space/info': [], 'upload/list': [] }, - audience: bob.did(), - }) + '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(), + }) + ) - assert.deepEqual(result, [ - Authorization.from({ - authority: bob.did(), - can: { 'space/info': [], 'upload/list': [] }, - subject: alice.did(), - proofs: [spaceInfo, uploadList], - }), - ]) - }, - - 'test find accounts': async (assert) => { - const localSpace = Result.unwrap( - await Space.create({ - name: 'local-box', - }) - ) - const localAuth = Result.unwrap(await localSpace.share(alice)) - - const { - login, - attestation, - account, - space: remoteSpace, - } = await setupAccount() - - const db = DB.from({ - proofs: [login, attestation, ...localAuth.proofs], - }) + const uploadList = yield* Task.wait( + Capability.Upload.list.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + }) + ) - const result = Authorization.find(db, { - subject: { glob: 'did:mailto:*' }, - can: { '*': [] }, - audience: alice.did(), - }) + const db = DB.from({ proofs: [spaceInfo, uploadList] }) - assert.deepEqual(result, [ - Authorization.from({ - subject: account.did(), - authority: alice.did(), - can: { '*': [] }, - proofs: [login, attestation], - }), - ]) + const result = Authorization.find(db, { + can: { 'space/info': [], 'upload/list': [] }, + audience: bob.did(), + }) - const [first, second, ...rest] = Authorization.find(db, { - subject: { glob: 'did:key:*' }, - can: { 'store/add': [] }, - audience: alice.did(), - }) + assert.deepEqual(result, [ + Authorization.from({ + authority: bob.did(), + can: { 'space/info': [], 'upload/list': [] }, + subject: alice.did(), + proofs: [spaceInfo, uploadList], + }), + ]) + }), - assert.deepEqual(first.toJSON(), { - authority: alice.did(), - can: { 'store/add': [] }, - subject: localSpace.did(), - proofs: localAuth.proofs, - }) + 'test find accounts': (assert) => + Task.spawn(function* () { + const localSpace = yield* Space.create({ name: 'local-box' }) - assert.deepEqual(second.toJSON(), { - authority: alice.did(), - can: { 'store/add': [] }, - subject: remoteSpace.did(), - proofs: [login, attestation], - }) + const localAuth = yield* localSpace.share({ audience: alice }) - assert.deepEqual(rest, []) - }, - - 'test find accounts and attestations': async (assert) => { - const { login, attestation } = await 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]), - ], - }) + const { + login, + attestation, + account, + space: remoteSpace, + } = yield* setupAccount() - assert.deepEqual(result, [ - { - loginProof: login.cid, - attestProof: attestation.cid, - }, - ]) - }, - - 'does not match expired ucans': async (assert) => { - const space = Result.unwrap(await Space.create({ name: 'space' })) - const time = (Date.now() / 1000) | 0 - const expired = Result.unwrap( - await space.share(alice, { - expiration: time - 60 * 60 * 24, + const db = DB.from({ + proofs: [login, attestation, ...localAuth.proofs], }) - ) - const valid = Result.unwrap( - await space.share(alice, { - expiration: time + 60 * 60 * 24, + const result = Authorization.find(db, { + subject: { glob: 'did:mailto:*' }, + can: { '*': [] }, + audience: alice.did(), }) - ) - - const db = DB.from({ - proofs: [...valid.proofs, ...expired.proofs], - }) - const withoutExpired = Authorization.find(db, { - can: { 'store/add': [] }, - audience: alice.did(), - time, - }) + assert.deepEqual(result, [ + Authorization.from({ + subject: account.did(), + authority: alice.did(), + can: { '*': [] }, + proofs: [login, attestation], + }), + ]) - assert.deepEqual(withoutExpired, [ - Authorization.from({ - authority: alice.did(), + const [first, second, ...rest] = Authorization.find(db, { + subject: { glob: 'did:key:*' }, 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, - }) + audience: alice.did(), + }) - assert.deepEqual(withExpired, [ - Authorization.from({ - authority: alice.did(), - can: { 'store/add': [] }, - subject: space.did(), - proofs: valid.proofs, - }), - Authorization.from({ + assert.deepEqual(first.toJSON(), { authority: alice.did(), can: { 'store/add': [] }, - subject: space.did(), - proofs: expired.proofs, - }), - ]) - }, - - 'does match non-expiring ucans': async (assert) => { - const space = Result.unwrap(await Space.create({ name: 'space' })) - const { proofs } = Result.unwrap( - await space.share(alice, { - expiration: Infinity, + subject: localSpace.did(), + proofs: localAuth.proofs, }) - ) - const db = DB.from({ - proofs, - }) - - const result = Authorization.find(db, { - can: { 'store/add': [] }, - audience: alice.did(), - }) - - assert.deepEqual(result, [ - Authorization.from({ + assert.deepEqual(second.toJSON(), { authority: alice.did(), can: { 'store/add': [] }, - subject: space.did(), - proofs, - }), - ]) - }, - - 'account view': async (assert) => { - const aliceAccount = await setupAccount({ - email: 'alice@web.mail', - agent: alice, - }) - const bobAccount = await 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() + subject: remoteSpace.did(), + proofs: [login, attestation], + }) - const accounts = DB.query(db.index, { - select: { - ucan, - account, - }, - where: [ - Account.match(ucan, { - time, - audience, - account, - }), - ], - }) + assert.deepEqual(rest, []) + }), - 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' - ) + 'test find accounts and attestations': (assert) => + Task.spawn(function* () { + const { login, attestation } = yield* setupAccount() - DB.query(db.index, { - select: { - ucan, - account, - }, - where: [ - Account.match(ucan, { - time, - audience, - account, - }), - ], - }) - }, + const db = DB.from({ proofs: [login, attestation] }) - 'find account spaces': async (assert) => { - const aliceLogin = await setupAccount({ - name: 'Alice', - email: 'alice@web.mail', - agent: alice, - }) - const bobLogin = await setupAccount({ - name: 'Bob', - email: 'bob@web3.storage', - agent: bob, - }) - const aliLogin = await setupAccount({ - name: 'Ali', - email: 'alice@web.mail', - agent: alice, - }) + const loginProof = DB.link() + const loginCan = DB.link() + const attestProof = DB.link() + const attestCan = DB.link() - const space = Result.unwrap(await Space.create({ name: 'space' })) - const { proofs } = Result.unwrap( - await space.share(alice, { - expiration: Infinity, + 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]), + ], }) - ) - - 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': async (assert) => { - const account = Absentee.from({ id: fromEmail('alice@web.mail') }) - const proof = await delegate({ - issuer: account, - audience: alice, - capabilities: [ + assert.deepEqual(result, [ { - with: 'ucan:*', - can: '*', + loginProof: login.cid, + attestProof: attestation.cid, }, - ], - proofs: [], - }) + ]) + }), - const attestation = await Capability.UCAN.attest.delegate({ - issuer: w3up, - audience: alice, - with: w3up.did(), - nb: { proof: proof.cid }, - expiration: Infinity, - }) + '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 db = DB.from({ - proofs: [proof, attestation], - }) + const valid = yield* space.share({ + audience: alice, + expiration: time + 60 * 60 * 24, + }) - const result = Authorization.find(db, { - can: { 'store/add': [] }, - audience: alice.did(), - subject: account.did(), - }) + const db = DB.from({ + proofs: [...valid.proofs, ...expired.proofs], + }) - assert.deepEqual( - result, - [ + const withoutExpired = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + time, + }) + + assert.deepEqual(withoutExpired, [ Authorization.from({ authority: alice.did(), - subject: account.did(), can: { 'store/add': [] }, - proofs: [proof, attestation], + subject: space.did(), + proofs: valid.proofs, }), - ], - 'requires attestation' - ) - }, - - 'account authority from authorization': async (assert) => { - const account = Absentee.from({ id: fromEmail('alice@web.mail') }) - const proof = await delegate({ - issuer: account, - audience: alice, - capabilities: [ - { - with: 'ucan:*', - can: 'store/*', - }, - ], - proofs: [], - }) - - const db = DB.from({ - proofs: [proof], - }) + ]) - assert.deepEqual( - Authorization.find(db, { + const withExpired = Authorization.find(db, { can: { 'store/add': [] }, audience: alice.did(), - subject: account.did(), - }), - [], - 'can not find without attestation' - ) - - const attestation = await Capability.UCAN.attest.delegate({ - issuer: w3up, - audience: alice, - with: w3up.did(), - nb: { proof: proof.cid }, - expiration: Infinity, - }) - - // save attestation - Result.unwrap(await DB.transact(db, [{ assert: { proof: attestation } }])) + time: time - 60 * 60 * 24 * 2, + }) - assert.deepEqual( - Authorization.find(db, { - can: { 'store/add': [] }, - audience: alice.did(), - subject: account.did(), - }), - [ + assert.deepEqual(withExpired, [ Authorization.from({ authority: alice.did(), - subject: account.did(), can: { 'store/add': [] }, - proofs: [proof, attestation], + subject: space.did(), + proofs: valid.proofs, }), - ], - 'found when attestation was added' - ) - }, + Authorization.from({ + authority: alice.did(), + can: { 'store/add': [] }, + subject: space.did(), + proofs: expired.proofs, + }), + ]) + }), - 'find whatever capabilities match': async (assert) => { - const space = Result.unwrap( - await Space.create({ - name: 'beet-box', + '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 { proofs } = Result.unwrap(await space.share(alice)) - const db = DB.from({ proofs }) - const result = Authorization.find(db, { - audience: alice.did(), - }) + const db = DB.from({ + proofs, + }) + + const result = Authorization.find(db, { + can: { 'store/add': [] }, + audience: alice.did(), + }) - assert.deepEqual( - result, - proofs[0].capabilities.map(({ can }) => + assert.deepEqual(result, [ Authorization.from({ authority: alice.did(), - can: { [can]: [] }, + 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: [], }) ) - ) - }, - - 'find capabilities grouped by spaces': async (assert) => { - const beetBox = Result.unwrap(await Space.create({ name: 'beet-box' })) - const yumBox = Result.unwrap(await Space.create({ name: 'yum-box' })) - const aliceLogin = await setupAccount({ - name: 'Alice', - email: 'alice@web.mail', - agent: alice, - }) - const db = DB.from({ - proofs: [ - await Capability.Space.space.delegate({ - issuer: bob, + const attestation = yield* Task.wait( + Capability.UCAN.attest.delegate({ + issuer: w3up, audience: alice, - with: bob.did(), - }), + with: w3up.did(), + nb: { proof: proof.cid }, + expiration: Infinity, + }) + ) - ...Result.unwrap(await beetBox.share(alice)).proofs, - ...Result.unwrap(await beetBox.share(alice, { can: { 'debug/*': [] } })) - .proofs, - ...Result.unwrap(await yumBox.share(alice, { can: { 'store/*': [] } })) - .proofs, - ...Result.unwrap(await yumBox.share(alice, { can: { 'upload/*': [] } })) - .proofs, - ...Result.unwrap(await yumBox.share(alice, { can: { 'space/*': [] } })) - .proofs, - ...Result.unwrap(await yumBox.share(alice, { can: { 'access/*': [] } })) - .proofs, - aliceLogin.login, - aliceLogin.attestation, - ], - }) + 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' + ) + }), - 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, { + '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(), - name, - space, + 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, + }) + ) - 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, { + // save attestation + yield* DB.transact(db, [{ assert: { proof: attestation } }]) + + assert.deepEqual( + Authorization.find(db, { + can: { 'store/add': [] }, audience: alice.did(), - name, - space, + subject: account.did(), }), - ], - }) + [ + Authorization.from({ + authority: alice.did(), + subject: account.did(), + can: { 'store/add': [] }, + proofs: [proof, attestation], + }), + ], + 'found when attestation was added' + ) + }), - 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(), + '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(all.map(({ space, name }) => [space, name])), - { - [beetBox.did()]: 'beet-box', - [yumBox.did()]: 'yum-box', - [aliceLogin.space.did()]: 'Alice', - [bob.did()]: undefined, - } - ) - }, + 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.perform(function* () { - const { - account, - space: subject, - login, - proofs, - } = yield* Task.wait(setupAccount()) + Task.spawn(function* () { + const { account, space: subject, login, proofs } = yield* setupAccount() const delegation = yield* Task.wait( delegate({ @@ -806,51 +817,52 @@ export const testDB = { assert.deepEqual(aliceAccounts, [ { account: account.did(), proof: login.cid }, ]) - - return { ok: {} } }), } /** * * @param {API.Delegation} delegation - * @returns {Promise>> + * @returns {Task.Task} */ -const attest = async (delegation) => { - const attestation = await Capability.UCAN.attest.delegate({ - issuer: w3up, - audience: delegation.audience, - with: w3up.did(), - nb: { proof: delegation.cid }, - expiration: Infinity, - }) - - return { ok: attestation } +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, + }) + ) } -const setupAccount = async ({ +function* setupAccount({ email = /** @type {`${string}@${string}`} */ ('alice@web.mail'), name = 'stuff', agent = alice, -} = {}) => { - const space = Result.unwrap(await Space.create({ name })) +} = {}) { + const space = yield* Space.create({ name }) + const account = Absentee.from({ id: fromEmail(email) }) - const recovery = Result.unwrap(await space.createRecovery(account)) - const login = await delegate({ - issuer: account, - audience: agent, - capabilities: [ - { - with: 'ucan:*', - can: '*', - }, - ], - proofs: recovery.proofs, - }) - - const attestation = Result.unwrap(await attest(login)) + 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, 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/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 4ee1c59f2..4e40d279e 100644 --- a/packages/w3up-client/test/space.test.js +++ b/packages/w3up-client/test/space.test.js @@ -13,12 +13,12 @@ import * as API from '../src/types.js' */ export const testSpace = { 'create a new space': (assert, { session, provisionsStorage }) => - Task.perform(function* () { + Task.spawn(function* () { const spaces = Space.view(session) const none = spaces.list() assert.deepEqual(none, {}) - const space = yield* Task.join(spaces.create({ name: 'my-space' })) + const space = yield* spaces.create({ name: 'my-space' }) assert.equal(space.name, 'my-space') // Provision space so the API can be used. @@ -33,7 +33,7 @@ export const testSpace = { }) ) - const info = yield* Task.join(space.info()) + const info = yield* space.info() assert.deepEqual(info, { did: space.did(), @@ -42,9 +42,9 @@ export const testSpace = { assert.deepEqual(spaces.list(), {}, 'space was not saved') - const sharedSpace = yield* Task.join(space.share(session.agent.signer)) + const sharedSpace = yield* space.share({ audience: session.agent.signer }) - yield* Task.join(spaces.add(sharedSpace)) + yield* spaces.add(sharedSpace) assert.deepEqual( spaces.list(), @@ -55,22 +55,20 @@ export const testSpace = { ) const saved = spaces.list()[space.did()] - const status = yield* Task.join(saved.info()) + const status = yield* saved.info() assert.deepEqual(status, { did: space.did(), providers: [session.connection.id.did()], }) - - return { ok: {} } }), 'should get usage': async ( assert, { session, grantAccess, mail, plansStorage } ) => - Task.perform(function* () { + Task.spawn(function* () { const product = 'did:web:test.web3.storage' - const space = yield* Task.join(session.spaces.create({ name: 'test' })) + const space = yield* session.spaces.create({ name: 'test' }) const email = 'alice@web.mail' const login = session.accounts.login({ email }) @@ -79,24 +77,22 @@ export const testSpace = { assert.deepEqual(message.to, email) yield* Task.wait(grantAccess(message)) - const account = yield* Task.join(login) + const account = yield* login // setup billing plan - yield* Task.join(plansStorage.set(account.did(), product)) + yield* Task.ok.wait(plansStorage.set(account.did(), product)) - const plans = yield* Task.join(account.plans.list()) + const plans = yield* account.plans.list() const [plan] = Object.values(plans) - yield* Task.join(plan.subscriptions.add({ consumer: space.did() })) + yield* plan.subscriptions.add({ consumer: space.did() }) - const shared = yield* Task.join(space.share(session.agent.signer)) - yield* Task.join(session.spaces.add(shared)) + const shared = yield* space.share({ audience: session.agent.signer }) + yield* session.spaces.add(shared) const [saved] = session.spaces assert.deepEqual(saved.did(), space.did()) - return { ok: {} } - // const size = 1138 // const archive = await randomCAR(size) // await client.agent.invokeAndExecute(StoreCapabilities.add, { @@ -115,10 +111,10 @@ export const testSpace = { assert, { session, mail, plansStorage, grantAccess } ) => - Task.perform(function* () { + Task.spawn(function* () { const product = 'did:web:test.web3.storage' const email = 'alice@web.mail' - yield* Task.join( + yield* Task.ok.wait( plansStorage.set(Account.DIDMailto.fromEmail(email), product) ) @@ -126,26 +122,23 @@ export const testSpace = { const message = yield* Task.wait(mail.take()) yield* Task.wait(grantAccess(message)) - const alice = yield* Task.join(login) + const alice = yield* login - const plans = yield* Task.join(alice.plans.list()) + const plans = yield* alice.plans.list() const [plan] = Object.values(plans) - assert.equal(plan.account, alice) assert.equal(plan.customer, alice.did()) assert.equal(plan.provider, session.connection.id.did()) - const space = yield* Task.join(Space.create({ name: 'test-space' })) + const space = yield* Space.create({ name: 'test-space' }) - yield* Task.join(plan.subscriptions.add({ consumer: space.did() })) + yield* plan.subscriptions.add({ consumer: space.did() }) - const info = yield* Task.join(space.connect(session.connection).info()) + const info = yield* space.connect(session.connection).info() assert.deepEqual(info, { did: space.did(), providers: [session.connection.id.did()], }) - - return { ok: {} } }), } 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 89b366af6..94bec13a0 100644 --- a/packages/w3up-client/test/test.js +++ b/packages/w3up-client/test/test.js @@ -1,8 +1,8 @@ // 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 { memory } from '../src/store/memory.js' -import * as Agent from '../src/agent.js' +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' @@ -51,17 +51,17 @@ const setupContext = async () => { * @param {API.Connection} connection */ export const connect = (connection) => - Agent.open({ - store: memory(), - }).connect(connection) + W3Up.open({ + store: open(), + connection, + }) export const setup = async () => { const context = await setupContext() - const { error, ok: session } = await connect(context.connection) - if (error) { - throw error - } + const session = /** @type {API.W3UpSession} */ ( + await connect(context.connection) + ) return { ...context, session } } diff --git a/packages/w3up-client/test/usage.test.js b/packages/w3up-client/test/usage.test.js index c476a075b..e6159a164 100644 --- a/packages/w3up-client/test/usage.test.js +++ b/packages/w3up-client/test/usage.test.js @@ -1,49 +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()': async ( + 'space.usage.report()': ( assert, { mail, session, grantAccess, plansStorage } - ) => { - // First we login to the workshop account - const login = session.accounts.login({ email: 'alice@web.mail' }) - const message = await mail.take() - await grantAccess(message) - const account = Result.unwrap(await login) - // Result.unwrap(await session.accounts.add(account)) - - // Then we setup a billing for this account - await plansStorage.set(account.did(), 'did:web:test.web3.storage') - - const space = Result.unwrap(await account.spaces.create({ name: 'home' })) - - const [plan] = Result.unwrap(await account.plans.list()) - Result.unwrap(await plan.subscriptions.add({ consumer: space.did() })) - - const period = { from: new Date(0), to: new Date(1709769229000) } - - const report = Result.unwrap(await 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(), + ) => + 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', }, - provider: 'did:web:test.web3.storage', - }, - }) + }) - const usage = Result.unwrap(await space.usage.get()) - assert.deepEqual(usage, 0n) - }, + 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 36d44b321..701fa0aca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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,8 +529,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/w3up-client: dependencies: @@ -570,6 +570,9 @@ 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 @@ -624,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: @@ -809,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 @@ -2162,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 @@ -2222,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) @@ -2358,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: @@ -2370,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) @@ -2406,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: @@ -2418,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) @@ -2452,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: @@ -2464,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) @@ -2491,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: @@ -2503,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) @@ -2530,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: @@ -2542,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 @@ -2565,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: @@ -2577,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 @@ -2601,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: @@ -2613,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 @@ -2636,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: @@ -2648,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) @@ -2676,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: @@ -2688,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: @@ -2737,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: @@ -2749,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) @@ -2794,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: @@ -2808,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 @@ -2841,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: @@ -2854,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) @@ -4147,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: @@ -4159,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 @@ -4170,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: @@ -4188,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 @@ -4205,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: @@ -4215,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 @@ -4230,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: @@ -4245,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: @@ -4262,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: @@ -5675,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: @@ -5688,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: @@ -6182,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 @@ -7037,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: @@ -7064,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 @@ -10447,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 @@ -10981,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: @@ -11000,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 @@ -11015,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 @@ -12322,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: @@ -12423,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): @@ -12431,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 @@ -12445,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 From 1b610aff778858ff85878993a1074d2057e99412 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Sun, 17 Mar 2024 12:44:09 -0700 Subject: [PATCH 15/16] feat: port indexed-db store test --- packages/w3up-client/src/agent/db.js | 2 +- .../stores/store-indexeddb.browser.test.js | 121 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/w3up-client/test/stores/store-indexeddb.browser.test.js diff --git a/packages/w3up-client/src/agent/db.js b/packages/w3up-client/src/agent/db.js index 2aae4cb15..b43c301bc 100644 --- a/packages/w3up-client/src/agent/db.js +++ b/packages/w3up-client/src/agent/db.js @@ -52,7 +52,7 @@ export const fromArchive = ({ for (const { meta, delegation } of delegations.values()) { const proof = Delegation.fromArchive(delegation) - proofs.set(`${proof.cid}`, { delegation, meta }) + proofs.set(`${proof.cid}`, { delegation: proof, meta }) } const db = Datalogia.Memory.create(Delegations.facts(proofs.values())) diff --git a/packages/w3up-client/test/stores/store-indexeddb.browser.test.js b/packages/w3up-client/test/stores/store-indexeddb.browser.test.js new file mode 100644 index 000000000..10887781f --- /dev/null +++ b/packages/w3up-client/test/stores/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) + }) +}) From e6c3b89fe01afc5c4ff14e1512022f35a6b293e2 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 25 Mar 2024 22:34:33 -0700 Subject: [PATCH 16/16] stash! --- .../w3up-client/test/store/conf.node.test.js | 97 +++++++++++++++++++ .../indexeddb.browser.test.js} | 0 2 files changed, 97 insertions(+) create mode 100644 packages/w3up-client/test/store/conf.node.test.js rename packages/w3up-client/test/{stores/store-indexeddb.browser.test.js => store/indexeddb.browser.test.js} (100%) 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/stores/store-indexeddb.browser.test.js b/packages/w3up-client/test/store/indexeddb.browser.test.js similarity index 100% rename from packages/w3up-client/test/stores/store-indexeddb.browser.test.js rename to packages/w3up-client/test/store/indexeddb.browser.test.js