co-maintainer 0.4.1 → 0.4.3
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 +2 -0
- package/dist/package.json +1 -1
- package/dist/src/cli/commands/serve.d.ts +4 -1
- package/dist/src/cli/commands/serve.js +30 -8
- package/dist/src/cli/commands/set.js +13 -2
- package/dist/src/config.d.ts +2 -0
- package/dist/src/knowledge/facts.js +6 -3
- package/dist/src/knowledge/validate.d.ts +2 -1
- package/dist/src/knowledge/validate.js +6 -4
- 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/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/setup.js +4 -1
- package/dist/src/store/sessions.d.ts +1 -0
- package/dist/src/store/sessions.js +5 -0
- package/dist/src/util/password.d.ts +3 -0
- package/dist/src/util/password.js +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# co-maintainer
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/co-maintainer)
|
|
4
|
+
|
|
3
5
|
`co-maintainer` analyzes a GitHub repository and writes repository-specific
|
|
4
6
|
`SKILL.md` guidance that helps developers contribute changes more reliably.
|
|
5
7
|
|
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,14 @@
|
|
|
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";
|
|
8
9
|
import { registerRemoteReviewHandler } from "../../services/remote_review.js";
|
|
9
10
|
import { startRemoteWatchdog } from "../../remote/server/sessions.js";
|
|
11
|
+
import { passwordProblem } from "../../util/password.js";
|
|
10
12
|
import { serveHttp } from "../../server/http.js";
|
|
11
13
|
import { currentPlatform, getEnv } from "../../util/runtime.js";
|
|
12
14
|
/** `undefined` on Linux, otherwise one line naming the platform (a pure
|
|
@@ -24,6 +26,25 @@ function generatePassword() {
|
|
|
24
26
|
crypto.getRandomValues(bytes);
|
|
25
27
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
26
28
|
}
|
|
29
|
+
/** `--password=` replaces the stored password. With no flag the stored one is
|
|
30
|
+
* kept, and a first start generates one, returned so the caller can print it. */
|
|
31
|
+
export async function ensureDashboardPassword(args, hasStoredPassword, store) {
|
|
32
|
+
const flag = args
|
|
33
|
+
.find((arg) => arg.startsWith("--password="))
|
|
34
|
+
?.slice("--password=".length);
|
|
35
|
+
if (flag !== undefined) {
|
|
36
|
+
const problem = passwordProblem(flag);
|
|
37
|
+
if (problem)
|
|
38
|
+
die(`--password: ${problem}`);
|
|
39
|
+
await store.set(flag);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (hasStoredPassword)
|
|
43
|
+
return undefined;
|
|
44
|
+
const generated = generatePassword();
|
|
45
|
+
await store.set(generated);
|
|
46
|
+
return generated;
|
|
47
|
+
}
|
|
27
48
|
/** `--disable-auth=password` / `--enable-auth=github` always win over the
|
|
28
49
|
* config defaults `co-maintainer set` persisted; dies if the result would
|
|
29
50
|
* leave no sign-in method active. Pure so it is testable without a server. */
|
|
@@ -117,6 +138,13 @@ export async function runServe(args) {
|
|
|
117
138
|
allowedUser: config.githubOAuthAllowedUser,
|
|
118
139
|
};
|
|
119
140
|
}
|
|
141
|
+
const passwordStore = configPasswordStore();
|
|
142
|
+
if (auth.password) {
|
|
143
|
+
const generated = await ensureDashboardPassword(args, Boolean(config.dashboardPasswordHash), passwordStore);
|
|
144
|
+
console.log(generated
|
|
145
|
+
? `[serve] dashboard password: ${generated}`
|
|
146
|
+
: "[serve] dashboard password is stored in config.json, change it in Settings");
|
|
147
|
+
}
|
|
120
148
|
const warning = platformWarning(currentPlatform());
|
|
121
149
|
if (warning)
|
|
122
150
|
console.log(`[serve] warning: ${warning}`);
|
|
@@ -136,13 +164,6 @@ export async function runServe(args) {
|
|
|
136
164
|
console.log(`[serve] requeued ${recoveredReplies} unfinished conversation repl${recoveredReplies === 1 ? "y" : "ies"}`);
|
|
137
165
|
}
|
|
138
166
|
startWorkerLoop();
|
|
139
|
-
const password = auth.password
|
|
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}`);
|
|
146
167
|
if (auth.github)
|
|
147
168
|
console.log(`[serve] GitHub sign-in enabled for ${githubOAuth.allowedUser}`);
|
|
148
169
|
const inject500 = args.includes("--inject-500") || getEnv("CM_INJECT_500") === "1";
|
|
@@ -150,7 +171,8 @@ export async function runServe(args) {
|
|
|
150
171
|
console.log("[serve] --inject-500 is on. Mutating /api requests return 500.");
|
|
151
172
|
}
|
|
152
173
|
const app = createApp({
|
|
153
|
-
password,
|
|
174
|
+
password: "",
|
|
175
|
+
passwordStore,
|
|
154
176
|
webhookUrl,
|
|
155
177
|
inject500,
|
|
156
178
|
auth,
|
|
@@ -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
|
@@ -45,6 +45,8 @@ export type UserConfig = {
|
|
|
45
45
|
* `--enable-auth` flag on the `serve` command overrides these per run. */
|
|
46
46
|
passwordAuthDisabled?: boolean;
|
|
47
47
|
githubAuthEnabled?: boolean;
|
|
48
|
+
/** scrypt hash of the dashboard password, never the password itself. */
|
|
49
|
+
dashboardPasswordHash?: string;
|
|
48
50
|
defaults?: {
|
|
49
51
|
maxCommits?: number;
|
|
50
52
|
maxPrMonths?: number;
|
|
@@ -314,9 +314,12 @@ export function extractFacts(source, options) {
|
|
|
314
314
|
.join("; ")}.`, "commit history", 2));
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
const currentPaths = new Set(source.tree);
|
|
318
|
+
const changedFiles = source.pullRequests
|
|
319
|
+
.flatMap((pr) => pr.changedFiles)
|
|
320
|
+
.filter((path) => currentPaths.has(path));
|
|
321
|
+
if (options.includePullRequestChanges && changedFiles.length) {
|
|
322
|
+
const files = counts(changedFiles).slice(0, 10);
|
|
320
323
|
add(facts, fact("review-bar", `Frequently changed files include ${files
|
|
321
324
|
.map(([path]) => `\`${path}\``)
|
|
322
325
|
.join(", ")}; check nearby tests and workflows before editing.`, "pull request files", 2));
|
|
@@ -2,5 +2,6 @@ import type { Source } from "./types.ts";
|
|
|
2
2
|
export type ValidationResult = {
|
|
3
3
|
valid: boolean;
|
|
4
4
|
errors: string[];
|
|
5
|
+
warnings: string[];
|
|
5
6
|
};
|
|
6
|
-
export declare function validateSkill(markdown: string, outputDirectory: string, source?: Source): Promise<ValidationResult>;
|
|
7
|
+
export declare function validateSkill(markdown: string, outputDirectory: string, source?: Source, referenceIssues?: "error" | "warning"): Promise<ValidationResult>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { stat } from "../util/runtime.js";
|
|
2
|
-
export async function validateSkill(markdown, outputDirectory, source) {
|
|
2
|
+
export async function validateSkill(markdown, outputDirectory, source, referenceIssues = "error") {
|
|
3
3
|
const errors = [];
|
|
4
|
+
const warnings = [];
|
|
5
|
+
const referenceProblems = referenceIssues === "error" ? errors : warnings;
|
|
4
6
|
if (!markdown.startsWith("---\n"))
|
|
5
7
|
errors.push("missing YAML frontmatter");
|
|
6
8
|
if (!/^name:\s+\S+/m.test(markdown))
|
|
@@ -104,14 +106,14 @@ export async function validateSkill(markdown, outputDirectory, source) {
|
|
|
104
106
|
![...paths].some((path) => wildcardPattern
|
|
105
107
|
? wildcardPattern.test(path)
|
|
106
108
|
: path.startsWith(`${normalized}/`))) {
|
|
107
|
-
|
|
109
|
+
referenceProblems.push(`referenced path is absent from source: ${reference}`);
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
if (/^(?:npm|pnpm|yarn|bun|deno|cargo|make|go|python|node|git)\s/.test(reference) &&
|
|
111
113
|
![...commands].some((command) => reference === command ||
|
|
112
114
|
reference.startsWith(`${command} `) ||
|
|
113
115
|
command.startsWith(`${reference} `))) {
|
|
114
|
-
|
|
116
|
+
referenceProblems.push(`referenced command is absent from source: ${reference}`);
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
}
|
|
@@ -130,5 +132,5 @@ export async function validateSkill(markdown, outputDirectory, source) {
|
|
|
130
132
|
errors.push(`linked file does not exist: ${link}`);
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
|
-
return { valid: errors.length === 0, errors };
|
|
135
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
134
136
|
}
|
|
@@ -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() {
|
|
@@ -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);
|
|
@@ -211,7 +211,10 @@ export async function runInitOrRemake(options) {
|
|
|
211
211
|
result = await timed("reassemble valid skill", options.logTime, () => assembleSkill(options.repo, facts, previousMarkdown, previous?.sectionHashes ?? {}, overrides));
|
|
212
212
|
result.markdown = addReviewLink(result.markdown, reviewDocuments);
|
|
213
213
|
}
|
|
214
|
-
const finalValidation = await timed("final skill validation", options.logTime, () => validateSkill(result.markdown, `${reposDir()}/${options.repo}`, source));
|
|
214
|
+
const finalValidation = await timed("final skill validation", options.logTime, () => validateSkill(result.markdown, `${reposDir()}/${options.repo}`, source, "warning"));
|
|
215
|
+
if (finalValidation.warnings.length) {
|
|
216
|
+
log("validate", `stale references kept: ${finalValidation.warnings.join(", ")}`);
|
|
217
|
+
}
|
|
215
218
|
if (!finalValidation.valid) {
|
|
216
219
|
throw new Error(`generated skill is invalid: ${finalValidation.errors.join("; ")}`);
|
|
217
220
|
}
|
|
@@ -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,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
|
+
}
|