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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- 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}`);
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
@@ -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
- export declare function handleSettingsRoute(request: Request, url: URL, webhookUrl: string): Promise<Response>;
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
- export async function handleSettingsRoute(request, url, webhookUrl) {
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({
@@ -1,8 +1,9 @@
1
- import type { AuthMethods } from "./auth.ts";
2
- /** `password` is the dashboard password `serve` generates or takes via
3
- * `--password`. */
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;
@@ -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 ?? ""), deps.password, ip);
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 — both return `undefined` — so a probe gains nothing either way. */
24
- export declare function login(password: string, expected: string, ip: string): Promise<LoginResult | undefined>;
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>;
@@ -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 { deleteSession, getSession, insertSession } from "../store/sessions.js";
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 — both return `undefined` — so a probe gains nothing either way. */
74
- export async function login(password, expected, ip) {
75
- if (isLockedOut(ip) || !(await constantTimeEqual(password, expected))) {
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 undefined;
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;
@@ -1,6 +1,6 @@
1
- import type { AuthMethods } from "../auth.ts";
1
+ import type { AuthMethods, PasswordStore } from "../auth.ts";
2
2
  export type PageDeps = {
3
- password: string;
3
+ passwordStore: PasswordStore;
4
4
  webhookUrl?: string;
5
5
  secureCookie?: boolean;
6
6
  githubApp?: {
@@ -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.password, ip);
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">Set with <code>--password</code> when you start serve, or printed to the console if you omit the flag.</div></div>
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,3 @@
1
+ export declare function passwordProblem(password: string): string | undefined;
2
+ export declare function hashPassword(password: string): Promise<string>;
3
+ export declare function verifyPasswordHash(password: string, stored: string): Promise<boolean>;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {