UNPKG

@totemorg/securelink

Version:

provide secure login link to web services

1,169 lines (929 loc) 29.8 kB
// UNCLASSIFIED /** Provides a private (end-to-end encrypted) message link between trusted clients via secure logins. This module documented in accordance with [jsdoc]{@link https://jsdoc.app/}. ## Env Vars LINK_PASS = passphrase to encrypt client information LINK_HOST = @name suffix of guest clients @module SECLINK @author [ACMESDS](https://totemorg.github.io) @requires [enums](https://www.npmjs.com/package/@totemorg/enums) @requires [socketio](https://www.npmjs.com/package/@totemorg/socketio) @requires [socket.io](https://www.npmjs.com/package/socket.io) @requires [crypto](https://nodejs.org/docs/latest/api/) @requires [cp](https://nodejs.org/docs/latest/api/) @example On the server: import {configLink,notifyClients} from "securelink"; configLink({ server: server, guest: {....} }); notifyClients( "update", { // send update request }); On the client: // <script src="securelink-client.js"></script> Sockets({ // establish sockets update: req => { // intercept update request console.log("update", req); }, // other sockets as needed ... }); */ const { LINK_PASS, LINK_HOST } = process.env; // globals import CRYPTO from "crypto"; import CP from "child_process"; // For legacy buggy socket.io //SOCKETIO = require('socket.io'), // Socket.io client mesh //SIOHUB = require('socket.io-clusterhub'); // Socket.io client mesh for multicore app //HUBIO = new (SIOHUB); // For working socketio import SOCKETIO from "@totemorg/socketio"; import { Copy, Each, Start, Log, sqlThread, neoThread, Notify } from "@totemorg/core"; var SIO = null; const minPassLen = 4, passwordLen = 16, //accountLen: 16, //sessionLen: 32, tokenLen = 32, expireTemp = [5,10], expirePerm = [365,0], expirePass = [1,0], TRACE = (msg, ...args) => `secure>>>${msg}`.trace( args ), ERR = err => new Error("securelink//"+err), SQL = { logVisit: "INSERT INTO openv.visits SET ?", setProfile: "UPDATE openv.profiles SET ? WHERE ?", //setOnline: "UPDATE openv.profiles SET online=1 WHERE Client=?", //addProfile: "INSERT INTO openv.profiles SET ?", getProfile: "SELECT * FROM openv.profiles WHERE Client=? LIMIT 1", //getSession: "SELECT * FROM openv.profiles WHERE SessionID=? LIMIT 1", addSession: "INSERT INTO openv.sessions SET ? ON DUPLICATE KEY UPDATE Count=Count+1,?", endSession: "SELECT max(timestampdiff(minute,Opened,now())) AS T, count(ID) AS N FROM openv.sessions WHERE Client=?", getRiddle: "SELECT * FROM openv.riddles WHERE ? LIMIT 1", getAccount: "SELECT *,aes_decrypt(unhex(Password),?) AS Password, hex(aes_encrypt(ID,?)) AS SessionID FROM openv.profiles WHERE Client=?", addAccount: "INSERT INTO openv.profiles SET ?, Password=hex(aes_encrypt(?,?))", //setPassword: "UPDATE openv.profiles SET Password=hex(aes_encrypt(?,?)), LinkToken=? WHERE Client=?", setPassword: "UPDATE openv.profiles SET Expires=null, Password=hex(aes_encrypt(?,?)) WHERE least(?,1)", getToken: "SELECT Client FROM openv.profiles WHERE TokenID=? AND Expires>now()", addToken: "UPDATE openv.profiles SET TokenID=? WHERE Client=?" //addSession: "UPDATE openv.profiles SET SessionID=? WHERE Client=?", //endSession: "UPDATE openv.profiles SET SessionID=null WHERE Client=?", }, ERROR = { /** * */ userOnline: ERR( "account already online" ), /** * */ userExpired: ERR( "account expired" ), /** * */ badReset: ERR("password reset failed"), /** * */ badPass: ERR("password not complex enough"), /** * */ badLogin: ERR("login failed"), /** * */ badOptions: ERR( "bad/missing options" ), //resetOk: ERR("password reset"), //loginBlocked: ERR("account blocked"), //noGuests: ERR("guests blocked"), //nonGuest: ERR("must be guest"), //userPending: ERR("account verification pending -- see email") }; /** Start a secure link and return the user profile corresponding for the supplied account/password login. The provided callback LOGIN(err,profile) where LOGIN = resetPassword || newAccount || newSession || guestSession determines the login session type being requested. @cfg {Function} @param {String} account credentials @param {String} password credentials @param {Function} cb callback (err,profile) to process the session */ export function Login (account,password,cb) { function passwordOk( pass ) { return (pass.length >= minPassLen); } function accountOk( acct ) { const [account,domain] = acct.split("@"); return bannedDomains[domain] ? false : true; } function getExpires( expire ) { const { round, random } = Math, [min,max] = expire, expires = new Date(); expires.setDate( expires.getDate() + min + round(random()*max) ); return expires; } function genToken( cb ) { function genCode( len, cb ) { return CRYPTO.randomBytes( len/2, (err, code) => cb( code.toString("hex") ) ); } genCode(tokenLen, token => cb(token, getExpires(expirePass)) ); } function newAccount( sql, account, password, cb) { const { guest } = SECLINK; //Log("new acct guest", guest); if ( guest ) // guest profiles allowed if (addAccount) if ( accountOk(account) ) genToken( (token,expires) => { //Log("token", token,expires); const trusted = isTrusted( account ), prof = Copy({ LinkToken: token, // secure validation token Trusted: trusted, // enable to allow client to send encrypted messages Challenge: !trusted, // enable to challenge user at session join Client: account, // client name/email Expires: expires // getExpires( trust ? expireTemp : expirePerm ) }, Copy( guest, {} )); sql.query( addAccount, [ prof, password, LINK_PASS ], (err,info) => { cb( (err || !info.insertId) ? null : prof ); }); }); else cb(null); else cb( null ); else { //TRACE("No account template"); cb( null ); } } function getProfile( sql, account, cb ) { //TRACE("get profile", account); sql.query( getAccount, [LINK_PASS, LINK_PASS, account], (err,profs) => { cb( err ? null : profs[0] || null ); }); } const { getAccount, addAccount, addToken, getToken, setPassword } = SQL; TRACE(cb.name, `${account}/${password}`); sqlThread( sql => { switch ( cb.name ) { case "resetPassword": // host requesting a password reset getProfile( sql, account, prof => { TRACE("login resetPass", [password, prof]); if ( prof ) { // have a valid user login const { LinkToken,Expires } = prof, chgreq = "password change request".tag("a", { href: "/link".tag("?", { login:account, token:LinkToken}) }); Notify({ to: account, subject: "Totem account verification", html: `Your ${chgreq} expires ${Expires}` }); cb( null, prof ); /* sql.query( setPassword, [password, LINK_PASS, secureToken, account], err => { TRACE("setpass", [account, password]); cb( err ? ERROR.badReset : null, prof ); }); */ /*genToken( sql, account, (token,expires) => { // gen a token account cb( ERROR.userPending ); Notify({ to: account, subject: "Totem password reset request", text: `Please login using ${token}/NEWPASSWORD by ${expires}` }); }); */ } else cb( ERROR.badLogin ); }); break; case "newAccount": // host requesting a new account //Log("login newAccount", [account, password]) ; newAccount( sql, account, password, prof => { if ( prof ) { const { LinkToken, Expires } = prof, valid = "login validation".tag("a", { href: "/link".tag("?", {login:account, token:LinkToken}) }); //Log("new acct", LinkToken, Expires); Notify({ to: account, subject: "Totem account verification", html: `Your ${valid} expires ${Expires}` }); cb( null, prof ); } else cb( ERROR.badLogin ); }); break; case "newSession": // host requesting an authorized session case "loginSession": case "authSession": getProfile( sql, account, prof => { if ( prof ) { // have a valid user login TRACE("login newSession", [password, prof.Password]); if ( prof.Banned ) // account banned for some reason cb( ERR(prof.Banned) ); /* else if ( prof.Online ) // account already online cb( ERROR.userOnline ); */ /* else if ( prof.Expires ? prof.Expires < new Date() : false ) cb( ERROR.userExpired ); */ /* else if ( prof.TokenID ) // password reset pending if ( passwordOk(password) ) sql.query( setPassword, [password, LINK_PASS, allowSecureConnect, account], err => { TRACE("setpass", account, password); cb( err ? ERROR.badReset : ERROR.resetOk ); }); else cb( ERROR.badPass ); */ else if (password == prof.Password) // login validated cb(null, prof); /*genSession( sql, account, (sessionID,expires) => cb(null, Copy({ sessionID: sessionID, expires: expires }, prof ) ));*/ else cb( ERROR.badLogin ); } else cb( ERROR.badLogin ); }); break; case "guestSession": // host requesting a guest session case "noauthSession": default: getProfile( sql, account, prof => { //Log("guestSession Login prof", prof); if ( prof ) if ( prof.Banned ) cb( ERR(prof.Banned) ); else cb( null, prof ); else newAccount( sql, account, "", prof => { //Log("new guest", prof); if ( prof ) cb( null, prof ); else cb( ERROR.badLogin ); }); /* else // foreign account sql.query( getToken, [account], (err,profs) => { // try to locate by tokenID if ( prof = profs[0] ) cb( null, prof ); else // try to locate by sessionID sql.query( getSession, [account], (err,profs) => { // try to locate by sessionID cb( err, err ? null : profs[0] || null ); }); }); */ }); } }); } /** Establish socketio channels for the SecureIntercom link (at store,restore,login,relay,status, sync,join,exit,content) and the insecure dbSync link (at select,update,insert,delete). */ export function configLink ( opts ) { function extendChallenger ( ) { //< Create antibot challenges. const { store, extend, map, captchaEndpoint } = challenge, { floor, random } = Math; TRACE( `Using ${extend} challenges from ${captchaEndpoint}` ); if ( captchaEndpoint ) for (var n=0; n<extend; n++) { var Q = { x: floor(random()*10), y: floor(random()*10), z: floor(random()*10), n: floor(random()*map["0"].length) }, A = { x: "".tag("img", {src: `${captchaEndpoint}/${Q.x}/${map[Q.x][Q.n]}.jpg`}), y: "".tag("img", {src: `${captchaEndpoint}/${Q.y}/${map[Q.y][Q.n]}.jpg`}), z: "".tag("img", {src: `${captchaEndpoint}/${Q.z}/${map[Q.z][Q.n]}.jpg`}) }; store.push( { Q: `${A.x} * ${A.y} + ${A.z}`, A: Q.x * Q.y + Q.z } ); } //TRACE(JSON.stringify(store)); } const { inspect, guest, server, challenge } = Copy( opts, SECLINK, "." ), { getProfile, addSession } = SQL; SIO = SOCKETIO(server); /*{ // socket.io defaults but can override using serveClient: true, // default true to prevent server from intercepting path path: "/socket.io" // default get-url that the client-side connect issues on calling io() }), */ TRACE( guest ? "Guest logins enabled" : "Guest logins disabled" ); if (guest) { // prepare default guest record for clone delete guest.ID; delete guest.LinkToken; delete guest.Password; } SIO.on("connect", socket => { // define socket listeners when client calls the socketio-client io() TRACE("listening to sockets"); socket.on("join", (req,socket) => { // join this client with other clients const {client,message,police,location,ip,agent,platform,room} = req; TRACE("join",{client,room,police}); sqlThread( sql => { if ( addSession ) { // log sessions if allowed const log = { // collect browser supplied info Opened: new Date(), Client: client, Location: location, IP: ip, Agent: agent, Platform: platform }; sql.query( addSession, [log,log] ); } sql.query(getProfile, [client], (err,profs) => { /* Create an antibot challenge and relay to client with specified profile parameters @param {String} client being challenged @param {Object} profile with a .Message riddle mask */ function getChallenge (profile, cb) { /** Check clients response req.query to a antibot challenge. @param {String} msg riddle mask contianing (riddle), (yesno), (ids), (rand), (card), (bio) keys @param {Array} rid List of riddles returned @param {Object} ids Hash of {id: value, ...} replaced by (ids) key */ function makeChallenge () { const { floor, random } = Math, rand = N => floor( random() * N ), N = store.length, randRiddle = () => store[rand(N)]; return N ? randRiddle() : {Q:"1+0", A:"1"}; } const { checkEndpoint, store } = challenge, { Message, Retries, Timeout } = profile, { Q,A } = makeChallenge( ); ///TRACE("genriddle", client, [Q,A]); sql.query("REPLACE INTO openv.riddles SET ?", { // track riddle Riddle: A, Client: client, Made: new Date(), Attempts: 0, maxAttempts: Retries }, (err,info) => cb({ // send challenge to client message: "??"+((Message||"What is: ").parse$(profile))+Q, retries: Retries || 5, timeout: Timeout || 30, callback: checkEndpoint, //passphrase: prof.LinkToken || "" }) ); } /* Get public keys for all clients in specified room */ function getMembers( room, cb ) { const keys = {}; //TRACE("========================", room); sql.query("SELECT Client,pubKey FROM openv.profiles WHERE Room=?", room ) .on("result", rec => keys[rec.Client] = rec.pubKey ) .on("end", () => cb( keys ) ); } //TRACE(err,profs); const prof = profs[0]; //Log(prof); if ( prof ) { const { Banned, LinkToken, Challenge } = prof; if ( Banned ) SIO.clients[client].emit("status", { // Notify client they are banned message: `${client} banned: ${Banned}` }); else if ( LinkToken ) // allowed to use secure link if ( Challenge ) // must solve challenge to use secure link getChallenge( prof, riddle => { // get a riddle for this challenge TRACE("challenge", riddle); //socket.emit("challenge", riddle); getMembers( room, pubKeys => { // get public keys from all online clients riddle.pubKeys = pubKeys; riddle.passphrase = LinkToken; TRACE("start riddle"); SIO.clients[client].emit("start", riddle); }); }); else getMembers( room, pubKeys => { // get public keys of clients in this room SIO.clients[client].emit("start", { // start secure link with passphrase of client's link token message: `Welcome ${client}`, from: "secureLink", passphrase: LinkToken, pubKeys: pubKeys }); }); else getMembers( room, pubKeys => { // get public keys of clients in this room SIO.clients[client].emit("start", { // start insecure link with no passphrase message: `Welcome ${client}`, from: "secureLink", passphrase: "", pubKeys: pubKeys }); }); } else SIO.clients[client].emit("status", { message: `Cant find ${client}` }); try { } catch (err) { TRACE(err,"join failed"); } }); }); }); socket.on("store", (req,socket) => { // store client's message history const {client,ip,location,message} = req; TRACE("store client history"); sqlThread( sql => { sql.query( "INSERT INTO openv.saves SET ? ON DUPLICATE KEY UPDATE Content=?", [{Client: client,Content:message}, message], err => { try { SIO.clients[client].emit("status", { message: err ? "failed to store history" : "history stored" }); } catch (err) { TRACE(err,"History load failed"); } }); }); }); socket.on("restore", (req,socket) => { // restore client's message history const {client,ip,location,message} = req; TRACE("restore client history"); sqlThread( sql => { sql.query("SELECT Content FROM openv.saves WHERE Client=? LIMIT 1", [client], (err,recs) => { //TRACE("restore",err,recs); try { if ( rec = err ? null : recs[0] ) SIO.clients[client].emit("content", { message: rec.Content }); else SIO.clients[client].emit("status", { message: "cant restore history" }); } catch (err) { TRACE(err,"History restore failed"); } }); }); }); socket.on("login", (req,socket) => { // login/logoff/reset client const { login, client, user, pass } = req, { setOnline, setPassword } = SQL; TRACE("login", {client,user,pass} ); try { if ( user ) switch ( pass || "" ) { /* case "reset": Login( password, function resetPassword(status) { TRACE("socket resetPassword", status); SIO.clients[password].emit("status", { message: status, }); }); break; */ case "logout": sqlThread( sql => sql.query("UPDATE openv.profiles SET online=0 WHERE Client=?", client) ); SIO.emit("remove", { // broadcast client's pubKey to everyone client: client }); break; case "reset": Login( user, pass, function resetPassword(err,prof) { TRACE("resetPassword", err, prof); SIO.clients[client].emit("status", { message: err ? "Password reset failed" : "Reset verification sent" }); }); break; case "new": Login( user, pass, function newAccount(err,prof) { TRACE("newAccount", err, prof); if ( err ) SIO.clients[client].emit("status", { // return error msg to client message: "Account creation failed" }); else SIO.clients[client].emit("status", { // return login ok msg to client with convenience cookie message: "Account verification sent" //cookie: `session=${prof.Client}; expires=${prof.Expires.toUTCString()}; path=/` //passphrase: prof.LinkToken // nonnull if account allowed to use secureLink }); }); break; case "": SIO.clients[client].emit("status", { // return error msg to client message: "Login missing password" }); break; default: Login( user, pass, function newSession(err,prof) { TRACE("newSession", err, prof); if ( err ) SIO.clients[client].emit("status", { // return error msg to client message: "Login failed" }); else { if ( setOnline ) sqlThread( sql => sql.query(setOnline, user) ); SIO.clients[client].emit("status", { // return login ok msg to client with convenience cookie message: "Login completed", cookie: `session=${prof.Client}; expires=${prof.Expires}; path=/` //passphrase: prof.LinkToken // nonnull if account allowed to use secureLink }); SIO.emit("remove", { // Notify all clients to remove this client client: client }); SIO.emit("accept", { // Notify all clients to accept this client client: user, pubKey: prof.pubKey }); } }); } else if ( setPassword ) sqlThread( sql => { sql.query( setPassword, [ pass, LINK_PASS, {Client:client} ], (err,info) => { //Log(err,info); SIO.clients[client].emit("status", { message: err ? "Password reset failed" : "Password reset" }); }); }); else SIO.clients[client].emit("status", { // return error msg to client message: "Login invalid" }); } catch (err) { throw err; } }); socket.on("relay", (req,socket) => { // relay message to another client const { from,message,to,police,route } = req; TRACE("relay client message", req); if ( message.indexOf("PGP PGP MESSAGE")>=0 ) // just relay encrypted messages SIO.emitOthers(from, "relay", { // broadcast message to everyone message: message, from: from, to: to }); else if ( police ) // relay scored messages that are unencrypted inspect( message, to, score => { sqlThread( sql => { sql.query( endSession, [from], (err,recs) => { const {N,T} = err ? {N:0,T:1} : recs[0], lambda = N/T; //TRACE("inspection", score, lambda, hops); if ( police ) // if tracking permitted by client then ... sql.query( "INSERT INTO openv.relays SET ?", { Message: message, Rx: new Date(), From: from, To: to, New: 1, Score: JSON.stringify(score) } ); SIO.emitOthers(from, "relay", { // broadcast message to everyone message: message, score: Copy(score, { Activity:lambda, Hopping:0 }), from: from, to: to }); }); }); }); else // relay message as-is SIO.emitOthers(from, "relay", { // broadcast message to everyone message: message, from: from, to: to }); }); socket.on("announce", req => { // announce arrival to other clients TRACE("announce client"); const { client,pubKey } = req; sqlThread( sql => { sql.query( "UPDATE openv.profiles SET pubKey=?,Online=1 WHERE Client=?", [pubKey,client] ); if (0) sql.query( "SELECT Client,pubKey FROM openv.profiles WHERE Client!=? AND length(pubKey)", [client] ) .on("result", rec => { TRACE("send sync to me"); socket.emit("sync", { // broadcast other pubKeys to this client message: rec.pubKey, from: rec.Client, to: client }); }); }); SIO.emit("accept", { // broadcast client's pubKey to everyone pubKey: pubKey, client: client, }); }); socket.on("kill", (req,socket) => { // kill session TRACE("kill client session"); socket.end(); }); }); /* // for debugging SIO.on("connect_error", err => { TRACE(err); }); SIO.on("disconnection", socket => { TRACE(">>DISCONNECT CLIENT"); }); */ extendChallenger ( ); return SECLINK; } /** */ export function notifyClients (channel, msg) { if (SIO) SIO.emit(channel,msg); } /** Reset account request with callback cb( err ). */ function resetClient(client, token, cb) { const {setPassword} = SQL; function genToken( cb ) { function genCode( len, cb ) { return CRYPTO.randomBytes( len/2, (err, code) => cb( code.toString("hex") ) ); } genCode( passwordLen, cb ); } if ( setPassword ) genToken( pass => { TRACE(setPassword, pass, token); sqlThread( sql => { sql.query( setPassword, [ pass, LINK_PASS, {Client:client, LinkToken:token} ], (err,info) => { //Log(err,info); cb( (err || !info.affectedRows) ? ERR(ERROR.badReset) : null ); }); }); }); else cb( null ); } /** Test response of client during a session challenge. @param {String} client name of client being challenged @param {String} guess guess provided by client @param {Function} res response callback( "pass" || "fail" || "retry" ) */ function testClient(client,guess,res) { const { getRiddle }= SQL; if ( getRiddle ) sqlThread( sql => { sql.query(getRiddle, {Client:client}, (err,recs) => { let rec = recs[0]; if ( rec ) { const ID = {ID:rec.ID}, Guess = (guess+"").replace(/ /g,""); TRACE("riddle",rec); if (rec.Riddle == Guess) { res( "pass" ); sql.query("DELETE FROM openv.riddles WHERE ?",ID); } else if (rec.Attempts > rec.maxAttempts) { res( "fail" ); sql.query("DELETE FROM openv.riddles WHERE ?",ID); } else { res( "retry" ); sql.query("UPDATE openv.riddles SET Attempts=Attempts+1 WHERE ?",ID); } } else res( "fail" ); }); }); else res( "pass" ); } export const bannedDomains = { "tempail.com": 1, "temp-mail.io":1, "anonymmail.net":1, "mail.tm": 1, "tempmail.ninja":1, "getnada.com":1, "protonmail.com":1, "maildrop.cc":1, "":1, //"guest": 1 }; const SECLINK = { //sqlThread: () => { throw ERR("sqlThread not configured"); }, server: null, // established on config - disabled by default guest: null, // guest profile - disabled by default challenge: { //< for antibot client challenger extend: 0, // number to add to store store: [], // challenge store checkEndpoint: "Undefined checkEndpoint", // endpoint to test client response captchaEndpoint: "Undefined captchaEndpoint", // endpoint to provide images map: [] }, inspect: (doc,to,cb) => { // throw ERR("securelink inspect not configured"); }, } /** Test if an account is "trusted" to use the secure com channel. */ function isTrusted(account) { return !account.startsWith("guest@"); } export const agents = { /** Endpoint to validate clients response to an antibot challenge, login client, set client focus, invite/accep/enter a client to a room, or promote client to a user. @param {requestHash} req session request @param {responseCallback} res session response */ link: (req,res) => { const { query, sql, type, body, action, client, profile } = req, { login , guess, token, invite, accept, detach, focus, enter, promote } = (action=="select") ? query : body, { Login } = profile, { setProfile, logVisit } = SQL; Log( "/link", {client,token,guess,focus,invite,accept,login,enter,promote,login,guess,token}); neoThread( neo => { if ( focus ) sql.query( setProfile, [{Focus: focus}, {Client: client}], err => res(err||"ok") ); else if (invite) { res("ok"); invite.split(",").forEach( user => neo.invite( client, user ) ); } else if (accept) { res("ok"); accept.split(",").forEach( user => neo.accept( client, user ) ); } else if (promote) { if ( promote.indexOf("@guest") >= 0 ) res( "A guest cant be promoted to User" ); else if (Login) res( "Already promoted" ); else { const login = "junk"; sql.query( setProfile, {Login: login}, err => Log("set login", err)); CP.exec( `mkdir ./config/users/${login}`, err => { Log("mkdir", err); CP.exec( `cp -r ./config/users/template/* ./config/users/${login}`, err => Log("cp", err) ); }); } } else if (login) if (guess) testClient(login,guess,res); else if (token) resetClient( login, token, res( err ? null : "password reset accepted" )); else res( ERROR.badLogin ); else if (enter) { const //pairs = {}, me = "brian james", scanRoom = enter.replace("site",""); sql.query( logVisit, { Entered: new Date(), Client: client, Room: enter }); sql.query( setProfile, [{Room: enter}, {Client: client}], err => { sql.getClients( scanRoom, clients => { //Log("get==============", clients, scanRoom); neo.getNetwork( "users", me, (err, {links}) => { // get network attached to me //net.forEach( pair => pairs[pair.a.name+"->"+pair.b.name] = pair.r ); //Log( {User, net, clients, pairs} ); //Log( JSON.stringify(net)); //Log( JSON.stringify(links) ); if (err) res( err ); else res( clients.map( client => { client.icons = ["avatar"].map( png => "".tag("img",{src:`/config/users/${client.Login}/icons/${png}.png`,width:20,height:20}) ).join(""); if (client.Login != "guest") { client.links = "Contact".link( "mailto:"+client.Client ); client.links += "|"+"Focus".link( "/link".tag("?", {focus: client.Login })); client.links += "|"+"Meet".link( `/${client.Room}.view` ); links.forEach( link => { switch ( link.name ) { case "invite": client.links += "|"+"Accept".link( "/link".tag("?", {accept: client.Name })); break; case "accept": client.links += "|"+"Retract".link( "/link".tag("?", {retract: client.Name })); break; default: client.links += "|"+"Invite".link( "/link".tag("?", {invite: client.Name })); } }); } return client; }) ); }); }); }); } else res( ERROR.badOptions ); }); }, } Start("securelink", { "env": { LINK_PASS } }); // UNCLASSIFIED