co-maintainer 0.4.0-beta.1
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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +9 -0
- package/dist/mod.d.ts +4 -0
- package/dist/mod.js +2 -0
- package/dist/package.json +54 -0
- package/dist/src/ai/batch.d.ts +16 -0
- package/dist/src/ai/batch.js +117 -0
- package/dist/src/ai/fake.d.ts +7 -0
- package/dist/src/ai/fake.js +40 -0
- package/dist/src/ai/hetzner.d.ts +16 -0
- package/dist/src/ai/hetzner.js +69 -0
- package/dist/src/ai/mermaid.d.ts +27 -0
- package/dist/src/ai/mermaid.js +457 -0
- package/dist/src/ai/mermaid_loop.d.ts +12 -0
- package/dist/src/ai/mermaid_loop.js +100 -0
- package/dist/src/ai/openrouter.d.ts +9 -0
- package/dist/src/ai/openrouter.js +29 -0
- package/dist/src/ai/provider.d.ts +4 -0
- package/dist/src/ai/provider.js +63 -0
- package/dist/src/cli/args.d.ts +5 -0
- package/dist/src/cli/args.js +252 -0
- package/dist/src/cli/commands/probe.d.ts +2 -0
- package/dist/src/cli/commands/probe.js +122 -0
- package/dist/src/cli/commands/review.d.ts +3 -0
- package/dist/src/cli/commands/review.js +84 -0
- package/dist/src/cli/commands/serve.d.ts +18 -0
- package/dist/src/cli/commands/serve.js +183 -0
- package/dist/src/cli/commands/set.d.ts +7 -0
- package/dist/src/cli/commands/set.js +171 -0
- package/dist/src/cli/main.d.ts +1 -0
- package/dist/src/cli/main.js +27 -0
- package/dist/src/cli/prompt.d.ts +4 -0
- package/dist/src/cli/prompt.js +26 -0
- package/dist/src/cli/review_args.d.ts +28 -0
- package/dist/src/cli/review_args.js +84 -0
- package/dist/src/cli/review_output.d.ts +2 -0
- package/dist/src/cli/review_output.js +10 -0
- package/dist/src/cli/review_result.d.ts +81 -0
- package/dist/src/cli/review_result.js +281 -0
- package/dist/src/config.d.ts +108 -0
- package/dist/src/config.js +138 -0
- package/dist/src/git/default_branch.d.ts +5 -0
- package/dist/src/git/default_branch.js +22 -0
- package/dist/src/github/app.d.ts +43 -0
- package/dist/src/github/app.js +120 -0
- package/dist/src/github/client.d.ts +17 -0
- package/dist/src/github/client.js +66 -0
- package/dist/src/github/collect.d.ts +7 -0
- package/dist/src/github/collect.js +368 -0
- package/dist/src/github/gh.d.ts +8 -0
- package/dist/src/github/gh.js +63 -0
- package/dist/src/github/jwt.d.ts +12 -0
- package/dist/src/github/jwt.js +84 -0
- package/dist/src/github/oauth.d.ts +9 -0
- package/dist/src/github/oauth.js +51 -0
- package/dist/src/github/pat.d.ts +10 -0
- package/dist/src/github/pat.js +60 -0
- package/dist/src/knowledge/facts.d.ts +3 -0
- package/dist/src/knowledge/facts.js +325 -0
- package/dist/src/knowledge/guide.d.ts +6 -0
- package/dist/src/knowledge/guide.js +82 -0
- package/dist/src/knowledge/probe.d.ts +12 -0
- package/dist/src/knowledge/probe.js +144 -0
- package/dist/src/knowledge/sections.d.ts +2 -0
- package/dist/src/knowledge/sections.js +13 -0
- package/dist/src/knowledge/skill.d.ts +10 -0
- package/dist/src/knowledge/skill.js +106 -0
- package/dist/src/knowledge/synthesis.d.ts +7 -0
- package/dist/src/knowledge/synthesis.js +315 -0
- package/dist/src/knowledge/types.d.ts +49 -0
- package/dist/src/knowledge/types.js +1 -0
- package/dist/src/knowledge/validate.d.ts +6 -0
- package/dist/src/knowledge/validate.js +134 -0
- package/dist/src/local/carry_over_store.d.ts +18 -0
- package/dist/src/local/carry_over_store.js +40 -0
- package/dist/src/local/codegraph_exclude.d.ts +3 -0
- package/dist/src/local/codegraph_exclude.js +26 -0
- package/dist/src/local/codegraph_prepare.d.ts +17 -0
- package/dist/src/local/codegraph_prepare.js +51 -0
- package/dist/src/local/git_ops.d.ts +12 -0
- package/dist/src/local/git_ops.js +98 -0
- package/dist/src/local/git_parse.d.ts +27 -0
- package/dist/src/local/git_parse.js +131 -0
- package/dist/src/local/git_revision.d.ts +15 -0
- package/dist/src/local/git_revision.js +141 -0
- package/dist/src/local/git_untracked.d.ts +21 -0
- package/dist/src/local/git_untracked.js +79 -0
- package/dist/src/local/review_local.d.ts +4 -0
- package/dist/src/local/review_local.js +273 -0
- package/dist/src/local/review_lock.d.ts +7 -0
- package/dist/src/local/review_lock.js +45 -0
- package/dist/src/pr/checkout.d.ts +25 -0
- package/dist/src/pr/checkout.js +136 -0
- package/dist/src/pr/codegraph_tool_args.d.ts +8 -0
- package/dist/src/pr/codegraph_tool_args.js +34 -0
- package/dist/src/pr/codegraph_tools.d.ts +15 -0
- package/dist/src/pr/codegraph_tools.js +408 -0
- package/dist/src/pr/diff_summary.d.ts +6 -0
- package/dist/src/pr/diff_summary.js +52 -0
- package/dist/src/pr/findings.d.ts +14 -0
- package/dist/src/pr/findings.js +94 -0
- package/dist/src/pr/github_revision.d.ts +4 -0
- package/dist/src/pr/github_revision.js +39 -0
- package/dist/src/pr/hunks.d.ts +18 -0
- package/dist/src/pr/hunks.js +73 -0
- package/dist/src/pr/reviewer.d.ts +40 -0
- package/dist/src/pr/reviewer.js +582 -0
- package/dist/src/pr/rounds.d.ts +17 -0
- package/dist/src/pr/rounds.js +43 -0
- package/dist/src/pr/scope.d.ts +35 -0
- package/dist/src/pr/scope.js +84 -0
- package/dist/src/pr/snapshot.d.ts +11 -0
- package/dist/src/pr/snapshot.js +1 -0
- package/dist/src/pr/suggestion.d.ts +17 -0
- package/dist/src/pr/suggestion.js +70 -0
- package/dist/src/remote/client.d.ts +4 -0
- package/dist/src/remote/client.js +199 -0
- package/dist/src/remote/client_tools.d.ts +13 -0
- package/dist/src/remote/client_tools.js +30 -0
- package/dist/src/remote/fixtures.d.ts +1 -0
- package/dist/src/remote/fixtures.js +30 -0
- package/dist/src/remote/revision_from_submit.d.ts +2 -0
- package/dist/src/remote/revision_from_submit.js +26 -0
- package/dist/src/remote/schema.d.ts +14 -0
- package/dist/src/remote/schema.js +30 -0
- package/dist/src/remote/server/body.d.ts +18 -0
- package/dist/src/remote/server/body.js +73 -0
- package/dist/src/remote/server/routes.d.ts +1 -0
- package/dist/src/remote/server/routes.js +150 -0
- package/dist/src/remote/server/sessions.d.ts +16 -0
- package/dist/src/remote/server/sessions.js +48 -0
- package/dist/src/remote/server/sync.d.ts +3 -0
- package/dist/src/remote/server/sync.js +90 -0
- package/dist/src/remote/tool_bridge.d.ts +15 -0
- package/dist/src/remote/tool_bridge.js +84 -0
- package/dist/src/remote/validate.d.ts +8 -0
- package/dist/src/remote/validate.js +248 -0
- package/dist/src/review/carry_over.d.ts +63 -0
- package/dist/src/review/carry_over.js +372 -0
- package/dist/src/review/engine.d.ts +13 -0
- package/dist/src/review/engine.js +4 -0
- package/dist/src/review/guides.d.ts +12 -0
- package/dist/src/review/guides.js +44 -0
- package/dist/src/review/revision.d.ts +26 -0
- package/dist/src/review/revision.js +57 -0
- package/dist/src/server/api/activity.d.ts +1 -0
- package/dist/src/server/api/activity.js +12 -0
- package/dist/src/server/api/analytics.d.ts +1 -0
- package/dist/src/server/api/analytics.js +9 -0
- package/dist/src/server/api/installations.d.ts +4 -0
- package/dist/src/server/api/installations.js +24 -0
- package/dist/src/server/api/jobs.d.ts +1 -0
- package/dist/src/server/api/jobs.js +86 -0
- package/dist/src/server/api/json_body.d.ts +1 -0
- package/dist/src/server/api/json_body.js +14 -0
- package/dist/src/server/api/remote_tokens.d.ts +1 -0
- package/dist/src/server/api/remote_tokens.js +71 -0
- package/dist/src/server/api/repos.d.ts +4 -0
- package/dist/src/server/api/repos.js +136 -0
- package/dist/src/server/api/settings.d.ts +1 -0
- package/dist/src/server/api/settings.js +210 -0
- package/dist/src/server/app.d.ts +27 -0
- package/dist/src/server/app.js +147 -0
- package/dist/src/server/assets.d.ts +3 -0
- package/dist/src/server/assets.js +21 -0
- package/dist/src/server/auth.d.ts +44 -0
- package/dist/src/server/auth.js +188 -0
- package/dist/src/server/errors.d.ts +8 -0
- package/dist/src/server/errors.js +10 -0
- package/dist/src/server/http.d.ts +8 -0
- package/dist/src/server/http.js +117 -0
- package/dist/src/server/logo.d.ts +1 -0
- package/dist/src/server/logo.js +2 -0
- package/dist/src/server/pages/activity.d.ts +4 -0
- package/dist/src/server/pages/activity.js +200 -0
- package/dist/src/server/pages/add_repo.d.ts +9 -0
- package/dist/src/server/pages/add_repo.js +56 -0
- package/dist/src/server/pages/analytics.d.ts +2 -0
- package/dist/src/server/pages/analytics.js +193 -0
- package/dist/src/server/pages/client.d.ts +2 -0
- package/dist/src/server/pages/client.js +2 -0
- package/dist/src/server/pages/home.d.ts +16 -0
- package/dist/src/server/pages/home.js +87 -0
- package/dist/src/server/pages/knowledge.d.ts +2 -0
- package/dist/src/server/pages/knowledge.js +64 -0
- package/dist/src/server/pages/layout.d.ts +32 -0
- package/dist/src/server/pages/layout.js +230 -0
- package/dist/src/server/pages/pr.d.ts +2 -0
- package/dist/src/server/pages/pr.js +109 -0
- package/dist/src/server/pages/repo.d.ts +2 -0
- package/dist/src/server/pages/repo.js +92 -0
- package/dist/src/server/pages/repo_prs.d.ts +2 -0
- package/dist/src/server/pages/repo_prs.js +75 -0
- package/dist/src/server/pages/repo_remote.d.ts +2 -0
- package/dist/src/server/pages/repo_remote.js +38 -0
- package/dist/src/server/pages/repo_settings.d.ts +3 -0
- package/dist/src/server/pages/repo_settings.js +127 -0
- package/dist/src/server/pages/router.d.ts +17 -0
- package/dist/src/server/pages/router.js +312 -0
- package/dist/src/server/pages/settings.d.ts +2 -0
- package/dist/src/server/pages/settings.js +337 -0
- package/dist/src/server/pages/setup.d.ts +5 -0
- package/dist/src/server/pages/setup.js +38 -0
- package/dist/src/server/pages/styles.d.ts +2 -0
- package/dist/src/server/pages/styles.js +2 -0
- package/dist/src/server/webhook/index.d.ts +1 -0
- package/dist/src/server/webhook/index.js +64 -0
- package/dist/src/server/webhook/signature.d.ts +7 -0
- package/dist/src/server/webhook/signature.js +41 -0
- package/dist/src/services/credentials.d.ts +19 -0
- package/dist/src/services/credentials.js +146 -0
- package/dist/src/services/dashboard.d.ts +165 -0
- package/dist/src/services/dashboard.js +200 -0
- package/dist/src/services/drift.d.ts +13 -0
- package/dist/src/services/drift.js +76 -0
- package/dist/src/services/jobs.d.ts +34 -0
- package/dist/src/services/jobs.js +222 -0
- package/dist/src/services/remote_review.d.ts +10 -0
- package/dist/src/services/remote_review.js +274 -0
- package/dist/src/services/remote_review_abort.d.ts +3 -0
- package/dist/src/services/remote_review_abort.js +11 -0
- package/dist/src/services/remote_token_jobs.d.ts +1 -0
- package/dist/src/services/remote_token_jobs.js +20 -0
- package/dist/src/services/remote_tokens.d.ts +13 -0
- package/dist/src/services/remote_tokens.js +57 -0
- package/dist/src/services/replies.d.ts +7 -0
- package/dist/src/services/replies.js +248 -0
- package/dist/src/services/review.d.ts +35 -0
- package/dist/src/services/review.js +671 -0
- package/dist/src/services/setup.d.ts +29 -0
- package/dist/src/services/setup.js +364 -0
- package/dist/src/services/webhook.d.ts +22 -0
- package/dist/src/services/webhook.js +401 -0
- package/dist/src/store/app_db.d.ts +10 -0
- package/dist/src/store/app_db.js +85 -0
- package/dist/src/store/cache_db.d.ts +4 -0
- package/dist/src/store/cache_db.js +63 -0
- package/dist/src/store/deliveries.d.ts +12 -0
- package/dist/src/store/deliveries.js +23 -0
- package/dist/src/store/drift.d.ts +4 -0
- package/dist/src/store/drift.js +22 -0
- package/dist/src/store/findings.d.ts +31 -0
- package/dist/src/store/findings.js +64 -0
- package/dist/src/store/installations.d.ts +9 -0
- package/dist/src/store/installations.js +35 -0
- package/dist/src/store/job_logs.d.ts +5 -0
- package/dist/src/store/job_logs.js +17 -0
- package/dist/src/store/jobs.d.ts +27 -0
- package/dist/src/store/jobs.js +86 -0
- package/dist/src/store/migrations.d.ts +5 -0
- package/dist/src/store/migrations.js +258 -0
- package/dist/src/store/remote_review_inputs.d.ts +11 -0
- package/dist/src/store/remote_review_inputs.js +25 -0
- package/dist/src/store/remote_tokens.d.ts +10 -0
- package/dist/src/store/remote_tokens.js +45 -0
- package/dist/src/store/replies.d.ts +24 -0
- package/dist/src/store/replies.js +79 -0
- package/dist/src/store/repos.d.ts +11 -0
- package/dist/src/store/repos.js +59 -0
- package/dist/src/store/reviews.d.ts +86 -0
- package/dist/src/store/reviews.js +220 -0
- package/dist/src/store/rows.d.ts +179 -0
- package/dist/src/store/rows.js +4 -0
- package/dist/src/store/sessions.d.ts +8 -0
- package/dist/src/store/sessions.js +34 -0
- package/dist/src/store/skill_state.d.ts +3 -0
- package/dist/src/store/skill_state.js +24 -0
- package/dist/src/store/sqlite.d.ts +39 -0
- package/dist/src/store/sqlite.js +77 -0
- package/dist/src/store/subjects.d.ts +15 -0
- package/dist/src/store/subjects.js +78 -0
- package/dist/src/tools/codegraph.d.ts +58 -0
- package/dist/src/tools/codegraph.js +165 -0
- package/dist/src/tools/codegraph_exec.d.ts +13 -0
- package/dist/src/tools/codegraph_exec.js +66 -0
- package/dist/src/types.d.ts +89 -0
- package/dist/src/types.js +1 -0
- package/dist/src/util/atomic.d.ts +4 -0
- package/dist/src/util/atomic.js +78 -0
- package/dist/src/util/keyed_lock.d.ts +3 -0
- package/dist/src/util/keyed_lock.js +33 -0
- package/dist/src/util/log.d.ts +11 -0
- package/dist/src/util/log.js +42 -0
- package/dist/src/util/redact.d.ts +5 -0
- package/dist/src/util/redact.js +30 -0
- package/dist/src/util/runtime.d.ts +90 -0
- package/dist/src/util/runtime.js +206 -0
- package/dist/src/util/time.d.ts +9 -0
- package/dist/src/util/time.js +36 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +5 -0
- package/package.json +54 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** `createApp(deps).fetch(request)` — no port, no sockets, so it is testable
|
|
2
|
+
* with a plain `Request`. */
|
|
3
|
+
import { VERSION } from "../version.js";
|
|
4
|
+
import { errorResponse } from "./errors.js";
|
|
5
|
+
import { hasCsrfHeader, login, logout, readSessionToken, sessionCookieHeader, verifySession, } from "./auth.js";
|
|
6
|
+
import { readConfig } from "../config.js";
|
|
7
|
+
import { listJobs } from "../store/jobs.js";
|
|
8
|
+
import { handleJobsRoute } from "./api/jobs.js";
|
|
9
|
+
import { handleReposRoute } from "./api/repos.js";
|
|
10
|
+
import { handleInstallationsRoute } from "./api/installations.js";
|
|
11
|
+
import { handleSettingsRoute } from "./api/settings.js";
|
|
12
|
+
import { handleActivityRoute } from "./api/activity.js";
|
|
13
|
+
import { handleAnalyticsRoute } from "./api/analytics.js";
|
|
14
|
+
import { handleRemoteTokensRoute } from "./api/remote_tokens.js";
|
|
15
|
+
import { handleRemoteRoute } from "../remote/server/routes.js";
|
|
16
|
+
import { handleWebhookRequest } from "./webhook/index.js";
|
|
17
|
+
import { handlePageRequest } from "./pages/router.js";
|
|
18
|
+
function setupStatus() {
|
|
19
|
+
const config = readConfig();
|
|
20
|
+
const ai = Boolean(config.ai && config.ai !== "none" && config.token);
|
|
21
|
+
const github = Boolean(config.auth === "gh" || (config.auth === "pat" && config.githubPat));
|
|
22
|
+
const app = Boolean(config.githubAppId && config.githubAppPrivateKey);
|
|
23
|
+
const missing = [
|
|
24
|
+
!ai && "ai",
|
|
25
|
+
!github && "github",
|
|
26
|
+
!app && "github app",
|
|
27
|
+
].filter((item) => Boolean(item));
|
|
28
|
+
return { setup: { ai, github, app }, missing };
|
|
29
|
+
}
|
|
30
|
+
async function requireSession(request) {
|
|
31
|
+
const token = readSessionToken(request);
|
|
32
|
+
if (!token)
|
|
33
|
+
return undefined;
|
|
34
|
+
return await verifySession(token);
|
|
35
|
+
}
|
|
36
|
+
async function handleLogin(request, deps, ip) {
|
|
37
|
+
if (deps.auth?.password === false) {
|
|
38
|
+
return errorResponse(403, "password_disabled", "password sign-in is disabled");
|
|
39
|
+
}
|
|
40
|
+
let body;
|
|
41
|
+
try {
|
|
42
|
+
body = await request.json();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return errorResponse(400, "bad_request", "expected a JSON body");
|
|
46
|
+
}
|
|
47
|
+
const result = await login(String(body.password ?? ""), deps.password, ip);
|
|
48
|
+
if (!result)
|
|
49
|
+
return errorResponse(401, "unauthorized", "wrong password");
|
|
50
|
+
return Response.json(result, {
|
|
51
|
+
headers: {
|
|
52
|
+
"set-cookie": sessionCookieHeader(result.token, Boolean(deps.secureCookie)),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function handleLogout(request) {
|
|
57
|
+
const token = readSessionToken(request);
|
|
58
|
+
if (token)
|
|
59
|
+
await logout(token);
|
|
60
|
+
return new Response(null, { status: 204 });
|
|
61
|
+
}
|
|
62
|
+
function health() {
|
|
63
|
+
let queue = { queued: 0, running: 0 };
|
|
64
|
+
let db = "ok";
|
|
65
|
+
try {
|
|
66
|
+
queue = {
|
|
67
|
+
queued: listJobs({ status: "queued" }).length,
|
|
68
|
+
running: listJobs({ status: "running" }).length,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
db = "unavailable";
|
|
73
|
+
}
|
|
74
|
+
return Response.json({
|
|
75
|
+
ok: true,
|
|
76
|
+
version: VERSION,
|
|
77
|
+
queue,
|
|
78
|
+
db,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function createApp(deps) {
|
|
82
|
+
return {
|
|
83
|
+
async fetch(request, remoteAddr = "unknown") {
|
|
84
|
+
const url = new URL(request.url);
|
|
85
|
+
const mutating = ["POST", "PATCH", "PUT", "DELETE"].includes(request.method);
|
|
86
|
+
if (url.pathname === "/api/health" && request.method === "GET") {
|
|
87
|
+
return health();
|
|
88
|
+
}
|
|
89
|
+
if (url.pathname === "/github/webhook" && request.method === "POST") {
|
|
90
|
+
return await handleWebhookRequest(request, deps.webhookSecret);
|
|
91
|
+
}
|
|
92
|
+
const page = await handlePageRequest(request, deps, remoteAddr);
|
|
93
|
+
if (page)
|
|
94
|
+
return page;
|
|
95
|
+
if (url.pathname.startsWith("/api/remote/")) {
|
|
96
|
+
return await handleRemoteRoute(request, url, remoteAddr);
|
|
97
|
+
}
|
|
98
|
+
if (url.pathname.startsWith("/api/")) {
|
|
99
|
+
if (mutating && !hasCsrfHeader(request)) {
|
|
100
|
+
return errorResponse(403, "csrf", `every mutating request needs the X-Requested-With header`);
|
|
101
|
+
}
|
|
102
|
+
if (url.pathname === "/api/login" && request.method === "POST") {
|
|
103
|
+
return await handleLogin(request, deps, remoteAddr);
|
|
104
|
+
}
|
|
105
|
+
const session = await requireSession(request);
|
|
106
|
+
if (!session) {
|
|
107
|
+
return errorResponse(401, "unauthorized", "sign in required");
|
|
108
|
+
}
|
|
109
|
+
if (deps.inject500 && mutating && url.pathname !== "/api/login") {
|
|
110
|
+
return errorResponse(500, "injected", "The request failed.");
|
|
111
|
+
}
|
|
112
|
+
if (url.pathname === "/api/logout" && request.method === "POST") {
|
|
113
|
+
return await handleLogout(request);
|
|
114
|
+
}
|
|
115
|
+
if (url.pathname === "/api/me" && request.method === "GET") {
|
|
116
|
+
return Response.json({
|
|
117
|
+
username: session.username,
|
|
118
|
+
...setupStatus(),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (url.pathname.startsWith("/api/jobs")) {
|
|
122
|
+
return handleJobsRoute(request, url);
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname.startsWith("/api/repos")) {
|
|
125
|
+
return await handleReposRoute(request, url, deps.githubApp);
|
|
126
|
+
}
|
|
127
|
+
if (url.pathname === "/api/installations") {
|
|
128
|
+
return await handleInstallationsRoute(request, deps.githubApp);
|
|
129
|
+
}
|
|
130
|
+
if (url.pathname.startsWith("/api/settings")) {
|
|
131
|
+
return await handleSettingsRoute(request, url, deps.webhookUrl ?? "");
|
|
132
|
+
}
|
|
133
|
+
if (url.pathname.startsWith("/api/activity")) {
|
|
134
|
+
return handleActivityRoute(request, url);
|
|
135
|
+
}
|
|
136
|
+
if (url.pathname === "/api/analytics") {
|
|
137
|
+
return handleAnalyticsRoute(request, url);
|
|
138
|
+
}
|
|
139
|
+
if (url.pathname.startsWith("/api/remote-tokens")) {
|
|
140
|
+
return await handleRemoteTokensRoute(request, url);
|
|
141
|
+
}
|
|
142
|
+
return errorResponse(404, "not_found", `no route for ${url.pathname}`);
|
|
143
|
+
}
|
|
144
|
+
return errorResponse(404, "not_found", `no route for ${url.pathname}`);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { client } from "./pages/client.js";
|
|
2
|
+
import { logo } from "./logo.js";
|
|
3
|
+
import { styles } from "./pages/styles.js";
|
|
4
|
+
export async function handleStyles() {
|
|
5
|
+
return new Response(styles, {
|
|
6
|
+
headers: { "content-type": "text/css; charset=utf-8" },
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export async function handleLogo() {
|
|
10
|
+
return new Response(logo, {
|
|
11
|
+
headers: {
|
|
12
|
+
"content-type": "image/png",
|
|
13
|
+
"cache-control": "public, max-age=86400",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export async function handleClient() {
|
|
18
|
+
return new Response(client, {
|
|
19
|
+
headers: { "content-type": "text/javascript; charset=utf-8" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const USERNAME = "admin";
|
|
2
|
+
export declare const CSRF_HEADER = "x-requested-with";
|
|
3
|
+
export declare const CSRF_VALUE = "co-maintainer";
|
|
4
|
+
export declare const SESSION_COOKIE = "cm";
|
|
5
|
+
export declare const OAUTH_STATE_COOKIE = "cm_oauth_state";
|
|
6
|
+
/** Which sign-in methods a running `serve` accepts. Resolved once at
|
|
7
|
+
* startup (CLI flag, falling back to config) and threaded through
|
|
8
|
+
* `AppDeps`/`PageDeps` — every route reads the same resolved value. */
|
|
9
|
+
export type AuthMethods = {
|
|
10
|
+
password: boolean;
|
|
11
|
+
github: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function randomToken(): string;
|
|
14
|
+
export declare function recordAuthFailure(ip: string): void;
|
|
15
|
+
export declare function isLockedOut(ip: string): boolean;
|
|
16
|
+
export declare function hasCsrfHeader(request: Request): boolean;
|
|
17
|
+
export type LoginResult = {
|
|
18
|
+
token: string;
|
|
19
|
+
username: string;
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
};
|
|
22
|
+
/** 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>;
|
|
25
|
+
/** Issues a session for the one dashboard identity, bypassing the password
|
|
26
|
+
* check — used after GitHub OAuth has already verified the caller. */
|
|
27
|
+
export declare function createSession(): Promise<LoginResult>;
|
|
28
|
+
export declare function verifySession(token: string): Promise<{
|
|
29
|
+
username: string;
|
|
30
|
+
} | undefined>;
|
|
31
|
+
export declare function logout(token: string): Promise<void>;
|
|
32
|
+
export declare function readBearerToken(request: Request): string | undefined;
|
|
33
|
+
export declare function readSessionToken(request: Request): string | undefined;
|
|
34
|
+
export declare function sessionCookieHeader(token: string, secure: boolean): string;
|
|
35
|
+
export declare function clearSessionCookieHeader(secure: boolean): string;
|
|
36
|
+
/** The GitHub OAuth `state` param, plus the post-login `next` path,
|
|
37
|
+
* round-tripped through an HttpOnly cookie instead of server memory — one
|
|
38
|
+
* admin, one in-flight login at a time, so a cookie is all this needs. */
|
|
39
|
+
export declare function readOauthState(request: Request): {
|
|
40
|
+
state: string;
|
|
41
|
+
next: string;
|
|
42
|
+
} | undefined;
|
|
43
|
+
export declare function oauthStateCookieHeader(state: string, next: string, secure: boolean): string;
|
|
44
|
+
export declare function clearOauthStateCookieHeader(secure: boolean): string;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Only a token's hash ever reaches `store/sessions.ts`; the raw token is
|
|
2
|
+
* handed back once, at login, and never stored. */
|
|
3
|
+
import { deleteSession, getSession, insertSession } from "../store/sessions.js";
|
|
4
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const LOCKOUT_THRESHOLD = 5;
|
|
6
|
+
const LOCKOUT_WINDOW_MS = 10 * 60 * 1000;
|
|
7
|
+
const LOCKOUT_DURATION_MS = 10 * 60 * 1000;
|
|
8
|
+
export const USERNAME = "admin";
|
|
9
|
+
export const CSRF_HEADER = "x-requested-with";
|
|
10
|
+
export const CSRF_VALUE = "co-maintainer";
|
|
11
|
+
export const SESSION_COOKIE = "cm";
|
|
12
|
+
export const OAUTH_STATE_COOKIE = "cm_oauth_state";
|
|
13
|
+
const SESSION_TTL_SEC = 7 * 24 * 60 * 60;
|
|
14
|
+
const OAUTH_STATE_TTL_SEC = 600;
|
|
15
|
+
async function sha256Hex(value) {
|
|
16
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
17
|
+
return [...new Uint8Array(bytes)]
|
|
18
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
19
|
+
.join("");
|
|
20
|
+
}
|
|
21
|
+
/** Hashing both sides to a fixed-length digest before comparing means the
|
|
22
|
+
* XOR loop's timing depends on neither the password's length nor content,
|
|
23
|
+
* closing the timing side-channel a plain `!==` leaves open. */
|
|
24
|
+
async function constantTimeEqual(a, b) {
|
|
25
|
+
const [ah, bh] = await Promise.all([sha256Hex(a), sha256Hex(b)]);
|
|
26
|
+
let diff = 0;
|
|
27
|
+
for (let index = 0; index < ah.length; index++) {
|
|
28
|
+
diff |= ah.charCodeAt(index) ^ bh.charCodeAt(index);
|
|
29
|
+
}
|
|
30
|
+
return diff === 0;
|
|
31
|
+
}
|
|
32
|
+
export function randomToken() {
|
|
33
|
+
const bytes = new Uint8Array(32);
|
|
34
|
+
crypto.getRandomValues(bytes);
|
|
35
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
36
|
+
}
|
|
37
|
+
// ponytail: an in-memory Map, one entry per distinct IP that has ever failed
|
|
38
|
+
// a login. Fine for a single self-hosted admin; add eviction if this ever
|
|
39
|
+
// needs to survive a hostile flood of spoofed source IPs.
|
|
40
|
+
const attempts = new Map();
|
|
41
|
+
export function recordAuthFailure(ip) {
|
|
42
|
+
recordFailure(ip);
|
|
43
|
+
}
|
|
44
|
+
export function isLockedOut(ip) {
|
|
45
|
+
const attempt = attempts.get(ip);
|
|
46
|
+
if (!attempt?.lockedUntil)
|
|
47
|
+
return false;
|
|
48
|
+
if (Date.now() >= attempt.lockedUntil) {
|
|
49
|
+
attempts.delete(ip);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
function recordFailure(ip) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const attempt = attempts.get(ip);
|
|
57
|
+
if (!attempt || now - attempt.firstFailureAt > LOCKOUT_WINDOW_MS) {
|
|
58
|
+
attempts.set(ip, { failures: 1, firstFailureAt: now });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
attempt.failures++;
|
|
62
|
+
if (attempt.failures >= LOCKOUT_THRESHOLD) {
|
|
63
|
+
attempt.lockedUntil = now + LOCKOUT_DURATION_MS;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function recordSuccess(ip) {
|
|
67
|
+
attempts.delete(ip);
|
|
68
|
+
}
|
|
69
|
+
export function hasCsrfHeader(request) {
|
|
70
|
+
return request.headers.get(CSRF_HEADER) === CSRF_VALUE;
|
|
71
|
+
}
|
|
72
|
+
/** 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))) {
|
|
76
|
+
recordFailure(ip);
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
recordSuccess(ip);
|
|
80
|
+
return await createSession();
|
|
81
|
+
}
|
|
82
|
+
/** Issues a session for the one dashboard identity, bypassing the password
|
|
83
|
+
* check — used after GitHub OAuth has already verified the caller. */
|
|
84
|
+
export async function createSession() {
|
|
85
|
+
const token = randomToken();
|
|
86
|
+
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
|
87
|
+
insertSession(await sha256Hex(token), USERNAME, expiresAt);
|
|
88
|
+
return { token, username: USERNAME, expiresAt };
|
|
89
|
+
}
|
|
90
|
+
export async function verifySession(token) {
|
|
91
|
+
const session = getSession(await sha256Hex(token));
|
|
92
|
+
return session ? { username: session.username } : undefined;
|
|
93
|
+
}
|
|
94
|
+
export async function logout(token) {
|
|
95
|
+
deleteSession(await sha256Hex(token));
|
|
96
|
+
}
|
|
97
|
+
function readCookie(request, name) {
|
|
98
|
+
const cookie = request.headers.get("cookie") ?? "";
|
|
99
|
+
for (const part of cookie.split(";")) {
|
|
100
|
+
const trimmed = part.trim();
|
|
101
|
+
const eq = trimmed.indexOf("=");
|
|
102
|
+
if (eq === -1)
|
|
103
|
+
continue;
|
|
104
|
+
if (trimmed.slice(0, eq) === name)
|
|
105
|
+
return trimmed.slice(eq + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function readBearerToken(request) {
|
|
109
|
+
const header = request.headers.get("authorization") ?? "";
|
|
110
|
+
if (!header.startsWith("Bearer "))
|
|
111
|
+
return undefined;
|
|
112
|
+
const token = header.slice("Bearer ".length).trim();
|
|
113
|
+
return token || undefined;
|
|
114
|
+
}
|
|
115
|
+
export function readSessionToken(request) {
|
|
116
|
+
const bearer = readBearerToken(request);
|
|
117
|
+
if (bearer)
|
|
118
|
+
return bearer;
|
|
119
|
+
return readCookie(request, SESSION_COOKIE);
|
|
120
|
+
}
|
|
121
|
+
export function sessionCookieHeader(token, secure) {
|
|
122
|
+
const flags = [
|
|
123
|
+
`${SESSION_COOKIE}=${token}`,
|
|
124
|
+
"HttpOnly",
|
|
125
|
+
"SameSite=Lax",
|
|
126
|
+
"Path=/",
|
|
127
|
+
`Max-Age=${SESSION_TTL_SEC}`,
|
|
128
|
+
];
|
|
129
|
+
if (secure)
|
|
130
|
+
flags.push("Secure");
|
|
131
|
+
return flags.join("; ");
|
|
132
|
+
}
|
|
133
|
+
export function clearSessionCookieHeader(secure) {
|
|
134
|
+
const flags = [
|
|
135
|
+
`${SESSION_COOKIE}=`,
|
|
136
|
+
"HttpOnly",
|
|
137
|
+
"SameSite=Lax",
|
|
138
|
+
"Path=/",
|
|
139
|
+
"Max-Age=0",
|
|
140
|
+
];
|
|
141
|
+
if (secure)
|
|
142
|
+
flags.push("Secure");
|
|
143
|
+
return flags.join("; ");
|
|
144
|
+
}
|
|
145
|
+
/** The GitHub OAuth `state` param, plus the post-login `next` path,
|
|
146
|
+
* round-tripped through an HttpOnly cookie instead of server memory — one
|
|
147
|
+
* admin, one in-flight login at a time, so a cookie is all this needs. */
|
|
148
|
+
export function readOauthState(request) {
|
|
149
|
+
const value = readCookie(request, OAUTH_STATE_COOKIE);
|
|
150
|
+
if (!value)
|
|
151
|
+
return undefined;
|
|
152
|
+
const sep = value.indexOf(":");
|
|
153
|
+
if (sep === -1)
|
|
154
|
+
return { state: value, next: "/" };
|
|
155
|
+
try {
|
|
156
|
+
return {
|
|
157
|
+
state: value.slice(0, sep),
|
|
158
|
+
next: decodeURIComponent(value.slice(sep + 1)),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function oauthStateCookieHeader(state, next, secure) {
|
|
166
|
+
const flags = [
|
|
167
|
+
`${OAUTH_STATE_COOKIE}=${state}:${encodeURIComponent(next)}`,
|
|
168
|
+
"HttpOnly",
|
|
169
|
+
"SameSite=Lax",
|
|
170
|
+
"Path=/",
|
|
171
|
+
`Max-Age=${OAUTH_STATE_TTL_SEC}`,
|
|
172
|
+
];
|
|
173
|
+
if (secure)
|
|
174
|
+
flags.push("Secure");
|
|
175
|
+
return flags.join("; ");
|
|
176
|
+
}
|
|
177
|
+
export function clearOauthStateCookieHeader(secure) {
|
|
178
|
+
const flags = [
|
|
179
|
+
`${OAUTH_STATE_COOKIE}=`,
|
|
180
|
+
"HttpOnly",
|
|
181
|
+
"SameSite=Lax",
|
|
182
|
+
"Path=/",
|
|
183
|
+
"Max-Age=0",
|
|
184
|
+
];
|
|
185
|
+
if (secure)
|
|
186
|
+
flags.push("Secure");
|
|
187
|
+
return flags.join("; ");
|
|
188
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The one error shape every `/api/*` response uses — see PLAN.md Section 7. */
|
|
2
|
+
export type ApiError = {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
detail?: unknown;
|
|
6
|
+
requestId: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function errorResponse(status: number, code: string, message: string, detail?: unknown): Response;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function errorResponse(status, code, message, detail) {
|
|
2
|
+
const requestId = crypto.randomUUID();
|
|
3
|
+
const error = detail === undefined
|
|
4
|
+
? { code, message, requestId }
|
|
5
|
+
: { code, message, detail, requestId };
|
|
6
|
+
return new Response(JSON.stringify({ error }), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { "content-type": "application/json" },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type FetchApp = (request: Request, remoteAddr?: string) => Promise<Response> | Response;
|
|
2
|
+
export type HttpServer = {
|
|
3
|
+
port: number;
|
|
4
|
+
shutdown: () => Promise<void>;
|
|
5
|
+
finished: Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
/** Starts an HTTP server that hands each request to `app`. */
|
|
8
|
+
export declare function serveHttp(app: FetchApp, port: number): HttpServer;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** A manual `node:http` ↔ `fetch` bridge (plan §2 HTTP).
|
|
2
|
+
*
|
|
3
|
+
* The app is written against the Web `Request`/`Response` types, which Deno
|
|
4
|
+
* served natively. Node has both types but no server that speaks them
|
|
5
|
+
* directly, so this translates per request and streams responses — including
|
|
6
|
+
* the SSE job feed — instead of buffering them. */
|
|
7
|
+
import { createServer, } from "node:http";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
function requestHeaders(req) {
|
|
10
|
+
const headers = new Headers();
|
|
11
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
continue;
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
for (const item of value)
|
|
16
|
+
headers.append(name, item);
|
|
17
|
+
else
|
|
18
|
+
headers.set(name, value);
|
|
19
|
+
}
|
|
20
|
+
return headers;
|
|
21
|
+
}
|
|
22
|
+
/** Node's `IncomingMessage` is a Node stream; `fetch` wants a Web body. A
|
|
23
|
+
* GET/HEAD has none, and passing `undefined` is required there — a body on a
|
|
24
|
+
* GET makes undici throw. */
|
|
25
|
+
function requestBody(req, method) {
|
|
26
|
+
if (method === "GET" || method === "HEAD")
|
|
27
|
+
return undefined;
|
|
28
|
+
return Readable.toWeb(req);
|
|
29
|
+
}
|
|
30
|
+
function toFetchRequest(req, secure) {
|
|
31
|
+
const method = req.method ?? "GET";
|
|
32
|
+
const host = req.headers.host ?? "localhost";
|
|
33
|
+
const url = new URL(req.url ?? "/", `${secure ? "https" : "http"}://${host}`);
|
|
34
|
+
return new Request(url, {
|
|
35
|
+
method,
|
|
36
|
+
headers: requestHeaders(req),
|
|
37
|
+
body: requestBody(req, method),
|
|
38
|
+
// Required by undici whenever a stream body is attached.
|
|
39
|
+
duplex: "half",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function writeResponse(res, response) {
|
|
43
|
+
res.statusCode = response.status;
|
|
44
|
+
for (const [name, value] of response.headers) {
|
|
45
|
+
// `set-cookie` repeats, and Node needs each as its own array entry.
|
|
46
|
+
if (name === "set-cookie")
|
|
47
|
+
res.appendHeader(name, value);
|
|
48
|
+
else
|
|
49
|
+
res.setHeader(name, value);
|
|
50
|
+
}
|
|
51
|
+
if (!response.body) {
|
|
52
|
+
res.end();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const reader = response.body.getReader();
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done)
|
|
60
|
+
break;
|
|
61
|
+
// Backpressure: pause until the socket drains before reading more, so
|
|
62
|
+
// a slow client cannot balloon memory (matters for SSE).
|
|
63
|
+
if (!res.write(value)) {
|
|
64
|
+
await new Promise((resolve) => res.once("drain", resolve));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
res.end();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
res.destroy();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
reader.releaseLock();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Starts an HTTP server that hands each request to `app`. */
|
|
77
|
+
export function serveHttp(app, port) {
|
|
78
|
+
let resolveFinished;
|
|
79
|
+
const finished = new Promise((resolve) => {
|
|
80
|
+
resolveFinished = resolve;
|
|
81
|
+
});
|
|
82
|
+
const server = createServer((req, res) => {
|
|
83
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
84
|
+
void (async () => {
|
|
85
|
+
try {
|
|
86
|
+
const request = toFetchRequest(req, false);
|
|
87
|
+
const response = await app(request, remoteAddr);
|
|
88
|
+
await writeResponse(res, response);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (!res.headersSent) {
|
|
92
|
+
res.statusCode = 500;
|
|
93
|
+
res.setHeader("content-type", "text/plain");
|
|
94
|
+
res.end(`Internal Server Error: ${String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
res.destroy();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
});
|
|
102
|
+
server.listen(port);
|
|
103
|
+
return {
|
|
104
|
+
port,
|
|
105
|
+
shutdown: () => new Promise((resolve) => {
|
|
106
|
+
// SSE streams never end on their own, so `close()` alone would wait
|
|
107
|
+
// forever. Tear the connections down, then wait for the close event
|
|
108
|
+
// so the listening socket is actually released before we return.
|
|
109
|
+
server.closeAllConnections();
|
|
110
|
+
server.close(() => {
|
|
111
|
+
resolveFinished();
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
}),
|
|
115
|
+
finished,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const logo: Uint8Array<ArrayBuffer>;
|