@storacha/client
Version:
Client for the storacha.network w3up api
341 lines • 12 kB
JavaScript
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 '@storacha/access/agent';
import { add as provision, AccountDID } from '@storacha/access/provider';
import { fromEmail, toEmail } from '@storacha/did-mailto';
import * as UCAN from '@storacha/capabilities/ucan';
export { fromEmail };
/**
* @typedef {import('@storacha/did-mailto').EmailAddress} EmailAddress
*/
/**
* List all accounts that agent has stored access to. Returns a dictionary
* of accounts keyed by their `did:mailto` identifier.
*
* @param {{agent: API.Agent}} client
* @param {object} query
* @param {API.DID<'mailto'>} [query.account]
*/
export const list = ({ agent }, { account } = {}) => {
const query = /** @type {API.CapabilityQuery} */ ({
with: account ?? /did:mailto:.*/,
can: '*',
});
const proofs = agent.proofs([query]);
/** @type {Record<API.DidMailto, Account>} */
const accounts = {};
/** @type {Record<string, API.Delegation>} */
const attestations = {};
for (const proof of proofs) {
const access = Delegation.allows(proof);
for (const [resource, abilities] of Object.entries(access)) {
if (AccountDID.is(resource) && abilities['*']) {
const id = /** @type {API.DidMailto} */ (resource);
const account = accounts[id] ||
(accounts[id] = new Account({ id, agent, proofs: [] }));
account.addProof(proof);
}
for (const settings of /** @type {{proof?:API.Link}[]} */ (abilities['ucan/attest'] || [])) {
const id = settings.proof;
if (id) {
attestations[`${id}`] = proof;
}
}
}
}
for (const account of Object.values(accounts)) {
for (const proof of account.proofs) {
const attestation = attestations[`${proof.cid}`];
if (attestation) {
account.addProof(attestation);
}
}
}
return accounts;
};
/**
* Attempts to obtains an account access by performing an authentication with
* the did:mailto account corresponding to given email. Process involves out
* of bound email verification, so this function returns a promise that will
* resolve to an account only after access has been granted by the email owner
* by clicking on the link in the email. If the link is not clicked within the
* authorization session time bounds (currently 15 minutes), the promise will
* resolve to an error.
*
* @param {{agent: API.Agent}} client
* @param {EmailAddress} email
* @param {object} [options]
* @param {AbortSignal} [options.signal]
* @param {API.AppName} [options.appName]
* @param {API.SSORequestParams} [options.sso] - SSO authentication (all fields required if provided)
* @returns {Promise<API.Result<Account, Error>>}
*/
export const login = async ({ agent }, email, options = {}) => {
const account = fromEmail(email);
// If we already have a session for this account we
// skip the authentication process, otherwise we will
// end up adding more UCAN proofs and attestations to
// the store which we then will be sending when using
// this account.
// Note: This is not a robust solution as there may be
// reasons to re-authenticate e.g. previous session is
// no longer valid because it was revoked. But dropping
// revoked UCANs from store is something we should do
// anyway.
const session = list({ agent }, { account })[account];
if (session) {
return { ok: session };
}
const result = await Access.request({ agent }, {
account,
access: Access.accountAccess,
appName: options.appName,
sso: options.sso,
});
const { ok: access, error } = result;
/* c8 ignore next 2 - don't know how to test this */
if (error) {
return { error };
}
else {
const { ok, error } = await access.claim({ signal: options.signal });
/* c8 ignore next 2 - don't know how to test this */
if (error) {
return { error };
}
else {
return { ok: new Account({ id: account, proofs: ok.proofs, agent }) };
}
}
};
/* c8 ignore start */
/**
* Attempts to obtain account access for an out of band authentication process.
* e.g. OAuth.
*
* Authentication is typically performed out of band by an OAuth provider. In
* the OAuth callback, a delegation for the requested capabilities is issued
* _from_ the email reported by the OAuth provider _to_ the agent. The service
* also issues an attestation for this delegation.
*
* These capabilities are then claimed (using `access/claim`) and the account
* email is derived from the delegation to the agent.
*
* @param {{agent: API.Agent}} client
* @param {object} input
* @param {API.Link} input.request Link to the `access/authorize` invocation.
* @param {API.UTCUnixTimestamp} input.expiration Seconds in UTC.
* @param {AbortSignal} [input.signal]
* @param {string} [input.receiptsEndpoint]
* @returns {Promise<API.Result<Account, Error>>}
*/
export const externalLogin = async ({ agent }, { request, expiration, ...options }) => {
const access = Access.createPendingAccessRequest({ agent }, { request, expiration });
const { ok, error } = await access.claim({ signal: options.signal });
/* c8 ignore next 2 - don't know how to test this */
if (error) {
return { error };
}
let attestedProof;
for (const p of ok.proofs) {
if (isUCANAttest(p)) {
attestedProof = p.capabilities[0].nb.proof;
break;
}
}
if (!attestedProof) {
return { error: new Error('missing attestation') };
}
let account;
for (const p of ok.proofs) {
if (p.cid.toString() === attestedProof.toString()) {
try {
account = Access.DIDMailto.fromString(p.issuer.did());
}
catch (err) {
return { error: new Error('invalid account DID', { cause: err }) };
}
break;
}
}
if (!account) {
return { error: new Error('missing attested delegation') };
}
return { ok: new Account({ id: account, proofs: ok.proofs, agent }) };
};
/* c8 ignore end */
/**
* @param {API.Delegation} d
* @returns {d is API.Delegation<[API.UCANAttest]>}
*/
const isUCANAttest = (d) => d.capabilities[0].can === UCAN.attest.can;
/**
* @typedef {object} Model
* @property {API.DidMailto} id
* @property {API.Agent} agent
* @property {API.Delegation[]} proofs
*/
export class Account {
/**
* @param {Model} model
*/
constructor(model) {
this.model = model;
this.plan = new AccountPlan(model);
}
get agent() {
return this.model.agent;
}
get proofs() {
return this.model.proofs;
}
did() {
return this.model.id;
}
toEmail() {
return toEmail(this.did());
}
/**
* @param {API.Delegation} proof
*/
addProof(proof) {
this.proofs.push(proof);
}
toJSON() {
return {
id: this.did(),
proofs: this.proofs
// we sort proofs to get a deterministic JSON representation.
.sort((a, b) => a.cid.toString().localeCompare(b.cid.toString()))
.map((proof) => proof.toJSON()),
};
}
/**
* Provisions given `space` with this account.
*
* @param {API.SpaceDID} space
* @param {object} input
* @param {API.ProviderDID} [input.provider]
* @param {API.Agent} [input.agent]
*/
provision(space, input = {}) {
return provision(this.agent, {
...input,
account: this.did(),
consumer: space,
proofs: this.proofs,
});
}
/**
* Saves account in the agent store so it can be accessed across sessions.
*
* @param {object} input
* @param {API.Agent} [input.agent]
*/
async save({ agent = this.agent } = {}) {
return await importAuthorization(agent, this);
}
}
export class AccountPlan {
/**
* @param {Model} model
*/
constructor(model) {
this.model = model;
}
/**
* Gets information about the plan associated with this account.
*
* @param {object} [options]
* @param {string} [options.nonce]
*/
async get(options) {
return await Plan.get(this.model, {
...options,
account: this.model.id,
proofs: this.model.proofs,
});
}
/**
* Sets the plan associated with this account.
*
* @param {import('@ucanto/interface').DID} productDID
* @param {object} [options]
* @param {string} [options.nonce]
*/
async set(productDID, options) {
return await Plan.set(this.model, {
...options,
account: this.model.id,
product: productDID,
proofs: this.model.proofs,
});
}
/**
* Waits for a payment plan to be selected.
* This method continuously checks the account's payment plan status
* at a specified interval until a valid plan is selected, or when the timeout is reached,
* or when the abort signal is aborted.
*
* @param {object} [options]
* @param {number} [options.interval] - The polling interval in milliseconds (default is 1000ms).
* @param {number} [options.timeout] - The maximum time to wait in milliseconds before throwing a timeout error (default is 15 minutes).
* @param {AbortSignal} [options.signal] - An optional AbortSignal to cancel the waiting process.
* @returns {Promise<import('@storacha/access').PlanGetSuccess>} - Resolves once a payment plan is selected within the timeout.
* @throws {Error} - Throws an error if there is an issue retrieving the payment plan or if the timeout is exceeded.
*/
async wait(options) {
const startTime = Date.now();
const interval = options?.interval || 1000; // 1 second
const timeout = options?.timeout || 60 * 15 * 1000; // 15 minutes
// eslint-disable-next-line no-constant-condition
while (true) {
const res = await this.get();
if (res.ok)
return res.ok;
if (res.error) {
if (res.error.name === 'PlanNotFound') {
continue;
}
throw new Error(`Error retrieving payment plan: ${JSON.stringify(res.error)}`);
}
if (Date.now() - startTime > timeout) {
throw new Error('Timeout: Payment plan selection took too long.');
}
if (options?.signal?.aborted) {
throw new Error('Aborted: Payment plan selection was aborted.');
}
console.log('Waiting for payment plan to be selected...');
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
/**
*
* @param {import('@storacha/access').AccountDID} accountDID
* @param {string} returnURL
* @param {object} [options]
* @param {string} [options.nonce]
*/
async createAdminSession(accountDID, returnURL, options) {
return await Plan.createAdminSession(this.model, {
...options,
account: accountDID,
returnURL,
});
}
/**
*
* @param {object} [options]
* @param {string} [options.nonce]
*/
async subscriptions(options) {
return await Subscription.list(this.model, {
...options,
account: this.model.id,
proofs: this.model.proofs,
});
}
}
//# sourceMappingURL=account.js.map