clay-server 2.27.0-beta.8 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/lib/daemon-projects.js +164 -0
- package/lib/daemon.js +13 -126
- package/lib/mates-identity.js +132 -0
- package/lib/mates-knowledge.js +113 -0
- package/lib/mates-prompts.js +398 -0
- package/lib/mates.js +40 -599
- package/lib/project-connection.js +2 -0
- package/lib/project-debate.js +19 -12
- package/lib/project-http.js +4 -2
- package/lib/project-loop.js +110 -48
- package/lib/project-mate-interaction.js +4 -0
- package/lib/project-notifications.js +210 -0
- package/lib/project-sessions.js +5 -2
- package/lib/project-user-message.js +2 -1
- package/lib/project.js +26 -2
- package/lib/public/app.js +1193 -8521
- package/lib/public/css/command-palette.css +14 -0
- package/lib/public/css/loop.css +301 -0
- package/lib/public/css/notifications-center.css +190 -0
- package/lib/public/css/rewind.css +6 -0
- package/lib/public/index.html +89 -35
- package/lib/public/modules/app-connection.js +160 -0
- package/lib/public/modules/app-cursors.js +473 -0
- package/lib/public/modules/app-debate-ui.js +389 -0
- package/lib/public/modules/app-dm.js +627 -0
- package/lib/public/modules/app-favicon.js +212 -0
- package/lib/public/modules/app-header.js +229 -0
- package/lib/public/modules/app-home-hub.js +600 -0
- package/lib/public/modules/app-loop-ui.js +589 -0
- package/lib/public/modules/app-loop-wizard.js +439 -0
- package/lib/public/modules/app-messages.js +1560 -0
- package/lib/public/modules/app-misc.js +299 -0
- package/lib/public/modules/app-notifications.js +372 -0
- package/lib/public/modules/app-panels.js +888 -0
- package/lib/public/modules/app-projects.js +798 -0
- package/lib/public/modules/app-rate-limit.js +451 -0
- package/lib/public/modules/app-rendering.js +597 -0
- package/lib/public/modules/app-skills-install.js +234 -0
- package/lib/public/modules/command-palette.js +27 -4
- package/lib/public/modules/input.js +31 -20
- package/lib/public/modules/scheduler-config.js +1532 -0
- package/lib/public/modules/scheduler-history.js +79 -0
- package/lib/public/modules/scheduler.js +33 -1554
- package/lib/public/modules/session-search.js +13 -1
- package/lib/public/modules/sidebar-mates.js +812 -0
- package/lib/public/modules/sidebar-mobile.js +1269 -0
- package/lib/public/modules/sidebar-projects.js +1449 -0
- package/lib/public/modules/sidebar-sessions.js +986 -0
- package/lib/public/modules/sidebar.js +232 -4591
- package/lib/public/modules/store.js +27 -0
- package/lib/public/modules/ws-ref.js +7 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +96 -717
- package/lib/sdk-message-processor.js +587 -0
- package/lib/sdk-message-queue.js +42 -0
- package/lib/sdk-skill-discovery.js +131 -0
- package/lib/server-admin.js +712 -0
- package/lib/server-auth.js +737 -0
- package/lib/server-dm.js +221 -0
- package/lib/server-mates.js +281 -0
- package/lib/server-palette.js +110 -0
- package/lib/server-settings.js +479 -0
- package/lib/server-skills.js +280 -0
- package/lib/server.js +246 -2755
- package/lib/sessions.js +11 -4
- package/lib/users-auth.js +146 -0
- package/lib/users-permissions.js +118 -0
- package/lib/users-preferences.js +210 -0
- package/lib/users.js +48 -398
- package/lib/ws-schema.js +498 -0
- package/package.json +1 -1
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
var path = require("path");
|
|
4
|
+
var { CONFIG_DIR } = require("./config");
|
|
5
|
+
var _isDevMode = require("./config").isDevMode;
|
|
6
|
+
|
|
7
|
+
// --- PIN hashing ---
|
|
8
|
+
|
|
9
|
+
function generateAuthToken(pin) {
|
|
10
|
+
var salt = crypto.randomBytes(16).toString("hex");
|
|
11
|
+
var hash = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
|
|
12
|
+
return salt + ":" + hash;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function verifyPin(pin, storedHash) {
|
|
16
|
+
if (!storedHash) return false;
|
|
17
|
+
// New scrypt format: salt_hex:hash_hex (contains colon)
|
|
18
|
+
if (storedHash.indexOf(":") !== -1) {
|
|
19
|
+
var parts = storedHash.split(":");
|
|
20
|
+
var salt = parts[0];
|
|
21
|
+
var hash = parts[1];
|
|
22
|
+
var derived = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
|
|
23
|
+
return crypto.timingSafeEqual(Buffer.from(derived, "hex"), Buffer.from(hash, "hex"));
|
|
24
|
+
}
|
|
25
|
+
// Legacy SHA256 format (no colon)
|
|
26
|
+
var legacyHash = crypto.createHash("sha256").update("clay:" + pin).digest("hex");
|
|
27
|
+
var match = crypto.timingSafeEqual(Buffer.from(legacyHash, "hex"), Buffer.from(storedHash, "hex"));
|
|
28
|
+
return match;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Cookie helpers ---
|
|
32
|
+
|
|
33
|
+
function parseCookies(req) {
|
|
34
|
+
var cookies = {};
|
|
35
|
+
var header = req.headers.cookie || "";
|
|
36
|
+
header.split(";").forEach(function (part) {
|
|
37
|
+
var pair = part.trim().split("=");
|
|
38
|
+
if (pair.length === 2) cookies[pair[0]] = pair[1];
|
|
39
|
+
});
|
|
40
|
+
return cookies;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isAuthed(req, authToken) {
|
|
44
|
+
if (!authToken) return true;
|
|
45
|
+
var cookies = parseCookies(req);
|
|
46
|
+
return cookies["relay_auth"] === authToken;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- PIN rate limiting ---
|
|
50
|
+
|
|
51
|
+
var PIN_MAX_ATTEMPTS = 5;
|
|
52
|
+
var PIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
53
|
+
|
|
54
|
+
function attachAuth(ctx) {
|
|
55
|
+
var users = ctx.users;
|
|
56
|
+
var smtp = ctx.smtp;
|
|
57
|
+
var pages = ctx.pages;
|
|
58
|
+
var tlsOptions = ctx.tlsOptions;
|
|
59
|
+
var osUsers = ctx.osUsers;
|
|
60
|
+
var provisionLinuxUser = ctx.provisionLinuxUser;
|
|
61
|
+
var onUpgradePin = ctx.onUpgradePin;
|
|
62
|
+
var onUserProvisioned = ctx.onUserProvisioned;
|
|
63
|
+
|
|
64
|
+
var authToken = ctx.pinHash || null;
|
|
65
|
+
|
|
66
|
+
// --- Multi-user auth tokens (persisted to disk) ---
|
|
67
|
+
var TOKENS_FILE = path.join(CONFIG_DIR, _isDevMode ? "auth-tokens-dev.json" : "auth-tokens.json");
|
|
68
|
+
var MULTI_USER_COOKIE = _isDevMode ? "relay_auth_user_dev" : "relay_auth_user";
|
|
69
|
+
var multiUserTokens = {};
|
|
70
|
+
|
|
71
|
+
function loadTokens() {
|
|
72
|
+
try {
|
|
73
|
+
var raw = fs.readFileSync(TOKENS_FILE, "utf8");
|
|
74
|
+
var data = JSON.parse(raw);
|
|
75
|
+
if (data && typeof data === "object") {
|
|
76
|
+
multiUserTokens = data;
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
multiUserTokens = {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function saveTokens() {
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(path.dirname(TOKENS_FILE), { recursive: true });
|
|
86
|
+
var tmpPath = TOKENS_FILE + ".tmp";
|
|
87
|
+
fs.writeFileSync(tmpPath, JSON.stringify(multiUserTokens));
|
|
88
|
+
fs.renameSync(tmpPath, TOKENS_FILE);
|
|
89
|
+
} catch (e) {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
loadTokens();
|
|
93
|
+
|
|
94
|
+
function createMultiUserSession(userId) {
|
|
95
|
+
var token = users.generateUserAuthToken(userId);
|
|
96
|
+
multiUserTokens[token] = userId;
|
|
97
|
+
saveTokens();
|
|
98
|
+
var cookie = MULTI_USER_COOKIE + "=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : "");
|
|
99
|
+
return { token: token, cookie: cookie };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getMultiUserFromReq(req) {
|
|
103
|
+
var cookies = parseCookies(req);
|
|
104
|
+
var token = cookies[MULTI_USER_COOKIE];
|
|
105
|
+
if (!token) return null;
|
|
106
|
+
var userId = multiUserTokens[token];
|
|
107
|
+
if (!userId) return null;
|
|
108
|
+
var user = users.findUserById(userId);
|
|
109
|
+
return user || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isMultiUserAuthed(req) {
|
|
113
|
+
return !!getMultiUserFromReq(req);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function revokeUserTokens(userId) {
|
|
117
|
+
var changed = false;
|
|
118
|
+
for (var token in multiUserTokens) {
|
|
119
|
+
if (multiUserTokens[token] === userId) {
|
|
120
|
+
delete multiUserTokens[token];
|
|
121
|
+
changed = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (changed) saveTokens();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- PIN rate limiting (per-instance state) ---
|
|
128
|
+
var pinAttempts = {};
|
|
129
|
+
|
|
130
|
+
function checkPinRateLimit(ip) {
|
|
131
|
+
var entry = pinAttempts[ip];
|
|
132
|
+
if (!entry) return null;
|
|
133
|
+
if (entry.count >= PIN_MAX_ATTEMPTS) {
|
|
134
|
+
var elapsed = Date.now() - entry.lastAttempt;
|
|
135
|
+
if (elapsed < PIN_LOCKOUT_MS) {
|
|
136
|
+
return Math.ceil((PIN_LOCKOUT_MS - elapsed) / 1000);
|
|
137
|
+
}
|
|
138
|
+
delete pinAttempts[ip];
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function recordPinFailure(ip) {
|
|
144
|
+
if (!pinAttempts[ip]) pinAttempts[ip] = { count: 0, lastAttempt: 0 };
|
|
145
|
+
pinAttempts[ip].count++;
|
|
146
|
+
pinAttempts[ip].lastAttempt = Date.now();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearPinFailures(ip) {
|
|
150
|
+
delete pinAttempts[ip];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Admin password recovery (in-memory, one-time) ---
|
|
154
|
+
var recovery = null;
|
|
155
|
+
|
|
156
|
+
function setRecovery(urlPath, password) {
|
|
157
|
+
recovery = { urlPath: urlPath, password: password };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function clearRecovery() {
|
|
161
|
+
recovery = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function recoveryPageHtml() {
|
|
165
|
+
return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
166
|
+
+ '<title>Admin Password Recovery</title>'
|
|
167
|
+
+ '<style>'
|
|
168
|
+
+ '*{margin:0;padding:0;box-sizing:border-box}'
|
|
169
|
+
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh}'
|
|
170
|
+
+ '.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:32px;width:100%;max-width:380px}'
|
|
171
|
+
+ 'h1{font-size:18px;font-weight:600;margin-bottom:4px}'
|
|
172
|
+
+ '.sub{font-size:13px;color:#737373;margin-bottom:24px}'
|
|
173
|
+
+ 'label{display:block;font-size:13px;color:#a3a3a3;margin-bottom:6px}'
|
|
174
|
+
+ 'input{width:100%;padding:10px 12px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#e5e5e5;font-size:14px;outline:none;margin-bottom:16px}'
|
|
175
|
+
+ 'input:focus{border-color:#7c3aed}'
|
|
176
|
+
+ 'button{width:100%;padding:10px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer}'
|
|
177
|
+
+ 'button:hover{background:#6d28d9}'
|
|
178
|
+
+ 'button:disabled{opacity:.5;cursor:not-allowed}'
|
|
179
|
+
+ '.error{color:#ef4444;font-size:13px;margin-bottom:12px;display:none}'
|
|
180
|
+
+ '.success{text-align:center;color:#22c55e;font-size:15px}'
|
|
181
|
+
+ '.hidden{display:none}'
|
|
182
|
+
+ '</style></head><body>'
|
|
183
|
+
+ '<div class="card">'
|
|
184
|
+
+ '<div id="step-verify">'
|
|
185
|
+
+ '<h1>Admin Recovery</h1>'
|
|
186
|
+
+ '<p class="sub">Enter the recovery password shown in your terminal.</p>'
|
|
187
|
+
+ '<div id="err-verify" class="error"></div>'
|
|
188
|
+
+ '<label for="recovery-pw">Recovery password</label>'
|
|
189
|
+
+ '<input id="recovery-pw" type="text" autocomplete="off" spellcheck="false" autofocus>'
|
|
190
|
+
+ '<button id="btn-verify">Verify</button>'
|
|
191
|
+
+ '</div>'
|
|
192
|
+
+ '<div id="step-reset" class="hidden">'
|
|
193
|
+
+ '<h1>Reset Admin PIN</h1>'
|
|
194
|
+
+ '<p class="sub">Enter a new 6-digit PIN for the admin account.</p>'
|
|
195
|
+
+ '<div id="err-reset" class="error"></div>'
|
|
196
|
+
+ '<label for="new-pin">New PIN</label>'
|
|
197
|
+
+ '<input id="new-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
|
|
198
|
+
+ '<label for="confirm-pin">Confirm PIN</label>'
|
|
199
|
+
+ '<input id="confirm-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
|
|
200
|
+
+ '<button id="btn-reset">Reset PIN</button>'
|
|
201
|
+
+ '</div>'
|
|
202
|
+
+ '<div id="step-done" class="hidden">'
|
|
203
|
+
+ '<p class="success">PIN has been reset successfully. You can now log in with your new PIN.</p>'
|
|
204
|
+
+ '</div>'
|
|
205
|
+
+ '</div>'
|
|
206
|
+
+ '<script>'
|
|
207
|
+
+ 'var pw="";\n'
|
|
208
|
+
+ 'document.getElementById("btn-verify").onclick=function(){\n'
|
|
209
|
+
+ ' var el=document.getElementById("recovery-pw");\n'
|
|
210
|
+
+ ' pw=el.value.trim();\n'
|
|
211
|
+
+ ' if(!pw)return;\n'
|
|
212
|
+
+ ' this.disabled=true;\n'
|
|
213
|
+
+ ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"verify",password:pw})})\n'
|
|
214
|
+
+ ' .then(function(r){return r.json()}).then(function(d){\n'
|
|
215
|
+
+ ' if(d.ok){document.getElementById("step-verify").classList.add("hidden");document.getElementById("step-reset").classList.remove("hidden");document.getElementById("new-pin").focus()}\n'
|
|
216
|
+
+ ' else{var e=document.getElementById("err-verify");e.textContent=d.error||"Invalid password";e.style.display="block";document.getElementById("btn-verify").disabled=false}\n'
|
|
217
|
+
+ ' }).catch(function(){document.getElementById("btn-verify").disabled=false})\n'
|
|
218
|
+
+ '};\n'
|
|
219
|
+
+ 'document.getElementById("recovery-pw").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-verify").click()});\n'
|
|
220
|
+
+ 'document.getElementById("btn-reset").onclick=function(){\n'
|
|
221
|
+
+ ' var pin=document.getElementById("new-pin").value;\n'
|
|
222
|
+
+ ' var confirm=document.getElementById("confirm-pin").value;\n'
|
|
223
|
+
+ ' var errEl=document.getElementById("err-reset");\n'
|
|
224
|
+
+ ' if(!/^\\d{6}$/.test(pin)){errEl.textContent="PIN must be exactly 6 digits";errEl.style.display="block";return}\n'
|
|
225
|
+
+ ' if(pin!==confirm){errEl.textContent="PINs do not match";errEl.style.display="block";return}\n'
|
|
226
|
+
+ ' this.disabled=true;errEl.style.display="none";\n'
|
|
227
|
+
+ ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"reset",password:pw,pin:pin})})\n'
|
|
228
|
+
+ ' .then(function(r){return r.json()}).then(function(d){\n'
|
|
229
|
+
+ ' if(d.ok){document.getElementById("step-reset").classList.add("hidden");document.getElementById("step-done").classList.remove("hidden")}\n'
|
|
230
|
+
+ ' else{errEl.textContent=d.error||"Failed";errEl.style.display="block";document.getElementById("btn-reset").disabled=false}\n'
|
|
231
|
+
+ ' }).catch(function(){document.getElementById("btn-reset").disabled=false})\n'
|
|
232
|
+
+ '};\n'
|
|
233
|
+
+ 'document.getElementById("confirm-pin").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-reset").click()});\n'
|
|
234
|
+
+ '</script></body></html>';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Auth page selection ---
|
|
238
|
+
var pinPage = pages.pinPageHtml();
|
|
239
|
+
var adminSetupPage = pages.adminSetupPageHtml();
|
|
240
|
+
var loginPage = pages.multiUserLoginPageHtml();
|
|
241
|
+
var smtpLoginPage = pages.smtpLoginPageHtml();
|
|
242
|
+
|
|
243
|
+
function getAuthPage() {
|
|
244
|
+
if (!users.isMultiUser()) return pinPage;
|
|
245
|
+
if (!users.hasAdmin()) return adminSetupPage;
|
|
246
|
+
if (smtp.isEmailLoginEnabled()) return smtpLoginPage;
|
|
247
|
+
return loginPage;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isRequestAuthed(req) {
|
|
251
|
+
if (users.isMultiUser()) return isMultiUserAuthed(req);
|
|
252
|
+
return isAuthed(req, authToken);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function setAuthToken(hash) {
|
|
256
|
+
authToken = hash;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Route handler ---
|
|
260
|
+
|
|
261
|
+
function handleRequest(req, res, fullUrl) {
|
|
262
|
+
// Admin password recovery
|
|
263
|
+
if (recovery && fullUrl === "/recover/" + recovery.urlPath) {
|
|
264
|
+
if (req.method === "GET") {
|
|
265
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
266
|
+
res.end(recoveryPageHtml());
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if (req.method === "POST") {
|
|
270
|
+
var ip = req.socket.remoteAddress || "";
|
|
271
|
+
var remaining = checkPinRateLimit(ip);
|
|
272
|
+
if (remaining !== null) {
|
|
273
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
274
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
var body = "";
|
|
278
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
279
|
+
req.on("end", function () {
|
|
280
|
+
try {
|
|
281
|
+
var data = JSON.parse(body);
|
|
282
|
+
if (data.step === "verify") {
|
|
283
|
+
if (!data.password || data.password !== recovery.password) {
|
|
284
|
+
recordPinFailure(ip);
|
|
285
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
286
|
+
res.end('{"error":"Invalid recovery password"}');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
clearPinFailures(ip);
|
|
290
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
291
|
+
res.end('{"ok":true}');
|
|
292
|
+
} else if (data.step === "reset") {
|
|
293
|
+
if (!data.password || data.password !== recovery.password) {
|
|
294
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
295
|
+
res.end('{"error":"Invalid recovery password"}');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
299
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
300
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
var admin = users.findAdmin();
|
|
304
|
+
if (!admin) {
|
|
305
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
306
|
+
res.end('{"error":"No admin account found"}');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
users.updateUserPin(admin.id, data.pin);
|
|
310
|
+
recovery = null;
|
|
311
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
312
|
+
res.end('{"ok":true}');
|
|
313
|
+
} else {
|
|
314
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
315
|
+
res.end('{"error":"Invalid step"}');
|
|
316
|
+
}
|
|
317
|
+
} catch (e) {
|
|
318
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
319
|
+
res.end('{"error":"Invalid request"}');
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Global auth endpoint (single-user PIN)
|
|
327
|
+
if (req.method === "POST" && req.url === "/auth") {
|
|
328
|
+
var ip = req.socket.remoteAddress || "";
|
|
329
|
+
var remaining = checkPinRateLimit(ip);
|
|
330
|
+
if (remaining !== null) {
|
|
331
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
332
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
var body = "";
|
|
336
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
337
|
+
req.on("end", function () {
|
|
338
|
+
try {
|
|
339
|
+
var data = JSON.parse(body);
|
|
340
|
+
if (authToken && verifyPin(data.pin, authToken)) {
|
|
341
|
+
clearPinFailures(ip);
|
|
342
|
+
// Auto-upgrade legacy SHA256 hash to scrypt
|
|
343
|
+
if (authToken.indexOf(":") === -1) {
|
|
344
|
+
var upgraded = generateAuthToken(data.pin);
|
|
345
|
+
authToken = upgraded;
|
|
346
|
+
if (typeof onUpgradePin === "function") {
|
|
347
|
+
onUpgradePin(upgraded);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
res.writeHead(200, {
|
|
351
|
+
"Set-Cookie": "relay_auth=" + authToken + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""),
|
|
352
|
+
"Content-Type": "application/json",
|
|
353
|
+
});
|
|
354
|
+
res.end('{"ok":true}');
|
|
355
|
+
} else {
|
|
356
|
+
recordPinFailure(ip);
|
|
357
|
+
var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
|
|
358
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
359
|
+
res.end(JSON.stringify({ ok: false, attemptsLeft: Math.max(attemptsLeft, 0) }));
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {
|
|
362
|
+
res.writeHead(400);
|
|
363
|
+
res.end("Bad request");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Admin setup (first-time multi-user setup)
|
|
370
|
+
if (req.method === "POST" && fullUrl === "/auth/setup") {
|
|
371
|
+
if (!users.isMultiUser()) {
|
|
372
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
373
|
+
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (users.hasAdmin()) {
|
|
377
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
378
|
+
res.end('{"error":"Admin already exists"}');
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
var body = "";
|
|
382
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
383
|
+
req.on("end", function () {
|
|
384
|
+
try {
|
|
385
|
+
var data = JSON.parse(body);
|
|
386
|
+
if (!users.validateSetupCode(data.setupCode)) {
|
|
387
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
388
|
+
res.end('{"error":"Invalid setup code"}');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
|
|
392
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
393
|
+
res.end('{"error":"Username is required"}');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
397
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
398
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Migrate existing profile.json to admin profile
|
|
402
|
+
var adminProfile = undefined;
|
|
403
|
+
try {
|
|
404
|
+
var existingProfile = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "profile.json"), "utf8"));
|
|
405
|
+
adminProfile = {
|
|
406
|
+
name: data.displayName || data.username,
|
|
407
|
+
lang: existingProfile.lang || "en-US",
|
|
408
|
+
avatarColor: existingProfile.avatarColor || "#7c3aed",
|
|
409
|
+
avatarStyle: existingProfile.avatarStyle || "thumbs",
|
|
410
|
+
avatarSeed: existingProfile.avatarSeed || crypto.randomBytes(4).toString("hex"),
|
|
411
|
+
};
|
|
412
|
+
} catch (e) {}
|
|
413
|
+
var result = users.createAdmin({
|
|
414
|
+
username: data.username.trim(),
|
|
415
|
+
displayName: data.displayName || data.username.trim(),
|
|
416
|
+
pin: data.pin,
|
|
417
|
+
profile: adminProfile,
|
|
418
|
+
});
|
|
419
|
+
if (result.error) {
|
|
420
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
421
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Auto-provision Linux account if OS users mode is enabled
|
|
425
|
+
if (osUsers && !result.user.linuxUser) {
|
|
426
|
+
var provision = provisionLinuxUser(result.user.username);
|
|
427
|
+
if (provision.ok) {
|
|
428
|
+
users.updateLinuxUser(result.user.id, provision.linuxUser);
|
|
429
|
+
if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
users.clearSetupCode();
|
|
433
|
+
var session = createMultiUserSession(result.user.id);
|
|
434
|
+
res.writeHead(200, {
|
|
435
|
+
"Set-Cookie": session.cookie,
|
|
436
|
+
"Content-Type": "application/json",
|
|
437
|
+
});
|
|
438
|
+
res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
|
|
439
|
+
} catch (e) {
|
|
440
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
441
|
+
res.end('{"error":"Invalid request"}');
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Multi-user login
|
|
448
|
+
if (req.method === "POST" && fullUrl === "/auth/login") {
|
|
449
|
+
if (!users.isMultiUser()) {
|
|
450
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
451
|
+
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
var ip = req.socket.remoteAddress || "";
|
|
455
|
+
var remaining = checkPinRateLimit(ip);
|
|
456
|
+
if (remaining !== null) {
|
|
457
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
458
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
var body = "";
|
|
462
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
463
|
+
req.on("end", function () {
|
|
464
|
+
try {
|
|
465
|
+
var data = JSON.parse(body);
|
|
466
|
+
var user = users.authenticateUser(data.username, data.pin);
|
|
467
|
+
if (!user) {
|
|
468
|
+
recordPinFailure(ip);
|
|
469
|
+
var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
|
|
470
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
471
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid username or PIN", attemptsLeft: Math.max(attemptsLeft, 0) }));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
clearPinFailures(ip);
|
|
475
|
+
var session = createMultiUserSession(user.id);
|
|
476
|
+
var loginResp = { ok: true, user: { id: user.id, username: user.username, role: user.role } };
|
|
477
|
+
if (user.mustChangePin) loginResp.mustChangePin = true;
|
|
478
|
+
res.writeHead(200, {
|
|
479
|
+
"Set-Cookie": session.cookie,
|
|
480
|
+
"Content-Type": "application/json",
|
|
481
|
+
});
|
|
482
|
+
res.end(JSON.stringify(loginResp));
|
|
483
|
+
} catch (e) {
|
|
484
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
485
|
+
res.end('{"error":"Invalid request"}');
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Request OTP code (SMTP login)
|
|
492
|
+
if (req.method === "POST" && fullUrl === "/auth/request-otp") {
|
|
493
|
+
if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
|
|
494
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
495
|
+
res.end('{"error":"OTP login not available"}');
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
var ip = req.socket.remoteAddress || "";
|
|
499
|
+
var remaining = checkPinRateLimit(ip);
|
|
500
|
+
if (remaining !== null) {
|
|
501
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
502
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
var body = "";
|
|
506
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
507
|
+
req.on("end", function () {
|
|
508
|
+
try {
|
|
509
|
+
var data = JSON.parse(body);
|
|
510
|
+
if (!data.email) {
|
|
511
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
512
|
+
res.end('{"error":"Email is required"}');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
var user = users.findUserByEmail(data.email);
|
|
516
|
+
if (!user) {
|
|
517
|
+
// Don't reveal whether user exists
|
|
518
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
519
|
+
res.end('{"ok":true}');
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
var result = smtp.requestOtp(data.email);
|
|
523
|
+
if (result.error) {
|
|
524
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
525
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
smtp.sendOtpEmail(data.email, result.code).then(function () {
|
|
529
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
530
|
+
res.end('{"ok":true}');
|
|
531
|
+
}).catch(function () {
|
|
532
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
533
|
+
res.end('{"error":"Failed to send email"}');
|
|
534
|
+
});
|
|
535
|
+
} catch (e) {
|
|
536
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
537
|
+
res.end('{"error":"Invalid request"}');
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Verify OTP code (SMTP login)
|
|
544
|
+
if (req.method === "POST" && fullUrl === "/auth/verify-otp") {
|
|
545
|
+
if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
|
|
546
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
547
|
+
res.end('{"error":"OTP login not available"}');
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
var ip = req.socket.remoteAddress || "";
|
|
551
|
+
var remaining = checkPinRateLimit(ip);
|
|
552
|
+
if (remaining !== null) {
|
|
553
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
554
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
var body = "";
|
|
558
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
559
|
+
req.on("end", function () {
|
|
560
|
+
try {
|
|
561
|
+
var data = JSON.parse(body);
|
|
562
|
+
if (!data.email || !data.code) {
|
|
563
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
564
|
+
res.end('{"error":"Email and code are required"}');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
var otpResult = smtp.verifyOtp(data.email, data.code);
|
|
568
|
+
if (!otpResult.valid) {
|
|
569
|
+
recordPinFailure(ip);
|
|
570
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
571
|
+
res.end(JSON.stringify({ ok: false, error: otpResult.error, attemptsLeft: otpResult.attemptsLeft }));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
var user = users.findUserByEmail(data.email);
|
|
575
|
+
if (!user) {
|
|
576
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
577
|
+
res.end('{"ok":false,"error":"Account not found"}');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
clearPinFailures(ip);
|
|
581
|
+
var session = createMultiUserSession(user.id);
|
|
582
|
+
res.writeHead(200, {
|
|
583
|
+
"Set-Cookie": session.cookie,
|
|
584
|
+
"Content-Type": "application/json",
|
|
585
|
+
});
|
|
586
|
+
res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
|
|
587
|
+
} catch (e) {
|
|
588
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
589
|
+
res.end('{"error":"Invalid request"}');
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Invite registration
|
|
596
|
+
if (req.method === "POST" && fullUrl === "/auth/register") {
|
|
597
|
+
if (!users.isMultiUser()) {
|
|
598
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
599
|
+
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
var body = "";
|
|
603
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
604
|
+
req.on("end", function () {
|
|
605
|
+
try {
|
|
606
|
+
var data = JSON.parse(body);
|
|
607
|
+
var validation = users.validateInvite(data.inviteCode);
|
|
608
|
+
if (!validation.valid) {
|
|
609
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
610
|
+
res.end(JSON.stringify({ error: validation.error }));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
|
|
614
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
615
|
+
res.end('{"error":"Username is required"}');
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
var result;
|
|
619
|
+
if (smtp.isEmailLoginEnabled() && !data.pin) {
|
|
620
|
+
// SMTP mode: username + email required, no PIN
|
|
621
|
+
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
622
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
623
|
+
res.end('{"error":"A valid email address is required"}');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
result = users.createUserWithoutPin({
|
|
627
|
+
username: data.username.trim(),
|
|
628
|
+
email: data.email,
|
|
629
|
+
displayName: data.displayName || data.username.trim(),
|
|
630
|
+
role: "user",
|
|
631
|
+
});
|
|
632
|
+
} else {
|
|
633
|
+
// PIN mode: username + PIN, no email required
|
|
634
|
+
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
635
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
636
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
result = users.createUser({
|
|
640
|
+
username: data.username.trim(),
|
|
641
|
+
email: data.email || null,
|
|
642
|
+
displayName: data.displayName || data.username.trim(),
|
|
643
|
+
pin: data.pin,
|
|
644
|
+
role: "user",
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
if (result.error) {
|
|
648
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
649
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
// Auto-provision Linux account if OS users mode is enabled
|
|
653
|
+
if (osUsers && !result.user.linuxUser) {
|
|
654
|
+
var provision = provisionLinuxUser(result.user.username);
|
|
655
|
+
if (provision.ok) {
|
|
656
|
+
users.updateLinuxUser(result.user.id, provision.linuxUser);
|
|
657
|
+
if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
users.markInviteUsed(data.inviteCode);
|
|
661
|
+
var session = createMultiUserSession(result.user.id);
|
|
662
|
+
res.writeHead(200, {
|
|
663
|
+
"Set-Cookie": session.cookie,
|
|
664
|
+
"Content-Type": "application/json",
|
|
665
|
+
});
|
|
666
|
+
res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
|
|
667
|
+
} catch (e) {
|
|
668
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
669
|
+
res.end('{"error":"Invalid request"}');
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Logout
|
|
676
|
+
if (req.method === "POST" && fullUrl === "/auth/logout") {
|
|
677
|
+
if (users.isMultiUser()) {
|
|
678
|
+
var cookies = parseCookies(req);
|
|
679
|
+
var token = cookies[MULTI_USER_COOKIE];
|
|
680
|
+
if (token && multiUserTokens[token]) {
|
|
681
|
+
delete multiUserTokens[token];
|
|
682
|
+
saveTokens();
|
|
683
|
+
}
|
|
684
|
+
res.writeHead(200, {
|
|
685
|
+
"Set-Cookie": MULTI_USER_COOKIE + "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + (tlsOptions ? "; Secure" : ""),
|
|
686
|
+
"Content-Type": "application/json",
|
|
687
|
+
});
|
|
688
|
+
} else {
|
|
689
|
+
res.writeHead(200, {
|
|
690
|
+
"Set-Cookie": "relay_auth=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + (tlsOptions ? "; Secure" : ""),
|
|
691
|
+
"Content-Type": "application/json",
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
res.end('{"ok":true}');
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Invite page (magic link)
|
|
699
|
+
if (req.method === "GET" && fullUrl.indexOf("/invite/") === 0) {
|
|
700
|
+
var inviteCode = fullUrl.substring("/invite/".length);
|
|
701
|
+
if (!users.isMultiUser()) {
|
|
702
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
703
|
+
res.end("Not found");
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
var validation = users.validateInvite(inviteCode);
|
|
707
|
+
if (!validation.valid) {
|
|
708
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
709
|
+
res.end('<!DOCTYPE html><html><head><title>Clay</title>' +
|
|
710
|
+
'<style>body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}' +
|
|
711
|
+
'.c{text-align:center;max-width:360px;padding:20px}h1{color:#DA7756;margin-bottom:16px}p{color:#908B81}</style></head>' +
|
|
712
|
+
'<body><div class="c"><h1>Clay</h1><p>' + (validation.error === "Invite expired" ? "This invite link has expired." : validation.error === "Invite already used" ? "This invite link has already been used." : "Invalid invite link.") + '</p></div></body></html>');
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
716
|
+
res.end(smtp.isEmailLoginEnabled() ? pages.smtpInvitePageHtml(inviteCode) : pages.invitePageHtml(inviteCode));
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
handleRequest: handleRequest,
|
|
725
|
+
getMultiUserFromReq: getMultiUserFromReq,
|
|
726
|
+
isRequestAuthed: isRequestAuthed,
|
|
727
|
+
parseCookies: parseCookies,
|
|
728
|
+
revokeUserTokens: revokeUserTokens,
|
|
729
|
+
setRecovery: setRecovery,
|
|
730
|
+
clearRecovery: clearRecovery,
|
|
731
|
+
setAuthToken: setAuthToken,
|
|
732
|
+
getAuthPage: getAuthPage,
|
|
733
|
+
createMultiUserSession: createMultiUserSession,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
module.exports = { attachAuth: attachAuth, generateAuthToken: generateAuthToken, verifyPin: verifyPin };
|