co-maintainer 0.4.2 → 0.4.4
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/dist/package.json +1 -1
- package/dist/src/cli/commands/serve.d.ts +4 -1
- package/dist/src/cli/commands/serve.js +33 -8
- package/dist/src/cli/commands/set.js +13 -2
- package/dist/src/config.d.ts +4 -0
- package/dist/src/server/api/repos.js +23 -2
- package/dist/src/server/api/settings.d.ts +2 -1
- package/dist/src/server/api/settings.js +25 -1
- package/dist/src/server/app.d.ts +4 -3
- package/dist/src/server/app.js +7 -5
- package/dist/src/server/auth.d.ts +14 -2
- package/dist/src/server/auth.js +41 -5
- package/dist/src/server/pages/repo_settings.js +34 -0
- package/dist/src/server/pages/router.d.ts +2 -2
- package/dist/src/server/pages/router.js +1 -1
- package/dist/src/server/pages/settings.js +37 -1
- package/dist/src/services/remake_cron.d.ts +8 -0
- package/dist/src/services/remake_cron.js +63 -0
- package/dist/src/store/sessions.d.ts +1 -0
- package/dist/src/store/sessions.js +5 -0
- package/dist/src/util/cron.d.ts +14 -0
- package/dist/src/util/cron.js +85 -0
- package/dist/src/util/password.d.ts +3 -0
- package/dist/src/util/password.js +29 -0
- package/package.json +1 -1
package/dist/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { UserConfig } from "../../config.ts";
|
|
2
|
-
import type { AuthMethods } from "../../server/auth.ts";
|
|
2
|
+
import type { AuthMethods, PasswordStore } from "../../server/auth.ts";
|
|
3
3
|
import { type Platform } from "../../util/runtime.ts";
|
|
4
4
|
/** `undefined` on Linux, otherwise one line naming the platform (a pure
|
|
5
5
|
* function so it is testable without actually being off Linux). */
|
|
6
6
|
export declare function platformWarning(os: Platform): string | undefined;
|
|
7
|
+
/** `--password=` replaces the stored password. With no flag the stored one is
|
|
8
|
+
* kept, and a first start generates one, returned so the caller can print it. */
|
|
9
|
+
export declare function ensureDashboardPassword(args: string[], hasStoredPassword: boolean, store: PasswordStore): Promise<string | undefined>;
|
|
7
10
|
/** `--disable-auth=password` / `--enable-auth=github` always win over the
|
|
8
11
|
* config defaults `co-maintainer set` persisted; dies if the result would
|
|
9
12
|
* leave no sign-in method active. Pure so it is testable without a server. */
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { readConfig } from "../../config.js";
|
|
2
2
|
import { appDbPath, closeAppDb, openAppDb } from "../../store/app_db.js";
|
|
3
3
|
import { createApp } from "../../server/app.js";
|
|
4
|
+
import { configPasswordStore } from "../../server/auth.js";
|
|
4
5
|
import { recoverOrphans, startWorkerLoop, stopWorkerLoop, } from "../../services/jobs.js";
|
|
5
6
|
import { registerSetupJobHandler } from "../../services/setup.js";
|
|
6
7
|
import { registerReviewJobHandler } from "../../services/review.js";
|
|
7
8
|
import { recoverReplyRequests, registerReplyJobHandler, } from "../../services/replies.js";
|
|
9
|
+
import { startRemakeScheduler, stopRemakeScheduler, } from "../../services/remake_cron.js";
|
|
8
10
|
import { registerRemoteReviewHandler } from "../../services/remote_review.js";
|
|
9
11
|
import { startRemoteWatchdog } from "../../remote/server/sessions.js";
|
|
12
|
+
import { passwordProblem } from "../../util/password.js";
|
|
10
13
|
import { serveHttp } from "../../server/http.js";
|
|
11
14
|
import { currentPlatform, getEnv } from "../../util/runtime.js";
|
|
12
15
|
/** `undefined` on Linux, otherwise one line naming the platform (a pure
|
|
@@ -24,6 +27,25 @@ function generatePassword() {
|
|
|
24
27
|
crypto.getRandomValues(bytes);
|
|
25
28
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
26
29
|
}
|
|
30
|
+
/** `--password=` replaces the stored password. With no flag the stored one is
|
|
31
|
+
* kept, and a first start generates one, returned so the caller can print it. */
|
|
32
|
+
export async function ensureDashboardPassword(args, hasStoredPassword, store) {
|
|
33
|
+
const flag = args
|
|
34
|
+
.find((arg) => arg.startsWith("--password="))
|
|
35
|
+
?.slice("--password=".length);
|
|
36
|
+
if (flag !== undefined) {
|
|
37
|
+
const problem = passwordProblem(flag);
|
|
38
|
+
if (problem)
|
|
39
|
+
die(`--password: ${problem}`);
|
|
40
|
+
await store.set(flag);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (hasStoredPassword)
|
|
44
|
+
return undefined;
|
|
45
|
+
const generated = generatePassword();
|
|
46
|
+
await store.set(generated);
|
|
47
|
+
return generated;
|
|
48
|
+
}
|
|
27
49
|
/** `--disable-auth=password` / `--enable-auth=github` always win over the
|
|
28
50
|
* config defaults `co-maintainer set` persisted; dies if the result would
|
|
29
51
|
* leave no sign-in method active. Pure so it is testable without a server. */
|
|
@@ -117,6 +139,13 @@ export async function runServe(args) {
|
|
|
117
139
|
allowedUser: config.githubOAuthAllowedUser,
|
|
118
140
|
};
|
|
119
141
|
}
|
|
142
|
+
const passwordStore = configPasswordStore();
|
|
143
|
+
if (auth.password) {
|
|
144
|
+
const generated = await ensureDashboardPassword(args, Boolean(config.dashboardPasswordHash), passwordStore);
|
|
145
|
+
console.log(generated
|
|
146
|
+
? `[serve] dashboard password: ${generated}`
|
|
147
|
+
: "[serve] dashboard password is stored in config.json, change it in Settings");
|
|
148
|
+
}
|
|
120
149
|
const warning = platformWarning(currentPlatform());
|
|
121
150
|
if (warning)
|
|
122
151
|
console.log(`[serve] warning: ${warning}`);
|
|
@@ -136,13 +165,7 @@ export async function runServe(args) {
|
|
|
136
165
|
console.log(`[serve] requeued ${recoveredReplies} unfinished conversation repl${recoveredReplies === 1 ? "y" : "ies"}`);
|
|
137
166
|
}
|
|
138
167
|
startWorkerLoop();
|
|
139
|
-
|
|
140
|
-
? (args
|
|
141
|
-
.find((arg) => arg.startsWith("--password="))
|
|
142
|
-
?.slice("--password=".length) ?? generatePassword())
|
|
143
|
-
: "";
|
|
144
|
-
if (auth.password)
|
|
145
|
-
console.log(`[serve] dashboard password: ${password}`);
|
|
168
|
+
startRemakeScheduler();
|
|
146
169
|
if (auth.github)
|
|
147
170
|
console.log(`[serve] GitHub sign-in enabled for ${githubOAuth.allowedUser}`);
|
|
148
171
|
const inject500 = args.includes("--inject-500") || getEnv("CM_INJECT_500") === "1";
|
|
@@ -150,7 +173,8 @@ export async function runServe(args) {
|
|
|
150
173
|
console.log("[serve] --inject-500 is on. Mutating /api requests return 500.");
|
|
151
174
|
}
|
|
152
175
|
const app = createApp({
|
|
153
|
-
password,
|
|
176
|
+
password: "",
|
|
177
|
+
passwordStore,
|
|
154
178
|
webhookUrl,
|
|
155
179
|
inject500,
|
|
156
180
|
auth,
|
|
@@ -166,6 +190,7 @@ export async function runServe(args) {
|
|
|
166
190
|
return;
|
|
167
191
|
shuttingDown = true;
|
|
168
192
|
console.log(`[serve] received ${signal}, stopping new requests and closing app.db`);
|
|
193
|
+
stopRemakeScheduler();
|
|
169
194
|
await stopWorkerLoop();
|
|
170
195
|
await server.shutdown();
|
|
171
196
|
await closeAppDb();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeUserConfig } from "../../config.js";
|
|
2
|
+
import { hashPassword, passwordProblem } from "../../util/password.js";
|
|
2
3
|
import { readTextFile } from "../../util/runtime.js";
|
|
3
4
|
function die(message) {
|
|
4
5
|
throw new Error(message);
|
|
@@ -15,6 +16,7 @@ const secretFields = new Set([
|
|
|
15
16
|
"githubWebhookSecret",
|
|
16
17
|
"githubOAuthClientSecret",
|
|
17
18
|
"remoteToken",
|
|
19
|
+
"dashboardPasswordHash",
|
|
18
20
|
]);
|
|
19
21
|
/** `co-maintainer set --token=... --ai=... --low-model=... --high-model=...
|
|
20
22
|
* --auth=... --github-app-id=... --github-app-private-key=... (or
|
|
@@ -28,7 +30,7 @@ export async function runSet(args) {
|
|
|
28
30
|
console.log(" --github-app-id=... --github-app-private-key=... | --github-app-private-key-file=path");
|
|
29
31
|
console.log(" --github-webhook-secret=...");
|
|
30
32
|
console.log(" --github-oauth-client-id=... --github-oauth-client-secret=... --github-oauth-allowed-user=...");
|
|
31
|
-
console.log(" --disable-auth=password --enable-auth=github");
|
|
33
|
+
console.log(" --password=... --disable-auth=password --enable-auth=github");
|
|
32
34
|
console.log("Writes to the user config file; unset an entry with --unset=name (e.g. --unset=token).");
|
|
33
35
|
return;
|
|
34
36
|
}
|
|
@@ -50,6 +52,7 @@ export async function runSet(args) {
|
|
|
50
52
|
"enable-auth",
|
|
51
53
|
"remote-host",
|
|
52
54
|
"remote-token",
|
|
55
|
+
"password",
|
|
53
56
|
"unset",
|
|
54
57
|
];
|
|
55
58
|
for (const arg of args) {
|
|
@@ -87,6 +90,7 @@ export async function runSet(args) {
|
|
|
87
90
|
"enable-auth": "githubAuthEnabled",
|
|
88
91
|
"remote-host": "remoteHost",
|
|
89
92
|
"remote-token": "remoteToken",
|
|
93
|
+
password: "dashboardPasswordHash",
|
|
90
94
|
};
|
|
91
95
|
const unset = new Set(args
|
|
92
96
|
.filter((arg) => arg.startsWith("--unset="))
|
|
@@ -157,11 +161,18 @@ export async function runSet(args) {
|
|
|
157
161
|
const remoteToken = text(args, "remote-token");
|
|
158
162
|
if (remoteToken)
|
|
159
163
|
patch.remoteToken = remoteToken;
|
|
164
|
+
const password = text(args, "password");
|
|
165
|
+
if (password) {
|
|
166
|
+
const problem = passwordProblem(password);
|
|
167
|
+
if (problem)
|
|
168
|
+
die(`--password: ${problem}`);
|
|
169
|
+
patch.dashboardPasswordHash = await hashPassword(password);
|
|
170
|
+
}
|
|
160
171
|
if (Object.keys(patch).length === 0) {
|
|
161
172
|
die("Nothing to set; pass --token=, --ai=, --low-model=, --high-model=, --auth=, --github-pat=, " +
|
|
162
173
|
"--github-app-id=, --github-app-private-key(-file)=, --github-webhook-secret=, " +
|
|
163
174
|
"--github-oauth-client-id=, --github-oauth-client-secret=, --github-oauth-allowed-user=, " +
|
|
164
|
-
"--disable-auth=password, --enable-auth=github, or --unset=name");
|
|
175
|
+
"--password=, --disable-auth=password, --enable-auth=github, or --unset=name");
|
|
165
176
|
}
|
|
166
177
|
await writeUserConfig(patch);
|
|
167
178
|
const summary = Object.entries(patch).map(([key, value]) => value === undefined
|
package/dist/src/config.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type RepoConfig = {
|
|
|
17
17
|
includePullRequestChanges?: boolean;
|
|
18
18
|
includeCommitHistory?: boolean;
|
|
19
19
|
includeHowRepoWorks?: boolean;
|
|
20
|
+
/** Five field cron in UTC, checked by `serve` to queue a remake. */
|
|
21
|
+
remakeCron?: string;
|
|
20
22
|
};
|
|
21
23
|
export type UserConfig = {
|
|
22
24
|
auth?: "gh" | "pat";
|
|
@@ -45,6 +47,8 @@ export type UserConfig = {
|
|
|
45
47
|
* `--enable-auth` flag on the `serve` command overrides these per run. */
|
|
46
48
|
passwordAuthDisabled?: boolean;
|
|
47
49
|
githubAuthEnabled?: boolean;
|
|
50
|
+
/** scrypt hash of the dashboard password, never the password itself. */
|
|
51
|
+
dashboardPasswordHash?: string;
|
|
48
52
|
defaults?: {
|
|
49
53
|
maxCommits?: number;
|
|
50
54
|
maxPrMonths?: number;
|
|
@@ -2,6 +2,7 @@ import { errorResponse } from "../errors.js";
|
|
|
2
2
|
import { findInstallationForRepo } from "../../github/app.js";
|
|
3
3
|
import { activateRepo, deactivateRepo, listActiveRepos, updateRepoSettings, } from "../../store/repos.js";
|
|
4
4
|
import { writeRepoConfig } from "../../config.js";
|
|
5
|
+
import { parseRemakeCron } from "../../services/remake_cron.js";
|
|
5
6
|
import { enqueueSetup } from "../../services/setup.js";
|
|
6
7
|
import { enqueueManualReview } from "../../services/review.js";
|
|
7
8
|
import { prDetail, repoKnowledge, RepoNotFound, repoOverview, repoPulls, requireActiveRepo, } from "../../services/dashboard.js";
|
|
@@ -64,6 +65,25 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
64
65
|
catch {
|
|
65
66
|
return errorResponse(400, "bad_request", "expected a JSON body");
|
|
66
67
|
}
|
|
68
|
+
const cronPatch = {};
|
|
69
|
+
if ("remakeCron" in body) {
|
|
70
|
+
const raw = body.remakeCron;
|
|
71
|
+
if (raw === null || raw === "") {
|
|
72
|
+
cronPatch.remakeCron = undefined;
|
|
73
|
+
}
|
|
74
|
+
else if (typeof raw === "string") {
|
|
75
|
+
try {
|
|
76
|
+
parseRemakeCron(raw);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return errorResponse(422, "invalid_cron", error instanceof Error ? error.message : String(error));
|
|
80
|
+
}
|
|
81
|
+
cronPatch.remakeCron = raw.trim();
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
return errorResponse(422, "invalid_cron", "remakeCron must be text or null");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
67
87
|
const patch = {};
|
|
68
88
|
if (typeof body.autoReview === "boolean") {
|
|
69
89
|
patch.auto_review = body.autoReview ? 1 : 0;
|
|
@@ -93,8 +113,9 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
93
113
|
init[key] = body[key];
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
|
-
if (Object.keys(init).length > 0)
|
|
97
|
-
await writeRepoConfig(fullName, init);
|
|
116
|
+
if (Object.keys(init).length > 0 || "remakeCron" in cronPatch) {
|
|
117
|
+
await writeRepoConfig(fullName, { ...init, ...cronPatch });
|
|
118
|
+
}
|
|
98
119
|
return Response.json({ ok: true });
|
|
99
120
|
}
|
|
100
121
|
if (!sub && request.method === "DELETE") {
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { PasswordStore } from "../auth.ts";
|
|
2
|
+
export declare function handleSettingsRoute(request: Request, url: URL, webhookUrl: string, passwords: PasswordStore, ip: string): Promise<Response>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { errorResponse } from "../errors.js";
|
|
2
2
|
import { readConfig, writeUserConfig } from "../../config.js";
|
|
3
3
|
import { testAppAccess, testGithubAccess } from "../../services/credentials.js";
|
|
4
|
+
import { checkPassword, readSessionToken, revokeOtherSessions, } from "../auth.js";
|
|
5
|
+
import { passwordProblem } from "../../util/password.js";
|
|
6
|
+
import { readJsonObject } from "./json_body.js";
|
|
4
7
|
function validateWebhookUrl(value) {
|
|
5
8
|
if (typeof value !== "string" || !value.trim()) {
|
|
6
9
|
return "webhook URL is required";
|
|
@@ -16,7 +19,28 @@ function validateWebhookUrl(value) {
|
|
|
16
19
|
}
|
|
17
20
|
return undefined;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
async function changePassword(request, passwords, ip) {
|
|
23
|
+
const body = await readJsonObject(request);
|
|
24
|
+
if (body instanceof Response)
|
|
25
|
+
return body;
|
|
26
|
+
const next = typeof body.newPassword === "string" ? body.newPassword : "";
|
|
27
|
+
const problem = passwordProblem(next);
|
|
28
|
+
if (problem)
|
|
29
|
+
return errorResponse(422, "weak_password", problem);
|
|
30
|
+
const current = String(body.currentPassword ?? "");
|
|
31
|
+
if (!(await checkPassword(current, passwords, ip))) {
|
|
32
|
+
return errorResponse(403, "wrong_password", "the current password is wrong");
|
|
33
|
+
}
|
|
34
|
+
await passwords.set(next);
|
|
35
|
+
const token = readSessionToken(request);
|
|
36
|
+
if (token)
|
|
37
|
+
await revokeOtherSessions(token);
|
|
38
|
+
return Response.json({ ok: true });
|
|
39
|
+
}
|
|
40
|
+
export async function handleSettingsRoute(request, url, webhookUrl, passwords, ip) {
|
|
41
|
+
if (url.pathname === "/api/settings/password" && request.method === "POST") {
|
|
42
|
+
return await changePassword(request, passwords, ip);
|
|
43
|
+
}
|
|
20
44
|
if (url.pathname === "/api/settings" && request.method === "GET") {
|
|
21
45
|
const config = readConfig();
|
|
22
46
|
return Response.json({
|
package/dist/src/server/app.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { AuthMethods } from "./auth.ts";
|
|
2
|
-
/** `password`
|
|
3
|
-
*
|
|
1
|
+
import type { AuthMethods, PasswordStore } from "./auth.ts";
|
|
2
|
+
/** `password` seeds an in-memory store. `serve` passes `passwordStore` so the
|
|
3
|
+
* password lives in config.json instead. */
|
|
4
4
|
export type AppDeps = {
|
|
5
5
|
password: string;
|
|
6
|
+
passwordStore?: PasswordStore;
|
|
6
7
|
webhookUrl?: string;
|
|
7
8
|
secureCookie?: boolean;
|
|
8
9
|
inject500?: boolean;
|
package/dist/src/server/app.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { VERSION } from "../version.js";
|
|
4
4
|
import { errorResponse } from "./errors.js";
|
|
5
5
|
import { hasCsrfHeader, login, logout, readSessionToken, sessionCookieHeader, verifySession, } from "./auth.js";
|
|
6
|
+
import { memoryPasswordStore } from "./auth.js";
|
|
6
7
|
import { readConfig } from "../config.js";
|
|
7
8
|
import { listJobs } from "../store/jobs.js";
|
|
8
9
|
import { handleJobsRoute } from "./api/jobs.js";
|
|
@@ -33,7 +34,7 @@ async function requireSession(request) {
|
|
|
33
34
|
return undefined;
|
|
34
35
|
return await verifySession(token);
|
|
35
36
|
}
|
|
36
|
-
async function handleLogin(request, deps, ip) {
|
|
37
|
+
async function handleLogin(request, deps, passwords, ip) {
|
|
37
38
|
if (deps.auth?.password === false) {
|
|
38
39
|
return errorResponse(403, "password_disabled", "password sign-in is disabled");
|
|
39
40
|
}
|
|
@@ -44,7 +45,7 @@ async function handleLogin(request, deps, ip) {
|
|
|
44
45
|
catch {
|
|
45
46
|
return errorResponse(400, "bad_request", "expected a JSON body");
|
|
46
47
|
}
|
|
47
|
-
const result = await login(String(body.password ?? ""),
|
|
48
|
+
const result = await login(String(body.password ?? ""), passwords, ip);
|
|
48
49
|
if (!result)
|
|
49
50
|
return errorResponse(401, "unauthorized", "wrong password");
|
|
50
51
|
return Response.json(result, {
|
|
@@ -93,6 +94,7 @@ function liveGithubConfig() {
|
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
export function createApp(deps) {
|
|
97
|
+
const passwords = deps.passwordStore ?? memoryPasswordStore(deps.password);
|
|
96
98
|
return {
|
|
97
99
|
async fetch(request, remoteAddr = "unknown") {
|
|
98
100
|
const url = new URL(request.url);
|
|
@@ -104,7 +106,7 @@ export function createApp(deps) {
|
|
|
104
106
|
if (url.pathname === "/github/webhook" && request.method === "POST") {
|
|
105
107
|
return await handleWebhookRequest(request, webhookSecret);
|
|
106
108
|
}
|
|
107
|
-
const page = await handlePageRequest(request, { ...deps, githubApp }, remoteAddr);
|
|
109
|
+
const page = await handlePageRequest(request, { ...deps, githubApp, passwordStore: passwords }, remoteAddr);
|
|
108
110
|
if (page)
|
|
109
111
|
return page;
|
|
110
112
|
if (url.pathname.startsWith("/api/remote/")) {
|
|
@@ -115,7 +117,7 @@ export function createApp(deps) {
|
|
|
115
117
|
return errorResponse(403, "csrf", `every mutating request needs the X-Requested-With header`);
|
|
116
118
|
}
|
|
117
119
|
if (url.pathname === "/api/login" && request.method === "POST") {
|
|
118
|
-
return await handleLogin(request, deps, remoteAddr);
|
|
120
|
+
return await handleLogin(request, deps, passwords, remoteAddr);
|
|
119
121
|
}
|
|
120
122
|
const session = await requireSession(request);
|
|
121
123
|
if (!session) {
|
|
@@ -143,7 +145,7 @@ export function createApp(deps) {
|
|
|
143
145
|
return await handleInstallationsRoute(request, githubApp);
|
|
144
146
|
}
|
|
145
147
|
if (url.pathname.startsWith("/api/settings")) {
|
|
146
|
-
return await handleSettingsRoute(request, url, deps.webhookUrl ?? "");
|
|
148
|
+
return await handleSettingsRoute(request, url, deps.webhookUrl ?? "", passwords, remoteAddr);
|
|
147
149
|
}
|
|
148
150
|
if (url.pathname.startsWith("/api/activity")) {
|
|
149
151
|
return handleActivityRoute(request, url);
|
|
@@ -19,9 +19,21 @@ export type LoginResult = {
|
|
|
19
19
|
username: string;
|
|
20
20
|
expiresAt: string;
|
|
21
21
|
};
|
|
22
|
+
export type PasswordStore = {
|
|
23
|
+
verify(password: string): Promise<boolean>;
|
|
24
|
+
set(password: string): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
/** Holds the password in memory only: what `createApp` uses when `serve` did
|
|
27
|
+
* not hand it a config-backed store. */
|
|
28
|
+
export declare function memoryPasswordStore(initial: string): PasswordStore;
|
|
29
|
+
/** Reads the hash on every check, so a change made from the dashboard or
|
|
30
|
+
* `co-maintainer set` applies without restarting `serve`. */
|
|
31
|
+
export declare function configPasswordStore(): PasswordStore;
|
|
22
32
|
/** A locked-out attempt is indistinguishable from a wrong password to the
|
|
23
|
-
* caller
|
|
24
|
-
export declare function
|
|
33
|
+
* caller, so a probe gains nothing either way. */
|
|
34
|
+
export declare function checkPassword(password: string, store: PasswordStore, ip: string): Promise<boolean>;
|
|
35
|
+
export declare function login(password: string, store: PasswordStore, ip: string): Promise<LoginResult | undefined>;
|
|
36
|
+
export declare function revokeOtherSessions(keepToken: string): Promise<void>;
|
|
25
37
|
/** Issues a session for the one dashboard identity, bypassing the password
|
|
26
38
|
* check — used after GitHub OAuth has already verified the caller. */
|
|
27
39
|
export declare function createSession(): Promise<LoginResult>;
|
package/dist/src/server/auth.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Only a token's hash ever reaches `store/sessions.ts`; the raw token is
|
|
2
2
|
* handed back once, at login, and never stored. */
|
|
3
|
-
import {
|
|
3
|
+
import { readConfig, writeUserConfig } from "../config.js";
|
|
4
|
+
import { hashPassword, verifyPasswordHash } from "../util/password.js";
|
|
5
|
+
import { deleteOtherSessions, deleteSession, getSession, insertSession, } from "../store/sessions.js";
|
|
4
6
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
7
|
const LOCKOUT_THRESHOLD = 5;
|
|
6
8
|
const LOCKOUT_WINDOW_MS = 10 * 60 * 1000;
|
|
@@ -69,16 +71,50 @@ function recordSuccess(ip) {
|
|
|
69
71
|
export function hasCsrfHeader(request) {
|
|
70
72
|
return request.headers.get(CSRF_HEADER) === CSRF_VALUE;
|
|
71
73
|
}
|
|
74
|
+
/** Holds the password in memory only: what `createApp` uses when `serve` did
|
|
75
|
+
* not hand it a config-backed store. */
|
|
76
|
+
export function memoryPasswordStore(initial) {
|
|
77
|
+
let current = initial;
|
|
78
|
+
return {
|
|
79
|
+
verify: async (password) => current !== "" && (await constantTimeEqual(password, current)),
|
|
80
|
+
set: async (password) => {
|
|
81
|
+
current = password;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Reads the hash on every check, so a change made from the dashboard or
|
|
86
|
+
* `co-maintainer set` applies without restarting `serve`. */
|
|
87
|
+
export function configPasswordStore() {
|
|
88
|
+
return {
|
|
89
|
+
verify: async (password) => {
|
|
90
|
+
const stored = readConfig().dashboardPasswordHash;
|
|
91
|
+
return stored ? await verifyPasswordHash(password, stored) : false;
|
|
92
|
+
},
|
|
93
|
+
set: async (password) => {
|
|
94
|
+
await writeUserConfig({
|
|
95
|
+
dashboardPasswordHash: await hashPassword(password),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
72
100
|
/** A locked-out attempt is indistinguishable from a wrong password to the
|
|
73
|
-
* caller
|
|
74
|
-
export async function
|
|
75
|
-
if (isLockedOut(ip) || !(await
|
|
101
|
+
* caller, so a probe gains nothing either way. */
|
|
102
|
+
export async function checkPassword(password, store, ip) {
|
|
103
|
+
if (isLockedOut(ip) || !(await store.verify(password))) {
|
|
76
104
|
recordFailure(ip);
|
|
77
|
-
return
|
|
105
|
+
return false;
|
|
78
106
|
}
|
|
79
107
|
recordSuccess(ip);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
export async function login(password, store, ip) {
|
|
111
|
+
if (!(await checkPassword(password, store, ip)))
|
|
112
|
+
return undefined;
|
|
80
113
|
return await createSession();
|
|
81
114
|
}
|
|
115
|
+
export async function revokeOtherSessions(keepToken) {
|
|
116
|
+
deleteOtherSessions(await sha256Hex(keepToken));
|
|
117
|
+
}
|
|
82
118
|
/** Issues a session for the one dashboard identity, bypassing the password
|
|
83
119
|
* check — used after GitHub OAuth has already verified the caller. */
|
|
84
120
|
export async function createSession() {
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { html, layout, repoNav, skSlot, text } from "./layout.js";
|
|
2
|
+
import { parseRemakeCron } from "../../services/remake_cron.js";
|
|
3
|
+
import { nextCronRun } from "../../util/cron.js";
|
|
4
|
+
function cronHint(repo, expression) {
|
|
5
|
+
const notBuilt = repo.knowledge_built_at
|
|
6
|
+
? ""
|
|
7
|
+
: " A schedule only runs after the first setup has finished.";
|
|
8
|
+
if (!expression) {
|
|
9
|
+
return `Leave blank to turn it off. For example, 0 3 * * 1 runs every Monday at 03:00.${notBuilt}`;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const next = nextCronRun(parseRemakeCron(expression), new Date());
|
|
13
|
+
if (!next)
|
|
14
|
+
return `This schedule has no run in the next year.${notBuilt}`;
|
|
15
|
+
return `Next run ${next.toISOString().slice(0, 16).replace("T", " ")} UTC.${notBuilt}`;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
19
|
+
return `This schedule is ignored, ${reason}.${notBuilt}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
export function renderRepoSettings(username, repo, config) {
|
|
3
23
|
const name = repo.full_name;
|
|
4
24
|
const skip = repo.skip_drafts && repo.skip_bots
|
|
@@ -68,6 +88,17 @@ export function renderRepoSettings(username, repo, config) {
|
|
|
68
88
|
</div>
|
|
69
89
|
<div class="ft"><button class="primary" id="save-init">Save</button></div>
|
|
70
90
|
</div>
|
|
91
|
+
<div class="card" data-async>
|
|
92
|
+
${skSlot()}
|
|
93
|
+
<div class="hd"><h2>Scheduled remake</h2></div>
|
|
94
|
+
<div class="bd">
|
|
95
|
+
<p class="muted" style="margin:0 0 18px">Rebuild the knowledge for this repository on a schedule. Times are UTC and a remake runs at most once an hour.</p>
|
|
96
|
+
<div class="field wide"><label>Cron schedule</label>
|
|
97
|
+
<input id="cron" placeholder="0 3 * * 1" value="${text(config.remakeCron ?? "")}">
|
|
98
|
+
<div class="hint">${text(cronHint(repo, config.remakeCron))}</div></div>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="ft"><button class="primary" id="save-cron">Save</button></div>
|
|
101
|
+
</div>
|
|
71
102
|
<div class="card" data-async>
|
|
72
103
|
${skSlot()}
|
|
73
104
|
<div class="hd"><h2>Remove</h2></div>
|
|
@@ -114,6 +145,9 @@ document.getElementById("save-init").addEventListener("click", function() {
|
|
|
114
145
|
maxComments: num("comments")
|
|
115
146
|
});
|
|
116
147
|
});
|
|
148
|
+
document.getElementById("save-cron").addEventListener("click", function() {
|
|
149
|
+
save(this, { remakeCron: document.getElementById("cron").value.trim() });
|
|
150
|
+
});
|
|
117
151
|
document.getElementById("remove").addEventListener("click", function() {
|
|
118
152
|
if (!confirm("Stop reviewing this repository?")) return;
|
|
119
153
|
var btn = this;
|
|
@@ -266,7 +266,7 @@ async function handleLoginForm(request, deps, ip, auth) {
|
|
|
266
266
|
showGithub: auth.github,
|
|
267
267
|
});
|
|
268
268
|
}
|
|
269
|
-
const result = await login(password, deps.
|
|
269
|
+
const result = await login(password, deps.passwordStore, ip);
|
|
270
270
|
if (!result) {
|
|
271
271
|
return renderLogin({
|
|
272
272
|
next,
|
|
@@ -144,7 +144,7 @@ Leave blank to keep the current key"></textarea></div>
|
|
|
144
144
|
<div class="bd">
|
|
145
145
|
<p class="muted" style="margin:0 0 16px">A <code>--disable-auth</code>/<code>--enable-auth</code> flag on <code>serve</code> overrides these for that run.</p>
|
|
146
146
|
<div class="field wide"><label><input type="checkbox" id="password-auth"${config.passwordAuthDisabled ? "" : " checked"}> Password sign-in</label>
|
|
147
|
-
<div class="hint">
|
|
147
|
+
<div class="hint">The first start prints a generated password to the console. Change it in the Password card below.</div></div>
|
|
148
148
|
<div class="field wide"><label><input type="checkbox" id="github-auth"${config.githubAuthEnabled ? " checked" : ""}> GitHub sign-in</label></div>
|
|
149
149
|
<div class="two" style="max-width:none">
|
|
150
150
|
<div class="field"><label>OAuth client ID</label>
|
|
@@ -160,6 +160,23 @@ Leave blank to keep the current key"></textarea></div>
|
|
|
160
160
|
</div>
|
|
161
161
|
<div class="ft"><button class="primary" id="save-access">Save</button></div>
|
|
162
162
|
</div>
|
|
163
|
+
<div class="card" id="password" data-async>
|
|
164
|
+
${skSlot()}
|
|
165
|
+
<div class="hd"><h2>Password</h2></div>
|
|
166
|
+
<div class="bd">
|
|
167
|
+
<p class="muted" style="margin:0 0 16px">Changing it signs out every other browser session.</p>
|
|
168
|
+
<div class="field wide"><label>Current password</label>
|
|
169
|
+
<input id="pw-current" type="password" autocomplete="current-password"></div>
|
|
170
|
+
<div class="two" style="max-width:none">
|
|
171
|
+
<div class="field"><label>New password</label>
|
|
172
|
+
<input id="pw-new" type="password" autocomplete="new-password">
|
|
173
|
+
<div class="hint">8 to 200 characters.</div></div>
|
|
174
|
+
<div class="field"><label>Repeat new password</label>
|
|
175
|
+
<input id="pw-repeat" type="password" autocomplete="new-password"></div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="ft"><button class="primary" id="save-password">Change password</button></div>
|
|
179
|
+
</div>
|
|
163
180
|
<div class="card" id="about">
|
|
164
181
|
<div class="hd"><h2>About</h2></div>
|
|
165
182
|
<div class="bd">
|
|
@@ -215,6 +232,25 @@ document.getElementById("save-access").addEventListener("click", function() {
|
|
|
215
232
|
githubOAuthAllowedUser: document.getElementById("oauth-allowed-user").value
|
|
216
233
|
});
|
|
217
234
|
});
|
|
235
|
+
document.getElementById("save-password").addEventListener("click", function() {
|
|
236
|
+
var btn = this;
|
|
237
|
+
var card = btn.closest("[data-async]");
|
|
238
|
+
var next = document.getElementById("pw-new").value;
|
|
239
|
+
if (next !== document.getElementById("pw-repeat").value) {
|
|
240
|
+
fail(card, "The two new passwords do not match.", function () {});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
run(btn, card, async function() {
|
|
244
|
+
await api("POST", "/api/settings/password", {
|
|
245
|
+
currentPassword: document.getElementById("pw-current").value,
|
|
246
|
+
newPassword: next
|
|
247
|
+
});
|
|
248
|
+
["pw-current", "pw-new", "pw-repeat"].forEach(function(id) {
|
|
249
|
+
document.getElementById(id).value = "";
|
|
250
|
+
});
|
|
251
|
+
toast("Password changed");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
218
254
|
function positiveOrNull(raw, label) {
|
|
219
255
|
if (raw === "") return { ok: true, value: null };
|
|
220
256
|
var n = Number(raw);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Cron } from "../util/cron.ts";
|
|
2
|
+
/** A remake costs model tokens, so a schedule may fire at most once an hour. */
|
|
3
|
+
export declare function parseRemakeCron(expression: string): Cron;
|
|
4
|
+
/** Each minute is handled once however often the ticker is called. A minute
|
|
5
|
+
* the process slept through is not made up. */
|
|
6
|
+
export declare function createRemakeCronTicker(): (now: Date) => string[];
|
|
7
|
+
export declare function startRemakeScheduler(intervalMs?: number): void;
|
|
8
|
+
export declare function stopRemakeScheduler(): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readConfig } from "../config.js";
|
|
2
|
+
import { getQueuedJobByKey, getRunningJobByKey } from "../store/jobs.js";
|
|
3
|
+
import { listActiveRepos } from "../store/repos.js";
|
|
4
|
+
import { cronMatches, parseCron } from "../util/cron.js";
|
|
5
|
+
import { enqueueSetup } from "./setup.js";
|
|
6
|
+
/** A remake costs model tokens, so a schedule may fire at most once an hour. */
|
|
7
|
+
export function parseRemakeCron(expression) {
|
|
8
|
+
const cron = parseCron(expression);
|
|
9
|
+
if (cron.minutes.size !== 1) {
|
|
10
|
+
throw new Error("the minute field must be a single value, so a remake runs at most once an hour");
|
|
11
|
+
}
|
|
12
|
+
return cron;
|
|
13
|
+
}
|
|
14
|
+
/** Each minute is handled once however often the ticker is called. A minute
|
|
15
|
+
* the process slept through is not made up. */
|
|
16
|
+
export function createRemakeCronTicker() {
|
|
17
|
+
let lastMinute = -1;
|
|
18
|
+
return (now) => {
|
|
19
|
+
const minute = Math.floor(now.getTime() / 60_000);
|
|
20
|
+
if (minute === lastMinute)
|
|
21
|
+
return [];
|
|
22
|
+
lastMinute = minute;
|
|
23
|
+
const config = readConfig();
|
|
24
|
+
const queued = [];
|
|
25
|
+
for (const repo of listActiveRepos()) {
|
|
26
|
+
const expression = config.repos?.[repo.full_name]?.remakeCron;
|
|
27
|
+
if (!expression || !repo.knowledge_built_at)
|
|
28
|
+
continue;
|
|
29
|
+
let cron;
|
|
30
|
+
try {
|
|
31
|
+
cron = parseRemakeCron(expression);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!cronMatches(cron, now))
|
|
37
|
+
continue;
|
|
38
|
+
const key = `setup:${repo.full_name}`;
|
|
39
|
+
if (getQueuedJobByKey(key) || getRunningJobByKey(key))
|
|
40
|
+
continue;
|
|
41
|
+
enqueueSetup(repo.full_name, "remake");
|
|
42
|
+
queued.push(repo.full_name);
|
|
43
|
+
}
|
|
44
|
+
return queued;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
let timer;
|
|
48
|
+
export function startRemakeScheduler(intervalMs = 15_000) {
|
|
49
|
+
if (timer !== undefined)
|
|
50
|
+
return;
|
|
51
|
+
const ticker = createRemakeCronTicker();
|
|
52
|
+
timer = setInterval(() => {
|
|
53
|
+
for (const repo of ticker(new Date())) {
|
|
54
|
+
console.log(`[cron] queued a scheduled remake for ${repo}`);
|
|
55
|
+
}
|
|
56
|
+
}, intervalMs);
|
|
57
|
+
}
|
|
58
|
+
export function stopRemakeScheduler() {
|
|
59
|
+
if (timer === undefined)
|
|
60
|
+
return;
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
timer = undefined;
|
|
63
|
+
}
|
|
@@ -6,3 +6,4 @@ export declare function insertSession(tokenHash: string, username: string, expir
|
|
|
6
6
|
export declare function getSession(tokenHash: string): SessionRow | undefined;
|
|
7
7
|
export declare function deleteSession(tokenHash: string): void;
|
|
8
8
|
export declare function deleteExpiredSessions(): void;
|
|
9
|
+
export declare function deleteOtherSessions(keepTokenHash: string): void;
|
|
@@ -32,3 +32,8 @@ export function deleteExpiredSessions() {
|
|
|
32
32
|
.prepare(`DELETE FROM sessions WHERE expires_at <= ?`)
|
|
33
33
|
.run(nowIso());
|
|
34
34
|
}
|
|
35
|
+
export function deleteOtherSessions(keepTokenHash) {
|
|
36
|
+
getAppDb()
|
|
37
|
+
.prepare(`DELETE FROM sessions WHERE token_hash != ?`)
|
|
38
|
+
.run(keepTokenHash);
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type Cron = {
|
|
2
|
+
minutes: Set<number>;
|
|
3
|
+
hours: Set<number>;
|
|
4
|
+
daysOfMonth: Set<number>;
|
|
5
|
+
months: Set<number>;
|
|
6
|
+
daysOfWeek: Set<number>;
|
|
7
|
+
anyDayOfMonth: boolean;
|
|
8
|
+
anyDayOfWeek: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function parseCron(expression: string): Cron;
|
|
11
|
+
/** All times are UTC. When both day fields are restricted a date matches if
|
|
12
|
+
* either one does, as in classic cron. */
|
|
13
|
+
export declare function cronMatches(cron: Cron, date: Date): boolean;
|
|
14
|
+
export declare function nextCronRun(cron: Cron, after: Date): Date | undefined;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const FIELDS = [
|
|
2
|
+
{ name: "minute", min: 0, max: 59 },
|
|
3
|
+
{ name: "hour", min: 0, max: 23 },
|
|
4
|
+
{ name: "day of month", min: 1, max: 31 },
|
|
5
|
+
{ name: "month", min: 1, max: 12 },
|
|
6
|
+
{ name: "day of week", min: 0, max: 7 },
|
|
7
|
+
];
|
|
8
|
+
function parseNumber(value, name) {
|
|
9
|
+
if (!/^\d+$/.test(value)) {
|
|
10
|
+
throw new Error(`${name} has an invalid value "${value}"`);
|
|
11
|
+
}
|
|
12
|
+
return Number(value);
|
|
13
|
+
}
|
|
14
|
+
function parseField(text, name, min, max) {
|
|
15
|
+
const values = new Set();
|
|
16
|
+
for (const part of text.split(",")) {
|
|
17
|
+
const [range, stepText, extra] = part.split("/");
|
|
18
|
+
if (extra !== undefined)
|
|
19
|
+
throw new Error(`${name} has an invalid step`);
|
|
20
|
+
const step = stepText === undefined ? 1 : parseNumber(stepText, name);
|
|
21
|
+
if (step < 1)
|
|
22
|
+
throw new Error(`${name} step must be at least 1`);
|
|
23
|
+
let from;
|
|
24
|
+
let to;
|
|
25
|
+
if (range === "*") {
|
|
26
|
+
from = min;
|
|
27
|
+
to = max;
|
|
28
|
+
}
|
|
29
|
+
else if (range.includes("-")) {
|
|
30
|
+
const [start, end, more] = range.split("-");
|
|
31
|
+
if (more !== undefined)
|
|
32
|
+
throw new Error(`${name} has an invalid range`);
|
|
33
|
+
from = parseNumber(start, name);
|
|
34
|
+
to = parseNumber(end ?? "", name);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
from = parseNumber(range, name);
|
|
38
|
+
to = stepText === undefined ? from : max;
|
|
39
|
+
}
|
|
40
|
+
if (from < min || to > max || from > to) {
|
|
41
|
+
throw new Error(`${name} must be between ${min} and ${max}`);
|
|
42
|
+
}
|
|
43
|
+
for (let value = from; value <= to; value += step)
|
|
44
|
+
values.add(value);
|
|
45
|
+
}
|
|
46
|
+
return values;
|
|
47
|
+
}
|
|
48
|
+
export function parseCron(expression) {
|
|
49
|
+
const fields = expression.trim().split(/\s+/);
|
|
50
|
+
if (fields.length !== 5) {
|
|
51
|
+
throw new Error("a cron expression needs 5 fields: minute, hour, day of month, month, day of week");
|
|
52
|
+
}
|
|
53
|
+
const [minutes, hours, daysOfMonth, months, daysOfWeek] = FIELDS.map((field, index) => parseField(fields[index], field.name, field.min, field.max));
|
|
54
|
+
return {
|
|
55
|
+
minutes,
|
|
56
|
+
hours,
|
|
57
|
+
daysOfMonth,
|
|
58
|
+
months,
|
|
59
|
+
daysOfWeek: new Set([...daysOfWeek].map((day) => day % 7)),
|
|
60
|
+
anyDayOfMonth: fields[2].startsWith("*"),
|
|
61
|
+
anyDayOfWeek: fields[4].startsWith("*"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** All times are UTC. When both day fields are restricted a date matches if
|
|
65
|
+
* either one does, as in classic cron. */
|
|
66
|
+
export function cronMatches(cron, date) {
|
|
67
|
+
if (!cron.minutes.has(date.getUTCMinutes()) ||
|
|
68
|
+
!cron.hours.has(date.getUTCHours()) ||
|
|
69
|
+
!cron.months.has(date.getUTCMonth() + 1)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const dayOfMonth = cron.daysOfMonth.has(date.getUTCDate());
|
|
73
|
+
const dayOfWeek = cron.daysOfWeek.has(date.getUTCDay());
|
|
74
|
+
return cron.anyDayOfMonth || cron.anyDayOfWeek
|
|
75
|
+
? dayOfMonth && dayOfWeek
|
|
76
|
+
: dayOfMonth || dayOfWeek;
|
|
77
|
+
}
|
|
78
|
+
const SEARCH_MINUTES = 366 * 24 * 60;
|
|
79
|
+
export function nextCronRun(cron, after) {
|
|
80
|
+
let time = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
|
|
81
|
+
for (let step = 0; step < SEARCH_MINUTES; step++, time += 60_000) {
|
|
82
|
+
if (cronMatches(cron, new Date(time)))
|
|
83
|
+
return new Date(time);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const KEY_LENGTH = 32;
|
|
3
|
+
const MIN_LENGTH = 8;
|
|
4
|
+
const MAX_LENGTH = 200;
|
|
5
|
+
function derive(password, salt, length) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
scrypt(password, salt, length, (error, key) => error ? reject(error) : resolve(key));
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function passwordProblem(password) {
|
|
11
|
+
if (password.length < MIN_LENGTH || password.length > MAX_LENGTH) {
|
|
12
|
+
return `the password must be ${MIN_LENGTH} to ${MAX_LENGTH} characters`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function hashPassword(password) {
|
|
16
|
+
const salt = randomBytes(16);
|
|
17
|
+
const key = await derive(password, salt, KEY_LENGTH);
|
|
18
|
+
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
|
19
|
+
}
|
|
20
|
+
export async function verifyPasswordHash(password, stored) {
|
|
21
|
+
const [scheme, saltHex, keyHex] = stored.split("$");
|
|
22
|
+
if (scheme !== "scrypt" || !saltHex || !keyHex)
|
|
23
|
+
return false;
|
|
24
|
+
const expected = Buffer.from(keyHex, "hex");
|
|
25
|
+
if (expected.length === 0)
|
|
26
|
+
return false;
|
|
27
|
+
const key = await derive(password, Buffer.from(saltHex, "hex"), expected.length);
|
|
28
|
+
return timingSafeEqual(key, expected);
|
|
29
|
+
}
|