timeld-cli
Version:
Live shared timesheets command interface
178 lines (166 loc) • 5.96 kB
JavaScript
import { signJwt } from '@m-ld/io-web-runtime/dist/server/auth';
import isEmail from 'validator/lib/isEmail.js';
import isInt from 'validator/lib/isInt.js';
import isJWT from 'validator/lib/isJWT.js';
import Cryptr from 'cryptr';
import { AuthKey, BaseGateway, resolveGateway } from 'timeld-common';
import { consume } from 'rx-flowable/consume';
import { flatMap } from 'rx-flowable/operators';
import setupFetch from '@zeit/fetch';
import ndjson from 'ndjson';
export default class GatewayClient extends BaseGateway {
/**
* @param {string} gateway
* @param {string} user
* @param {string} [key] available authorisation key, if missing, {@link activate}
* must be called before other methods
* @param {import('@zeit/fetch').Fetch} fetch injected fetch
*/
constructor({
gateway,
user,
auth: { key } = {}
}, fetch = setupFetch()) {
const { root, domainName } = resolveGateway(gateway);
super(domainName);
this.user = user;
this.authKey = key != null ? AuthKey.fromString(key) : null;
this.gatewayRoot = root;
// noinspection HttpUrlsUsage
/**
* Resolve our user name against the gateway to get the canonical user URI.
* Gateway-based URIs use HTTP by default (see also {@link AccountOwnedId}).
*/
// This leaves an absolute URI alone
this.principalId = new URL(this.user, `http://${this.domainName}`).toString();
this.fetch = fetch;
}
/**
* @param {string} path path after `api` to fetch
* @param {import('@zeit/fetch').FetchOptions} options fetch options
* @param {false} [options.user] turn off user parameter
* @param {string|false} [options.jwt] provide or turn off JWT bearer auth
* @param {object} [options.params] query parameters
* @param {*} [options.json] JSON body (default POST)
* @returns {Promise<import('@zeit/fetch').Response>}
*/
async fetchApi(path, options = {}) {
// Add the given JWT or a user JWT, unless disabled
if (options.jwt !== false)
(options.headers ||= {}).Authorization =
`Bearer ${options.jwt || await this.userJwt()}`;
// Add the user as a query parameter, unless disabled
if (options.user !== false)
(options.params ||= {}).user = this.user;
// noinspection JSCheckFunctionSignatures
const url = new URL(`api/${path}`, await this.gatewayRoot);
// Add the query parameters to the URL
if (options.params != null)
Object.entries(options.params).forEach(([name, value]) =>
url.searchParams.append(name, `${value}`));
// Posting JSON
if (options.json != null) {
options.method ||= 'POST';
(options.headers ||= {})['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.json);
}
return this.fetch(url.toString(), options);
}
/**
* @param {(question: string) => Promise<string>} ask
*/
async activate(ask) {
if (this.authKey == null) {
const email = await ask(
'Please enter your email address to register this device: ');
if (!isEmail(email))
throw `"${email}" is not a valid email address`;
const { jwe } = await this.fetchApi(`jwe/${this.user}`,
{ params: { email }, jwt: false, user: false })
.then(checkSuccessRes).then(resJson);
const code = await ask(
'Please enter the activation code we sent you: ');
if (!isInt(code, { min: 111111, max: 999999 }))
throw `"${code}" is not a valid activation code`;
const jwt = new Cryptr(code).decrypt(jwe);
if (!isJWT(jwt))
throw 'Sorry, that code was incorrect, please start again.';
const { key } = await this.fetchApi(`key/${this.user}`,
{ jwt, user: false })
.then(checkSuccessRes).then(resJson);
this.authKey = AuthKey.fromString(key);
}
}
/**
* @returns {{auth: {key: string}}}
*/
get accessConfig() {
return { auth: { key: this.authKey.toString() } };
}
/**
* @param {string} account to which the timesheet belongs
* @param {string} timesheet the timesheet name
* @returns {Promise<import('@m-ld/m-ld').MeldConfig>} configuration for
* timesheet domain
*/
async config(account, timesheet) {
return this.fetchApi(`cfg/${account}/tsh/${timesheet}`)
.then(checkSuccessRes).then(resJson);
}
/**
* @param {import('@m-ld/m-ld').Read} pattern
* @returns {Results} results
*/
read(pattern) {
return consume(this.fetchApi('read', { json: pattern }).then(checkSuccessRes))
.pipe(flatMap(res => consume(res.body.pipe(ndjson.parse()))));
}
/**
* @param {import('@m-ld/m-ld').Write} pattern
*/
async write(pattern) {
await checkSuccessRes(await this.fetchApi('write', { json: pattern }));
}
/**
* Reports on the given timesheet OR project with the given ID.
*
* @param {string} account to which the project or timesheet belongs
* @param {string} owned project or timesheet ID
* @returns {Results} results subjects
* @see Gateway#report
*/
report(account, owned) {
return consume(this.fetchApi(`rpt/${account}/own/${owned}`).then(checkSuccessRes))
.pipe(flatMap(res => consume(res.body.pipe(ndjson.parse()))));
}
/**
* User JWT suitable for authenticating to the gateway
* @returns {Promise<string>} JWT
*/
async userJwt() {
const { secret, keyid } = this.authKey;
return await signJwt({}, secret, {
subject: this.user, keyid, expiresIn: '1m'
});
}
}
/**
* @param {import('@zeit/fetch').Response} res
* @returns {import('@zeit/fetch').Response}
*/
const checkSuccessRes = async res => {
if (res.ok)
return res;
else
throw (await res.json().catch(() => ({})))?.message || res.statusText;
};
/**
* @param {import('@zeit/fetch').Response} res
* @returns {Promise<*>}
*/
const resJson = async res => {
const json = await res.json();
if (json == null)
throw `No JSON returned from ${res.url}`;
return json;
};