toolroll 0.9.48 → 0.9.50
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/CHANGELOG.md +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.d.ts +1 -0
- package/dist/mcp-http.js +12 -15
- package/dist/mcp-oauth.d.ts +3 -0
- package/dist/mcp-oauth.js +13 -21
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.js +4 -1
- package/dist/public-access.d.ts +3 -1
- package/dist/public-access.js +5 -1
- package/dist/request-budget.d.ts +30 -3
- package/dist/request-budget.js +44 -1
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +462 -23733
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +694 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +185 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.d.ts +5 -0
- package/dist/session-http.js +16 -0
- package/dist/session-server.d.ts +5 -0
- package/dist/session-server.js +2 -0
- package/dist/team-cli.js +28 -6
- package/dist/team-contract.d.ts +7 -0
- package/dist/team-contract.js +7 -0
- package/dist/team-http.d.ts +10 -1
- package/dist/team-http.js +23 -3
- package/dist/team-leads.d.ts +5 -1
- package/dist/team-leads.js +42 -4
- package/dist/team-runtime.d.ts +1 -1
- package/dist/team-runtime.js +3 -0
- package/dist/teams.d.ts +3 -0
- package/dist/teams.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/** pages handlers, moved without changing their route bodies. */
|
|
2
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, opendirSync, readdirSync, realpathSync } from "node:fs";
|
|
4
|
+
import {} from "node:http";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { setImmediate as yieldEventLoop } from "node:timers/promises";
|
|
10
|
+
import { INSTALLATION_SCOPE, resolvePhaseAgent } from "../agentconfig.js";
|
|
11
|
+
import { parseApiToken, tokenLive } from "../api-tokens.js";
|
|
12
|
+
import { classify } from "../board.js";
|
|
13
|
+
import { serveBrowserAsset } from '../browser-shell.js';
|
|
14
|
+
import { projectCheckLevel } from '../check-levels.js';
|
|
15
|
+
import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
16
|
+
import { hasForbiddenControls } from "../decision.js";
|
|
17
|
+
import { exportDay, ledgerExportChunks } from "../evidence-pack.js";
|
|
18
|
+
import { run as execRun } from "../exec.js";
|
|
19
|
+
import { computeGaps } from "../gaps.js";
|
|
20
|
+
import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
|
|
21
|
+
import { ledgerBody } from "../ledger-view.js";
|
|
22
|
+
import { readLiveWindow } from "../live.js";
|
|
23
|
+
import { prometheusMetrics } from "../metrics.js";
|
|
24
|
+
import { readMonitoring } from "../monitoring-settings.js";
|
|
25
|
+
import { targetOf } from "../monitoring.js";
|
|
26
|
+
import { cloneGithubRepo, isLargeRepo, listGithubRepos, parseGithubRepo, previewGithubRepo } from "../onboard.js";
|
|
27
|
+
import { admitProject, releaseProject } from "../project-admission.js";
|
|
28
|
+
import { authorizedProject, canonicalProject, isGitRepo, projectName, sameRepo } from "../project.js";
|
|
29
|
+
import { publishingOf } from '../pull-request-flow.js';
|
|
30
|
+
import { register as registerRunner } from "../runner.js";
|
|
31
|
+
import { healthWords } from "../server-telemetry.js";
|
|
32
|
+
import { tally } from "../summary.js";
|
|
33
|
+
import { serveInstallAsset } from "./pages-assets.js";
|
|
34
|
+
import { healthz } from "./remote-hooks.js";
|
|
35
|
+
import { browsePage, capsPage, decisionAnswerScript, escape, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectChip, projectsPage, redirect, refuse, regionScript, relativeAge, respond, safeReturn, SAFETY, screen, settingsRows, systemPage, taskBody, taskHref, transcriptScript, workbenchRail, WORKSPACE_STYLE, workToolRows } from "./shared.js";
|
|
36
|
+
export function createPagesHandlers(runtime) {
|
|
37
|
+
const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, projectOf, lookupSession } = runtime;
|
|
38
|
+
async function get(ctx) {
|
|
39
|
+
const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
|
|
40
|
+
if (url.pathname === "/projects/browse") {
|
|
41
|
+
// The filesystem browser (operator request): pick a project folder by
|
|
42
|
+
// looking, not typing. CONFINED to exactly what opening allows —
|
|
43
|
+
// explicit --repo mode has an enumerable list and gets no browser at
|
|
44
|
+
// all; --project-root mode browses its roots; unscoped mode (trust-
|
|
45
|
+
// everything, named as such) browses from the home directory.
|
|
46
|
+
// Directory NAMES only, never file contents; symlinks are resolved
|
|
47
|
+
// and re-checked so a link cannot walk out of the fence.
|
|
48
|
+
if (who.via !== "cookie")
|
|
49
|
+
return refuse(response, who, 403, "browsing feeds a browser session's act");
|
|
50
|
+
const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [realpathSync(homedir())] : [];
|
|
51
|
+
if (browseRoots.length === 0) {
|
|
52
|
+
return refuse(response, who, 404, "this server was configured with an explicit repo list — the openable projects are all on the projects page", "/projects");
|
|
53
|
+
}
|
|
54
|
+
const asked = url.searchParams.get("at") ?? browseRoots[0];
|
|
55
|
+
const canonical = canonicalProject(asked);
|
|
56
|
+
const inside = canonical !== null &&
|
|
57
|
+
browseRoots.some(root => canonical === root || canonical.startsWith(`${root}/`));
|
|
58
|
+
if (canonical === null || !inside) {
|
|
59
|
+
return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
|
|
60
|
+
}
|
|
61
|
+
let entries = [];
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(canonical, { withFileTypes: true })
|
|
64
|
+
.filter(one => one.isDirectory() || one.isSymbolicLink())
|
|
65
|
+
.filter(one => !one.name.startsWith(".") && one.name !== "node_modules")
|
|
66
|
+
.slice(0, 400)
|
|
67
|
+
.map(one => {
|
|
68
|
+
const path = join(canonical, one.name);
|
|
69
|
+
// Resolve NOW so a symlink pointing outside the fence renders
|
|
70
|
+
// as nothing rather than as a door.
|
|
71
|
+
const resolved = canonicalProject(path);
|
|
72
|
+
if (resolved === null || !browseRoots.some(root => resolved === root || resolved.startsWith(`${root}/`))) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return { name: one.name, path: resolved, git: existsSync(join(resolved, ".git")) };
|
|
76
|
+
})
|
|
77
|
+
.filter((one) => one !== null)
|
|
78
|
+
.sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
|
|
79
|
+
.slice(0, 200);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
|
|
83
|
+
}
|
|
84
|
+
const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
|
|
85
|
+
const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
|
|
86
|
+
const csrf = who.session.csrf;
|
|
87
|
+
return sendScreen(response, 200, browsePage(chromeFor(who.session.project, "projects"), {
|
|
88
|
+
at: canonical,
|
|
89
|
+
root,
|
|
90
|
+
roots: browseRoots,
|
|
91
|
+
parent,
|
|
92
|
+
entries,
|
|
93
|
+
csrf,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname === "/projects/github") {
|
|
97
|
+
// The signed-in gh account's repositories, OFFERED instead of typed
|
|
98
|
+
// (operator request): read-only listing through the same hardened gh
|
|
99
|
+
// runner onboarding uses, every identity re-parsed strictly, and each
|
|
100
|
+
// row carries exactly ONE honest action — open (already a project
|
|
101
|
+
// here), add (a clone under a root this server may serve, never yet
|
|
102
|
+
// opened), or the EXISTING preview-and-password clone ceremony.
|
|
103
|
+
// Approver only: a viewer has no business enumerating private
|
|
104
|
+
// repository names.
|
|
105
|
+
if (who.via !== "cookie")
|
|
106
|
+
return refuse(response, who, 403, "listing repositories feeds a browser session's act");
|
|
107
|
+
if (who.role !== "approver")
|
|
108
|
+
return refuse(response, who, 403, "your login can watch — adding projects is an approver's act");
|
|
109
|
+
if (store.isDemo())
|
|
110
|
+
return refuse(response, who, 403, "the sandbox never talks to GitHub");
|
|
111
|
+
const listed = await (options.ghList ?? listGithubRepos)();
|
|
112
|
+
// What this machine already has, matched by the clone's own recorded
|
|
113
|
+
// origin — registered projects first, then the immediate children of
|
|
114
|
+
// the configured roots. Identities are compared case-insensitively
|
|
115
|
+
// (GitHub's rule); the FIRST match wins.
|
|
116
|
+
const localByIdentity = new Map();
|
|
117
|
+
const registered = new Set();
|
|
118
|
+
for (const project of store.listProjects()) {
|
|
119
|
+
if (!visible(project.path))
|
|
120
|
+
continue;
|
|
121
|
+
registered.add(project.path);
|
|
122
|
+
const identity = githubIdentityOf(originUrlOf(project.path) ?? "");
|
|
123
|
+
if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
|
|
124
|
+
localByIdentity.set(identity.toLowerCase(), project.path);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Roots are walked ONLY when there are listed repos to match against
|
|
128
|
+
// (finding 1), through an iterator that stops at 400 entries — a
|
|
129
|
+
// directory with a million children costs 400 stats, not a scan.
|
|
130
|
+
if (listed.ok && listed.repos.length > 0) {
|
|
131
|
+
for (const root of ceiling.roots) {
|
|
132
|
+
const children = [];
|
|
133
|
+
try {
|
|
134
|
+
const dir = opendirSync(root);
|
|
135
|
+
try {
|
|
136
|
+
for (let seen = 0; seen < 400; seen++) {
|
|
137
|
+
const entry = dir.readSync();
|
|
138
|
+
if (entry === null)
|
|
139
|
+
break;
|
|
140
|
+
if (entry.isDirectory())
|
|
141
|
+
children.push(join(root, entry.name));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
dir.closeSync();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// an unreadable root offers nothing
|
|
150
|
+
}
|
|
151
|
+
for (const child of children) {
|
|
152
|
+
if (!existsSync(join(child, ".git")))
|
|
153
|
+
continue;
|
|
154
|
+
const identity = githubIdentityOf(originUrlOf(child) ?? "");
|
|
155
|
+
if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
|
|
156
|
+
localByIdentity.set(identity.toLowerCase(), child);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return sendScreen(response, 200, githubReposPage(chromeFor(who.session.project, "projects"), {
|
|
162
|
+
listed,
|
|
163
|
+
local: localByIdentity,
|
|
164
|
+
registered,
|
|
165
|
+
csrf: who.session.csrf,
|
|
166
|
+
cloneReady: ceiling.roots.length > 0,
|
|
167
|
+
openProject: who.session.project ?? null,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
if (url.pathname === "/projects") {
|
|
171
|
+
return void projectsScreen(response, who, url.searchParams.get("said"), 200, safeReturn(url.searchParams.get("return")));
|
|
172
|
+
}
|
|
173
|
+
if (url.pathname === "/")
|
|
174
|
+
return redirect(response, who.via === "cookie" && !restricted() ? "/chat" : "/work");
|
|
175
|
+
// The board's old name; bookmarks keep working. A GET-only alias, so a
|
|
176
|
+
// plain 302 — never the shared 303 helper, which belongs to POST landings.
|
|
177
|
+
if (url.pathname === "/morning") {
|
|
178
|
+
response.writeHead(302, { ...SAFETY, Location: "/activity" });
|
|
179
|
+
response.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (url.pathname === "/workbench") {
|
|
183
|
+
// The fleet under one gaze (attended A1): the rail rolls up the whole
|
|
184
|
+
// ceiling regardless of the open project — watching is cross-project
|
|
185
|
+
// by nature; acting happens on task screens that re-prove everything.
|
|
186
|
+
const admission = admissionList();
|
|
187
|
+
const snapshot = store.boardScoped(null, now, 200, admission);
|
|
188
|
+
const rows = snapshot.tasks.filter(facts => facts.repo === null || visible(facts.repo));
|
|
189
|
+
const cards = rows.map(facts => classify(facts.blockerRepo !== null && !visible(facts.blockerRepo) ? { ...facts, blockerState: null } : facts, now));
|
|
190
|
+
const attention = cards.filter(card => card.lane === "attention").slice(0, 100);
|
|
191
|
+
const building = cards.filter(card => card.lane === "building");
|
|
192
|
+
const waiting = cards.filter(card => card.lane === "waiting");
|
|
193
|
+
const queued = cards.filter(card => card.lane === "queued");
|
|
194
|
+
const done = snapshot.done
|
|
195
|
+
.filter(one => one.repo === null || visible(one.repo))
|
|
196
|
+
.slice(0, 5)
|
|
197
|
+
.map(one => ({ taskId: one.taskId, title: one.title, outcome: one.outcome, repo: one.repo }));
|
|
198
|
+
const selected = url.searchParams.get("t");
|
|
199
|
+
const rail = workbenchRail({ attention, building, waiting, queued, done, selected, saturated: snapshot.saturated });
|
|
200
|
+
if (url.searchParams.get("fragment") === "rail") {
|
|
201
|
+
// The rail alone: same auth, same ceiling, no shell, no scripts.
|
|
202
|
+
return respond(response, 200, "text/html; charset=utf-8", rail);
|
|
203
|
+
}
|
|
204
|
+
// The portfolio overview (arc slice 1a). All-scope hygiene throughout:
|
|
205
|
+
// admission binds inside every bounded SQL read, and every unlimited
|
|
206
|
+
// feed's rows pass visible() BEFORE they render or tally — a hidden
|
|
207
|
+
// project must not leak into a row, a count, or a dollar.
|
|
208
|
+
const sinceIso = new Date(now.getTime() - 24 * 3_600_000).toISOString();
|
|
209
|
+
const decisions = store
|
|
210
|
+
.listDecisionsScoped(null)
|
|
211
|
+
.filter(one => visible(one.repo ?? null))
|
|
212
|
+
.slice(0, 10);
|
|
213
|
+
const approvals = store.scopesAwaitingApproval(null, 10, admission).filter(one => visible(one.repo ?? null));
|
|
214
|
+
const requeueables = store.listRequeueablesScoped(null, now, 10, admission).filter(one => visible(one.repo ?? null));
|
|
215
|
+
const cancelledBlockers = store
|
|
216
|
+
.listCancelledBlockersScoped(null, 10, admission)
|
|
217
|
+
.filter(one => visible(one.repo ?? null) && visible(one.blockerRepo ?? null));
|
|
218
|
+
const runs24 = store.runsSinceScoped(sinceIso, null).filter(run => visible(taskRepoOf(run.taskRef)));
|
|
219
|
+
const live = store.liveClaims(null, now).filter(one => visible(one.repo));
|
|
220
|
+
const ledger = store
|
|
221
|
+
.portfolioLedgerScoped(null, sinceIso, 30, admission)
|
|
222
|
+
.filter(row => visible(row.repo));
|
|
223
|
+
// Gaps stay project-relative (the roll-up inbox's rule): computed for
|
|
224
|
+
// the OPEN project only — there is no scope-safe road to another
|
|
225
|
+
// project's /caps.
|
|
226
|
+
const gaps = project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10);
|
|
227
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
228
|
+
let detail = portfolioOverview({
|
|
229
|
+
attention, building, waiting, queued, done, saturated: snapshot.saturated,
|
|
230
|
+
decisions, approvals, requeueables, cancelledBlockers, gaps,
|
|
231
|
+
gapsProject: project, runs24, live, ledger, csrf, now,
|
|
232
|
+
}) +
|
|
233
|
+
`<div class="workbench-mobile-rail">${rail}</div>`;
|
|
234
|
+
if (selected !== null) {
|
|
235
|
+
const view = taskViewData(selected, who, null);
|
|
236
|
+
detail = view === null
|
|
237
|
+
? `<p class="workbench-mobile-back"><a href="/workbench">← all work</a></p><div class="card"><p class="meta">No such task — it may have been outside this console's view</p></div>`
|
|
238
|
+
: `<p class="workbench-mobile-back"><a href="/workbench">← all work</a></p>${taskBody({ ...view, degraded: "pane" })}`;
|
|
239
|
+
}
|
|
240
|
+
return sendScreen(response, 200, screen("portfolio", detail, {
|
|
241
|
+
chrome: chromeFor(project, "workbench", `<div id="wb-rail">${rail}</div><p class="meta" id="wb-rail-stamp"></p>`, "all"),
|
|
242
|
+
functional: {
|
|
243
|
+
// The decision enhancement rides ONLY the overview: a selected
|
|
244
|
+
// task's pane may carry a password ceremony, and sensitive
|
|
245
|
+
// pages gain no new scripts (commit-1 review, finding 1).
|
|
246
|
+
script: regionScript("wb-rail", "rail", building.length > 0 ? 10 : 30) +
|
|
247
|
+
(selected === null && decisions.length > 0 ? decisionAnswerScript() : ""),
|
|
248
|
+
fetches: true,
|
|
249
|
+
},
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
if (url.pathname === "/ledger") {
|
|
253
|
+
const admitted = admissionList();
|
|
254
|
+
const projects = admitted ?? consoleProjects();
|
|
255
|
+
const chosen = url.searchParams.get("project") ?? "";
|
|
256
|
+
if (chosen !== "" && !projects.includes(chosen))
|
|
257
|
+
return refuse(response, who, 403, "That project is outside your access.", "/ledger");
|
|
258
|
+
const before = url.searchParams.get("before");
|
|
259
|
+
if (before !== null && (!/^[1-9][0-9]{0,14}$/.test(before) || !Number.isSafeInteger(Number(before))))
|
|
260
|
+
return refuse(response, who, 400, "Invalid ledger cursor.", "/ledger");
|
|
261
|
+
// Events that belong to no project (sign-ins, accounts, installation policy) are an instance operator's to read.
|
|
262
|
+
const query = { repos: chosen === "" ? admitted : [chosen], instance: chosen === "" && store.isInstanceOperator(who.name),
|
|
263
|
+
actor: (url.searchParams.get("actor") ?? "").slice(0, 200), outcome: (url.searchParams.get("outcome") ?? "").slice(0, 100),
|
|
264
|
+
source: url.searchParams.get("source") ?? "", taskId: (url.searchParams.get("task") ?? "").slice(0, 200) };
|
|
265
|
+
if (url.searchParams.get("format") === "csv") {
|
|
266
|
+
const generation = store.accountOf(who.name)?.generation;
|
|
267
|
+
// Descending immutable IDs pin the export at its first read; later
|
|
268
|
+
// arrivals cannot duplicate or extend it. Yield between bounded pages
|
|
269
|
+
// and honor backpressure/disconnects, so exports cannot fill memory.
|
|
270
|
+
const first = store.actionLedger({ ...query, limit: 100 });
|
|
271
|
+
async function* chunks() {
|
|
272
|
+
yield LEDGER_CSV_HEADER;
|
|
273
|
+
let page = first;
|
|
274
|
+
while (page.length > 0) {
|
|
275
|
+
const current = store.accountOf(who.name);
|
|
276
|
+
if (current === null || current.revokedAt !== null || current.generation !== generation ||
|
|
277
|
+
page.some(row => !visible(row.repo)))
|
|
278
|
+
throw new Error("Ledger access changed during export");
|
|
279
|
+
yield ledgerCsvRows(page);
|
|
280
|
+
const cursor = page.at(-1).id;
|
|
281
|
+
await yieldEventLoop();
|
|
282
|
+
const next = store.actionLedger({ ...query, before: cursor, limit: 100 });
|
|
283
|
+
// Pages only ever move to older entries; one that doesn't is the end.
|
|
284
|
+
page = next.length > 0 && next[0].id < cursor ? next : [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
response.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="standing-orders-actions.csv"', "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
288
|
+
try {
|
|
289
|
+
await pipeline(Readable.from(chunks()), response);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
response.destroy();
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// v103: the chain is sealed and checked before it's shown; an operator sees the checkpoint to copy off the machine.
|
|
297
|
+
const report = store.ledgerChain();
|
|
298
|
+
const operator = who.via === "cookie" && store.isInstanceOperator(who.name);
|
|
299
|
+
const latest = store.ledgerCheckpoints(1)[0] ?? null;
|
|
300
|
+
const chainView = { ok: report.ok, entries: report.entries, through: report.through, head: report.head, unsealed: report.unsealed, problem: report.problem, checkedAt: report.checkedAt,
|
|
301
|
+
latest: operator && latest !== null ? latest : null, csrf: operator ? who.session.csrf : null, today: now.toISOString().slice(0, 10) };
|
|
302
|
+
const rows = store.actionLedger({ ...query, ...(before === null ? {} : { before: Number(before) }), limit: 51 });
|
|
303
|
+
if (url.searchParams.get("format") === "json")
|
|
304
|
+
return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("ledgerPage", { chain: { ok: report.ok, entries: report.entries, through: report.through, head: report.head, problem: report.problem }, entries: rows.slice(0, 50), nextBefore: rows.length > 50 ? rows[49].id : null })));
|
|
305
|
+
return sendScreen(response, 200, screen("Action ledger", ledgerBody(rows, projects, url.searchParams, chainView), { chrome: chromeFor(chosen === "" ? null : chosen, "ledger", undefined, chosen === "" ? "all" : "project") }));
|
|
306
|
+
}
|
|
307
|
+
if (url.pathname === "/activity") {
|
|
308
|
+
const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
|
309
|
+
return sendScreen(response, 200, homePage(chromeFor(project, "runs"), {
|
|
310
|
+
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
311
|
+
taskCount: store.listTasksScoped(project, undefined, 1, null).length,
|
|
312
|
+
repo: project,
|
|
313
|
+
summary: tally(store.runsSinceScoped(since, project)),
|
|
314
|
+
decisions: store.listDecisionsScoped(project),
|
|
315
|
+
incidents: store.openIncidents(project),
|
|
316
|
+
stranded: store.strandedTasks(project),
|
|
317
|
+
gaps: project === null ? null : computeGaps(store, project, now),
|
|
318
|
+
// Rows that want a person, plus task updates the live pairing failed to send (never quiet progress).
|
|
319
|
+
outboxPending: store.pendingForAttention().length,
|
|
320
|
+
settings: options.telegramTokenFile !== undefined,
|
|
321
|
+
building: store.liveClaims(project, now),
|
|
322
|
+
runners: store.listRunners(),
|
|
323
|
+
heldSessions: new Map(store.openHeldSessions().reduce((by, one) => by.set(one.runner, (by.get(one.runner) ?? 0) + 1), new Map())),
|
|
324
|
+
worktrees: store
|
|
325
|
+
.listWorktrees()
|
|
326
|
+
.filter(one => project === null || sameRepo(one.repo, project)),
|
|
327
|
+
episode: project === null ? null : store.latestWatchEpisode(project),
|
|
328
|
+
now,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
if (url.pathname === "/system") {
|
|
332
|
+
const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
|
333
|
+
void since;
|
|
334
|
+
return sendScreen(response, 200, systemPage(chromeFor(project, "system"), {
|
|
335
|
+
agents: ["plan", "build", "repair", "review"].map(phase => {
|
|
336
|
+
const answer = resolvePhaseAgent(store, phase, project, {});
|
|
337
|
+
const row = (project === null ? null : store.phaseConfig(project, phase)) ??
|
|
338
|
+
store.phaseConfig(INSTALLATION_SCOPE, phase);
|
|
339
|
+
return {
|
|
340
|
+
phase,
|
|
341
|
+
...(answer.ok
|
|
342
|
+
? { provider: answer.spec.provider, model: answer.spec.model, source: answer.source }
|
|
343
|
+
: { problem: answer.problem }),
|
|
344
|
+
setBy: row?.updatedBy ?? null,
|
|
345
|
+
};
|
|
346
|
+
}),
|
|
347
|
+
building: store.liveClaims(project, now),
|
|
348
|
+
runners: store.listRunners(),
|
|
349
|
+
heldSessions: new Map(store.openHeldSessions().reduce((by, one) => by.set(one.runner, (by.get(one.runner) ?? 0) + 1), new Map())),
|
|
350
|
+
worktrees: store.listWorktrees().filter(one => project === null || sameRepo(one.repo, project)),
|
|
351
|
+
episode: project === null ? null : store.latestWatchEpisode(project),
|
|
352
|
+
outboxPending: store.pendingForAttention().length,
|
|
353
|
+
// External work at a glance (arc 3 finding 24): every dispatch
|
|
354
|
+
// grant with its blocked state, plus any open sync episode — the
|
|
355
|
+
// page a sync-failed push deep-links to now shows the fact.
|
|
356
|
+
externalWork: store
|
|
357
|
+
.listGrants()
|
|
358
|
+
.filter(one => one.dispatch === true && one.remoteRepo != null && visible(one.repo))
|
|
359
|
+
.map(one => ({
|
|
360
|
+
remoteRepo: String(one.remoteRepo),
|
|
361
|
+
blocked: one.dispatchBlocked ?? null,
|
|
362
|
+
openEpisode: store
|
|
363
|
+
.listNotifications("all")
|
|
364
|
+
.find(n => n.kind === "sync-failed" && n.resolvedAt === null && n.dedupeKey.startsWith(`sync:${one.remoteRepo}:`))?.subject ?? null,
|
|
365
|
+
})),
|
|
366
|
+
now,
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
if (url.pathname === "/peek") {
|
|
370
|
+
// The multiplexer in the console (peek): one pane per live run the
|
|
371
|
+
// ceiling admits, each fed by the run page's own transcript poller
|
|
372
|
+
// (the byte-offset JSON protocol — text into textContent, never
|
|
373
|
+
// markup). The pane SET is re-checked on a slow cadence; when it
|
|
374
|
+
// changes the page reloads rather than swapping pollers mid-flight.
|
|
375
|
+
const live = store.liveRuns(clock()).filter(run => runVisible(run));
|
|
376
|
+
if (url.searchParams.get("fragment") === "1") {
|
|
377
|
+
return respond(response, 200, "application/json", JSON.stringify({ runs: live.map(run => run.id) }));
|
|
378
|
+
}
|
|
379
|
+
const now = clock();
|
|
380
|
+
const panes = live.map(run => {
|
|
381
|
+
const window = options.localRunner === undefined ? null : readLiveWindow(evidenceRoot, run.id, 0);
|
|
382
|
+
const tail = window !== null && window.ok ? window.text.split("\n").filter(one => one !== "").slice(-40).join("\n") : "";
|
|
383
|
+
const stage = run.phase !== null ? run.phase.replace(/-/g, " ") : run.providerStartedAt === null ? "preparing workspace" : "the agent is working";
|
|
384
|
+
return [
|
|
385
|
+
`<section class="card peek-pane" data-run="${run.id}">`,
|
|
386
|
+
`<p class="row"><a href="/r/${run.id}" class="mono">#${run.id}</a> <a href="${taskHref(run.taskId)}"><strong>${escape(run.title)}</strong></a>${projectChip(run.repo)} ` +
|
|
387
|
+
`<span class="right meta mono">${escape(run.runner)} \u00b7 ${escape(run.role)} \u00b7 ${escape(stage)} \u00b7 ${escape(relativeAge(run.startedAt, now))}</span></p>`,
|
|
388
|
+
options.localRunner === undefined
|
|
389
|
+
? `<p class="meta">The transcript is readable on the machine that runs the worker \u2014 start the console there with <code>toolroll up</code></p>`
|
|
390
|
+
: `<pre class="recap plan-doc live-pane" id="live-transcript-${run.id}" data-initial-offset="0">${escape(tail)}</pre><p class="meta" id="live-transcript-${run.id}-state"></p>`,
|
|
391
|
+
`</section>`,
|
|
392
|
+
].join("\n");
|
|
393
|
+
});
|
|
394
|
+
const script = (options.localRunner === undefined ? "" : live.map(run => transcriptScript(`/r/${run.id}`, `live-transcript-${run.id}`)).join("")) +
|
|
395
|
+
`(function(){var seen=${JSON.stringify(live.map(run => run.id))};function check(){if(document.hidden){setTimeout(check,12000);return;}` +
|
|
396
|
+
`fetch("/peek?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(r){return r.ok?r.json():null;})` +
|
|
397
|
+
`.then(function(d){if(d&&Array.isArray(d.runs)&&JSON.stringify(d.runs)!==JSON.stringify(seen)){location.reload();return;}setTimeout(check,12000);})` +
|
|
398
|
+
`.catch(function(){setTimeout(check,12000);});}setTimeout(check,12000);})();`;
|
|
399
|
+
return sendScreen(response, 200, screen("peek", [
|
|
400
|
+
`<h1>Peek</h1>`,
|
|
401
|
+
panes.length === 0
|
|
402
|
+
? `<p class="meta">No agent is working right now \u2014 this page follows them the moment one starts</p>`
|
|
403
|
+
: panes.join("\n"),
|
|
404
|
+
].join("\n"), { chrome: chromeFor(project, "runs"), functional: { script, fetches: true } }));
|
|
405
|
+
}
|
|
406
|
+
if (url.pathname === "/fleet") {
|
|
407
|
+
// Cross-project by design: which agent is on which project is the
|
|
408
|
+
// question, so this screen is a survey — reads are still filtered
|
|
409
|
+
// through the ceiling before a card renders. No project needed.
|
|
410
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
411
|
+
const queued = store.fleetQueue(clock());
|
|
412
|
+
const building = store.liveClaims(null, clock());
|
|
413
|
+
const owned = new Set(queued.map(one => one.assignedRunner).filter((one) => one !== null));
|
|
414
|
+
const runners = store.listRunners().filter(one => one.retiredAt === null || owned.has(one.name));
|
|
415
|
+
const body = fleetBody(queued, building, runners, csrf, store.queueRevision(), visible);
|
|
416
|
+
if (url.searchParams.get("fragment") === "1") {
|
|
417
|
+
return respond(response, 200, "text/html; charset=utf-8", body);
|
|
418
|
+
}
|
|
419
|
+
const said = url.searchParams.get("said");
|
|
420
|
+
const fleetScreen = screen("fleet", [
|
|
421
|
+
`<h1>Fleet</h1>`,
|
|
422
|
+
`<p class="hint">one lane per worker — drag a queued card onto another worker to re-reserve it</p>`,
|
|
423
|
+
...(said === null ? [] : [`<p class="meta">${escape(said)}</p>`]),
|
|
424
|
+
`<div id="fleet-region">${body}</div>`,
|
|
425
|
+
`<p class="meta" id="fleet-region-stamp"></p>`,
|
|
426
|
+
`<h2>Register a worker</h2>`,
|
|
427
|
+
`<form method="post" action="/fleet/runner/register" class="card">` +
|
|
428
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}">` +
|
|
429
|
+
`<label>Name<input type="text" name="name" placeholder="builder-2" maxlength="60" required></label>` +
|
|
430
|
+
`<label>Capacity<input type="text" name="capacity" inputmode="numeric" value="1" aria-label="capacity"></label>` +
|
|
431
|
+
`<label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
432
|
+
`<button type="submit">Register — its token is shown once</button></form>`,
|
|
433
|
+
`<details class="arm-danger"><summary>Retire a worker</summary>` +
|
|
434
|
+
`<form method="post" action="/fleet/runner/retire" class="card">` +
|
|
435
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}">` +
|
|
436
|
+
`<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label>` +
|
|
437
|
+
`<label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
438
|
+
`<button type="submit" class="danger">Retire it</button></form></details>`,
|
|
439
|
+
].join("\n"),
|
|
440
|
+
// The password fields make this screen sensitive: sendScreen strips
|
|
441
|
+
// the chrome additions and keeps the reorder poller — the named
|
|
442
|
+
// functional exception (it never reads the fields).
|
|
443
|
+
{ chrome: chromeFor(project, "fleet", undefined, "all"), functional: { script: fleetScript(), fetches: true } });
|
|
444
|
+
return sendScreen(response, 200, fleetScreen);
|
|
445
|
+
}
|
|
446
|
+
// v103: every ledger entry in a date range, sealed, with an evidence pack for each task it names.
|
|
447
|
+
if (url.pathname === "/ledger/export") {
|
|
448
|
+
const from = exportDay(url.searchParams.get("from")), last = exportDay(url.searchParams.get("to"));
|
|
449
|
+
if (from === null || last === null)
|
|
450
|
+
return refuse(response, who, 400, "Choose a start and end day.", "/ledger");
|
|
451
|
+
const to = new Date(Date.parse(last) + 86_400_000).toISOString();
|
|
452
|
+
if (to <= from || Date.parse(to) - Date.parse(from) > 366 * 86_400_000)
|
|
453
|
+
return refuse(response, who, 400, "Choose a range of a year or less, ending on or after its start.", "/ledger");
|
|
454
|
+
const access = workAccess();
|
|
455
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "ledger exported", outcome: "exported", source: "access",
|
|
456
|
+
detail: `${from.slice(0, 10)} to ${last.slice(0, 10)}` });
|
|
457
|
+
// A piece at a time (a page of entries, or one pack), yielding between them, and stopping if the reader's access
|
|
458
|
+
// changes: their account, and the session or API token they came with (read without writing anything).
|
|
459
|
+
const generation = store.accountOf(who.name)?.generation;
|
|
460
|
+
const tokenId = who.via === "bearer" && who.token !== undefined ? parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id ?? null : null;
|
|
461
|
+
const stillLive = () => {
|
|
462
|
+
const current = store.accountOf(who.name);
|
|
463
|
+
if (current === null || current.revokedAt !== null || current.generation !== generation)
|
|
464
|
+
return false;
|
|
465
|
+
if (who.via === "cookie")
|
|
466
|
+
return identify(request, false)?.name === who.name;
|
|
467
|
+
if (tokenId === null)
|
|
468
|
+
return true;
|
|
469
|
+
const token = store.apiTokenSecret(tokenId)?.row;
|
|
470
|
+
return token !== undefined && tokenLive(token, Date.now());
|
|
471
|
+
};
|
|
472
|
+
// The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
|
|
473
|
+
const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
|
|
474
|
+
async function* chunks() {
|
|
475
|
+
for (const piece of pieces) {
|
|
476
|
+
if (!stillLive())
|
|
477
|
+
throw new Error("Ledger access changed during export");
|
|
478
|
+
yield piece;
|
|
479
|
+
await yieldEventLoop();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
response.setTimeout(60_000, () => response.destroy());
|
|
483
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="standing-orders-audit-${from.slice(0, 10)}-to-${last.slice(0, 10)}.json"`,
|
|
484
|
+
"cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
485
|
+
try {
|
|
486
|
+
await pipeline(Readable.from(chunks()), response);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
response.destroy();
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (url.pathname === "/menu") {
|
|
494
|
+
// The phone's overflow drawer as an honest page: every destination
|
|
495
|
+
// the tab bar does not carry, one tap away, no JavaScript — grouped
|
|
496
|
+
// under the same workflows/admin headings as the rail's accordion,
|
|
497
|
+
// with settings last, outside both.
|
|
498
|
+
const chrome = chromeFor(project, "menu");
|
|
499
|
+
const section = (label, rows) => `<h2 class="menu-group-label">${label}</h2><div class="menu-list">` +
|
|
500
|
+
rows.map(row => `<a class="menu-row" href="${row.href}"><strong>${row.label}</strong><span class="meta">${row.hint}</span></a>`).join("\n") +
|
|
501
|
+
`</div>`;
|
|
502
|
+
return sendScreen(response, 200, screen("Workspace tools", [
|
|
503
|
+
section("Work tools", workToolRows(chrome.projectScoped, chrome.chat, chrome.code)),
|
|
504
|
+
section("Settings", settingsRows(chrome.projectScoped, chrome.settings)),
|
|
505
|
+
].join("\n"), { chrome }));
|
|
506
|
+
}
|
|
507
|
+
if (url.pathname === "/caps") {
|
|
508
|
+
if (project === null) {
|
|
509
|
+
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), null, [], ""));
|
|
510
|
+
}
|
|
511
|
+
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
|
|
512
|
+
}
|
|
513
|
+
// v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
|
|
514
|
+
if (url.pathname === "/health") {
|
|
515
|
+
if (!store.isInstanceOperator(who.name))
|
|
516
|
+
return refuse(response, who, 403, "An instance operator reads server health.", "/");
|
|
517
|
+
const health = store.telemetry.snapshot();
|
|
518
|
+
return request.headers.accept?.includes('application/json')
|
|
519
|
+
? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
|
|
520
|
+
: respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
|
|
521
|
+
}
|
|
522
|
+
if (url.pathname === "/metrics") {
|
|
523
|
+
if (!store.isInstanceOperator(who.name))
|
|
524
|
+
return refuse(response, who, 403, "An instance operator reads metrics.", "/");
|
|
525
|
+
response.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
526
|
+
const set = options.configDir === undefined ? null : readMonitoring(options.configDir);
|
|
527
|
+
const destinations = new Map([
|
|
528
|
+
...(set?.webhook ? [["webhook", targetOf(set.webhook.url)]] : []),
|
|
529
|
+
...(set?.folder ? [["folder", targetOf(set.folder.path)]] : []),
|
|
530
|
+
...(set?.traces ? [["traces", targetOf(set.traces.endpoint)]] : []),
|
|
531
|
+
]);
|
|
532
|
+
return void response.end(prometheusMetrics(store, now, admissionList(), destinations));
|
|
533
|
+
}
|
|
534
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
535
|
+
}
|
|
536
|
+
async function post(ctx) {
|
|
537
|
+
const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
|
|
538
|
+
if (url.pathname === "/ledger/checkpoint") {
|
|
539
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
540
|
+
return refuse(response, who, 403, "An instance operator makes ledger checkpoints.", "/ledger");
|
|
541
|
+
const made = store.ledgerCheckpoint(who.name, now);
|
|
542
|
+
if (made !== null && "problem" in made)
|
|
543
|
+
return refuse(response, who, 409, `No checkpoint was made: the chain doesn't verify (${made.problem}).`, "/ledger");
|
|
544
|
+
return redirect(response, made === null ? "/ledger" : `/ledger?checkpoint=${made.through}`);
|
|
545
|
+
}
|
|
546
|
+
if (url.pathname === "/projects/select") {
|
|
547
|
+
const body = readForm(posted, CONSOLE_FORMS.projectsSelect);
|
|
548
|
+
// Session-only project switching (E2): reads the registry, writes
|
|
549
|
+
// ONLY session.project — the durable upsert lives in /projects/open,
|
|
550
|
+
// which stays an approver's act. This is the one project verb a
|
|
551
|
+
// viewer may use.
|
|
552
|
+
if (who.via !== "cookie") {
|
|
553
|
+
return refuse(response, who, 403, "selecting a project is a browser session's act");
|
|
554
|
+
}
|
|
555
|
+
const askedPath = (body.get("path") ?? "").trim();
|
|
556
|
+
const canonicalPick = askedPath === "" ? null : canonicalProject(askedPath);
|
|
557
|
+
if ((askedPath !== "" && canonicalPick === null) || (canonicalPick !== null && (!visible(canonicalPick) || !(await authorizedProject(liveCeiling(), canonicalPick))))) {
|
|
558
|
+
return refuse(response, who, 403, "that project is outside this console's reach");
|
|
559
|
+
}
|
|
560
|
+
who.session.project = canonicalPick;
|
|
561
|
+
who.session.projectRevision += 1;
|
|
562
|
+
sessions.persist(who.session);
|
|
563
|
+
return redirect(response, safeReturn(body.get("return")));
|
|
564
|
+
}
|
|
565
|
+
if (url.pathname === "/projects/remove") {
|
|
566
|
+
const body = readForm(posted, CONSOLE_FORMS.projectsRemove);
|
|
567
|
+
// The reversible remove: off every list and the builder; tasks, results and settings stay. Deleting is
|
|
568
|
+
// Settings → Project → Delete project. The same admission code as `repos remove` (project-admission.ts).
|
|
569
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
570
|
+
return refuse(response, who, 403, "An instance operator removes projects.", "/settings/project");
|
|
571
|
+
const repo = body.get("repo") ?? "";
|
|
572
|
+
if (!consoleProjects().includes(repo))
|
|
573
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
574
|
+
const released = await releaseProject(store, { registryFile: options.registryPath ?? null, path: repo, actor: { label: who.name, origin: "console" }, now });
|
|
575
|
+
if (!released.ok)
|
|
576
|
+
return redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&problem=${encodeURIComponent(`${projectName(repo)} wasn't removed: ${released.message}`)}`);
|
|
577
|
+
deletedRepos.add(repo);
|
|
578
|
+
if (who.session.project === repo) {
|
|
579
|
+
who.session.project = null;
|
|
580
|
+
who.session.projectRevision += 1;
|
|
581
|
+
sessions.persist(who.session);
|
|
582
|
+
}
|
|
583
|
+
return redirect(response, `/settings/project?said=${encodeURIComponent(`Removed ${projectName(repo)}. Its tasks and results stay saved.`)}`);
|
|
584
|
+
}
|
|
585
|
+
if (url.pathname === "/projects/open") {
|
|
586
|
+
const body = readForm(posted, CONSOLE_FORMS.projectsOpen);
|
|
587
|
+
// Sessions only: a bearer caller names its project per request and has
|
|
588
|
+
// no session to mutate — refusing here keeps that boundary legible.
|
|
589
|
+
if (who.via !== "cookie") {
|
|
590
|
+
return refuse(response, who, 403, "opening a project is a browser session's act");
|
|
591
|
+
}
|
|
592
|
+
const asked = (body.get("path") ?? "").trim();
|
|
593
|
+
const canonical = asked === "" ? null : canonicalProject(asked);
|
|
594
|
+
if (canonical === null) {
|
|
595
|
+
return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
|
|
596
|
+
}
|
|
597
|
+
// Authorization is the ceiling, then the path must actually be a git
|
|
598
|
+
// repository — validation with direct argv and a bound, never a shell.
|
|
599
|
+
// Both checks apply even to configured repos: naming a directory in
|
|
600
|
+
// config authorizes it; only being a repository makes it openable.
|
|
601
|
+
if (!(await authorizedProject(liveCeiling(), canonical))) {
|
|
602
|
+
return void projectsScreen(response, who, "that path is outside what this server was configured to serve", 403, safeReturn(body.get("return")));
|
|
603
|
+
}
|
|
604
|
+
if (!(await isGitRepo(canonical))) {
|
|
605
|
+
return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")));
|
|
606
|
+
}
|
|
607
|
+
// An account limited to listed projects never widens its own list.
|
|
608
|
+
if (!visible(canonical)) {
|
|
609
|
+
return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
|
|
610
|
+
}
|
|
611
|
+
// Opening an allowed repository admits it the same way `repos add` does
|
|
612
|
+
// (project-admission.ts). A co-located `up` notices the durable list and
|
|
613
|
+
// connects its builder; there is no separate restart or binding step.
|
|
614
|
+
const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
|
|
615
|
+
if (!admitted.ok) {
|
|
616
|
+
return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, 400, safeReturn(body.get("return")));
|
|
617
|
+
}
|
|
618
|
+
deletedRepos.delete(admitted.repo);
|
|
619
|
+
who.session.project = admitted.repo;
|
|
620
|
+
who.session.projectRevision++;
|
|
621
|
+
// The switcher (board pass) opens a project from any screen and
|
|
622
|
+
// returns there — a same-site path only, never an off-site road.
|
|
623
|
+
return redirect(response, safeReturn(body.get("return")));
|
|
624
|
+
}
|
|
625
|
+
// Runner lifecycle, brought in from the terminal behind the same
|
|
626
|
+
// password step-up the console uses for every other credential-grade
|
|
627
|
+
// act. Register mints a token that is shown ONCE and stored only as a
|
|
628
|
+
// hash — the page that shows it renders no self-refreshing script, so
|
|
629
|
+
// the token is never re-rendered.
|
|
630
|
+
if (url.pathname === "/fleet/runner/register") {
|
|
631
|
+
const body = readForm(posted, CONSOLE_FORMS.runnerRegister);
|
|
632
|
+
if (who.via !== "cookie")
|
|
633
|
+
return refuse(response, who, 403, "runner registration is a browser surface");
|
|
634
|
+
const token = body.get("token") ?? "";
|
|
635
|
+
if (!authenticateApprover(who, token).ok) {
|
|
636
|
+
return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
|
|
637
|
+
}
|
|
638
|
+
const name = (body.get("name") ?? "").trim();
|
|
639
|
+
if (name === "" || name.length > 60 || /[\r\n\t]/.test(name) || hasForbiddenControls(name)) {
|
|
640
|
+
return refuse(response, who, 400, "a worker's name is one short line, no control characters", "/fleet");
|
|
641
|
+
}
|
|
642
|
+
const capacity = Number((body.get("capacity") ?? "1").trim() || "1");
|
|
643
|
+
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 64) {
|
|
644
|
+
return refuse(response, who, 400, "capacity is a whole number of tasks, 1 to 64", "/fleet");
|
|
645
|
+
}
|
|
646
|
+
const { token: minted } = registerRunner(store, { name, host: hostname(), capacity, now });
|
|
647
|
+
const tokenScreen = screen("fleet", [
|
|
648
|
+
`<h1>${escape(name)} is registered</h1>`,
|
|
649
|
+
`<div class="card">`,
|
|
650
|
+
`<p>Its token, shown once. Only a hash is kept, so copy it now.</p>`,
|
|
651
|
+
`<p class="mono secret-value">${escape(minted)}</p>`,
|
|
652
|
+
`<p class="meta">Keep it beside the worker, in a file only you can read (0600) or a secrets manager. If it's lost, register the name again; its old claims are taken back.</p>`,
|
|
653
|
+
`</div>`,
|
|
654
|
+
`<p class="meta"><a href="/fleet">Back to Fleet</a></p>`,
|
|
655
|
+
].join("\n"),
|
|
656
|
+
// A one-time secret on screen: no script of any kind rides along.
|
|
657
|
+
{ chrome: chromeFor(projectOf(who, request) ?? null, "fleet"), forceSensitive: true });
|
|
658
|
+
return sendScreen(response, 200, tokenScreen);
|
|
659
|
+
}
|
|
660
|
+
if (url.pathname === "/fleet/runner/retire") {
|
|
661
|
+
const body = readForm(posted, CONSOLE_FORMS.runnerRetire);
|
|
662
|
+
if (who.via !== "cookie")
|
|
663
|
+
return refuse(response, who, 403, "runner retirement is a browser surface");
|
|
664
|
+
const token = body.get("token") ?? "";
|
|
665
|
+
if (!authenticateApprover(who, token).ok) {
|
|
666
|
+
return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
|
|
667
|
+
}
|
|
668
|
+
const name = (body.get("name") ?? "").trim();
|
|
669
|
+
const retired = store.retireRunner(name, now);
|
|
670
|
+
if (!retired)
|
|
671
|
+
return refuse(response, who, 404, `no worker \`${name}\``, "/fleet");
|
|
672
|
+
return redirect(response, `/fleet?said=${encodeURIComponent(`${name} is retired — its queued work is still here, drag it elsewhere`)}`);
|
|
673
|
+
}
|
|
674
|
+
if (url.pathname === "/projects/onboard-preview" || url.pathname === "/projects/onboard-confirm") {
|
|
675
|
+
const body = readForm(posted, CONSOLE_FORMS.onboard);
|
|
676
|
+
// Repo onboarding (findings 1-39): bearer and demo refuse BEFORE any
|
|
677
|
+
// gh call; the console flow exists only on root-configured serves.
|
|
678
|
+
if (who.via !== "cookie")
|
|
679
|
+
return refuse(response, who, 403, "adding repositories is a browser session's act");
|
|
680
|
+
if (store.isDemo())
|
|
681
|
+
return refuse(response, who, 403, "the sandbox never clones");
|
|
682
|
+
if (process.platform === "win32")
|
|
683
|
+
return refuse(response, who, 403, "adding from GitHub is not supported on Windows yet");
|
|
684
|
+
if (ceiling.roots.length === 0)
|
|
685
|
+
return projectsScreen(response, who, "naming where repositories live takes --project-root", 400);
|
|
686
|
+
if (url.pathname === "/projects/onboard-preview") {
|
|
687
|
+
const shape = parseGithubRepo(body.get("repo") ?? "");
|
|
688
|
+
if (!shape.ok)
|
|
689
|
+
return projectsScreen(response, who, shape.problem, 400);
|
|
690
|
+
// Roots by INDEX (finding 13) — a posted path is never accepted.
|
|
691
|
+
const rootIndex = Number(body.get("root") ?? "0");
|
|
692
|
+
const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
|
|
693
|
+
if (rootRaw === undefined)
|
|
694
|
+
return projectsScreen(response, who, "pick one of the configured roots", 400);
|
|
695
|
+
let root;
|
|
696
|
+
try {
|
|
697
|
+
root = realpathSync(rootRaw);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return projectsScreen(response, who, "that projects root does not resolve right now", 400);
|
|
701
|
+
}
|
|
702
|
+
const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
|
|
703
|
+
if (!previewed.ok)
|
|
704
|
+
return projectsScreen(response, who, previewed.message, 400);
|
|
705
|
+
const reparsed = parseGithubRepo(previewed.preview.nameWithOwner);
|
|
706
|
+
if (!reparsed.ok)
|
|
707
|
+
return projectsScreen(response, who, "GitHub named a repository shape this console refuses", 400);
|
|
708
|
+
// The hook boundary is NORMALIZED like the real preview would be
|
|
709
|
+
// (verification finding 2): a size that is not a nonnegative finite
|
|
710
|
+
// number is unknown, and unknown is LARGE.
|
|
711
|
+
const diskUsageKib = typeof previewed.preview.diskUsageKib === "number" && Number.isFinite(previewed.preview.diskUsageKib) && previewed.preview.diskUsageKib >= 0
|
|
712
|
+
? previewed.preview.diskUsageKib
|
|
713
|
+
: null;
|
|
714
|
+
const target = join(root, reparsed.name);
|
|
715
|
+
if (dirname(target) !== root)
|
|
716
|
+
return projectsScreen(response, who, "the target escaped its root — refused", 400);
|
|
717
|
+
// The session-held record (finding 14): swept at mint, capped 3.
|
|
718
|
+
const session = who.session;
|
|
719
|
+
session.onboard ??= new Map();
|
|
720
|
+
const cutoff = Date.now() - 10 * 60_000;
|
|
721
|
+
for (const [key, record] of session.onboard)
|
|
722
|
+
if (record.mintedAt < cutoff)
|
|
723
|
+
session.onboard.delete(key);
|
|
724
|
+
if (session.onboard.size >= 3)
|
|
725
|
+
return projectsScreen(response, who, "three previews are already waiting — confirm or let one expire", 400);
|
|
726
|
+
const nonce = randomBytes(16).toString("hex");
|
|
727
|
+
session.onboard.set(nonce, {
|
|
728
|
+
nameWithOwner: previewed.preview.nameWithOwner,
|
|
729
|
+
rootIndex,
|
|
730
|
+
target,
|
|
731
|
+
diskUsageKib,
|
|
732
|
+
large: isLargeRepo({ ...previewed.preview, diskUsageKib }),
|
|
733
|
+
mintedAt: Date.now(),
|
|
734
|
+
});
|
|
735
|
+
return projectsScreen(response, who, null, 200);
|
|
736
|
+
}
|
|
737
|
+
// CONFIRM: password, then session liveness AGAIN via lookupSession
|
|
738
|
+
// (finding 22/33) — the record is consumed from the RETURNED live
|
|
739
|
+
// session, synchronously, before the first await.
|
|
740
|
+
const password = body.get("token") ?? "";
|
|
741
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
742
|
+
return projectsScreen(response, who, "cloning takes your password, typed again", 403);
|
|
743
|
+
}
|
|
744
|
+
const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
|
|
745
|
+
const live = cookieId === undefined ? null : lookupSession(cookieId, false);
|
|
746
|
+
if (live === null)
|
|
747
|
+
return refuse(response, who, 403, "this session ended while the form was open — sign in again");
|
|
748
|
+
const nonce = body.get("nonce") ?? "";
|
|
749
|
+
const record = live.onboard?.get(nonce);
|
|
750
|
+
if (record === undefined || record.mintedAt < Date.now() - 10 * 60_000) {
|
|
751
|
+
live.onboard?.delete(nonce);
|
|
752
|
+
return projectsScreen(response, who, "that preview expired or was already used — preview again", 400);
|
|
753
|
+
}
|
|
754
|
+
live.onboard?.delete(nonce); // consumed BEFORE the first await
|
|
755
|
+
if (record.large && body.get("big-ok") !== "1") {
|
|
756
|
+
return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400);
|
|
757
|
+
}
|
|
758
|
+
const rootRaw = ceiling.roots[record.rootIndex];
|
|
759
|
+
if (rootRaw === undefined)
|
|
760
|
+
return projectsScreen(response, who, "the configured roots changed — preview again", 400);
|
|
761
|
+
let root;
|
|
762
|
+
try {
|
|
763
|
+
root = realpathSync(rootRaw);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return projectsScreen(response, who, "that projects root does not resolve right now", 400);
|
|
767
|
+
}
|
|
768
|
+
if (dirname(record.target) !== root)
|
|
769
|
+
return projectsScreen(response, who, "the configured roots changed — preview again", 400);
|
|
770
|
+
const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
|
|
771
|
+
const answer = await execRun("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 15_000 });
|
|
772
|
+
return { code: answer.code, stdout: answer.stdout };
|
|
773
|
+
});
|
|
774
|
+
if (!cloned.ok)
|
|
775
|
+
return projectsScreen(response, who, cloned.message, 400);
|
|
776
|
+
// The clone answer is BOUND to the record's exact target (verification
|
|
777
|
+
// finding 2): whatever produced it — the real primitive or a test
|
|
778
|
+
// hook — an answer naming any other path never enrolls or opens.
|
|
779
|
+
if (cloned.target !== record.target) {
|
|
780
|
+
return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
|
|
781
|
+
}
|
|
782
|
+
// authorizedProject AFTER the repository exists (finding 20).
|
|
783
|
+
const proved = await authorizedProject(liveCeiling(), cloned.target);
|
|
784
|
+
const admitted = proved ? (canonicalProject(cloned.target) ?? cloned.target) : null;
|
|
785
|
+
if (admitted === null) {
|
|
786
|
+
return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400);
|
|
787
|
+
}
|
|
788
|
+
const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
|
|
789
|
+
if (!joined.ok) {
|
|
790
|
+
return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
|
|
791
|
+
}
|
|
792
|
+
deletedRepos.delete(admitted);
|
|
793
|
+
who.session.project = admitted;
|
|
794
|
+
who.session.projectRevision += 1;
|
|
795
|
+
return projectsScreen(response, who, options.upConsole === true
|
|
796
|
+
? `${projectName(admitted)} is ready — the builder is connecting automatically`
|
|
797
|
+
: `${admitted} is ready and open`, 200);
|
|
798
|
+
}
|
|
799
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The opener: every project inside the ceiling, most recently opened
|
|
803
|
+
* first, plus repos the queue has seen — shown only when the ceiling
|
|
804
|
+
* admits them — and an open-by-path field validated server-side. A
|
|
805
|
+
* registry row never confers access; this page only offers what the
|
|
806
|
+
* server was configured to allow.
|
|
807
|
+
*/
|
|
808
|
+
async function projectsScreen(response, who, problem, status, returnTo = "/") {
|
|
809
|
+
const now = clock();
|
|
810
|
+
const recent = store.listProjects().filter(one => visible(one.path));
|
|
811
|
+
const recentPaths = new Set(recent.map(one => one.path));
|
|
812
|
+
const candidates = new Set();
|
|
813
|
+
for (const path of consoleProjects()) {
|
|
814
|
+
const canonical = canonicalProject(path) ?? path;
|
|
815
|
+
if (!recentPaths.has(canonical) && visible(canonical))
|
|
816
|
+
candidates.add(canonical);
|
|
817
|
+
}
|
|
818
|
+
const open = who.via === "cookie" ? who.session.project : null;
|
|
819
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
820
|
+
// Onboarding availability (findings 2/28/32/39): a cookie session on a
|
|
821
|
+
// root-configured, non-demo, POSIX serve gets the live card; everybody
|
|
822
|
+
// else gets the card DISABLED with the reason in words.
|
|
823
|
+
const onboardState = restricted() || who.via !== "cookie"
|
|
824
|
+
? { enabled: false, why: "An instance operator adds projects." }
|
|
825
|
+
: store.isDemo()
|
|
826
|
+
? { enabled: false, why: "the sandbox never clones — this is demo data" }
|
|
827
|
+
: process.platform === "win32"
|
|
828
|
+
? { enabled: false, why: "adding from GitHub is not supported on Windows yet" }
|
|
829
|
+
: ceiling.roots.length === 0
|
|
830
|
+
? {
|
|
831
|
+
enabled: false,
|
|
832
|
+
why: options.upConsole === true
|
|
833
|
+
? "Choose a projects folder once: toolroll up --project-root <dir>"
|
|
834
|
+
: "choose which folder this server may use with --project-root <dir>",
|
|
835
|
+
}
|
|
836
|
+
: { enabled: true, roots: ceiling.roots, record: [...(who.session.onboard?.entries() ?? [])][0] ?? null };
|
|
837
|
+
// A cheap peek per project for the switcher cards — one small set of
|
|
838
|
+
// COUNTs each, scoped to the exact repo.
|
|
839
|
+
const peekOf = (path) => {
|
|
840
|
+
try {
|
|
841
|
+
return projectFamilyPeek(path, now);
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
const peeks = {};
|
|
848
|
+
for (const one of recent)
|
|
849
|
+
peeks[one.path] = peekOf(one.path);
|
|
850
|
+
for (const path of candidates)
|
|
851
|
+
peeks[path] = peekOf(path);
|
|
852
|
+
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
|
|
853
|
+
}
|
|
854
|
+
async function edge(ctx) {
|
|
855
|
+
const { route, request, response, url, method } = ctx;
|
|
856
|
+
if (route.id === 'edge.healthz')
|
|
857
|
+
return healthz(store, response, method === 'HEAD');
|
|
858
|
+
if (route.id === 'edge.browser-asset') {
|
|
859
|
+
serveBrowserAsset(request, response, url.pathname);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (route.id === 'edge.workspace-style' && url.pathname === WORKSPACE_STYLE.path)
|
|
863
|
+
return WORKSPACE_STYLE.serve(request, response);
|
|
864
|
+
if (route.id === 'edge.install-asset' || route.id === 'edge.font') {
|
|
865
|
+
serveInstallAsset(response, url.pathname, respond);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (route.id === 'edge.desktop-health' && options.desktopIdentity !== undefined) {
|
|
869
|
+
const challenge = url.searchParams.get("challenge") ?? "";
|
|
870
|
+
if (!/^[a-f0-9]{64}$/.test(challenge) || !/^[a-f0-9]{64}$/.test(options.desktopIdentity))
|
|
871
|
+
return respond(response, 400, "application/json", JSON.stringify({ error: "invalid challenge" }));
|
|
872
|
+
response.setHeader("cache-control", "no-store");
|
|
873
|
+
return respond(response, 200, "application/json", JSON.stringify({ proof: createHmac("sha256", Buffer.from(options.desktopIdentity, "hex")).update(challenge).digest("hex") }));
|
|
874
|
+
}
|
|
875
|
+
return respond(response, 404, 'text/plain; charset=utf-8', 'No such address.');
|
|
876
|
+
}
|
|
877
|
+
const registrations = [
|
|
878
|
+
{ id: "edge.healthz", domain: "pages", stage: "edge", method: "GET,HEAD", handle: edge },
|
|
879
|
+
{ id: "edge.browser-asset", domain: "pages", stage: "edge", method: "GET,HEAD", handle: edge },
|
|
880
|
+
{ id: "edge.workspace-style", domain: "pages", stage: "edge", method: "GET,HEAD", handle: edge },
|
|
881
|
+
{ id: "edge.install-asset", domain: "pages", stage: "edge", method: "GET", handle: edge },
|
|
882
|
+
{ id: "edge.font", domain: "pages", stage: "edge", method: "GET", handle: edge },
|
|
883
|
+
{ id: "edge.desktop-health", domain: "pages", stage: "edge", method: "GET", handle: edge },
|
|
884
|
+
{ id: "projects.browse", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
885
|
+
{ id: "projects.github", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
886
|
+
{ id: "projects.page", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
887
|
+
{ id: "home", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
888
|
+
{ id: "morning", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
889
|
+
{ id: "workbench", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
890
|
+
{ id: "ledger", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
891
|
+
{ id: "activity", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
892
|
+
{ id: "system", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
893
|
+
{ id: "peek", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
894
|
+
{ id: "fleet", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
895
|
+
{ id: "ledger.export", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
896
|
+
{ id: "menu", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
897
|
+
{ id: "caps", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
898
|
+
{ id: "health", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
899
|
+
{ id: "metrics", domain: "pages", stage: "console", method: "GET", handle: get },
|
|
900
|
+
{ id: "ledger.checkpoint", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
901
|
+
{ id: "projects.select", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
902
|
+
{ id: "projects.remove", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
903
|
+
{ id: "projects.open", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
904
|
+
{ id: "fleet.register", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
905
|
+
{ id: "fleet.retire", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
906
|
+
{ id: "projects.onboard-preview", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
907
|
+
{ id: "projects.onboard-confirm", domain: "pages", stage: "console", method: "POST", handle: post },
|
|
908
|
+
];
|
|
909
|
+
return { registrations, get, post, projectsScreen };
|
|
910
|
+
}
|