diff --git a/bin.js b/bin.js index 62e13c8..ee95a07 100755 --- a/bin.js +++ b/bin.js @@ -7,6 +7,7 @@ import { getPkg } from './lib.js' import { Account, Space, + Coupon, accessClaim, addSpace, listSpaces, @@ -21,7 +22,8 @@ import { remove, list, whoami, - usageReport + usageReport, + getPlan, } from './index.js' import { storeAdd, @@ -30,7 +32,7 @@ import { uploadAdd, uploadList, uploadRemove, - filecoinInfo + filecoinInfo, } from './can.js' const pkg = getPkg() @@ -52,6 +54,12 @@ cli ) .action(Account.login) +cli + .command('plan get [email]') + .example('plan get user@example.com') + .describe('Displays plan given account is on') + .action(getPlan) + cli .command('account ls') .alias('account list') @@ -122,6 +130,8 @@ cli .command('space provision [name]') .describe('Associating space with a billing account') .option('-c, --customer', 'The email address of the billing account') + .option('--coupon', 'Coupon URL to provision space with') + .option('-p, -password', 'Coupon password') .option( '-p, --provider', 'The storage provider to associate with this space.' @@ -152,6 +162,21 @@ cli .describe('Set the current space in use by the agent') .action(useSpace) +cli + .command('coupon create ') + .option('--password', 'Password for created coupon.') + .option('-c, --can', 'One or more abilities to delegate.') + .option( + '-e, --expiration', + 'Unix timestamp when the delegation is no longer valid. Zero indicates no expiration.', + 0 + ) + .option( + '-o, --output', + 'Path of file to write the exported delegation data to.' + ) + .action(Coupon.issue) + cli .command('delegation create ') .describe( diff --git a/coupon.js b/coupon.js new file mode 100644 index 0000000..0be7032 --- /dev/null +++ b/coupon.js @@ -0,0 +1,55 @@ +import fs from 'node:fs/promises' +import * as DID from '@ipld/dag-ucan/did' +import * as Account from './account.js' +import * as Space from './space.js' +import { getClient } from './lib.js' +import * as ucanto from '@ucanto/core' + +export { Account, Space } + +/** + * @typedef {object} CouponIssueOptions + * @property {string} customer + * @property {string[]|string} [can] + * @property {string} [password] + * @property {number} [expiration] + * @property {string} [output] + * + * @param {string} customer + * @param {CouponIssueOptions} options + */ +export const issue = async ( + customer, + { can = 'provider/add', expiration, password, output } +) => { + const client = await getClient() + + const audience = DID.parse(customer) + const abilities = can ? [can].flat() : [] + if (!abilities.length) { + console.error('Error: missing capabilities for delegation') + process.exit(1) + } + + const capabilities = /** @type {ucanto.API.Capabilities} */ ( + abilities.map((can) => ({ can, with: audience.did() })) + ) + + const coupon = await client.coupon.issue({ + capabilities, + expiration: expiration === 0 ? Infinity : expiration, + password, + }) + + const { ok: bytes, error } = await coupon.archive() + if (!bytes) { + console.error(error) + return process.exit(1) + } + + if (output) { + await fs.writeFile(output, bytes) + } else { + process.stdout.write(bytes) + } +} diff --git a/index.js b/index.js index 8a61fe5..6dab1e1 100644 --- a/index.js +++ b/index.js @@ -20,7 +20,7 @@ import { } from './lib.js' import * as ucanto from '@ucanto/core' import chalk from 'chalk' - +export * as Coupon from './coupon.js' export { Account, Space } /** @@ -31,6 +31,31 @@ export async function accessClaim() { await client.capability.access.claim() } +/** + * @param {string} email + */ +export const getPlan = async (email = '') => { + const client = await getClient() + const account = + email === '' + ? await Space.selectAccount(client) + : await Space.useAccount(client, { email }) + + if (account) { + const { ok: plan, error } = await account.plan.get() + if (plan) { + console.log(`⁂ ${plan.product}`) + } else if (error?.name === 'PlanNotFound') { + console.log('⁂ no plan has been selected yet') + } else { + console.error(`Failed to get plan - ${error.message}`) + process.exit(1) + } + } else { + process.exit(1) + } +} + /** * @param {`${string}@${string}`} email * @param {object} [opts] @@ -95,8 +120,8 @@ export async function upload(firstPath, opts) { const uploadFn = opts?.car ? client.uploadCAR.bind(client, files[0]) : files.length === 1 && opts?.['no-wrap'] - ? client.uploadFile.bind(client, files[0]) - : client.uploadDirectory.bind(client, files) + ? client.uploadFile.bind(client, files[0]) + : client.uploadDirectory.bind(client, files) const root = await uploadFn({ onShardStored: ({ cid, size, piece }) => { @@ -492,18 +517,31 @@ export async function usageReport(opts) { 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 + to: now, } let total = 0 - for await (const { account, provider, space, size } of getSpaceUsageReports(client, period)) { + for await (const { account, provider, space, size } of getSpaceUsageReports( + client, + period + )) { if (opts?.json) { - console.log(dagJSON.stringify({ account, provider, space, size, reportedAt: now.toISOString() })) + console.log( + dagJSON.stringify({ + account, + provider, + space, + size, + reportedAt: now.toISOString(), + }) + ) } else { console.log(` Account: ${account}`) console.log(`Provider: ${provider}`) console.log(` Space: ${space}`) - console.log(` Size: ${opts?.human ? filesize(size.final) : size.final}\n`) + console.log( + ` Size: ${opts?.human ? filesize(size.final) : size.final}\n` + ) } total += size.final } @@ -516,9 +554,11 @@ export async function usageReport(opts) { * @param {import('@web3-storage/w3up-client').Client} client * @param {{ from: Date, to: Date }} period */ -async function * getSpaceUsageReports (client, period) { +async function* getSpaceUsageReports(client, period) { for (const account of Object.values(client.accounts())) { - const subscriptions = await client.capability.subscription.list(account.did()) + const subscriptions = await client.capability.subscription.list( + account.did() + ) for (const { consumers } of subscriptions.results) { for (const space of consumers) { const result = await client.capability.usage.report(space, period) diff --git a/package-lock.json b/package-lock.json index 8bf9e1e..a3af989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,10 @@ "@ucanto/client": "^9.0.0", "@ucanto/core": "^9.0.0", "@ucanto/transport": "^9.0.0", - "@web3-storage/access": "^17.0.0", + "@web3-storage/access": "18.0.0", "@web3-storage/data-segment": "^5.0.0", "@web3-storage/did-mailto": "^2.1.0", - "@web3-storage/w3up-client": "^10.2.0", + "@web3-storage/w3up-client": "^11.0.0", "ansi-escapes": "^6.2.0", "chalk": "^5.3.0", "files-from-path": "^1.0.0", @@ -40,10 +40,10 @@ "@ucanto/principal": "^9.0.0", "@ucanto/server": "^9.0.1", "@web-std/blob": "^3.0.5", - "@web3-storage/capabilities": "^11.4.0", + "@web3-storage/capabilities": "^12.0.0", "@web3-storage/eslint-config-w3up": "^1.0.0", "@web3-storage/sigv4": "^1.0.2", - "@web3-storage/upload-api": "^7.3.1", + "@web3-storage/upload-api": "^7.3.2", "@web3-storage/upload-client": "^12.0.0", "entail": "^2.1.1", "multiformats": "^12.0.1", @@ -1485,9 +1485,9 @@ } }, "node_modules/@ucanto/core": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@ucanto/core/-/core-9.0.0.tgz", - "integrity": "sha512-O2c+UOQ5wAvUsuN7BbZR6QAoUgYpWzN0HAAVbNBLT4I8/OUzMcxSYeu08/ph0sNtLGlOPDcPn+ANclTwxc5UcA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@ucanto/core/-/core-9.0.1.tgz", + "integrity": "sha512-SsYvKCO3FD27roTVcg8ASxnixjn+j96sPlijpVq1uBUxq7SmuNxNPYFZqpxXKj2R4gty/Oc8XTse12ebB9Kofg==", "dependencies": { "@ipld/car": "^5.1.0", "@ipld/dag-cbor": "^9.0.0", @@ -1616,21 +1616,21 @@ } }, "node_modules/@web3-storage/access": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@web3-storage/access/-/access-17.0.0.tgz", - "integrity": "sha512-5tU7cD7tZx1Izv3EzQMhhd9BPxBk7b6AZu97fYQNqNgXPxKEnD4nJ/RrQGqAn82aAOM8tMPn/D7jb6IA1nsApg==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@web3-storage/access/-/access-18.0.0.tgz", + "integrity": "sha512-zDw5zlowMytd9HXCUwisYgBhjHohD4wx+Mzq33GfrJyjZOe203CzkuHG2JSsD3d9hx/HxJG2U/NlIJlFRMatSA==", "dependencies": { "@ipld/car": "^5.1.1", "@ipld/dag-ucan": "^3.4.0", "@scure/bip39": "^1.2.1", "@ucanto/client": "^9.0.0", - "@ucanto/core": "^9.0.0", + "@ucanto/core": "^9.0.1", "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", "@ucanto/validator": "^9.0.0", - "@web3-storage/capabilities": "^11.4.0", - "@web3-storage/did-mailto": "^2.0.2", + "@web3-storage/capabilities": "^12.0.0", + "@web3-storage/did-mailto": "^2.1.0", "bigint-mod-arith": "^3.1.2", "conf": "11.0.2", "multiformats": "^12.1.2", @@ -1641,11 +1641,11 @@ } }, "node_modules/@web3-storage/capabilities": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/@web3-storage/capabilities/-/capabilities-11.4.0.tgz", - "integrity": "sha512-OGiNVBcivotl05PcQTK/9cqzS3vZ68LUKmwmvyXZkNziCv+09/mneHdLVOCXwEFJdk8IQNVeejB35KWSiEUuGQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@web3-storage/capabilities/-/capabilities-12.0.0.tgz", + "integrity": "sha512-pn7scBIE/ZKc0F8nJlUjMfxU27E7WbRdxyiaSVN2obaKTB5/+QnHkaXv0wvlOzOiM0kpchrcIasJCdyCQdvWJQ==", "dependencies": { - "@ucanto/core": "^9.0.0", + "@ucanto/core": "^9.0.1", "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", @@ -1714,24 +1714,49 @@ } }, "node_modules/@web3-storage/filecoin-api": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@web3-storage/filecoin-api/-/filecoin-api-4.1.0.tgz", - "integrity": "sha512-HVToL9T685G4Gjqwzt9wN+/9E2+9gp9rZvNhB8HCrZCBBsCjv8AJRBHhFgo4liNHmLiJFZbRQpbEQ43Cx38+IA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@web3-storage/filecoin-api/-/filecoin-api-4.1.1.tgz", + "integrity": "sha512-VOVo+0eigelqHC/vitwEO7wG7oVNhWur9t7LGFKVqjlVZKMuXGS2xtkF7oUn5yRwmckh+4ip3/5Bp+YbpEP/Zg==", "dev": true, "dependencies": { "@ipld/dag-ucan": "^3.4.0", "@ucanto/client": "^9.0.0", - "@ucanto/core": "^9.0.0", + "@ucanto/core": "^9.0.1", "@ucanto/interface": "^9.0.0", "@ucanto/server": "^9.0.1", "@ucanto/transport": "^9.0.0", - "@web3-storage/capabilities": "^11.3.0", + "@web3-storage/capabilities": "^11.4.1", "@web3-storage/data-segment": "^4.0.0" }, "engines": { "node": ">=16.15" } }, + "node_modules/@web3-storage/filecoin-api/node_modules/@web3-storage/capabilities": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@web3-storage/capabilities/-/capabilities-11.4.1.tgz", + "integrity": "sha512-PjIewEg/T3wfNavxzsZZ5MpH2WBldNz94qOQOKg5iH/4UrS8SPWWGsJx/Tu760O+PFhpTFwvi5cHCtkb08OdAA==", + "dev": true, + "dependencies": { + "@ucanto/core": "^9.0.1", + "@ucanto/interface": "^9.0.0", + "@ucanto/principal": "^9.0.0", + "@ucanto/transport": "^9.0.0", + "@ucanto/validator": "^9.0.0", + "@web3-storage/data-segment": "^3.2.0" + } + }, + "node_modules/@web3-storage/filecoin-api/node_modules/@web3-storage/capabilities/node_modules/@web3-storage/data-segment": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@web3-storage/data-segment/-/data-segment-3.2.0.tgz", + "integrity": "sha512-SM6eNumXzrXiQE2/J59+eEgCRZNYPxKhRoHX2QvV3/scD4qgcf4g+paWBc3UriLEY1rCboygGoPsnqYJNyZyfA==", + "dev": true, + "dependencies": { + "@ipld/dag-cbor": "^9.0.5", + "multiformats": "^11.0.2", + "sync-multihash-sha2": "^1.0.0" + } + }, "node_modules/@web3-storage/filecoin-api/node_modules/@web3-storage/data-segment": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@web3-storage/data-segment/-/data-segment-4.0.0.tgz", @@ -1754,16 +1779,48 @@ } }, "node_modules/@web3-storage/filecoin-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@web3-storage/filecoin-client/-/filecoin-client-3.1.0.tgz", - "integrity": "sha512-hR+uEpYpKNv4kcpx1PpAFC2p+hJ27XwVPnP6bc7ZC5p/JMjIAwa2TBm479WdncH/v2uJDqn06J4ux9vYNEFkfw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@web3-storage/filecoin-client/-/filecoin-client-3.1.1.tgz", + "integrity": "sha512-UVSke3IiBHrEvKkwyAjCkttOE2VC+ILEAQg5qVAm7CxApKkhjVHJRh9Xpm8JYI7gZcb6m8/YdgFLOXTjtz3zvQ==", "dependencies": { "@ipld/dag-ucan": "^3.4.0", "@ucanto/client": "^9.0.0", - "@ucanto/core": "^9.0.0", + "@ucanto/core": "^9.0.1", "@ucanto/interface": "^9.0.0", "@ucanto/transport": "^9.0.0", - "@web3-storage/capabilities": "^11.3.0" + "@web3-storage/capabilities": "^11.4.1" + } + }, + "node_modules/@web3-storage/filecoin-client/node_modules/@web3-storage/capabilities": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@web3-storage/capabilities/-/capabilities-11.4.1.tgz", + "integrity": "sha512-PjIewEg/T3wfNavxzsZZ5MpH2WBldNz94qOQOKg5iH/4UrS8SPWWGsJx/Tu760O+PFhpTFwvi5cHCtkb08OdAA==", + "dependencies": { + "@ucanto/core": "^9.0.1", + "@ucanto/interface": "^9.0.0", + "@ucanto/principal": "^9.0.0", + "@ucanto/transport": "^9.0.0", + "@ucanto/validator": "^9.0.0", + "@web3-storage/data-segment": "^3.2.0" + } + }, + "node_modules/@web3-storage/filecoin-client/node_modules/@web3-storage/data-segment": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@web3-storage/data-segment/-/data-segment-3.2.0.tgz", + "integrity": "sha512-SM6eNumXzrXiQE2/J59+eEgCRZNYPxKhRoHX2QvV3/scD4qgcf4g+paWBc3UriLEY1rCboygGoPsnqYJNyZyfA==", + "dependencies": { + "@ipld/dag-cbor": "^9.0.5", + "multiformats": "^11.0.2", + "sync-multihash-sha2": "^1.0.0" + } + }, + "node_modules/@web3-storage/filecoin-client/node_modules/multiformats": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", + "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" } }, "node_modules/@web3-storage/sigv4": { @@ -1776,9 +1833,9 @@ } }, "node_modules/@web3-storage/upload-api": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@web3-storage/upload-api/-/upload-api-7.3.1.tgz", - "integrity": "sha512-SO0KWshPWKmLDcHfNhCKVtrcoBWpzzx2NsM1s12BI4ZWP2oBrYQeY3MZaaMu5GnArg6PHnSGNK1QaMvBKzXA/Q==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@web3-storage/upload-api/-/upload-api-7.3.2.tgz", + "integrity": "sha512-qCkXosQnkpCiLa1HzeGRPjwgScOUSll2Y+jY18MaKaT7RdWw8kmlTYxnesLXw/+nwTwgFeG0hQ2WFTGGNrKpCA==", "dev": true, "dependencies": { "@ucanto/client": "^9.0.0", @@ -1787,10 +1844,10 @@ "@ucanto/server": "^9.0.1", "@ucanto/transport": "^9.0.0", "@ucanto/validator": "^9.0.0", - "@web3-storage/access": "^16.5.1", - "@web3-storage/capabilities": "^11.4.0", - "@web3-storage/did-mailto": "^2.0.2", - "@web3-storage/filecoin-api": "^4.1.0", + "@web3-storage/access": "^17.1.0", + "@web3-storage/capabilities": "^11.4.1", + "@web3-storage/did-mailto": "^2.1.0", + "@web3-storage/filecoin-api": "^4.1.1", "multiformats": "^12.1.2", "p-retry": "^5.1.2" }, @@ -1799,10 +1856,9 @@ } }, "node_modules/@web3-storage/upload-api/node_modules/@web3-storage/access": { - "version": "16.5.1", - "resolved": "https://registry.npmjs.org/@web3-storage/access/-/access-16.5.1.tgz", - "integrity": "sha512-dbqujfXIdETCVfpPQ3b07A2k7aHnxUtK7LMs9d0clbKl3MUQM1wdfBgkEvsMogOikIDN2Db8Av7H6xJI2wlreg==", - "deprecated": "backwards incompatible changes", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/@web3-storage/access/-/access-17.1.0.tgz", + "integrity": "sha512-CRVjMfO3LynU9wwCy1XHvWSVlOrccCaqAPPO1siLS/UVYLb4ZT+XuHrM9cYhqZMsBD7yewQ5JG0TDoy/UP7wOQ==", "dev": true, "dependencies": { "@ipld/car": "^5.1.1", @@ -1814,8 +1870,8 @@ "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", "@ucanto/validator": "^9.0.0", - "@web3-storage/capabilities": "^11.2.0", - "@web3-storage/did-mailto": "^2.0.2", + "@web3-storage/capabilities": "^11.4.0", + "@web3-storage/did-mailto": "^2.1.0", "bigint-mod-arith": "^3.1.2", "conf": "11.0.2", "multiformats": "^12.1.2", @@ -1825,6 +1881,41 @@ "uint8arrays": "^4.0.6" } }, + "node_modules/@web3-storage/upload-api/node_modules/@web3-storage/capabilities": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@web3-storage/capabilities/-/capabilities-11.4.1.tgz", + "integrity": "sha512-PjIewEg/T3wfNavxzsZZ5MpH2WBldNz94qOQOKg5iH/4UrS8SPWWGsJx/Tu760O+PFhpTFwvi5cHCtkb08OdAA==", + "dev": true, + "dependencies": { + "@ucanto/core": "^9.0.1", + "@ucanto/interface": "^9.0.0", + "@ucanto/principal": "^9.0.0", + "@ucanto/transport": "^9.0.0", + "@ucanto/validator": "^9.0.0", + "@web3-storage/data-segment": "^3.2.0" + } + }, + "node_modules/@web3-storage/upload-api/node_modules/@web3-storage/data-segment": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@web3-storage/data-segment/-/data-segment-3.2.0.tgz", + "integrity": "sha512-SM6eNumXzrXiQE2/J59+eEgCRZNYPxKhRoHX2QvV3/scD4qgcf4g+paWBc3UriLEY1rCboygGoPsnqYJNyZyfA==", + "dev": true, + "dependencies": { + "@ipld/dag-cbor": "^9.0.5", + "multiformats": "^11.0.2", + "sync-multihash-sha2": "^1.0.0" + } + }, + "node_modules/@web3-storage/upload-api/node_modules/@web3-storage/data-segment/node_modules/multiformats": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", + "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", + "dev": true, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@web3-storage/upload-client": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@web3-storage/upload-client/-/upload-client-12.0.0.tgz", @@ -1846,21 +1937,53 @@ "varint": "^6.0.0" } }, + "node_modules/@web3-storage/upload-client/node_modules/@web3-storage/capabilities": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@web3-storage/capabilities/-/capabilities-11.4.1.tgz", + "integrity": "sha512-PjIewEg/T3wfNavxzsZZ5MpH2WBldNz94qOQOKg5iH/4UrS8SPWWGsJx/Tu760O+PFhpTFwvi5cHCtkb08OdAA==", + "dependencies": { + "@ucanto/core": "^9.0.1", + "@ucanto/interface": "^9.0.0", + "@ucanto/principal": "^9.0.0", + "@ucanto/transport": "^9.0.0", + "@ucanto/validator": "^9.0.0", + "@web3-storage/data-segment": "^3.2.0" + } + }, + "node_modules/@web3-storage/upload-client/node_modules/@web3-storage/data-segment": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@web3-storage/data-segment/-/data-segment-3.2.0.tgz", + "integrity": "sha512-SM6eNumXzrXiQE2/J59+eEgCRZNYPxKhRoHX2QvV3/scD4qgcf4g+paWBc3UriLEY1rCboygGoPsnqYJNyZyfA==", + "dependencies": { + "@ipld/dag-cbor": "^9.0.5", + "multiformats": "^11.0.2", + "sync-multihash-sha2": "^1.0.0" + } + }, + "node_modules/@web3-storage/upload-client/node_modules/@web3-storage/data-segment/node_modules/multiformats": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", + "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@web3-storage/w3up-client": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@web3-storage/w3up-client/-/w3up-client-10.2.0.tgz", - "integrity": "sha512-fi5tteUty4fVpIdcyIG6v4Lg8dA/RvG9xWLO1PC3QV0dpVUt/q3RUelihpzkgr5cksigmIwhH1lCBzz5FjjPOg==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@web3-storage/w3up-client/-/w3up-client-11.0.0.tgz", + "integrity": "sha512-VpS3xBhidcAdsLJdlp/lIAinRq7hug7wXL8M2dMKGG1zus11E4lzJ/uZOHLDbtjXfx58niQcHspZRpMJVCGuaw==", "dependencies": { "@ipld/dag-ucan": "^3.4.0", "@ucanto/client": "^9.0.0", - "@ucanto/core": "^9.0.0", + "@ucanto/core": "^9.0.1", "@ucanto/interface": "^9.0.0", "@ucanto/principal": "^9.0.0", "@ucanto/transport": "^9.0.0", - "@web3-storage/access": "^17.0.0", - "@web3-storage/capabilities": "^11.4.0", + "@web3-storage/access": "^18.0.0", + "@web3-storage/capabilities": "^12.0.0", "@web3-storage/did-mailto": "^2.1.0", - "@web3-storage/filecoin-client": "^3.1.0", + "@web3-storage/filecoin-client": "^3.1.1", "@web3-storage/upload-client": "^12.0.0" } }, diff --git a/package.json b/package.json index 76a6f84..1bcd8b6 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ "@ucanto/principal": "^9.0.0", "@ucanto/server": "^9.0.1", "@web-std/blob": "^3.0.5", - "@web3-storage/capabilities": "^11.4.0", + "@web3-storage/capabilities": "^12.0.0", "@web3-storage/eslint-config-w3up": "^1.0.0", "@web3-storage/sigv4": "^1.0.2", - "@web3-storage/upload-api": "^7.3.1", + "@web3-storage/upload-api": "^7.3.2", "@web3-storage/upload-client": "^12.0.0", "entail": "^2.1.1", "multiformats": "^12.0.1", @@ -54,10 +54,10 @@ "@ucanto/client": "^9.0.0", "@ucanto/core": "^9.0.0", "@ucanto/transport": "^9.0.0", - "@web3-storage/access": "^17.0.0", + "@web3-storage/access": "18.0.0", "@web3-storage/data-segment": "^5.0.0", + "@web3-storage/w3up-client": "^11.0.0", "@web3-storage/did-mailto": "^2.1.0", - "@web3-storage/w3up-client": "^10.2.0", "chalk": "^5.3.0", "ansi-escapes": "^6.2.0", "@inquirer/prompts": "^3.3.0", diff --git a/space.js b/space.js index 028be5b..41cea8d 100644 --- a/space.js +++ b/space.js @@ -9,6 +9,7 @@ import ora from 'ora' import { select, input } from '@inquirer/prompts' import { mnemonic } from './dialog.js' import { API } from '@ucanto/core' +import * as Result from '@web3-storage/w3up-client/result' /** * @typedef {object} CreateOptions @@ -163,7 +164,9 @@ const setupBilling = async ( /** * @typedef {object} ProvisionOptions * @property {DIDMailto.EmailAddress} [customer] + * @property {string} [coupon] * @property {string} [provider] + * @property {string} [password] * * @param {string} name * @param {ProvisionOptions} options @@ -178,19 +181,55 @@ export const provision = async (name = '', options = {}) => { process.exit(1) } - const setup = await setupBilling(client, { - customer: options.customer, - space, - }) + if (options.coupon) { + const { ok: bytes, error: fetchError } = await fetch(options.coupon) + .then((response) => response.arrayBuffer()) + .then((buffer) => Result.ok(new Uint8Array(buffer))) + .catch((error) => Result.error(/** @type {Error} */ (error))) - if (setup.ok) { - console.log(`✨ Billing account is set`) - } else if (setup.error?.reason === 'error') { - console.error( - `⚠️ Failed to set billing account - ${setup.error.cause.message}` + if (fetchError) { + console.error(`Failed to fetch coupon from ${options.coupon}`) + process.exit(1) + } + + const { ok: access, error: couponError } = await client.coupon + .redeem(bytes, options) + .then(Result.ok, Result.error) + + if (!access) { + console.error(`Failed to redeem coupon: ${couponError.message}}`) + process.exit(1) + } + + const result = await W3Space.provision( + { did: () => space }, + { + proofs: access.proofs, + agent: client.agent, + } ) - process.exit(1) + + if (result.error) { + console.log(`Failed to provision space: ${result.error.message}`) + process.exit(1) + } + } else { + const result = await setupBilling(client, { + customer: options.customer, + space, + }) + + if (result.error) { + console.error( + `⚠️ Failed to set up billing account,\n ${ + Object(result.error).message ?? '' + }` + ) + process.exit(1) + } } + + console.log(`✨ Billing account is set`) } /** diff --git a/test/bin.spec.js b/test/bin.spec.js index 03e7996..22d8c37 100644 --- a/test/bin.spec.js +++ b/test/bin.spec.js @@ -3,7 +3,7 @@ import os from 'os' import path from 'path' import * as Signer from '@ucanto/principal/ed25519' import { importDAG } from '@ucanto/core/delegation' -import { parseLink, provide } from '@ucanto/server' +import { parseLink } from '@ucanto/server' import * as DID from '@ipld/dag-ucan/did' import * as dagJSON from '@ipld/dag-json' import { SpaceDID } from '@web3-storage/capabilities/utils' @@ -12,7 +12,12 @@ import { test } from './helpers/context.js' import * as Test from './helpers/context.js' import { pattern, match } from './helpers/util.js' import * as Command from './helpers/process.js' +import { Absentee, ed25519 } from '@ucanto/principal' import * as DIDMailto from '@web3-storage/did-mailto' +import { UCAN, Provider } from '@web3-storage/capabilities' +import * as ED25519 from '@ucanto/principal/ed25519' +import { sha256, delegate } from '@ucanto/core' +import * as Result from '@web3-storage/w3up-client/result' const w3 = Command.create('./bin.js') @@ -514,6 +519,41 @@ export const testSpace = { 'added provider shows up in the space info' ) }), + + 'w3 space provision --coupon': test(async (assert, context) => { + const spaceDID = await createSpace(context, { customer: null }) + + assert.deepEqual( + await context.provisionsStorage.getStorageProviders(spaceDID), + { ok: [] }, + 'space has no providers yet' + ) + + const archive = await createCustomerSession(context) + context.router['/proof.car'] = async () => { + return { + status: 200, + headers: { 'content-type': 'application/car' }, + body: archive, + } + } + + const url = new URL('/proof.car', context.serverURL) + const provision = await w3 + .env(context.env.alice) + .args(['space', 'provision', '--coupon', url.href]) + .join() + + assert.match(provision.output, /Billing account is set/) + + const info = await w3.env(context.env.alice).args(['space', 'info']).join() + + assert.match( + info.output, + pattern`Providers: ${context.service.did()}`, + 'space got provisioned' + ) + }), } export const testW3Up = { @@ -1111,6 +1151,23 @@ export const testCan = { }), } +export const testPlan = { + 'w3 plan get': test(async (assert, context) => { + await login(context) + const notFound = await w3 + .args(['plan', 'get']) + .env(context.env.alice) + .join() + + assert.match(notFound.output, /no plan/i) + + await selectPlan(context) + + const plan = await w3.args(['plan', 'get']).env(context.env.alice).join() + assert.match(plan.output, /did:web:free.web3.storage/) + }), +} + /** * @param {Test.Context} context * @param {object} options @@ -1192,3 +1249,57 @@ export const createSpace = async ( return SpaceDID.from(did) } + +/** + * @param {Test.Context} context + * @param {object} options + * @param {string} [options.password] + */ +export const createCustomerSession = async ( + context, + { password = '' } = {} +) => { + // Derive delegation audience from the password + const { digest } = await sha256.digest(new TextEncoder().encode(password)) + const audience = await ED25519.derive(digest) + + // Generate the agent that will be authorized to act on behalf of the customer + const agent = await ed25519.generate() + + const customer = Absentee.from({ id: 'did:mailto:web.mail:workshop' }) + + // First we create delegation from the customer to the agent that authorizing + // it to perform `provider/add` on their behalf. + const delegation = await delegate({ + issuer: customer, + audience: agent, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + expiration: Infinity, + }) + + // Then we create an attestation from the service to proof that agent has + // been authorized + const attestation = await UCAN.attest.delegate({ + issuer: context.service, + audience: agent, + with: context.service.did(), + nb: { proof: delegation.cid }, + expiration: delegation.expiration, + }) + + // Finally we create a short lived session that authorizes the audience to + // provider/add with their billing account. + const session = await Provider.add.delegate({ + issuer: agent, + audience, + with: customer.did(), + proofs: [delegation, attestation], + }) + + return Result.try(await session.archive()) +} diff --git a/test/helpers/context.js b/test/helpers/context.js index 7cf3559..dfe20b7 100644 --- a/test/helpers/context.js +++ b/test/helpers/context.js @@ -8,7 +8,6 @@ import { createEnv } from './env.js' import { Signer } from '@ucanto/principal/ed25519' import { createServer as createHTTPServer } from './http-server.js' import http from 'node:http' -import { createHTTPListener } from './ucanto.js' import { StoreConf } from '@web3-storage/access/stores/store-conf' import * as FS from 'node:fs/promises' @@ -50,17 +49,24 @@ export const provisionSpace = async (context, { space, account, provider }) => { * @typedef {import('@web3-storage/w3up-client/types').StoreAddSuccess} StoreAddSuccess * @typedef {UcantoServerTestContext & { * server: import('./http-server').TestingServer['server'] + * router: import('./http-server').Router * env: { alice: Record, bob: Record } + * serverURL: URL * }} Context * * @returns {Promise} */ export const setup = async () => { - const { server, serverURL, setRequestListener } = await createHTTPServer() const context = await createContext({ http }) - setRequestListener(createHTTPListener(context.connection.channel)) + const { server, serverURL, router } = await createHTTPServer({ + '/': context.connection.channel.request.bind(context.connection.channel), + }) + return Object.assign(context, { server, + serverURL, + router, + serverRouter: router, env: { alice: createEnv({ storeName: `w3cli-test-alice-${context.service.did()}`, diff --git a/test/helpers/http-server.js b/test/helpers/http-server.js index 208fe88..ef68714 100644 --- a/test/helpers/http-server.js +++ b/test/helpers/http-server.js @@ -2,36 +2,60 @@ import http from 'http' import { once } from 'events' /** + * @typedef {import('@ucanto/interface').HTTPRequest} HTTPRequest + * @typedef {import('@ucanto/server').HTTPResponse} HTTPResponse + * @typedef {Record PromiseLike|HTTPResponse>} Router + * * @typedef {{ * server: http.Server * serverURL: URL - * setRequestListener: (l: http.RequestListener) => void + * router: Router * }} TestingServer */ -/** @returns {Promise} */ -export async function createServer() { - /** @type {http.RequestListener} */ - let listener = (_, response) => { - response.statusCode = 500 - response.write('no request listener set') +/** + * @param {Router} router + * @returns {Promise} + */ +export async function createServer(router) { + /** + * @param {http.IncomingMessage} request + * @param {http.ServerResponse} response + */ + const listener = async (request, response) => { + const chunks = [] + for await (const chunk of request) { + chunks.push(chunk) + } + + const handler = router[request.url ?? '/'] + if (!handler) { + response.writeHead(404) + response.end() + return undefined + } + + const { headers, body } = await handler({ + headers: /** @type {Readonly>} */ ( + request.headers + ), + body: Buffer.concat(chunks), + }) + + response.writeHead(200, headers) + response.write(body) response.end() + return undefined } - const server = http - .createServer((request, response) => { - listener(request, response) - }) - .listen() + const server = http.createServer(listener).listen() await once(server, 'listening') return { server, + router, // @ts-expect-error serverURL: new URL(`http://127.0.0.1:${server.address().port}`), - setRequestListener: (l) => { - listener = l - }, } } diff --git a/test/helpers/ucanto.js b/test/helpers/ucanto.js deleted file mode 100644 index d7e37ba..0000000 --- a/test/helpers/ucanto.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @template {Record} T - * @param {import('@ucanto/server').Transport.Channel} server - */ -export function createHTTPListener(server) { - /** @type {import('http').RequestListener} */ - return async (request, response) => { - const chunks = [] - for await (const chunk of request) { - chunks.push(chunk) - } - - const { headers, body } = await server.request({ - // @ts-ignore - headers: request.headers, - body: Buffer.concat(chunks), - }) - - response.writeHead(200, headers) - response.write(body) - response.end() - } -}